From 188e9ec642f468174182c748dad9ac0ff85e50e7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 23:26:27 +0200 Subject: [PATCH 1/6] specs --- docs/plans/deploy-v1-codex-spec.md | 384 ++++++ docs/plans/deploy-v1-schema-cascade-spec.md | 1274 +++++++++++++++++++ 2 files changed, 1658 insertions(+) create mode 100644 docs/plans/deploy-v1-codex-spec.md create mode 100644 docs/plans/deploy-v1-schema-cascade-spec.md diff --git a/docs/plans/deploy-v1-codex-spec.md b/docs/plans/deploy-v1-codex-spec.md new file mode 100644 index 00000000..ff792755 --- /dev/null +++ b/docs/plans/deploy-v1-codex-spec.md @@ -0,0 +1,384 @@ +# Codex agent spec — `workforce deploy` v1 + +You are implementing the parallelizable, mechanical pieces of the `workforce deploy` v1 feature. The product plan lives at `docs/plans/deploy-v1.md` — read it first, top to bottom, for context. **This file is your contract**: do exactly what it specifies, in the listed order of priority, opening one PR per numbered task against the `workforce` repo. + +You are working in parallel with a human engineer who owns the schema diff in `persona-kit`, the `@agentworkforce/runtime` core (`handler()` + ctx builder + shim), the deploy orchestrator's main flow, and the CLI dispatch case. **Treat their files as published interfaces — do not modify them.** If something you need is missing, leave a `TODO(human): exposed surface needed — ` comment and skip ahead. + +## Companion Ricky workflow (parallel cross-repo tracks) + +A separate Ricky-orchestrated workflow runs in parallel to this spec. See `docs/plans/deploy-v1-workflow-spec.md` for the full narrative; the short version is: + +- **Track A** (`cloud` repo, branch `feat/workforce-daytona-runner`): extracts `DaytonaRuntime` into the publishable `@workforce/daytona-runner` package and adds `POST /api/v1/workspaces/:id/sandboxes`. Your Task 4 (`packages/deploy/src/modes/sandbox.ts`) will consume the published package; keep its `runSandbox(input: SandboxRunInput): Promise` signature stable so Ricky's Track B can rebase onto it. +- **Track B** (`workforce` repo, branch `feat/deploy-v1-daytona-consume`): rewrites `packages/deploy/src/modes/sandbox.ts` to use `@workforce/daytona-runner` and adds workforce-managed sandbox issuance. Depends on your Task 4 landing first. +- **Track C** (`workforce` repo worktree, branch `feat/mcp-workforce`): adds `@agentworkforce/mcp-workforce` MCP server. Consumes your Task 1 integration clients (`packages/runtime/src/clients/{github,linear,slack,notion,jira}.ts`) via the runtime — keep that surface stable. +- **Track INT** (`workforce` repo worktree, branch `feat/deploy-v1-e2e`): cross-repo E2E test. Depends on Tasks 1–5 merged. + +You do **not** need to coordinate with Ricky directly. Your branches (`codex/deploy-v1-`) are namespaced separately from Ricky's (`feat/workforce-daytona-runner`, `feat/deploy-v1-daytona-consume`, `feat/mcp-workforce`, `feat/deploy-v1-e2e`); no collision is possible. If the Task 1 client surface or the Task 4 `runSandbox` signature changes after this spec, flag it in your PR body so the Ricky lead can rebase the downstream tracks. + +## Working agreement + +- **Branch per task.** One branch per numbered task. Naming: `codex/deploy-v1-` (e.g. `codex/deploy-v1-github-client`). +- **PR per task.** Title format: `feat(): `. Body links back to this spec section. +- **No schema decisions.** If a persona JSON field is ambiguous, re-read §3 of `deploy-v1.md`. If still ambiguous, surface in PR body — do not invent. +- **TypeScript only.** ESM modules. `"type": "module"`. Match existing workforce package style (`packages/persona-kit/package.json` is the reference). +- **No new dependencies without justification.** Prefer extending existing imports. If you need a new dep, state why in the PR body. +- **Tests required.** Each new file must have a `*.test.ts` next to it covering happy path + one error case. Use the existing test runner — check `packages/persona-kit` for the pattern. +- **Run `corepack pnpm run check` before declaring a task done.** If it's red, fix it before opening the PR. + +## Interfaces you can rely on (published by the human) + +By the time you start, these will exist: + +```ts +// from @agentworkforce/persona-kit (extended schema) +import type { PersonaSpec, IntegrationConfig, Schedule, SandboxConfig, MemoryConfig, Traits } from '@agentworkforce/persona-kit'; + +// from @agentworkforce/runtime (core) +import { handler, type WorkforceCtx, type WorkforceEvent, type IntegrationClients } from '@agentworkforce/runtime'; +import { buildCtx, type CtxBuildOptions } from '@agentworkforce/runtime/internal'; // internal subpath +``` + +If any of these aren't exported yet when you reach for them, leave the `TODO(human)` comment described above and move on. + +--- + +## Task 1 — Per-integration clients (HIGHEST PRIORITY) + +**Goal:** Concrete TS clients for each Relayfile provider, exposed on `WorkforceCtx` as `ctx.github`, `ctx.linear`, etc. + +**Files to create:** +- `packages/runtime/src/clients/github.ts` +- `packages/runtime/src/clients/linear.ts` +- `packages/runtime/src/clients/slack.ts` +- `packages/runtime/src/clients/notion.ts` +- `packages/runtime/src/clients/jira.ts` +- `packages/runtime/src/clients/index.ts` (barrel) +- `packages/runtime/src/clients/.test.ts` for each + +**Per-client contract:** + +```ts +export interface GithubClient { + comment(target: { owner: string; repo: string; number: number }, body: string): Promise<{ id: string; url: string }>; + createIssue(args: { owner: string; repo: string; title: string; body: string; labels?: string[] }): Promise<{ number: number; url: string }>; + upsertIssue(args: { owner: string; repo: string; title: string; body: string; labels?: string[]; matchTitle: string }): Promise<{ number: number; url: string; created: boolean }>; + getPr(target: { owner: string; repo: string; number: number }): Promise<{ title: string; body: string; diff: string; head: string; base: string; author: string }>; + postReview(target: { owner: string; repo: string; number: number }, args: { body: string; event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'; comments?: Array<{ path: string; line: number; body: string }> }): Promise; +} + +export function createGithubClient(opts: { + connectionId: string; // from Relayfile + relayfileBaseUrl: string; +}): GithubClient; +``` + +Mirror this shape for the other providers. Method coverage per provider: + +| Provider | Methods (v1) | +|---|---| +| `github` | as above | +| `linear` | `createIssue`, `updateIssue`, `comment`, `getIssue` | +| `slack` | `post(channel, text)`, `reply(threadTs, text)`, `dm(user, text)` | +| `notion` | `createPage(parent, properties, content)`, `updatePage`, `getPage` | +| `jira` | `createIssue`, `comment`, `transition` | + +**Implementation pattern:** +- Auth: each call sends `Authorization: Bearer `; the connection-id resolves to a token at request time via Relayfile's `/api/v1/connections/:id/token` (check `@relayfile/sdk` for the exact helper — `RelayfileSetup` likely has one). +- Errors: throw `WorkforceIntegrationError` (define in `packages/runtime/src/errors.ts`) with `provider`, `operation`, `cause`, `retryable` fields. +- Don't implement retries here — the runtime's outer loop handles it. Just throw with `retryable: true` for 5xx and 429s. +- No SDK heavy lifting — use `fetch`. Each client should be <150 lines. + +**Acceptance:** +- All five client files compile and pass their tests. +- `WorkforceCtx`'s per-integration fields are populated by `ctx.ts` when the persona declares that integration. (The human owns `ctx.ts`; you just expose `createXxxClient` so it can call them.) + +**Effort:** ~2–3h total across all five (~30min each). + +--- + +## Task 2 — Bundle stager (`bundle.ts`) + +**Goal:** Pure file-staging function the deploy orchestrator calls to produce a runnable bundle in `.workforce/build//`. + +**File to create:** `packages/deploy/src/bundle.ts` (+ test) + +**Contract:** + +```ts +export interface BundleInput { + personaPath: string; // absolute path to the persona JSON + persona: PersonaSpec; // already-parsed + outDir: string; // .workforce/build/ + bundlerOptions?: { minify?: boolean }; +} + +export interface BundleResult { + personaCopyPath: string; // outDir/persona.json + runnerPath: string; // outDir/runner.mjs (entry) + bundlePath: string; // outDir/agent.bundle.mjs (esbuild'd agent.ts) + packageJsonPath: string; + sizeBytes: number; +} + +export async function stageBundle(input: BundleInput): Promise; +``` + +**What it does:** +1. Resolve `persona.onEvent` relative to `personaPath`. Verify file exists. +2. Esbuild the `onEvent` file as ESM bundle → `outDir/agent.bundle.mjs`. Bundle target `node20`, format `esm`, platform `node`, sourcemap `inline`, external all `node:*` plus `@agentworkforce/runtime/raw`. +3. Copy `persona.json` (the parsed object stringified) to `outDir/persona.json`. +4. Generate `outDir/runner.mjs` from this exact template: + ```js + import { startRunner } from '@agentworkforce/runtime/runner'; + import persona from './persona.json' assert { type: 'json' }; + import * as agentModule from './agent.bundle.mjs'; + const handler = agentModule.default ?? agentModule.handler; + startRunner({ persona, handler }); + ``` +5. Write `outDir/package.json` listing `@agentworkforce/runtime` at the workspace version. +6. Return `BundleResult` with byte size. + +**Dependencies allowed:** `esbuild`, `node:fs/promises`, `node:path`. Nothing else. + +**Acceptance:** +- Given `examples/weekly-digest/persona.json`, `stageBundle` produces a working bundle whose `runner.mjs` runs under `node runner.mjs` (assuming `@agentworkforce/runtime/runner` exists — if not, leave the runner template and TODO). +- Idempotent: running twice cleans and rewrites. +- Tested with a fixture persona under `packages/deploy/src/__fixtures__/`. + +**Effort:** ~1h. + +--- + +## Task 3 — `modes/dev.ts` — local long-lived runner + +**Goal:** Spawn the bundled runner as a child Node process, stream logs to stdout, hold the parent process open until SIGINT. + +**File to create:** `packages/deploy/src/modes/dev.ts` (+ test) + +**Contract:** + +```ts +export interface DevRunInput { + bundle: BundleResult; + env?: Record; + onLog?: (line: string) => void; +} + +export interface DevRunHandle { + pid: number; + stop(): Promise; + done: Promise<{ code: number; signal: NodeJS.Signals | null }>; +} + +export async function runDev(input: DevRunInput): Promise; +``` + +**Implementation:** +- Use `node:child_process.spawn('node', [bundle.runnerPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...input.env } })`. +- Line-buffer stdout/stderr (avoid partial lines). Prefix each line with `[runtime]` before forwarding to `onLog` or `console.log` default. +- `stop()` sends SIGTERM, escalates to SIGKILL after 5s. +- Forward SIGINT on the parent: kill the child cleanly. +- `done` resolves on child exit. + +**Acceptance:** A unit test using a stub `runner.mjs` that prints "hello" and exits — verifies the log line is captured and `done` resolves with `code: 0`. + +**Effort:** ~45min. + +--- + +## Task 4 — `modes/sandbox.ts` — Daytona launcher + +**Goal:** Same shape as `runDev`, but launches inside a Daytona sandbox. + +**File to create:** `packages/deploy/src/modes/sandbox.ts` (+ test using a Daytona mock) + +**Contract:** + +```ts +export interface SandboxRunInput { + bundle: BundleResult; + sandboxConfig: SandboxConfig | true; + env?: Record; + onLog?: (line: string) => void; + daytona: { apiKey: string; jwtToken?: string; organizationId?: string }; +} + +export interface SandboxRunHandle { + sandboxId: string; + stop(): Promise; + done: Promise<{ code: number }>; +} + +export async function runSandbox(input: SandboxRunInput): Promise; +``` + +**Implementation:** +- `import { Daytona } from '@daytonaio/sdk'` (already used in `cloud/packages/core/src/runtime/daytona.ts` — same SDK). +- `new Daytona({ apiKey })`; `daytona.create({ language: 'typescript', envVars: input.env })`. +- `sandbox.fs.uploadFiles([...])` — upload the entire bundle directory recursively. +- `sandbox.process.executeCommand('node runner.mjs', '/home/user/project', input.env, input.sandboxConfig?.timeoutSeconds ?? 1800)`. +- Stream output. Daytona's `executeCommand` is final-result-only; for log streaming use `sandbox.process.createSession()` + `executeSessionCommand` with the streaming variant if available. If not (check SDK version), fall back to polling `executeCommand` output every 2s. Note the gap in the PR body. +- `stop()` calls `sandbox.delete()`. + +**Acceptance:** Test with a mocked `@daytonaio/sdk` that simulates create + exec + delete; assert call sequence. + +**Effort:** ~1.5h (Daytona SDK surface verification is most of the time). + +--- + +## Task 5 — Examples + +### 5a. `examples/weekly-digest/` + +**Files:** `examples/weekly-digest/persona.json`, `examples/weekly-digest/agent.ts`, `examples/weekly-digest/README.md`. + +**persona.json:** Use the shape from `deploy-v1.md` §7.1. `id: "weekly-digest"`. Schedule `0 9 * * 6`. GitHub integration only. Memory enabled with `workspace` scope. No sandbox config (defaults on). + +**agent.ts:** Default-export a `handler(...)`. On `event.source === 'cron'`: +1. Fetch search results from Brave Search API (env: `BRAVE_API_KEY`) for the topics in `persona.inputs.TOPICS` (define this input with a default list of 3 topics). +2. Dedupe + cluster by URL host. +3. Upsert one GitHub issue per week titled `Weekly digest — `, body listing clustered findings. Use `ctx.github.upsertIssue` with `matchTitle`. +4. Save a memory note: "digest published for week N" with `tags: ['digest', 'week:']`. + +Aim for ~80 lines. Keep it readable. + +### 5b. `examples/review-agent/` + +**Files:** `examples/review-agent/persona.json`, `examples/review-agent/agent.ts`, `examples/review-agent/README.md`. + +**persona.json:** From §7.2 of the plan. GitHub + Slack integrations. `useSubscription: true`. Memory enabled. Traits set. + +**agent.ts:** Dispatches on `event.source` + `event.type`: +- `github.pull_request.opened` → `ctx.harness.run({ prompt: \`Review this PR:\n${diff}\`, cwd: ctx.sandbox.cwd })` → `ctx.github.postReview(target, { event: 'COMMENT', body: result.output })`. +- `github.issue_comment.created` (matched as `@mention`) → reply with `ctx.github.comment` using harness output with thread context. +- `github.pull_request_review_comment.created` → similar reply. +- `github.check_run.completed` w/ failure → harness with the failed CI logs, post a comment with the proposed fix. +- `slack.app_mention` → conversational reply via memory + harness. Use `ctx.slack.reply`. + +Aim for ~120 lines. + +**Acceptance:** +- Both `agent.ts` files typecheck against `@agentworkforce/runtime`. +- Both `persona.json` parse via `parsePersonaSpec` without errors. +- Both READMEs document setup (which integrations to connect first, which env vars to set). + +**Effort:** ~1h for both. + +--- + +## Task 6 — Trigger registry expansion + +**File:** `packages/persona-kit/src/triggers.ts` (the human creates a stub; you fill it in). + +**Source of truth:** `/Users/khaliqgant/Projects/AgentWorkforce/relayfile/docs/` and the Relayfile adapter packages under `/Users/khaliqgant/Projects/AgentWorkforce/relayfile-adapters/`. + +For each Tier-1 provider (`github`, `linear`, `slack`, `notion`, `jira`), enumerate every event name the adapter normalizes. Output: + +```ts +export const KNOWN_TRIGGERS = { + github: ['pull_request.opened', 'pull_request.closed', /* ... */] as const, + linear: ['issue.created', /* ... */] as const, + // ... +} as const satisfies Record; + +export type ProviderName = keyof typeof KNOWN_TRIGGERS; +export type TriggerOf

= (typeof KNOWN_TRIGGERS)[P][number]; +``` + +Also expose a `lintTriggers(persona: PersonaSpec): TriggerLintIssue[]` function that returns warnings for unknown trigger names (don't throw). + +**Acceptance:** Each provider has ≥8 trigger names. `lintTriggers` returns `[]` for the two examples shipped in Task 5. + +**Effort:** ~45min. + +--- + +## Task 7 — JSON Schema export + persona fixtures + +**Files:** +- `packages/persona-kit/scripts/emit-schema.mjs` — emits JSON Schema for the extended `PersonaSpec` (use `ts-json-schema-generator` or `typescript-json-schema`; pick whichever has fewer transitive deps). +- `packages/persona-kit/schemas/persona.schema.json` — generated artifact, checked in. +- `packages/persona-kit/src/__fixtures__/personas/minimal.json` — bare persona (no cloud fields). +- `packages/persona-kit/src/__fixtures__/personas/cron-only.json` — cloud + schedules, no integrations. +- `packages/persona-kit/src/__fixtures__/personas/full.json` — every optional field populated. +- `packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json` — should produce a lint warning. +- `packages/persona-kit/scripts/emit-schema.test.ts` — round-trips each fixture through the schema. + +Wire `emit-schema.mjs` to run as part of `pnpm run build` (or `prebuild`). + +**Acceptance:** Each fixture validates against the emitted schema. The script is idempotent. + +**Effort:** ~1h. + +--- + +## Task 8 — `examples/linear-shipper/` (paraglide pattern) + +**Files:** `examples/linear-shipper/persona.json`, `examples/linear-shipper/agent.ts`, `examples/linear-shipper/README.md`. + +**persona.json:** Headless (no traits). `cloud: true`. Linear + GitHub integrations. Trigger: `linear.issue.created`. Sandbox on. + +**agent.ts:** On `linear.issue.created`: +1. Pull issue body via `ctx.linear.getIssue`. +2. Clone the target repo into `ctx.sandbox.cwd` via `ctx.sandbox.exec('git clone ...')`. +3. `ctx.harness.run({ prompt: \`Implement this Linear issue:\n${issue.body}\`, cwd: ctx.sandbox.cwd })`. +4. Open a draft PR via `ctx.github.createIssue` (or `createPr` if exposed — add to GithubClient if needed and update Task 1's contract via a TODO comment). +5. Comment back on the Linear issue with the PR link via `ctx.linear.comment`. + +Aim for ~100 lines. + +**Acceptance:** persona.json parses; agent.ts typechecks. + +**Effort:** ~45min. + +--- + +## Task 9 — README rewrite + +**File:** `README.md` at the workforce repo root. + +Lead with the deploy story: "A persona is a deployable agent." Show `workforce deploy ./review-agent.json` as the headline example. Demote the existing local-CLI usage section to "Local agents" further down. + +Sections: +1. Quick start: `workforce deploy ./examples/weekly-digest/persona.json` +2. What a persona looks like (short JSON snippet) +3. Run modes (`--dev`, `--sandbox`, `--cloud`) +4. Integrations supported +5. Local agents (existing content) +6. Personas as packages (existing content) + +Keep marketing language minimal. Match the existing voice. + +**Acceptance:** Renders cleanly on GitHub. Links to the examples and to `docs/plans/deploy-v1.md`. + +**Effort:** ~30min. + +--- + +## Suggested execution order + +If you have one agent: 1 → 6 → 7 → 2 → 3 → 4 → 5 → 8 → 9. + +If you parallelize across multiple codex agents: +- Track A (independent): Task 1 (github + linear first, then slack/notion/jira). +- Track B (independent): Task 6, Task 7. +- Track C (depends on A's github+linear): Task 5, Task 8. +- Track D (depends on bundle.ts contract being agreed — read it from this spec): Task 2, Task 3, Task 4 sequentially. +- Track E (last): Task 9, after Tasks 1–5 are merged. + +Each track is its own PR series. No track waits on another's review. + +## When you are blocked + +- **Missing exported symbol from `@agentworkforce/runtime`?** Leave `TODO(human): need ` in code + flag in PR body. Don't speculate. +- **Disagreement with the plan?** Open a comment thread on the PR for `deploy-v1.md` — don't unilaterally change the contract. +- **Test failing for a reason you can't isolate?** Skip it with `it.skip(..., 'TODO(human): ')` and ship the rest of the task. Don't block a track on a flake. + +## Out of scope for you (the human owns these) + +- Schema types in `packages/persona-kit/src/types.ts` and the parser in `parse.ts`. +- `@agentworkforce/runtime` core: `handler()`, `WorkforceCtx`, `WorkforceEvent`, ctx builder, the `@agent-relay/agent` shim. +- `@agentworkforce/deploy` orchestrator entry (`index.ts`). +- CLI dispatch case in `packages/cli/src/cli.ts`. +- `workforce login` helper. +- This plan and spec files. diff --git a/docs/plans/deploy-v1-schema-cascade-spec.md b/docs/plans/deploy-v1-schema-cascade-spec.md new file mode 100644 index 00000000..05774af1 --- /dev/null +++ b/docs/plans/deploy-v1-schema-cascade-spec.md @@ -0,0 +1,1274 @@ +# Ricky workflow spec — Deploy v1 schema cascade + persona refactor + +**Status:** ready for Ricky to generate + run a workflow. +**Resolves:** locked-in decisions from cloud#553 thread + two May 12 architecture meetings. +**Companion docs:** +- `workforce/docs/plans/deploy-v1.md` (product plan) +- `workforce/docs/plans/deploy-v1-codex-spec.md` +- `workforce/docs/plans/deploy-v1-workflow-spec.md` (reference workflow shape — mirror conventions) + +**Reference workflow file (shape to mirror):** `cloud-proactive-runtime-spec/workflows/proactive-runtime-m1.ts` + +**Hard precondition (cleared):** workforce#95 (`refactor/flatten-persona-tiers`) **MERGED 2026-05-12T21:09:01Z**. All tracks may start. + +**Current state of upstream dependencies (verify at workflow start):** +- workforce#95 — **MERGED 2026-05-12T21:09:01Z**. Hard gate cleared. +- relay#844 — **MERGED 2026-05-12T19:50:04Z**; `@agent-relay/events@6.0.17` + `@agent-relay/agent@6.0.17` published. Track C coordination comment already posted. +- agent-assistant#91 — MERGED; `@agent-assistant/proactive@0.4.32` published. Track E4 picks this up. +- workforce#97 (`feat/persona-integration-source`) — DRAFT, ready for rebase in Track E5 after Track D. +- cloud#548 (now M1-M6, title stale) — open, +33k/-57. Trigger registration code VERIFIED (schedules → relaycron via `services/agent-gateway/src/relaycron-client.ts:registerCronSchedules()`; watches → gateway DO via `packages/agent-relay-agent/src/index.ts:registerWatches()` at agent startup). Missing piece for persona+bundle deploy: persona → watch-glob translation; lives in Track G below. **Track A must rebase on #548's migrations if #548 merges first; review showed #548 is additive on existing `agent_deployments`, so two-table split is layerable.** +- relay#843 — open, +3.5k. Adds `agent-relay` CLI commands (login/workspaces/tokens/dlq/runtime) + new `@agent-relay/cloud` library. **Parallel to workforce CLI; no spec dependency.** +- relaycron#5 — open, +2k. WS-delivery + cancel API + buffered ticks. **Track G dependency** — without this merged, schedule registration via the agent-gateway's `relaycron-client.ts` is half-wired. Preflight Track G to verify relaycron#5 is merged. +- relayauth#39 — open, docs-only +3/-1. No spec impact. +- cloud#554 (Daytona meter) — draft, platform-team gates only. +- cloud#555 (workflow-invocations shim) — draft, 2 follow-ups in Track J. + +--- + +## How to consume + +Generate one workflow TS file (suggested name `workforce-schema-cascade.ts`) under `cloud-proactive-runtime-spec/workflows/` that orchestrates the six tracks below. + +- Tracks A and C run on cloud; D, E, F on workforce. +- A, C may run in parallel from workflow start. +- B depends on A. +- D depends on workforce#95 merged. +- E depends on D. +- F depends on D merged AND A merged. + +### Run command + +```sh +npx tsx cloud-proactive-runtime-spec/workflows/workforce-schema-cascade.ts +``` + +### Required env + +``` +HOME=/Users/khaliqgant +ROOT=$HOME/Projects/AgentWorkforce + +CLOUD_REPO=$ROOT/cloud +WORKFORCE_REPO=$ROOT/workforce +RELAY_REPO=$ROOT/relay # read-only — verify relay#844 merge state +``` + +### Required secrets + +``` +GITHUB_TOKEN +``` + +### Coordination + +Hub-spoke. Lead Claude Opus stays on `#wf-schema-cascade` as architect + ambient reviewer. + +### Never-fail mechanics (mirror `proactive-runtime-m1.ts`) + +- Every test / typecheck / regression gate runs as **soft → fixer → hard**. +- Two review rounds: peer review → signoff → router → fix-r2 → final signoff. If round 2 still has gaps, the PR opens as DRAFT with the gap list in the body. Workflow exits 0. +- Global `onError`: retry 2× with 10s backoff. +- Per-track self-reflection vs that track's section below. + +### Worktree layout + +``` +$CLOUD_REPO # Track A (issue body + migrations PR) — operates in place +$CLOUD_REPO.wt-resolver # Track B +$WORKFORCE_REPO # Track D (persona-kit refactor — owns primary checkout) +$WORKFORCE_REPO.wt-runtime # Track F (runtime input-values resolution) +$WORKFORCE_REPO.wt-rebase-92 # Track E sub-tracks (one worktree per workforce PR) +$WORKFORCE_REPO.wt-rebase-93 +$WORKFORCE_REPO.wt-rebase-94 +$WORKFORCE_REPO.wt-rebase-96 +$WORKFORCE_REPO.wt-rebase-97 +``` + +Create with `git worktree add -b `. Cleanup: never run `git worktree remove`. Leave worktrees in place for human inspection. + +### PR conventions + +- **PRs open as DRAFT initially. Workflow auto-flips to ready + AUTO-MERGES** when all gates pass (see Auto-merge directive below). Khaliq has explicitly authorized auto-merge for this workflow so when he wakes up everything is wired and ready to test. +- Title format: `():

`. +- Body references this spec file path (`workforce/docs/plans/deploy-v1-schema-cascade-spec.md`) and a per-track acceptance checklist (checked/unchecked). +- Commit trailer: `Co-Authored-By: via Ricky `. +- No `--no-verify`. Fix hook failures and create a new commit. + +### Auto-merge directive (FLIP from prior DRAFT-only posture) + +After each track's PR is opened, the workflow's lead Claude: + +1. **Waits for CI green** on the PR (all required checks passing, no failures, no in-progress). +2. **Runs the soft → fixer → hard gate loop** until tests + typecheck are clean. +3. **Verifies all upstream dependencies are merged** (per the Merge DAG below). +4. **Verifies no merge conflicts** with target base branch. +5. **Verifies no human review has requested changes** (`gh pr view --json reviews` returns no `CHANGES_REQUESTED` from a non-bot reviewer). +6. **Flips PR from draft to ready** (`gh pr ready `). +7. **Merges via squash** (`gh pr merge --squash --auto`) — uses `--auto` so if CI is still settling, GitHub merges as soon as it goes green. +8. **Posts a status line into `#wf-schema-cascade`**: "merged: (#X)". + +**Gates that BLOCK auto-merge** (workflow stops cascade, posts loud alert): +- Any required CI check returns FAILURE after the fixer loop. +- Any human reviewer left `CHANGES_REQUESTED` (don't override). +- Merge conflict that fixer can't resolve. +- A downstream-track PR was already opened and its CI breaks post-merge of an upstream track → STOP, do not merge further. + +**Cross-repo merge ordering:** the workflow walks the Merge DAG (below) topologically. Within a single repo, tracks merge sequentially. Across repos, paired-contract PRs (cloud#548 + relaycron#5) merge as a pair via short polling: workflow merges cloud#548 first, then immediately verifies relaycron#5 still green + merges it; if relaycron#5 breaks in between, the workflow flags it but doesn't roll back cloud#548 (Khaliq handles). + +**What the workflow will NOT auto-merge:** +- workforce#89 (README rewrite — DRAFT by design, docs polish, not blocking). +- workforce#87 (proactive-agent-builder persona) — auto-merge IF #87 still has the `parseInputsShape` `optional: true` regression fix, since Track F's input resolution depends on it. Otherwise skip. +- cloud#554 (Daytona meter) — platform-team gates on meter name + autostop reconciliation; flag for Khaliq's morning review, don't merge. +- Anything in the "Out of scope" list. + +**Rollback policy:** the workflow doesn't auto-revert. If a merge breaks a downstream track, the workflow stops, posts the broken state, and leaves all repos in their merged-so-far state for Khaliq to inspect. This is intentional: incomplete cascade is recoverable; rolling back partial cross-repo merges is not. + +--- + +## Out of scope (DO NOT implement) + +The following decisions were explicitly punted in the May 12 meetings. **Ricky must NOT enact any of these.** If an implementer agent proposes changes in these areas, fail the soft-gate. + +1. **Multi-persona collaboration team table.** `agent_teams` or similar grouping table is NOT in v1. RelayCast workspace IS the de facto grouping; the only multi-agent observability is the `spawned_by_agent_id` back-pointer in Track A. +2. **Persona-spec timeout fields.** Timeouts are runtime-managed for v1 with sensible defaults per `trigger_kind`. Don't add `timeout_seconds` to `PersonaSpec`. +3. **`workforce deployments destroy/list` CLI commands.** M3 milestone — separate workflow file. +4. **Persona-personality-builder tool.** Future package; not part of persona-kit v1. +5. **Trait → expression auto-mapping** in the proactive bridge. Traits removed entirely from persona spec (Track D); no replacement in v1. +6. **LLM-judge timeout resumption logic.** Khaliq mentioned as "an option for later" — runtime layer, not schema. +7. **`@workforce/daytona-runner` npm publish.** A separate agent is handling publishing under the `@workforce` OIDC trusted-publisher scope. Do NOT touch the daytona-runner package or its workspace ref in this workflow. + +### Loud hole: memory wiring (intentionally out of scope, intentionally loud) + +Memory is NOT wired end-to-end after this workflow completes. The schema has the supermemory pointer in External state, and `PersonaSpec.memory` declares `scopes` + `ttlDays`, but **the runtime does not inject the supermemory API key, does not call save/recall, and `ctx.memory` returns a stub.** + +This is a deliberate hole. Memory architecture is being worked through separately. After this workflow lands: + +- `ctx.memory.save(...)` will type-check and compile, but at runtime it will log a warning and no-op. +- `ctx.memory.recall(...)` returns `[]`. +- A deployed agent that calls memory APIs runs cleanly but has no persistence. + +**When memory IS wired** (separate follow-up spec), the locked decisions from the May 12 diagrams are: + +- `enabled: bool` +- `scopes: 'workspace' | 'user' | 'global'` (per image 1 of the whimsical diagram — note: **no `session` scope, and a `global` scope is added** vs the old deploy-v1.md prose). +- `ttl: number` + +Track D's persona-kit refactor MUST keep `PersonaSpec.memory.scopes` accepting `'workspace' | 'user' | 'global'` (drop `session` from the accepted union if it's there from pre-flatten code). + +**Document the hole prominently:** every track's PR body MUST include the following line in a "Known gaps after this PR" section: + +> ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). + +A separate spec — `docs/plans/deploy-v1-memory-spec.md` — will own the wiring. Out of scope here. + +### Terminology notes (diagram ↔ schema) + +The May 12 whimsical diagrams use a few names that differ from the locked schema. Ricky must use the **schema** names in code and migrations; diagram names are informal aliases. + +| Diagram term | Schema term | Notes | +|---|---|---| +| `harnesses` table | `provider_credentials` | The "user-owned llm credential" row — `(user_id, model_provider, auth_type, label)`. The diagram's "harness" is the runner program (claude code, codex, opencode); the row is the credential to run it. Schema name `provider_credentials` stays. | +| `harnessShare` field | `provider_credentials.label` (or N/A) | The diagram's right-side table sketch was lossy here; treat as informal. | +| "Listeners" (image 1, item 4) + "schedule" (image 1, item 5) | `PersonaSpec.integrations.

.triggers[]` + `PersonaSpec.schedules[]` | Listed adjacently in image 1; spec keeps the existing shape with listeners as the unifying narrative (Track D JSDoc). | +| "Setup relaycast environment + agents.md with relaycast credentials" (image 3) | Runtime concern in cloud#548's agent-gateway, NOT a persona-spec field | Every deployed agent gets relaycast wired so it can communicate. Doesn't require the persona to declare an `inbox` listener. | + +--- + +## Track A — Cloud #553 schema lock-ins (issue body + migrations PR) + +**Repo:** `$CLOUD_REPO` (operates in place — single track on this repo at a time) +**Implementer model:** codex (high reasoning). +**Working branch:** `chore/db1-schema-lockin` +**PR title:** `feat(db): DB1 schema lock-ins per cloud#553 thread` + +**Allowed-dirty regex:** `package(-lock)?\.json|packages/web/drizzle/.*|packages/web/lib/db/.*|packages/web/lib/proactive-runtime/.*|docs/.*` + +### A1 — Update issue body of cloud#553 + +Read the current issue body first: +```bash +gh issue view 553 --repo AgentWorkforce/cloud --json body -q .body > /tmp/553-current.md +``` + +Edit the body to reflect ALL of the following lock-ins. If a lock-in is already in the body (Will has applied some already), leave it; only add what's missing. + +#### Two-table agent model (multi-instance per persona) + +Replace the existing single `agent_deployments` definition with a two-table model: + +**`agents`** — persona-level, addressable identity. One row per `(workspace_id, persona_id)` not yet destroyed. + +| Column | Notes | +|---|---| +| `id` uuid PK | The addressable agent ID — used in inter-agent communication, billing, observability grouping | +| `workspace_id` uuid FK→workspaces | | +| `persona_id` uuid FK→personas | | +| `deployed_name` text | denorm of `persona.slug` at deploy time | +| `deployed_by_user_id` uuid FK→users | | +| `credential_selections` jsonb | per-provider credential pick | +| `input_values` jsonb | per-deployment overrides for `persona.spec.inputs` | +| `pinned_version_id` uuid NULL FK→persona_versions | when NULL, agent tracks persona's latest version | +| `spec_hash_at_deploy` text | for "agent is behind persona" UI | +| `status` enum | `active \| disabled \| error \| destroyed` | +| `destroyed_at` timestamptz NULL | | +| `destroyed_by_user_id` uuid NULL | | +| `spawned_by_agent_id` uuid NULL FK→agents(id) | observability when one agent spawns another | +| `last_used_at`, `last_error` | | + +`UNIQUE (workspace_id, persona_id) WHERE status != 'destroyed'` +`UNIQUE (workspace_id, deployed_name) WHERE status != 'destroyed'` + +**`agent_deployments`** — per-running-instance row (a "head"). Many rows per `agents.id`. Two simultaneous Linear-ticket triggers for the same agent fan out to two `agent_deployments` rows under one `agents` row. + +| Column | Notes | +|---|---| +| `id` uuid PK | per-instance ID | +| `agent_id` uuid FK→agents | | +| `trigger_kind` text | `'inbox' \| 'clock' \| 'radio'` | +| `trigger_payload` jsonb | what fired this deployment (cron name, integration event envelope, inbox message id, etc.) | +| `started_at`, `last_active_at` timestamptz | | +| `status` enum | `running \| idle \| timed_out \| completed \| failed` | +| `spec_hash_at_run` text | snapshot of which spec version this instance executed | +| `timed_out_at` timestamptz NULL | set when this deployment times out | +| `compaction_summary` text NULL | LLM-summarized conversation written when this deployment compacts | +| `parent_deployment_id` uuid NULL FK→agent_deployments(id) | chain to prior compaction so the "thread" of a conversation is reconstructable | + +Add a `## Multi-instance + compaction semantics` section in the issue body: + +> A single `agents` row can have N concurrent `agent_deployments`. Two simultaneous triggers (e.g. two Linear tickets arriving for the same MSD agent) fan out to two `agent_deployments` rows. Each deployment has its own conversation context. +> +> **Timeouts are runtime-managed**, per `trigger_kind`: human DM ≈ 5 min idle, GitHub review ≈ 24h, etc. (not in persona spec for v1.) +> +> **On timeout: compaction.** Runtime runs a compaction step — LLM summarizes the conversation; `compaction_summary` written; `timed_out_at` set; status moves to `timed_out`. The next trigger creates a new `agent_deployments` row with `parent_deployment_id` pointing at the timed-out row; the new row's system prompt is seeded from the parent's `compaction_summary`. + +#### Integrations — two-table model + +Already in body per Will's earlier edits — verify: +- `user_integrations` + `workspace_integrations`, nullable `name`, partial-unique indexes. `workspace_service_accounts` absorbed via `name IS NOT NULL`. + +Add if missing: +- **`adapter` column on both integration tables** — `text NOT NULL DEFAULT 'nango'`, values `'nango' | 'composio' | 'pipedream'`. Will explicitly: "There should be adapter." Cloud already brokers via Composio (`packages/web/lib/integrations/composio-service.ts`); Pipedream is in the picture too. + +#### `integration_scopes` generic table — replaces `slack_channel_configs` + +``` +integration_scopes + id uuid PK + user_integration_id uuid NULL FK→user_integrations(id) + workspace_integration_id uuid NULL FK→workspace_integrations(id) + scope_kind text -- 'slack_channel' | 'github_repo' | 'jira_project' | 'notion_database' | … + scope_id text -- provider-side id (channel id, repo full_name, project key, …) + config_json jsonb -- per-kind extras (enabled flag, mode, etc.), zod-validated by scope_kind + created_at, updated_at + CHECK ((user_integration_id IS NULL) <> (workspace_integration_id IS NULL)) + UNIQUE (user_integration_id, scope_kind, scope_id) WHERE user_integration_id IS NOT NULL + UNIQUE (workspace_integration_id, scope_kind, scope_id) WHERE workspace_integration_id IS NOT NULL +``` + +Mirrors the two-table integration pattern via two nullable FKs + CHECK. + +#### `persona_versions` table — in v1 + +``` +persona_versions + id uuid PK + persona_id uuid FK→personas + version int + spec jsonb + spec_hash text + created_at timestamptz + UNIQUE (persona_id, version) + UNIQUE (persona_id, spec_hash) +``` + +Add authoring note: "The persona-maker authoring agent writes a new `persona_versions` row on each persona edit. No separate version-management UI in v1." + +`agents.spec_snapshot jsonb` is removed; replaced by `agents.pinned_version_id uuid NULL FK→persona_versions(id)`. When NULL, agent tracks persona's latest version. + +#### `cli_auth_sessions` split + +Rename existing table → `cloud_cli_bootstrap_sessions` (preserves Daytona + SSH bootstrap shape). + +Add new: +``` +workforce_cli_auth_sessions + id uuid PK + user_id uuid FK→users + code_challenge text + code_challenge_method text + state text + redirect_uri text + token_hash text NULL -- set on successful exchange; nulled on revoke + issued_at, exchanged_at, expires_at, revoked_at timestamptz +``` + +#### Sharing rule prose + +Replace any "OAuth credentials cannot be shared org-wide" language with: + +> A persona can be shared org-wide regardless of credential type. The persona itself is shareable; credentials are deployer-scoped. Deploys fail with a clear error when the deploying user hasn't connected the required credential. + +#### GitHub App + user OAuth combine (resolution flow doc) + +Add to §"Resolution at deployment-run time": + +> For provider `github`, `source: { kind: 'deployer_user' }` loads the deployer's `user_integrations` row **and** the workspace's matching `workspace_integrations` row (matching workspace + provider, `name IS NULL`). Both are required at runtime: the App install gates repo access (workspace `installation_id`); the user OAuth identifies the actor. + +#### Sub-agents / teams note + +Add to schema doc: + +> **Harness sub-instances** inside a handler invocation are captured in `session_events`, not new `agents` or `agent_deployments` rows. +> +> **Multi-persona teams.** When agent A spawns agent B (a different persona), B gets its own `agents` row. RelayCast workspace IS the de facto team grouping in v1; no new `agent_teams` table. `agents.spawned_by_agent_id NULL` is the observability back-pointer. + +#### External state: sandbox-minute metering + +Add row to the External state table: + +| Concern | Stored in | How DB1 references it | +|---|---|---| +| Sandbox-minute usage events | platform metering pipeline (emitted via structured `logger.info` from `packages/web/app/api/v1/workspaces/[workspaceId]/sandboxes/workforce-sandbox-meter.ts`) | events carry `agent_id`, `workspace_id`, `sandbox_id`; no DB1 row; reconcile in billing dashboard | + +#### Lock-in revision history + +Add a `## Lock-in revision history` section at the bottom of the issue body referencing this workflow run + the May 12 transcripts + the date. + +Apply via: +```bash +gh issue edit 553 --repo AgentWorkforce/cloud --body-file /tmp/553-updated.md +``` + +### A2 — Open migrations PR + +Branch `chore/db1-schema-lockin` off `origin/main` in `$CLOUD_REPO`. Generate Drizzle migrations in `packages/web/drizzle/`: + +**New tables:** +- `agents` — the persona-level identity (see schema in A1) +- `persona_versions` +- `integration_scopes` +- `user_integrations` (if not already shipped — verify) +- `workspace_integrations` (if not already shipped — absorb `workspace_service_accounts` if it exists) +- `workforce_cli_auth_sessions` + +**Renames:** +- `cli_auth_sessions` → `cloud_cli_bootstrap_sessions` + +**Repurpose `agent_deployments`:** the existing table moves from "persona-level deployment" semantics to "per-instance run" semantics. This is largely additive: keep `agent_deployments.id` as the per-instance ID; move persona-level columns (deployed_name, credential_selections, input_values, status, destroyed_at, etc.) to the new `agents` table. Add: +- `agent_deployments.agent_id uuid NOT NULL FK→agents(id)` +- `agent_deployments.trigger_kind text NOT NULL DEFAULT 'inbox'` (back-fill for existing rows) +- `agent_deployments.trigger_payload jsonb NULL` +- `agent_deployments.started_at`, `last_active_at` timestamptz +- `agent_deployments.timed_out_at timestamptz NULL` +- `agent_deployments.compaction_summary text NULL` +- `agent_deployments.parent_deployment_id uuid NULL FK→agent_deployments(id)` +- `agent_deployments.spec_hash_at_run text` +- `agent_deployments.status` enum updated to `running | idle | timed_out | completed | failed` + +**Back-fill migration for existing `agent_deployments` rows:** for each existing row: +1. Create an `agents` row, copy persona-level columns. +2. Point the original row's new `agent_id` at it. +3. Translate the old status enum (`active | disabled | error | destroyed`) → new statuses (`running | timed_out | failed | completed`) using a best-effort mapping (active→running, disabled→completed, error→failed, destroyed→completed with destroyed_at copied to agents). + +**Column adds on existing tables:** +- `user_integrations.adapter text NOT NULL DEFAULT 'nango'` +- `workspace_integrations.adapter text NOT NULL DEFAULT 'nango'` + +**Data migrations:** +- `slack_channel_configs` → `integration_scopes` with `scope_kind = 'slack_channel'`. After move, drop `slack_channel_configs`. +- `workspace_service_accounts` → `workspace_integrations` with `name = `. After move, drop `workspace_service_accounts`. + +**Constraint updates:** +- `agents` unique indexes filtered `WHERE status != 'destroyed'`. + +**Codegen:** +- Run Drizzle codegen so the TypeScript schema (`packages/web/lib/db/schema.ts`) matches. + +### Track A acceptance + +- [ ] Issue body of cloud#553 reflects every bullet above. +- [ ] `agents` table created; `agent_deployments` repurposed for per-instance rows. +- [ ] All migrations + back-fill steps land in the same PR. +- [ ] Migrations PR opens as DRAFT. +- [ ] `npm run typecheck` clean. +- [ ] `npm test` passes (existing tests; new tables not yet exercised — that's Track B). +- [ ] No `--no-verify`; all hooks pass. + +**Effort estimate:** ~5h (back-fill migration is the bulk of the work). + +--- + +## Track B — Cloud resolver: dispatch on `source` + `adapter` + +**Repo:** `$CLOUD_REPO.wt-resolver` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `feat/integration-resolver-source-dispatch` +**Base:** Track A's branch (or `main` if Track A merges first). +**Depends on:** Track A's migrations PR merged (or mergeable + schema types stable). + +**Allowed-dirty regex:** `packages/web/lib/integrations/.*|packages/web/lib/proactive-runtime/deploy-manager\.ts|packages/web/app/api/v1/integrations/.*` + +### Implementation + +Update cloud's integration resolver (find it under `packages/web/lib/integrations/` and `packages/web/lib/proactive-runtime/deploy-manager.ts`). + +1. **Read `source` from persona spec.** Persona-side `PersonaIntegrationConfig.source` ships in workforce#97 (rebased in Track E5). For each declared integration: + - `source.kind === 'deployer_user'` → query `user_integrations WHERE user_id = $deployer AND provider = $p AND name IS NULL`. + - `source.kind === 'workspace'` → query `workspace_integrations WHERE workspace_id = $ws AND provider = $p AND name IS NULL`. + - `source.kind === 'workspace_service_account'` → query `workspace_integrations WHERE workspace_id = $ws AND provider = $p AND name = $source.name`. + - Missing/undefined `source` → default `{ kind: 'deployer_user' }`. Mirror persona-kit's parser default-injection. + +2. **GitHub App combine.** When provider is `github` AND `source.kind === 'deployer_user'`, ALSO load the workspace's `workspace_integrations` row (`name IS NULL`) for the installation_id. Return a combined resolved-integration object: + ```ts + { user_oauth: UserIntegrationRow, workspace_install: WorkspaceIntegrationRow } + ``` + If the workspace install is missing, deploy must fail with: `GitHub deploys require both a user OAuth and a workspace GitHub App install. Workspace install missing.` + +3. **`adapter` dispatch.** When invoking the connection's token-refresh / introspection logic, branch on `integration.adapter`: + - `'nango'` → existing Nango path (unchanged). + - `'composio'` → existing Composio path in `packages/web/lib/integrations/composio-service.ts`. + - `'pipedream'` → throw `Adapter 'pipedream' not yet wired` (stub for future). + +4. **Default `source` injection on the cloud side.** Mirror persona-kit's behavior: any spec arriving without `source` gets `{ kind: 'deployer_user' }` injected at resolver entry. + +### Track B tests + +Add resolver test fixtures (vitest): +- [ ] deployer_user happy path +- [ ] workspace happy path +- [ ] workspace_service_account happy path (named) +- [ ] GitHub combine: both rows present → success +- [ ] GitHub combine: workspace install missing → clear error +- [ ] Missing user_integrations row → clear error +- [ ] Unknown `adapter` → clean "not yet wired" error +- [ ] Default source injection when persona spec omits it +- [ ] Adapter dispatch routes correctly (Nango / Composio) + +### Track B acceptance + +- [ ] Resolver dispatches by `source.kind` without inference. +- [ ] GitHub combine returns both rows when both required. +- [ ] Adapter dispatch routes to existing Nango + Composio paths. +- [ ] All new tests green; existing tests unchanged. +- [ ] `npm run typecheck && npm test` clean. + +**Effort estimate:** ~4h. + +--- + +## Track C — Cloud #548 OSS-scope rebase coordination + +**Repo:** `$CLOUD_REPO` (no worktree — comment-only) +**Implementer model:** claude (medium reasoning). +**No branch.** Comment-only via `gh pr comment`. + +**Note:** relay#844 already merged at 2026-05-12T19:50:04Z. `@agent-relay/events@6.0.17` and `@agent-relay/agent@6.0.17` are published on npm. A coordination comment has already been posted on cloud#548 (see comment `4434762449`). Track C is effectively **already done** at workflow start; Ricky should verify the comment exists and skip if so. + +### Preflight + +```bash +COMMENT_EXISTS=$(gh pr view 548 --repo AgentWorkforce/cloud --json comments \ + -q '.comments[] | select(.body | test("@agent-relay/events@6\\.0\\.17")) | .id' | head -1) + +if [ -n "$COMMENT_EXISTS" ]; then + echo "SKIP: Track C already posted via comment $COMMENT_EXISTS" + exit 0 +fi +``` + +If somehow the comment is missing (rolled back, etc.), re-post it: + +```bash +gh pr comment 548 --repo AgentWorkforce/cloud -F +``` + +with the same contents as comment `4434762449` (relay#844 merged, versions live, rebase recommendation, alternative cleanup-PR option). + +### Track C acceptance + +- [ ] Coordination comment exists on cloud#548 referencing `@agent-relay/{events,agent}@6.0.17`. + +**Effort estimate:** ~5min. + +--- + +## Track D — Workforce persona-kit refactor (traits-out, sandbox-out, listeners doc) + +**Repo:** `$WORKFORCE_REPO` (operates in place — Track D owns the primary checkout; E/F use worktrees) +**Implementer model:** codex (high reasoning). +**Working branch:** `refactor/persona-kit-schema-lockin` +**Base:** `origin/main` AFTER workforce#95 merges. + +**Hard precondition:** +```bash +MERGED_AT=$(gh pr view 95 --repo AgentWorkforce/workforce --json mergedAt -q '.mergedAt') +if [ -z "$MERGED_AT" ] || [ "$MERGED_AT" = "null" ]; then + echo "WAITING: workforce#95 not merged"; exit 0 +fi +``` + +**Allowed-dirty regex:** `packages/persona-kit/.*|packages/runtime/src/proactive\.ts|packages/runtime/src/types\.ts|packages/runtime/src/ctx\.ts|packages/deploy/src/.*|examples/.*|docs/plans/.*` + +### Implementation + +1. **Remove `traits` from `PersonaSpec`.** + - Delete `Traits` type from `packages/persona-kit/src/types.ts`. + - Delete `spec.traits` parsing logic from `packages/persona-kit/src/parse.ts`. + - Update all persona fixtures in `packages/persona-kit/src/__fixtures__/` and `examples/*/persona.json` to remove any `traits` block. + - Remove `traits`-related re-exports / imports from `packages/runtime/src/proactive.ts`. If `expressionFromTraits` (or similar) is still referenced, remove it. + - Parser must REJECT personas containing a `traits` key with a clear error: `traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md`. + +2. **Remove `sandbox` from `PersonaSpec`.** + - Delete `SandboxConfig` type and `spec.sandbox` parsing. + - Update fixtures and examples removing any `sandbox` blocks. + - Verify `@agentworkforce/deploy` (`packages/deploy/src/index.ts` and `packages/deploy/src/modes/sandbox.ts`) reads sandbox config from deploy options (the `--mode sandbox` CLI flag and any defaults baked into the deploy package), NOT from `persona.spec`. If any code reads `spec.sandbox`, refactor. + - Parser must REJECT personas containing a `sandbox` key with a clear error: `sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md`. + +3. **Listeners section rename (DOCS + COMMENTS ONLY — keep current SHAPE).** + Khaliq explicitly: "I don't know if we have to be so literal with inbox, clock, radio, current shape is probably fine, but can use listeners." + - Add JSDoc comments on `PersonaIntegrationConfig` and `Schedule` describing them as the "radio listener" and "clock listener" parts of a persona's listener surface. + - Top-level JSDoc on `PersonaSpec`: + > A persona listens for events. Three listener kinds: **clock** (cron schedules — `schedules[]`), **radio** (RelayFile integration events — `integrations..triggers[]`), **inbox** (RelayCast targeted messages — not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent. + - Update `docs/plans/deploy-v1.md` §3 prose with the listeners narrative (recover from git if untracked: `git show 11ed713:docs/plans/deploy-v1.md > docs/plans/deploy-v1.md`). + - Do NOT restructure JSON schema. Do NOT rename existing types. + +4. **Regenerate persona JSON schema if applicable.** + - If `packages/persona-kit/scripts/emit-schema.mjs` exists on the branch: run it, commit the regenerated `packages/persona-kit/schemas/persona.schema.json`. + - If not, skip and note in PR body that #94 will pick it up on rebase. + +5. **Update tests.** + - Remove tests asserting on `traits`/`sandbox` fields. + - Add tests asserting parse FAILURE (with the specific error messages) when `traits` or `sandbox` keys appear. + - Verify the 14 personas in `packages/personas-core` still validate via `corepack pnpm -r --filter @agentworkforce/personas-core run lint`. + +6. **Examples cleanup.** Strip `traits` + `sandbox` from `examples/weekly-digest/persona.json`, `examples/review-agent/persona.json`, `examples/linear-shipper/persona.json` if they exist on this branch. + +### Track D acceptance + +- [ ] `traits` and `sandbox` types removed from persona-kit `types.ts` and `parse.ts`. +- [ ] Parser rejects `traits` and `sandbox` with the specified errors. +- [ ] All persona fixtures + 14 core personas parse without errors. +- [ ] Listeners JSDoc + `deploy-v1.md` §3 narrative updated. +- [ ] Persona JSON schema regenerated if emit-schema is on the branch. +- [ ] `corepack pnpm -r run build && corepack pnpm run typecheck && corepack pnpm -r run test` green. +- [ ] PR opens as DRAFT. + +**Effort estimate:** ~2.5h. + +--- + +## Track E — Workforce queue rebase (#92, #93, #94, #96, #97) + +**Repo:** `$WORKFORCE_REPO.wt-rebase-` (one worktree per PR) +**Implementer model:** codex (medium reasoning). +**Depends on:** Track D merged. + +For each PR in the workforce queue, rebase its branch onto post-Track-D `main`. Resolve conflicts from traits/sandbox removal. Do NOT introduce new functionality. Push with `git push --force-with-lease`. + +### Sub-tracks + +| ID | PR | Branch | Worktree | Rebase action | +|---|---|---|---|---| +| **E1** | #92 | `feat/integrations-vfs` | `wt-rebase-92` | Rebase. VFS substrate doesn't touch traits/sandbox; conflicts should be minimal. | +| **E2** | #93 | `feat/integrations-vfs-examples` | `wt-rebase-93` | Rebase + strip `traits` and `sandbox` blocks from `examples/review-agent/persona.json` and `examples/linear-shipper/persona.json`. Verify both still type-check against #92's `WorkforceCtx`. | +| **E3** | #94 | `feat/persona-json-schema` | `wt-rebase-94` | Rebase + run `scripts/emit-schema.mjs` to regenerate `packages/persona-kit/schemas/persona.schema.json`. Verify fixtures still validate. | +| **E4** | #96 | `feat/proactive-bridge` | `wt-rebase-96` | Rebase. Drop any remaining `expressionFromTraits` references. Bump `@agent-assistant/proactive ^0.4.31 → ^0.4.32` per agent-assistant#91 publish; run `corepack pnpm install` to refresh `pnpm-lock.yaml`. Verify the existing test baseline passes. | +| **E5** | #97 | `feat/persona-integration-source` | `wt-rebase-97` | Rebase. Interface name is `PersonaIntegrationConfig` (verified in #97). No content change beyond rebase. | + +### Per-sub-track gates (soft → fixer → hard) + +```bash +corepack pnpm -r run build +corepack pnpm run typecheck +corepack pnpm -r run test +``` + +### Track E acceptance (per sub-track) + +- [ ] Rebased branch pushes successfully with `--force-with-lease`. +- [ ] CI on the PR is green after rebase. +- [ ] No functional regression vs the PR's original acceptance bullets. +- [ ] If conflicts unresolvable: open `-rebased`, post a comment on the original linking it, STOP that sub-track. Others continue. + +**Effort estimate:** ~1h per sub-track; E1–E5 can run in parallel after Track D. + +--- + +## Track F — Workforce runtime input-values + agent identity wiring + +**Repo:** `$WORKFORCE_REPO.wt-runtime` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `feat/runtime-input-values-resolution` +**Base:** post-Track-D `main`. +**Depends on:** Track D merged AND Track A's migrations PR merged (need `agents.input_values` column). + +**Allowed-dirty regex:** `packages/runtime/src/ctx\.ts|packages/runtime/src/types\.ts|packages/runtime/src/ctx\.test\.ts|packages/runtime/src/__tests__/.*` + +### Implementation + +In `packages/runtime/src/ctx.ts`: + +1. **Read `input_values` from the `agents` row** (NOT `agent_deployments` — input values are agent-level, not per-instance). + ``` + resolved[key] = agents.input_values[key] ?? persona.spec.inputs[key].default + ``` + When a required input has no value from either source, throw before the handler runs: + ``` + Required input '' has no value (no deployment override, no spec default). Set it via 'workforce deploy --input =' or by editing the agent record. + ``` + +2. **Update `WorkforceCtx.persona.inputs` shape** (`types.ts`): + - Currently exposes `Record` (defaults). + - New: expose `Record` (resolved values). + - Add `ctx.persona.inputSpecs: Record` for consumers that need the spec. + +3. **Add `ctx.agent` and `ctx.deployment` accessors** to mirror the schema: + ```ts + ctx.agent: { id: string; deployedName: string; spawnedByAgentId: string | null; ... } + ctx.deployment: { id: string; triggerKind: 'inbox' | 'clock' | 'radio'; parentDeploymentId: string | null; ... } + ``` + The runtime injects these from the agent + agent_deployment rows that fired this handler. + +4. **Tests:** + - [ ] Override wins over default. + - [ ] Default fills when override absent. + - [ ] Required input with no value → throws specified error. + - [ ] `ctx.persona.inputSpecs` still exposes the spec defaults. + - [ ] `ctx.agent.id` + `ctx.deployment.id` correctly populated. + +### Track F acceptance + +- [ ] `ctx.persona.inputs` returns resolved values. +- [ ] Required-but-missing inputs throw with the specified error. +- [ ] `ctx.persona.inputSpecs` accessor added. +- [ ] `ctx.agent` and `ctx.deployment` accessors added. +- [ ] `corepack pnpm -r run build && corepack pnpm -r run test` green. +- [ ] PR title: `feat(runtime): resolve persona inputs from agents.input_values + expose ctx.agent/ctx.deployment` +- [ ] Opens as DRAFT. + +**Effort estimate:** ~2h. + +--- + +--- + +# Phase 2 — Deploy enablement tracks + +Phase 1 (Tracks A–F) lands the schema, persona-kit refactor, runtime accessors, and queue rebase. **Phase 2 lights up end-to-end deploy** — cloud accepts a persona+bundle payload, workforce CLI speaks that contract OSS-generically, deploy-time inputs are wired, and the MCP `workflow.run` tool actually returns results. + +Phase 2 tracks depend on Phase 1 tracks being merged. Order: G → H (workforce-side consumer of G's contract); I depends on A (schema) + D (persona-kit); J depends on cloud#555 being live on main. + +## Track G — Cloud persona+bundle deploy endpoint + +**Repo:** `$CLOUD_REPO.wt-deploy-endpoint` (worktree) +**Implementer model:** codex (high reasoning). +**Working branch:** `feat/persona-bundle-deploy-endpoint` +**Base:** Track A merged (`agents` + `agent_deployments` schema live); cloud#548 ideally merged (for agent-gateway + DO infra) but the endpoint can ship as a stub that queues for the gateway if #548 is still in flight. + +**Depends on:** +- Track A merged. +- cloud#548 merged (for agent-gateway DO + `relaycron-client.ts` + `registerWatches` infra). +- **relaycron#5 merged** — without this, the WS-delivery + cancel API in relaycron isn't live, and schedule registration via cloud's `relaycron-client.ts` returns errors at runtime. Preflight check: + ```bash + RC5_MERGED=$(gh pr view 5 --repo AgentWorkforce/relaycron --json mergedAt -q '.mergedAt') + if [ -z "$RC5_MERGED" ] || [ "$RC5_MERGED" = "null" ]; then + echo "WAITING: relaycron#5 not merged"; exit 0 + fi + ``` + +**Allowed-dirty regex:** `packages/web/app/api/v1/workspaces/\[workspaceId\]/deployments/.*|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*persona.*|services/agent-gateway/.*` + +### Why this exists + +cloud#548's `/api/v1/deploy` takes `{ entrypoint, source }` — single-file TS. workforce's deploy CLI is built to upload a persona+bundle. The decision (Khaliq): **cloud adds a new endpoint for the persona+bundle contract.** Single-file `/api/v1/deploy` stays for power users; persona+bundle is the workforce-CLI surface. + +### Endpoint contract + +``` +POST /api/v1/workspaces/:workspaceId/deployments +Auth: workspace token (mirror sandbox endpoint auth scopes) +Body: +{ + persona: PersonaSpec, // full persona JSON, validated via @agentworkforce/persona-kit + bundle: { + runner: string, // contents of runner.mjs + agent: string, // contents of agent.bundle.mjs (esbuild output) + packageJson: object // contents of package.json + }, + inputs?: Record, // initial input values for agents.input_values + pinnedVersion?: { version: number } // optional; if set, pin to that persona_versions row +} +Returns 201: +{ + agentId: string, // agents.id + workspaceId: string, + status: 'starting' | 'active' | 'failed', + deploymentId: string // first agent_deployments row created at boot +} +``` + +### Implementation + +1. **Validate** `persona` via `@agentworkforce/persona-kit`'s `parsePersonaSpec`. Fail with field-pointed errors on schema problems. + +2. **Persist `persona_versions` row.** Compute `spec_hash`; insert a new `persona_versions` row if no existing row matches (`UNIQUE (persona_id, spec_hash)`). Set `pinned_version_id` on the agent row to this new version. + +3. **Upsert `agents` row.** Match on `(workspace_id, persona_id)` where `status != 'destroyed'`: + - If exists → update `pinned_version_id`, `input_values`, `spec_hash_at_deploy`, bump `last_used_at`. + - If not → insert new row with `status='active'`. + +4. **Translate `persona.integrations..triggers[]` → watch glob list.** The convention the agent-gateway DO and `@agent-relay/agent`'s `registerWatches` expect is glob paths under provider namespaces (e.g. `/github/pull_requests/**`). Read `services/agent-gateway/src/durable-object.ts` + `packages/agent-relay-agent/src/index.ts` to confirm the exact glob format. Translation rule: + + ``` + provider=github, trigger.on='pull_request.opened' → /github/pull_requests/opened/** + provider=linear, trigger.on='issue.created' → /linear/issues/created/** + provider=slack, trigger.on='app_mention' → /slack/app_mention/** + ``` + + Build a lookup table from RelayFile adapter docs (`relayfile-adapters/packages/*/docs/` if present). For unknown provider/trigger combinations, fail deploy with a clear error. + +5. **Persist watch globs** on the `agents` row OR in a sidecar — depends on how cloud#548's deploy-manager stores them. Most likely: store on `agents.watch_globs text[] NULL` (add to Track A if not already there) so the agent-gateway can pull them at agent boot. **Update Track A's migrations to add this column.** + +6. **Translate `persona.schedules[]` → relaycron registrations.** Call `services/agent-gateway/src/relaycron-client.ts:registerCronSchedules()` with each schedule, scoped by `agentId`. Persist returned `gatewayScheduleId`s on `agents.schedule_ids text[]` (add to Track A migrations). + +7. **Provision Daytona sandbox + upload bundle.** Use the existing `POST /api/v1/workspaces/:id/sandboxes` (cloud#543) infrastructure. Write the bundle files (`runner.mjs`, `agent.bundle.mjs`, `persona.json`, `package.json`) to the sandbox via the existing files-proxy route. + +8. **Start the runner.** Call the sandbox's exec route with `node runner.mjs`. The runner internally calls `agent({...})` which calls `registerWatches` against the gateway, completing the watch subscription. + +9. **Insert initial `agent_deployments` row** with `status='running'`, `trigger_kind='inbox'` (or the trigger that launched it), `started_at=now`. + +10. **Audit-log** every deployment creation (mirror sandbox endpoint audit pattern). + +### Track G tests + +- [ ] Happy path: valid persona+bundle → 201 with agentId +- [ ] Re-deploy same persona → agentId stable; persona_versions has new row only if spec_hash differs +- [ ] Invalid persona (e.g. has `traits`) → 400 with field-pointed error +- [ ] Trigger translation: known github/linear/slack/notion/jira triggers map correctly +- [ ] Unknown trigger → 400 with clear error +- [ ] Cron schedules registered with relaycron (mock relaycron client) +- [ ] Daytona sandbox creation + bundle upload happen in order +- [ ] Auth: missing workspace token → 401; wrong scope → 403 + +### Track G acceptance + +- [ ] Endpoint added at `POST /api/v1/workspaces/:workspaceId/deployments`. +- [ ] Persona validation, version persistence, agent upsert, trigger translation, schedule registration all wired. +- [ ] Sandbox provisioning + bundle upload + runner start work end-to-end against a test workspace. +- [ ] All new tests green; no regressions on cloud#548's existing `/api/v1/deploy` endpoint. +- [ ] PR opens as DRAFT. +- [ ] Required Track A schema additions (`agents.watch_globs`, `agents.schedule_ids`) included in Track A's migration PR. + +**Effort estimate:** ~6h. + +--- + +## Track H — Workforce `--mode cloud` (OSS-generic implementation) + +**Repo:** `$WORKFORCE_REPO.wt-mode-cloud` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `feat/deploy-mode-cloud` +**Base:** post-Track-D `main` (after persona-kit refactor). +**Depends on:** Track D merged. Track G's endpoint contract STABLE (need not be merged on cloud; can stub against the spec). + +**Allowed-dirty regex:** `packages/deploy/src/modes/cloud\.ts|packages/deploy/src/index\.ts|packages/deploy/src/login\.ts|packages/cli/src/cli\.ts` + +### OSS / cloud split rationale + +The workforce deploy CLI is OSS. Anyone running a workforce-compatible runtime (their own AWS, on-prem, anything) speaks the **persona+bundle contract** with whatever cloud endpoint URL is configured. The deploy CLI does NOT bake in `agentrelay.com`. The CLI ships generic; cloud (the proprietary side) implements the endpoint. + +- **workforce (OSS)** — Track H: this track. Replace the stubbed `packages/deploy/src/modes/cloud.ts` with a real implementation that POSTs persona+bundle to a configurable cloud-deploy URL. +- **cloud (proprietary)** — Track G: cloud-specific endpoint implementation (above). + +### Decision-tree mapping (image 2) + +Track H implements the full deploy decision tree from image 2 of the May 12 whimsical diagram. Each step in the tree maps to a stage in the CLI flow: + +``` +agentworkforce deploy + │ + ├─► STAGE 1: Choose runtime + │ ├─ --cloud-url flag → use that + │ ├─ WORKFORCE_CLOUD_URL env → use that + │ ├─ persona.cloud.deployUrl → use that + │ └─ default → https://agentrelay.com (note: "build your own" docs link printed when default is overridden) + │ + ├─► STAGE 2: Logged in? + │ ├─ no → open browser to /cli-auth (relayauth PKCE flow) + │ │ save returned token to OS keychain + │ └─ yes → use token saved on machine + │ + ├─► STAGE 3: Harness availability check + │ For each harness the persona declares (claude/codex/opencode): + │ Query GET /api/v1/users/me/provider_credentials?model_provider= + │ ├─ have a connected credential → continue + │ └─ none → + │ Prompt: "Do you want to set up your harness's subscription? (Y/n)" + │ ├─ yes → trigger provider_oauth flow (existing /provider_credentials/auth-session endpoint) + │ └─ no → + │ Prompt: "AgentRelay plan or BYOK?" + │ ├─ plan → set auth_type='relay_managed' (cloud uses its key, tracks spend, charges markup) + │ └─ BYOK → prompt for API key; save encrypted via cloud /provider_credentials POST (auth_type='byo_api_key') + │ + ├─► STAGE 4: Review listeners, determine required integrations + │ For each persona.integrations.: + │ Query GET /api/v1/workspaces/:id/integrations?provider=

+ │ ├─ connected → continue + │ └─ missing → open browser to /integrations?provider=

&workspace=&return_to= + │ block until OAuth callback completes + │ + ├─► STAGE 5: Persona exists? + │ Query GET /api/v1/workspaces/:id/agents?persona_slug= + │ ├─ no → continue to deploy + │ └─ yes → + │ Prompt: "This persona is already deployed as agent (status: ). + │ Update existing, destroy and create new, or cancel?" + │ ├─ update → continue to deploy (UNIQUE constraint will UPSERT) + │ ├─ destroy → POST /api/v1/workspaces/:id/agents/:agentId/destroy (M3 endpoint, may not be wired — if missing, exit with "destroy not yet wired; cancel and run with --force-replace later") + │ └─ cancel → exit 0 + │ + └─► STAGE 6: POST persona+bundle to Track G's endpoint + See implementation below. +``` + +For non-interactive use (CI / scripts), the CLI accepts flag overrides for every interactive prompt: +- `--no-prompt` — fail fast on any decision that would normally prompt (instead of asking). +- `--harness-source plan|byok|oauth` — pre-answer Stage 3 decisions. +- `--byok-key ` — pre-answer BYOK prompt. +- `--on-exists update|destroy|cancel` — pre-answer Stage 5 decision (default: `cancel`). + +### Implementation + +In `packages/deploy/src/modes/cloud.ts`: + +1. **Resolve cloud-deploy URL** as Stage 1 above. + +2. **Load workspace token** from keychain via `packages/deploy/src/login.ts` (the relayauth PKCE flow already shipped in workforce#90). If absent and not `--no-prompt`, trigger login as Stage 2. + +3. **Run Stages 3-5** with the prompt logic above (or flag overrides for non-interactive mode). + +4. **POST persona+bundle (Stage 6):** + ```ts + const res = await fetch(`${cloudUrl}/api/v1/workspaces/${workspaceId}/deployments`, { + method: 'POST', + headers: { + authorization: `Bearer ${workspaceToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + persona, + bundle: { + runner: await fs.readFile(bundle.runnerPath, 'utf8'), + agent: await fs.readFile(bundle.bundlePath, 'utf8'), + packageJson: JSON.parse(await fs.readFile(bundle.packageJsonPath, 'utf8')), + }, + inputs: input.inputs, // populated by Track I's --input flags + }), + }); + if (!res.ok) throw new Error(`Cloud deploy failed: ${res.status} ${await res.text()}`); + const { agentId, status, deploymentId } = await res.json(); + ``` + +5. **Status polling.** After POST returns `status: 'starting'`, poll `GET /api/v1/workspaces/:id/agents/:agentId` until `status='active'` or `'failed'` (60s timeout). Stream updates via `onLog`. + +6. **Return a `CloudRunHandle`** that exposes `{ agentId, stop(): Promise, done: Promise<...> }`. `stop()` calls the M3 destroy endpoint; if not wired, throw cleanly. + +7. **Remove the "not yet available" stub** from `packages/deploy/src/index.ts`. + +8. **Add the `--cloud-url`, `--no-prompt`, `--harness-source`, `--byok-key`, `--on-exists` CLI flags** to `packages/cli/src/cli.ts`'s `deploy` case. + +### Track H tests + +- [ ] Happy path: persona + bundle POST → returns CloudRunHandle with agentId. +- [ ] Cloud URL override via flag, env, persona field, default — precedence tested. +- [ ] 401 from cloud → clean error suggesting `workforce login`. +- [ ] Network error → retry with backoff (3 attempts). +- [ ] Status polling resolves on `active` and `failed`. +- [ ] `stop()` calls DELETE endpoint. + +### Track H acceptance + +- [ ] `workforce deploy --mode cloud` no longer prints "not yet available." +- [ ] Posts to the configured cloud URL with persona+bundle contract. +- [ ] OSS-generic: no `agentrelay.com` baked into code paths (only as a default URL). +- [ ] PR opens as DRAFT. + +**Effort estimate:** ~3h. + +--- + +## Track I — Deploy CLI `--input =` flags + +**Repo:** `$WORKFORCE_REPO.wt-deploy-inputs` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `feat/deploy-input-flags` +**Base:** post-Track-D `main`. +**Depends on:** Track D merged. Track A merged (need `agents.input_values` column). Track F merged (runtime reads from `input_values`). + +**Allowed-dirty regex:** `packages/cli/src/cli\.ts|packages/deploy/src/index\.ts|packages/deploy/src/types\.ts|packages/deploy/src/modes/.*` + +### Implementation + +1. **Accept `--input =` flag in `packages/cli/src/cli.ts`** (repeatable). Parse into `Record`. Reject malformed flags with a clear error. + +2. **Plumb through `packages/deploy/src/index.ts`'s `deploy()` function** as `DeployOptions.inputs?: Record`. + +3. **Validate against persona spec at deploy time.** For each provided input key: + - Must be declared in `persona.spec.inputs` — else fail with `Unknown input ''; persona declares: `. + - Value must be a string (basic type check; persona-kit may add more later). + +4. **Forward to each mode:** + - `--mode dev`: pass as env vars to the spawned child process (`WORKFORCE_INPUT_=`). + - `--mode sandbox`: pass as env vars to the Daytona sandbox (`envVars` arg). + - `--mode cloud`: include in the POST body's `inputs` field (Track H consumes this). + +5. **Update persona spec docs in `docs/plans/deploy-v1.md` §3** to mention `--input` as the deploy-time override mechanism. + +### Track I tests + +- [ ] Single `--input` parses and forwards. +- [ ] Multiple `--input` flags accumulate. +- [ ] Malformed flag (`--input foo`) → clean error. +- [ ] Undeclared input key → clean error citing persona's declared inputs. +- [ ] `--mode dev` env vars actually reach the child process. +- [ ] `--mode cloud` POST body includes the `inputs` field. + +### Track I acceptance + +- [ ] `workforce deploy --input topic=AI --input region=us-east-1 ./persona.json` works against all three modes. +- [ ] Undeclared inputs fail fast with a clear error. +- [ ] PR opens as DRAFT. + +**Effort estimate:** ~1.5h. + +--- + +## Track J — `workflow.run` MCP synthesis + scope mint (cloud#555 follow-ups) + +**Repo:** `$CLOUD_REPO.wt-workflow-shim-followups` (worktree) +**Implementer model:** codex (high reasoning). +**Working branch:** `feat/workflow-invocations-followups` +**Base:** cloud#555 merged (the URL surface). +**Depends on:** cloud#555 merged. + +**Allowed-dirty regex:** `packages/web/app/api/v1/workspaces/\[workspaceId\]/workflows/.*|packages/web/lib/workflows/.*|packages/web/lib/auth/.*sandbox.*` + +### Why this exists + +cloud#555 shipped `POST /api/v1/workspaces/:id/workflows/run` taking `{ name, args }`, but it returns 501 for any registered slug — because the heavy `/api/v1/workflows/run` requires `s3CodeKey`/`sourceFileType`/`runtime` fields that can't be derived from `{ name, args }`. Two follow-ups to actually light it up: + +### J1 — Synthesis policy + named-workflow registry + +Implement a slug → workflow translation in `packages/web/lib/workflows/invocation-registry.ts` (created in #555). Convention: + +- **Named workflows live at a known S3 prefix.** Every named workflow has a pre-staged tarball at `s3://workflows//latest.tar.gz` (or similar — match what the heavy workflow engine expects). The synthesis fills in `s3CodeKey: 'workflows//latest.tar.gz'`. +- **`sourceFileType` defaults to `'workflow'`** unless the slug's registry entry overrides it. +- **`runtime` defaults to `{ id: 'daytona' }`** from the workspace's `default_runtime` column (the cloud-side dispatch target Will explained earlier). +- **`args`** from the MCP tool call is forwarded as `metadata.invocationArgs` to the heavy engine, since the heavy engine doesn't have a first-class args field. + +Add an initial registry of named workflows. Start with one slug (e.g. `'echo'` — a minimal workflow that just echoes args back) so the round-trip can be smoke-tested. + +Implementer should read the existing heavy `/api/v1/workflows/run/route.ts` to confirm the exact `RunRequestBody` synthesis. If a required field genuinely can't be synthesized, surface in PR body. + +### J2 — Scope mint additions + +The sandbox-token mint flow at `packages/web/app/api/v1/workflows/run/route.ts` currently mints `workflow:runs:read`, `workflow:logs:read`, `workflow:runs:events:write`. The MCP server expects to call the new lightweight endpoints, which require `workflow:invoke:write` (for `workflow.run`) and `workflow:invoke:read` (for `workflow.status`). + +Add these scopes to the mint: +- `workflow:invoke:write` — minted on sandbox creation for any workspace running a proactive runtime agent. +- `workflow:invoke:read` — same. + +Ensure `requireAuthScope` checks in the new `/workspaces/:id/workflows/run` and `/workspaces/:id/workflows/runs/:runId` routes accept these scopes. + +### Track J tests + +- [ ] J1: `POST /workspaces/:id/workflows/run` with `name='echo', args={foo:1}` returns a runId; the heavy engine receives a synthesized RunRequestBody. +- [ ] J1: Unknown slug → 404 with list of known slugs. +- [ ] J2: A sandbox token without `workflow:invoke:write` → 403 on POST. +- [ ] J2: Token with the right scope → success path. +- [ ] End-to-end: MCP `workflow.run` call from a Daytona sandbox actually returns a runId, no longer 501. + +### Track J acceptance + +- [ ] `workflow.run` MCP tool returns a real runId for at least one registered slug (`echo` is fine for v1). +- [ ] Scope mint includes the two new scopes for sandbox tokens. +- [ ] PR opens as DRAFT. +- [ ] cloud#555's `Status: Ready for Review` note updated to reflect that J1+J2 lit it up. + +**Effort estimate:** ~3.5h. + +--- + +## Acceptance contract (workflow-level) + +After ALL tracks (Phase 1 + Phase 2) complete: + +### Phase 1 +1. cloud#553 issue body reflects every lock-in (Track A1). +2. Cloud migrations PR (Track A2) is open as DRAFT, CI green; `agents` table created, `agent_deployments` repurposed for per-instance rows. +3. Cloud resolver PR (Track B) is open as DRAFT, CI green; dispatches on `source` + `adapter`. +4. cloud#548 has the relay#844 coordination comment (Track C — already posted). +5. Workforce persona-kit PR (Track D) is open as DRAFT, CI green; traits + sandbox removed. +6. Workforce queue (#92, #93, #94, #96, #97) is all rebased + green. +7. Workforce runtime PR (Track F) is open as DRAFT, CI green; ctx.agent + ctx.deployment + resolved inputs. + +### Phase 2 +8. Cloud persona+bundle endpoint PR (Track G) is open as DRAFT, CI green; validates persona, persists version, upserts agent, registers schedules, translates triggers, provisions sandbox. +9. Workforce `--mode cloud` PR (Track H) is open as DRAFT, CI green; speaks Track G's contract OSS-generically. +10. Workforce `--input` flags PR (Track I) is open as DRAFT, CI green; flows through all three modes. +11. Workflow-invocations follow-ups PR (Track J) is open as DRAFT, CI green; `workflow.run` MCP tool returns real runIds. + +### Loud holes after this workflow + +- ⚠️ **Memory is not wired.** `ctx.memory` is a stub. Follow-up workflow needed. +- ⚠️ **M3 destroy/list CLI commands** not implemented. Out of scope; M3 milestone workflow. +- ⚠️ **`@workforce/daytona-runner` not on npm** under `@workforce` scope. Handled by a separate agent per platform-team OIDC setup; not blocking morning state because cloud consumes via workspace ref. + +--- + +## Track K — End-to-end smoke test + +**Repo:** `$WORKFORCE_REPO.wt-smoke` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `test/deploy-v1-e2e-smoke` +**Base:** post-everything-merged `main`. +**Depends on:** All Phase 1 + Phase 2 tracks merged. cloud#548 + relaycron#5 + relay#843 merged. + +**Allowed-dirty regex:** `packages/deploy/test/e2e/.*|\.github/workflows/deploy-e2e\.yml` + +### Why this exists + +When Khaliq wakes up, the workflow should have proved that everything actually works end-to-end, not just compiled. This track runs a real deploy against staging cloud and asserts the agent fires on a real trigger. + +### Implementation + +Add `packages/deploy/test/e2e/weekly-digest.smoke.test.ts`: + +1. **Build the bundle locally** for `examples/weekly-digest/persona.json`: + ```ts + const bundle = await stageBundle({ + personaPath: path.resolve('examples/weekly-digest/persona.json'), + persona: parsePersonaSpec(/* loaded */), + outDir: '.workforce/build/smoke-weekly-digest', + }); + ``` + +2. **Authenticate** using `WORKFORCE_E2E_STAGING_TOKEN` from env (CI secret). Skip the test gracefully if missing. + +3. **Deploy via Track H's `--mode cloud`** against the staging cloud URL (`WORKFORCE_E2E_STAGING_URL`). + +4. **Force a cron tick** by directly POSTing to the runtime test hook (`POST /api/v1/workspaces/:id/agents/:agentId/_test/tick`, mirror what cloud#548 exposes — if no hook, skip the trigger and assert deployment was created + status='active' instead). + +5. **Assert** the agent posts a GitHub issue on the fixture repo `AgentWorkforce/deploy-e2e-fixtures` within 90s, with title pattern `Weekly digest — *`. + +6. **Cleanup**: close the issue, optionally destroy the agent (skip if M3 destroy isn't wired). + +7. **Add `.github/workflows/deploy-e2e.yml`** running this on nightly schedule + manual dispatch. Failures notify `#workforce-alerts`. + +### Run during workflow + +The workflow runs Track K's smoke test ONCE after all upstream tracks have merged — but does NOT block the cascade on it. The smoke test result is reported separately as `SMOKE_TEST: PASS` or `SMOKE_TEST: FAIL — see logs`. If it fails for environmental reasons (staging Daytona down, OAuth tokens missing, fixture repo unreachable), the workflow logs but doesn't unwind any merges. + +### Track K acceptance + +- [ ] Smoke test file added. +- [ ] Test passes when run against staging (or skipped cleanly if `WORKFORCE_E2E_STAGING_TOKEN` is unset). +- [ ] GitHub Actions workflow added. +- [ ] PR title: `test(deploy): e2e smoke for weekly-digest --mode cloud`. + +**Effort estimate:** ~3h. + +--- + +## Workforce PR queue triage (existing PRs the workflow handles) + +The workflow operates on these existing workforce PRs in addition to the new tracks above. Each is either rebased + auto-merged in Track E, or explicitly skipped. + +| PR | Branch | Track in this workflow | Auto-merge? | +|---|---|---|---| +| #97 | feat/persona-integration-source | Track E5 (rebase) | YES | +| #96 | feat/proactive-bridge | Track E4 (rebase + agent-assistant bump) | YES | +| #94 | feat/persona-json-schema | Track E3 (rebase + schema regen) | YES | +| #93 | feat/integrations-vfs-examples | Track E2 (rebase + strip traits/sandbox) | YES | +| #92 | feat/integrations-vfs | Track E1 (rebase) | YES | +| #91 | feat/mcp-workforce | Track E (rebase; stacks on #92) | YES | +| **#87** | feat/proactive-agent-builder-persona | NEW: auto-merge — contains `parseInputsShape` `optional: true` regression fix that Track F depends on; the new persona JSON is additive | YES (verify fix still in branch first) | +| **#89** | codex/deploy-v1-readme | NEW: AUTO-MERGE for docs alignment | YES (nice-to-have; merges if green) | + +Open cloud PRs handled: + +| PR | Handled by | Auto-merge? | +|---|---|---| +| cloud#548 | Verified for trigger registration; paired with relaycron#5 | YES (after architectural items resolved — see below) | +| cloud#551 | Phase 3 dispatcher, already unblocked | YES | +| cloud#554 | Daytona meter | NO — platform-team gates on meter name; flag for Khaliq | +| cloud#555 | Workflow-invocations shim; Track J adds follow-ups | Merge #555 first, then merge Track J's follow-ups on top | + +Open chain-branch PRs: + +| PR | Repo | Auto-merge? | +|---|---|---| +| relay#843 | relay | YES | +| relaycron#5 | relaycron | YES (pair with cloud#548) | +| relayauth#39 | relayauth | YES (docs-only, low risk) | + +### cloud#548 special handling + +cloud#548 still has my three architectural items unaddressed (deploy payload shape, URL scoping, OSS scope governance). Track C's coordination comment is posted. **The workflow's lead Claude must verify before auto-merging cloud#548**: + +1. **Payload shape resolved.** Track G adds a new endpoint at `/api/v1/workspaces/:id/deployments` taking persona+bundle, separate from #548's `/api/v1/deploy` taking single-file. Both coexist. ✅ Resolved by Track G shipping in parallel. +2. **URL scoping** — #548 has top-level `/api/v1/deploy` while reads are workspace-scoped. Track G's new endpoint is workspace-scoped. The mixed shape is acceptable for v1 (legacy `/api/v1/deploy` deprecates later); proceed. +3. **OSS scope governance** — relay#844 merged, packages live. Once relay#843 merges too, the cleanup PR to remove cloud/packages/agent-relay-{events,agent} can land. **The workflow should land cloud#548 as-is** (with the OSS packages still in cloud), then run a follow-up cleanup PR (Track L below) that removes them and pins to `^6.0.17`. + +--- + +## Track L — Cloud OSS-scope cleanup (post-#548 merge) + +**Repo:** `$CLOUD_REPO.wt-oss-cleanup` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `chore/remove-agent-relay-packages` +**Base:** post-cloud#548 + post-relay#843 merged `main`. + +### Implementation + +1. Delete `cloud/packages/agent-relay-events/` and `cloud/packages/agent-relay-agent/` directories. +2. Add `"@agent-relay/events": "^6.0.17"` and `"@agent-relay/agent": "^6.0.17"` to `services/agent-gateway/package.json` and any other consumer (verify via `grep -rln "agent-relay-events\|agent-relay-agent" services/ packages/`). +3. Refresh `package-lock.json` via `npm install`. +4. Run typecheck + tests; verify agent-gateway service still builds against the OSS packages. +5. PR title: `chore: remove in-tree @agent-relay/{events,agent}; consume from npm`. + +**Auto-merge?** YES on gates green. + +**Effort estimate:** ~1h. + +--- + +## What Khaliq sees when waking up + +After the workflow completes (assuming no aborts), morning state: + +**Merged on `main`:** +- Workforce: #87 (with input fix), #91, #92, #93, #94, #96, #97, plus 6 new Track D/F/H/I/K branches, plus #89 README (optional). +- Cloud: #548, #551, #555, plus 5 new Track A/B/G/J/L branches. +- Relay: #843. +- Relaycron: #5. +- Relayauth: #39. + +**Open (intentional holds):** +- cloud#554 (Daytona meter — platform-team gates). +- Anything from "Out of scope" list. + +**Ready for testing:** +- ✅ `workforce deploy ./examples/weekly-digest/persona.json --mode cloud` should work end-to-end against staging. +- ✅ Cloud deploy endpoint accepts persona+bundle. +- ✅ Schedules registered with relaycron; watches registered at agent startup with gateway DO. +- ✅ Sandbox provisions; runner executes; handler runs. +- ⚠️ Memory calls no-op (stub). +- ⚠️ Workflow.run MCP tool returns runIds for registered slugs (Track J's `echo` registered as proof-of-life). + +**Smoke test result** in workflow log: +- `SMOKE_TEST: PASS` — weekly-digest deployed against staging; cron tick posted GitHub issue within 90s. +- OR `SMOKE_TEST: FAIL — ` with logs. + +**Loud holes (documented in every track PR body):** +- ⚠️ Memory not wired (`ctx.memory` is a stub). +- ⚠️ M3 destroy/list commands missing. + +**What Khaliq does in the morning:** +1. Read the workflow's final summary comment on cloud#553 (lists every merged PR + smoke test result). +2. If smoke test passed: run `workforce deploy ./examples/review-agent/persona.json --mode cloud` against a personal GitHub repo, force-open a PR, watch the agent post a review. +3. If smoke test failed: inspect logs, decide whether to revert or push fix. + +### What this workflow does NOT deliver + +- Memory wiring (loud hole). +- M3 destroy/list CLI commands. +- `@workforce/daytona-runner` npm publish (separate agent). +- cloud#554 Daytona meter flip-to-ready (platform-team gates). + +--- + +## Merge DAG — auto-merge order + +The workflow's lead Claude walks this DAG topologically. Each node auto-merges when (a) it's opened/exists, (b) all its dependencies are merged, (c) CI green, (d) no `CHANGES_REQUESTED` reviews, (e) no merge conflicts. + +``` + ┌───────────────────────────────┐ + │ workforce#95 (already merged) │ + └─────────────┬─────────────────┘ + │ + ┌──────────────────────────┴───────────────────────────┐ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────────┐ + │ Track A (cloud) │ │ Track D (workforce) │ + │ #553 body + DB │ │ persona-kit refactor │ + │ migrations │ │ traits-out + sandbox-out │ + └──────────┬───────────┘ └──────────┬───────────────────┘ + │ │ + ┌─────────┼──────────┐ ┌──────────┼─────────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + Track B Track G Track F Track E1 Track E2 ... E5 + resolver endpoint runtime (deps on A + D) rebase rebase + (cloud) (cloud) (workforce) #92 #93/#94/#96/#97 + │ │ + ▼ ▼ + Track H (workforce) (queue rebased) + --mode cloud │ + │ ▼ + ▼ Track I (workforce) + Track K (smoke test) --input flags + + ┌────────────────────────────────────────────────────────────┐ + │ Chain branch (paired contracts — verify both merged): │ + │ cloud#548 ─── relaycron#5 ─── relay#843 ─── relayauth#39 │ + │ ↓ │ + │ Track L (cloud) — remove in-tree @agent-relay/* packages │ + │ Track C (already done) — coordination comment │ + └────────────────────────────────────────────────────────────┘ + + Track J (cloud) ← depends on cloud#555 merged +``` + +**Concrete merge sequence the lead Claude executes:** + +1. workforce#87 (auto-merge with input fix verified) +2. Track A opens + auto-merges (cloud schema) +3. cloud#551 auto-merges (already unblocked; orthogonal) +4. Track D opens + auto-merges (workforce persona-kit) +5. Track E1–E5 (rebase #92, #93, #94, #96, #97) — parallel; each auto-merges on green +6. cloud#548 + relaycron#5 + relay#843 + relayauth#39 — chain branch group merges (verify all on chain branch are green simultaneously, then merge in repo order: relayauth#39 → relay#843 → relaycron#5 → cloud#548) +7. Track G opens + auto-merges (cloud deploy endpoint; depends on Track A + cloud#548 + relaycron#5) +8. Track B opens + auto-merges (cloud resolver) +9. Track F opens + auto-merges (workforce runtime; depends on Track D + Track A) +10. Track H opens + auto-merges (workforce --mode cloud; depends on Track D + Track G) +11. Track I opens + auto-merges (workforce --input flags; depends on Track D + Track A) +12. Track L opens + auto-merges (cloud OSS-scope cleanup; depends on cloud#548 + relay#843 merged) +13. cloud#555 auto-merges if green + Track J's follow-ups open + auto-merge +14. workforce#89 (README, nice-to-have) auto-merges last +15. Track K runs (smoke test); reports result; does NOT block any merge. + +**Failure handling per step:** if any node breaks the cascade (CI red after fixer loop, conflict-resolution fails), the workflow: +- Posts a loud failure to `#wf-schema-cascade`. +- Annotates the broken PR with a comment explaining the failure. +- Leaves all previously-merged work merged. +- Continues with INDEPENDENT downstream nodes (e.g. Track J doesn't depend on Track F; if F breaks, J can still proceed). +- Stops dependent nodes (e.g. if Track A breaks, Track G can't run). + +## When Ricky is blocked + +- **workforce#95 not merged at start?** Tracks D/E/F exit immediately with `WAITING: workforce#95`. A/B/C may proceed. +- **Track A back-fill migration breaks on existing prod-shaped data?** Open the migrations PR as DRAFT with the failing back-fill rows listed; don't try to skip them. Human resolves. +- **Track E sub-track has unmergeable conflicts?** Open `-rebased` as a separate PR, comment on original linking it, STOP that sub-track. Others continue. +- **A persona fixture had a `traits` block that consumers depend on?** Don't add traits back. Surface in PR body: `TODO(human): consumer X expected traits.; recommend extracting to persona-personality-builder (out of scope for v1).` +- **`agent-assistant/proactive@0.4.32` introduces a breaking change in `fromContext`?** Pin to `0.4.31` in #96 with a `TODO(human): bump after consuming 0.4.32 breaking changes` note. Don't modify proactive bridge logic. + +--- + +## Notes for the workflow author + +- Use `proactive-runtime-m1.ts` as the structural reference for `dependsOn` edges, soft/hard gates, and review rounds. +- Tracks A/B/C run on cloud; D/E/F on workforce. Repo isolation prevents cross-track conflicts. +- Track E sub-tracks are independent of each other — generate parallel `dependsOn` edges (all five depend only on Track D). +- Lead Claude posts a per-track summary into `#wf-schema-cascade` at each gate transition. +- Final run report: post a summary comment on cloud#553 linking every PR + the migration plan. From 109cac0b482bc8c03ab3872de6e51f6ef1235717 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 23:29:13 +0200 Subject: [PATCH 2/6] bring in latest --- docs/plans/deploy-v1-schema-cascade-spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/deploy-v1-schema-cascade-spec.md b/docs/plans/deploy-v1-schema-cascade-spec.md index 05774af1..78d7cd00 100644 --- a/docs/plans/deploy-v1-schema-cascade-spec.md +++ b/docs/plans/deploy-v1-schema-cascade-spec.md @@ -110,6 +110,7 @@ After each track's PR is opened, the workflow's lead Claude: **Gates that BLOCK auto-merge** (workflow stops cascade, posts loud alert): - Any required CI check returns FAILURE after the fixer loop. - Any human reviewer left `CHANGES_REQUESTED` (don't override). +- **Any unresolved review comment thread** from a human reviewer — query `gh api repos///pulls//comments` and skip auto-merge if any thread has `in_reply_to_id` chains where the last reply is from a non-bot reviewer and the thread isn't marked resolved. ("As long as there are no outstanding review comments" — Khaliq, May 12.) - Merge conflict that fixer can't resolve. - A downstream-track PR was already opened and its CI breaks post-merge of an upstream track → STOP, do not merge further. From 10d68370ab808c4b9ed7aab689afc3ca43ad7eb8 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 13 May 2026 00:22:53 +0200 Subject: [PATCH 3/6] add files --- docs/plans/deploy-v1-schema-cascade-spec.md | 198 ++- ...ade-persona-refactor-status-ready-for-r.ts | 1551 +++++++++++++++++ 2 files changed, 1738 insertions(+), 11 deletions(-) create mode 100644 workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts diff --git a/docs/plans/deploy-v1-schema-cascade-spec.md b/docs/plans/deploy-v1-schema-cascade-spec.md index 78d7cd00..9146bee2 100644 --- a/docs/plans/deploy-v1-schema-cascade-spec.md +++ b/docs/plans/deploy-v1-schema-cascade-spec.md @@ -17,8 +17,8 @@ - agent-assistant#91 — MERGED; `@agent-assistant/proactive@0.4.32` published. Track E4 picks this up. - workforce#97 (`feat/persona-integration-source`) — DRAFT, ready for rebase in Track E5 after Track D. - cloud#548 (now M1-M6, title stale) — open, +33k/-57. Trigger registration code VERIFIED (schedules → relaycron via `services/agent-gateway/src/relaycron-client.ts:registerCronSchedules()`; watches → gateway DO via `packages/agent-relay-agent/src/index.ts:registerWatches()` at agent startup). Missing piece for persona+bundle deploy: persona → watch-glob translation; lives in Track G below. **Track A must rebase on #548's migrations if #548 merges first; review showed #548 is additive on existing `agent_deployments`, so two-table split is layerable.** -- relay#843 — open, +3.5k. Adds `agent-relay` CLI commands (login/workspaces/tokens/dlq/runtime) + new `@agent-relay/cloud` library. **Parallel to workforce CLI; no spec dependency.** -- relaycron#5 — open, +2k. WS-delivery + cancel API + buffered ticks. **Track G dependency** — without this merged, schedule registration via the agent-gateway's `relaycron-client.ts` is half-wired. Preflight Track G to verify relaycron#5 is merged. +- relay#843 — **MERGED 2026-05-12T21:30:54Z**; publish workflow run `25763431116` **completed 21:49:38 UTC**. All `@agent-relay/*` packages now at **`6.0.18`** (lockstep umbrella bump from `6.0.17`). Track L pins to `^6.0.18`. +- relaycron#5 — **MERGED 2026-05-12T21:32:06Z**; `@relaycron/server@0.1.3` and `@relaycron/types@0.1.3` published 21:35 UTC. Track G's preflight on relaycron#5 already cleared; Track M (cloud `@relaycron/*` pin bump) targets `^0.1.3`. - relayauth#39 — open, docs-only +3/-1. No spec impact. - cloud#554 (Daytona meter) — draft, platform-team gates only. - cloud#555 (workflow-invocations shim) — draft, 2 follow-ups in Track J. @@ -1123,20 +1123,186 @@ cloud#548 still has my three architectural items unaddressed (deploy payload sha --- +## Track N — Cloud sandbox token path-scoping (use `POST /v1/tokens/path`) + +**Repo:** `$CLOUD_REPO.wt-token-paths` (worktree) +**Implementer model:** codex (medium reasoning). +**Working branch:** `feat/sandbox-token-path-scoped` +**Base:** post-relayauth#39 merged `main` (officially documented contract). cloud#548 ideally merged so agent-gateway is the consumer; can ship against `main` if #548 still in flight. +**Depends on:** relayauth#39 merged. Track G merged (Track N updates Track G's sandbox-provisioning flow to use path-scoped tokens). + +**Allowed-dirty regex:** `packages/core/src/relayfile/client\.ts|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*sandbox.*|services/agent-gateway/.*` + +### Why this exists + +Today's `mintRelayfileToken` in `packages/core/src/relayfile/client.ts` issues sandbox tokens via the two-step `POST /v1/identities` + `POST /v1/tokens` flow with broad `relayfile:fs:read:*` / `relayfile:fs:write:*` scopes. Every deployed agent's sandbox can therefore read/write **the entire workspace VFS mount**, including paths unrelated to the persona's declared listeners. + +relayauth implemented `POST /v1/tokens/path` in M1 (relayauth#38) for path-scoped token issuance — workspace-token auth in, `relay_pa_*` token out with scopes intersected to the requested path list. Documentation lands in relayauth#39. Cloud doesn't consume it yet. + +This is **least-privilege hardening, not a functional blocker.** First deploy works without it; production hardening wants it. + +### Implementation + +1. **Add a new helper `mintPathScopedRelayfileToken`** in `packages/core/src/relayfile/client.ts`: + + ```ts + export interface MintPathScopedRelayfileTokenOptions { + workspaceId: string; + relayAuthUrl: string; + workspaceToken: string; // user/workspace token authorizing the mint (NOT the relayAuthApiKey) + paths: string[]; // e.g. ['/github/pull_requests/**', '/linear/issues/**'] + ttlSeconds?: number; + agentName?: string; // for token labeling/audit + } + + export async function mintPathScopedRelayfileToken( + opts: MintPathScopedRelayfileTokenOptions, + ): Promise { + const url = normalizeBaseUrl(opts.relayAuthUrl); + const res = await fetch(`${url}/v1/tokens/path`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${opts.workspaceToken}`, + }, + body: JSON.stringify({ + workspaceId: opts.workspaceId, + paths: opts.paths, + ttlSeconds: opts.ttlSeconds ?? 3600, + agentName: opts.agentName ?? 'cloud-orchestrator', + }), + }); + if (!res.ok) { + throw new Error(`relayauth path-token mint failed: ${res.status} ${await res.text()}`); + } + const { accessToken } = await res.json() as { accessToken: string }; + if (!accessToken?.startsWith('relay_pa_')) { + throw new Error('relayauth returned token without expected relay_pa_ prefix'); + } + return accessToken; + } + ``` + + Keep the existing `mintRelayfileToken` (broad-scoped) for legacy call sites — don't remove until all consumers migrated. + +2. **Update Track G's sandbox-provisioning flow** to use the new helper. When provisioning the Daytona sandbox in the persona+bundle endpoint: + - Derive `paths` from `persona.integrations..triggers[]` → watch globs (the same translation Track G already does). + - Call `mintPathScopedRelayfileToken({ workspaceId, relayAuthUrl, workspaceToken, paths, agentName: persona.id })`. + - Inject the returned token as `RELAYFILE_TOKEN` (or whatever the runner expects) into the sandbox env vars instead of the broad-scoped token. + +3. **Migrate `services/agent-gateway/`** if it has its own minting path (verify via grep). The gateway likely uses `mintRelayfileToken` to bootstrap; if so, route through `mintPathScopedRelayfileToken` when the consumer is a deployed agent (not the workflow orchestrator). + +4. **Audit log** every path-token mint with `workspaceId`, `agentId`, `paths`, `requester`. Mirror the existing relayfile-token audit pattern. + +### Track N tests + +- [ ] `mintPathScopedRelayfileToken` happy path: paths array → 200 with `relay_pa_*` token. +- [ ] Mock relayauth returning 403 / 4xx → wraps clean error. +- [ ] Mock relayauth returning malformed token (no `relay_pa_` prefix) → throws. +- [ ] Track G integration test: persona declaring `github.triggers[pull_request.opened]` results in a sandbox token whose scopes are `/github/pull_requests/**` only (not `*`). +- [ ] Legacy `mintRelayfileToken` callers still work (no regression). + +### Track N acceptance + +- [ ] `mintPathScopedRelayfileToken` exported from `packages/core/src/relayfile/client.ts`. +- [ ] Track G's sandbox provisioning uses path-scoped tokens. +- [ ] Audit logging on mint. +- [ ] Legacy broad-scope helper still works for orchestrator-internal calls. +- [ ] PR title: `feat(security): mint path-scoped relayfile tokens for sandbox agents`. +- [ ] Auto-merge on gates green. + +**Effort estimate:** ~2h. + +--- + +## Track M — Cloud `@relaycron/*` pin bump (post-relaycron#5 merge) + +**Repo:** `$CLOUD_REPO.wt-relaycron-bump` (worktree) +**Implementer model:** codex (low reasoning — pure dep bump). +**Working branch:** `chore/bump-relaycron-packages` +**Base:** post-relaycron#5 merged `main`. +**Depends on:** relaycron#5 merged (✅ 2026-05-12T21:32:06Z); `@relaycron/server@0.1.3` and `@relaycron/types@0.1.3` published (✅ 21:35 UTC). + +### Why this exists + +Cloud's `packages/relaycron-cloud/` (workspace path `packages/relaycron/`) and `packages/relaycron-types/` consume `@relaycron/server` and `@relaycron/types` as npm deps pinned at `^0.1.0`. relaycron#5 merged at 21:32 UTC and published `0.1.3` of both packages with the WS-delivery + cancel API + buffered-ticks changes. The lockfile is pinned to the pre-#5 version, so the bump is needed for cloud to actually consume the new code. + +This is separate from cloud#548's agent-gateway consumption — agent-gateway talks to relaycron over WS/HTTP via `services/agent-gateway/src/relaycron-client.ts`, not via the npm package, so it doesn't need this bump. + +### Preflight (already cleared at spec authoring time) + +```bash +# Both should pass; included for re-runs / future workflows. +RC5_MERGED=$(gh pr view 5 --repo AgentWorkforce/relaycron --json mergedAt -q '.mergedAt') +SERVER_VER=$(npm view @relaycron/server version) +TYPES_VER=$(npm view @relaycron/types version) + +if [ -z "$RC5_MERGED" ] || [ "$RC5_MERGED" = "null" ]; then + echo "WAITING: relaycron#5 not merged"; exit 0 +fi +if [ "$SERVER_VER" != "0.1.3" ]; then + echo "NOTE: @relaycron/server resolved to $SERVER_VER, expected 0.1.3 — proceed with $SERVER_VER"; +fi +``` + +### Implementation + +1. Bump these pins to `^0.1.3`: + + | File | Pin | From | To | + |---|---|---|---| + | `packages/relaycron/package.json` | `@relaycron/server` | `^0.1.0` | `^0.1.3` | + | `packages/relaycron/package.json` | `@relaycron/types` | `^0.1.0` | `^0.1.3` | + | `packages/relaycron-types/package.json` (if pinned there too — verify via grep) | `@relaycron/types` | as-is | `^0.1.3` | + | root `package.json` (if pinned there — verify via grep) | both | as-is | `^0.1.3` | + +2. Run `npm install` to refresh `package-lock.json`. +3. Run `npm run typecheck` — should stay clean (WS API additions are additive within `0.1.x`). +4. Run `npm run relaycron:test` — passes. +5. **Per workforce-publish-workflow memory: grep `.github/workflows/*.yml` for any references to `@relaycron/server` or `@relaycron/types` that need version bumps** (most likely none, but check). + +### Track M acceptance + +- [ ] All `@relaycron/*` pins on the new published version. +- [ ] Lockfile refreshed. +- [ ] Typecheck + tests green. +- [ ] PR title: `chore(deps): bump @relaycron/{server,types} to `. +- [ ] Auto-merge on gates green. + +**Effort estimate:** ~20min (mechanical bump). + +--- + ## Track L — Cloud OSS-scope cleanup (post-#548 merge) **Repo:** `$CLOUD_REPO.wt-oss-cleanup` (worktree) **Implementer model:** codex (medium reasoning). **Working branch:** `chore/remove-agent-relay-packages` -**Base:** post-cloud#548 + post-relay#843 merged `main`. +**Base:** post-cloud#548 merged `main`. + +### Preflight — already cleared at spec authoring time + +```bash +# relay#843 merged + publish run 25763431116 completed at 21:49:38 UTC. +# All @agent-relay/* packages are at 6.0.18 on npm. +# This block is left for re-runs / future workflows. + +LATEST=$(npm view @agent-relay/sdk version 2>/dev/null) +if [ -z "$LATEST" ] || [ "$LATEST" = "6.0.17" ]; then + echo "WAITING: @agent-relay/sdk publish hasn't propagated"; exit 0 +fi +echo "OK: @agent-relay/sdk at $LATEST" +``` ### Implementation 1. Delete `cloud/packages/agent-relay-events/` and `cloud/packages/agent-relay-agent/` directories. -2. Add `"@agent-relay/events": "^6.0.17"` and `"@agent-relay/agent": "^6.0.17"` to `services/agent-gateway/package.json` and any other consumer (verify via `grep -rln "agent-relay-events\|agent-relay-agent" services/ packages/`). +2. Add `"@agent-relay/events": "^6.0.18"` and `"@agent-relay/agent": "^6.0.18"` to `services/agent-gateway/package.json` and any other consumer (verify via `grep -rln "agent-relay-events\|agent-relay-agent" services/ packages/`). 3. Refresh `package-lock.json` via `npm install`. -4. Run typecheck + tests; verify agent-gateway service still builds against the OSS packages. -5. PR title: `chore: remove in-tree @agent-relay/{events,agent}; consume from npm`. +4. **Also bump other `@agent-relay/*` pins** in the workspace to `^6.0.18` for umbrella alignment — `@agent-relay/{config,credential-proxy,sdk}` on `main` are at `^6.0.13`; the chain branch already moved them to `^6.0.17`. Take them to `^6.0.18` so the workspace is consistent. +5. Run typecheck + tests; verify agent-gateway service still builds against the OSS packages. +6. **Per workforce-publish-workflow memory: grep `.github/workflows/*.yml` + `Makefile`** for any references to `agent-relay-events` / `agent-relay-agent` that need cleanup. +7. PR title: `chore: remove in-tree @agent-relay/{events,agent}; consume from npm @ ^6.0.18`. **Auto-merge?** YES on gates green. @@ -1238,16 +1404,26 @@ The workflow's lead Claude walks this DAG topologically. Each node auto-merges w 3. cloud#551 auto-merges (already unblocked; orthogonal) 4. Track D opens + auto-merges (workforce persona-kit) 5. Track E1–E5 (rebase #92, #93, #94, #96, #97) — parallel; each auto-merges on green -6. cloud#548 + relaycron#5 + relay#843 + relayauth#39 — chain branch group merges (verify all on chain branch are green simultaneously, then merge in repo order: relayauth#39 → relay#843 → relaycron#5 → cloud#548) +6. Chain branch group — most already merged; only cloud#548 + relayauth#39 remain. + - **relaycron#5** — ✅ **merged 2026-05-12T21:32:06Z**; `@relaycron/{server,types}@0.1.3` published 21:35 UTC. Skip. + - **relay#843** — ✅ **merged 2026-05-12T21:30:54Z**; publish workflow `25763431116` ✅ **completed 21:49:38 UTC** — `@agent-relay/*` lockstep-bumped to `6.0.18`. Track L's preflight already satisfied; skip the polling step. + - **relayauth#39** — still open, docs-only, +3/-1. Merge first (lowest risk). + - **cloud#548** — still open, last (highest blast radius). + + **Release behavior of remaining merges:** + - relayauth#39: docs-only, no release. + - cloud#548: ⚠️ **merge triggers cloud's SST production deploy pipeline**. Lead Claude posts a `#wf-schema-cascade` confirmation **immediately before** merging #548 so Khaliq can intercept if awake. If no intercept within 5 minutes, proceed with merge. Post-merge, monitor the preview + production deploy job; if it fails, log the failure but don't try to revert (rollback is manual). 7. Track G opens + auto-merges (cloud deploy endpoint; depends on Track A + cloud#548 + relaycron#5) 8. Track B opens + auto-merges (cloud resolver) 9. Track F opens + auto-merges (workforce runtime; depends on Track D + Track A) 10. Track H opens + auto-merges (workforce --mode cloud; depends on Track D + Track G) 11. Track I opens + auto-merges (workforce --input flags; depends on Track D + Track A) -12. Track L opens + auto-merges (cloud OSS-scope cleanup; depends on cloud#548 + relay#843 merged) -13. cloud#555 auto-merges if green + Track J's follow-ups open + auto-merge -14. workforce#89 (README, nice-to-have) auto-merges last -15. Track K runs (smoke test); reports result; does NOT block any merge. +12. Track L opens + auto-merges (cloud OSS-scope cleanup; depends on cloud#548 + relay#843 merged + relay#843's npm publish settled) +13. Track M opens + auto-merges (cloud `@relaycron/*` pin bump; depends on relaycron#5 merged + npm publish settled — waits ~5 min after relaycron#5 merge for npm propagation) +14. Track N opens + auto-merges (cloud sandbox token path-scoping; depends on relayauth#39 merged + Track G merged) +15. cloud#555 auto-merges if green + Track J's follow-ups open + auto-merge +16. workforce#89 (README, nice-to-have) auto-merges last +17. Track K runs (smoke test); reports result; does NOT block any merge. **Failure handling per step:** if any node breaks the cascade (CI red after fixer loop, conflict-resolution fails), the workflow: - Posts a loud failure to `#wf-schema-cascade`. diff --git a/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts b/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts new file mode 100644 index 00000000..5518a9c3 --- /dev/null +++ b/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts @@ -0,0 +1,1551 @@ +import { workflow } from '@agent-relay/sdk/workflows'; + +// ============================================================================= +// Ricky workflow: deploy-v1 schema cascade + persona refactor +// ============================================================================= +// Source spec: workforce/docs/plans/deploy-v1-schema-cascade-spec.md +// Reference shape: cloud-proactive-runtime-spec/workflows/proactive-runtime-m1.ts +// +// SWARM PATTERN: hub-spoke (per spec line 64; per choosing-swarm-patterns SKILL). +// +// Why hub-spoke and not pipeline/dag/fan-out: +// - Lead Claude needs to STAY ALIVE on #wf-schema-cascade and adapt in real +// time (ambient peer review, cross-repo contract reconciliation, status +// probes every 5 min). That is the canonical hub-spoke use case +// (choosing-swarm-patterns SKILL.md "Quick Decision Framework": +// "Does a coordinator need to stay alive and adapt? YES -> hub-spoke"). +// - 14+ tracks across 5 repos (cloud, workforce, relay, relaycron, +// relayauth) with cross-repo contracts (paired cloud#548 + relaycron#5 +// + relay#843 merges). Workers must coordinate via the channel; the +// lead picks up contract drift and pings both owning implementers. +// - pipeline is wrong: most tracks fan out in parallel from a shared +// readiness gate (Tracks A and D run concurrently; E1-E5 run as five +// parallel rebases after D). +// - pure dag is wrong: there is no live coordinator. The spec REQUIRES a +// live coordinator ("Lead Claude Opus stays on #wf-schema-cascade as +// architect + ambient reviewer" - spec line 64). +// - fan-out would lose the bidirectional lead<->worker conversation +// needed for CHANGES_REQUESTED iteration. +// +// IMPLEMENTATION_WORKFLOW_CONTRACT: every track produces source changes, +// tests, non-empty diff evidence, and PR/result reporting. Auto-merge after +// CI green + no CHANGES_REQUESTED + no unresolved review comments. +// +// 80-to-100 contract: child track work performs fix-loop work; the lead and +// signoff perform final-review evidence checks before flipping draft -> ready +// and squash-merging. PRs only flip to ready and auto-merge when: +// - CI is green on the PR +// - typecheck + tests pass after soft -> fixer -> hard loop +// - upstream dependencies in the Merge DAG are merged +// - no human reviewer has CHANGES_REQUESTED +// - no unresolved review comments +// Otherwise the PR stays as DRAFT with the loud-hole gap list templated into +// the body. Workflow exits 0 either way. +// +// Never-fail mechanics (mirror proactive-runtime-m1.ts): +// - Every test / typecheck / regression gate runs as soft -> fixer -> hard. +// - Per-track self-reflection vs the spec's per-track acceptance bullets. +// - Two self-review passes per track: normal review + fresh-eyes review +// (different reviewer, no prior context). +// - Peer review by a DIFFERENT implementer; if CHANGES_REQUESTED, the +// ORIGINAL implementer fixes (preserves track context). +// - Final signoff agent verifies acceptance bullets; on INCOMPLETE, route +// to fix-r2 then back through signoff-final. +// - Lead Claude does ambient peer review on the channel during impl. +// - Global onError: retry 2x, 10s backoff. +// +// Run: +// npx tsx workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts +// ============================================================================= + +const HOME = process.env.HOME ?? '/Users/khaliqgant'; +const ROOT = `${HOME}/Projects/AgentWorkforce`; + +const REPOS = { + cloud: process.env.CLOUD_REPO ?? `${ROOT}/cloud`, + workforce: process.env.WORKFORCE_REPO ?? `${ROOT}/workforce`, + relay: process.env.RELAY_REPO ?? `${ROOT}/relay`, + relaycron: process.env.RELAYCRON_REPO ?? `${ROOT}/relaycron`, + relayauth: process.env.RELAYAUTH_REPO ?? `${ROOT}/relayauth`, +} as const; + +const GH_REPOS = { + cloud: 'AgentWorkforce/cloud', + workforce: 'AgentWorkforce/workforce', + relay: 'AgentWorkforce/relay', + relaycron: 'AgentWorkforce/relaycron', + relayauth: 'AgentWorkforce/relayauth', +} as const; + +const CHANNEL = 'wf-schema-cascade'; +const SPEC_FILE = `${REPOS.workforce}/docs/plans/deploy-v1-schema-cascade-spec.md`; +const ARTIFACTS = `${REPOS.workforce}/.workflow-artifacts/deploy-v1-schema-cascade`; + +type TrackId = + | 'A' | 'B' | 'C' | 'D' + | 'E1' | 'E2' | 'E3' | 'E4' | 'E5' + | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N'; + +interface TrackDef { + id: TrackId; + repo: keyof typeof REPOS; + ghRepo: string; + branch: string; + // Worktree directory ('' means operate in place in REPOS[repo]). + worktreeSuffix: string; + prTitle: string; + // Tracks whose PRs must be merged before this track auto-merges + // (per spec "Merge DAG - auto-merge order"). + mergeAfter: TrackId[]; + // External PR numbers that must be merged first (paired-contract handling). + externalMergeAfter: { repo: keyof typeof REPOS; pr: number; description: string }[]; + // Spec section heading for self-reflection prompts. + specSection: string; + // Brief per-track scope summary used in implementer prompts. + scope: string; + // Allow-list of files that may be dirty on entry (for preflight tolerance). + allowedDirty: string; + typecheckCmd: string; + testCmd: string; + reasoning: 'low' | 'medium' | 'high'; + autoMerge: boolean; + commentOnly?: boolean; +} + +const TRACKS: TrackDef[] = [ + { + id: 'A', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'chore/db1-schema-lockin', + worktreeSuffix: '', + prTitle: 'feat(db): DB1 schema lock-ins per cloud#553 thread', + mergeAfter: [], + externalMergeAfter: [], + specSection: 'Track A — Cloud #553 schema lock-ins (issue body + migrations PR)', + scope: [ + 'Update cloud#553 issue body to reflect every lock-in (two-table agent model, integration_scopes, persona_versions, cli_auth_sessions split, sharing rule prose, sub-agents note, sandbox-minute metering, lock-in revision history).', + 'Open migrations PR on branch chore/db1-schema-lockin. New tables: agents, persona_versions, integration_scopes, user_integrations, workspace_integrations, workforce_cli_auth_sessions. Rename cli_auth_sessions -> cloud_cli_bootstrap_sessions. Repurpose agent_deployments for per-instance rows with back-fill migration.', + 'Add agents.watch_globs text[] NULL and agents.schedule_ids text[] NULL columns for Track G consumption.', + 'Add adapter text NOT NULL DEFAULT \'nango\' columns to user_integrations and workspace_integrations.', + 'Run drizzle codegen so packages/web/lib/db/schema.ts matches.', + ].join(' '), + allowedDirty: 'package(-lock)?\\.json|packages/web/drizzle/.*|packages/web/lib/db/.*|packages/web/lib/proactive-runtime/.*|docs/.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'high', + autoMerge: true, + }, + { + id: 'B', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'feat/integration-resolver-source-dispatch', + worktreeSuffix: '.wt-resolver', + prTitle: 'feat(integrations): resolver dispatches on source + adapter', + mergeAfter: ['A'], + externalMergeAfter: [], + specSection: 'Track B — Cloud resolver: dispatch on `source` + `adapter`', + scope: [ + 'Update cloud integration resolver in packages/web/lib/integrations/ and packages/web/lib/proactive-runtime/deploy-manager.ts.', + 'Read source from persona spec: deployer_user / workspace / workspace_service_account. Default missing source to { kind: deployer_user }.', + 'GitHub combine: provider=github AND source.kind=deployer_user loads workspace_integrations row. Fail clearly if workspace install missing.', + 'Adapter dispatch: nango (existing) / composio (existing) / pipedream (throw not-yet-wired).', + 'Add resolver test fixtures covering all source kinds, GitHub combine paths, default injection, adapter dispatch.', + ].join(' '), + allowedDirty: 'packages/web/lib/integrations/.*|packages/web/lib/proactive-runtime/deploy-manager\\.ts|packages/web/app/api/v1/integrations/.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'C', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: '', + worktreeSuffix: '', + prTitle: '(no PR — comment-only)', + mergeAfter: [], + externalMergeAfter: [], + specSection: 'Track C — Cloud #548 OSS-scope rebase coordination', + scope: 'Verify the coordination comment already exists on cloud#548 referencing @agent-relay/{events,agent}@6.0.18 (the version that landed when relay#843 publish completed 2026-05-12T21:49:38Z). If missing, re-post the comment body.', + allowedDirty: '', + typecheckCmd: 'true', + testCmd: 'true', + reasoning: 'medium', + autoMerge: false, + commentOnly: true, + }, + { + id: 'D', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'refactor/persona-kit-schema-lockin', + worktreeSuffix: '', + prTitle: 'refactor(persona-kit): remove traits + sandbox, add listeners JSDoc (deploy-v1)', + mergeAfter: [], + externalMergeAfter: [], + specSection: 'Track D — Workforce persona-kit refactor (traits-out, sandbox-out, listeners doc)', + scope: [ + 'Remove Traits type and spec.traits parsing from packages/persona-kit. Update fixtures + examples. Parser REJECTS personas containing a traits key with the specified error.', + 'Remove SandboxConfig type and spec.sandbox parsing. Verify @agentworkforce/deploy reads sandbox config from deploy options, NOT persona.spec. Parser REJECTS personas containing a sandbox key.', + 'Add listeners JSDoc on PersonaIntegrationConfig, Schedule, and top-level PersonaSpec (clock/radio/inbox narrative).', + 'Keep PersonaSpec.memory.scopes accepting workspace | user | global (drop session if present).', + 'Regenerate persona JSON schema via packages/persona-kit/scripts/emit-schema.mjs if present.', + 'Add parse-failure tests with specific error messages.', + 'Verify 14 core personas validate via corepack pnpm -r --filter @agentworkforce/personas-core run lint.', + ].join(' '), + allowedDirty: 'packages/persona-kit/.*|packages/runtime/src/proactive\\.ts|packages/runtime/src/types\\.ts|packages/runtime/src/ctx\\.ts|packages/deploy/src/.*|examples/.*|docs/plans/.*', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'high', + autoMerge: true, + }, + { + id: 'E1', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/integrations-vfs', + worktreeSuffix: '.wt-rebase-92', + prTitle: '(rebase PR #92 onto post-Track-D main)', + mergeAfter: ['D'], + externalMergeAfter: [], + specSection: 'Track E1 — rebase #92 (feat/integrations-vfs)', + scope: 'Rebase #92 onto post-Track-D main. VFS substrate doesn\'t touch traits/sandbox; conflicts should be minimal. Push with --force-with-lease.', + allowedDirty: 'packages/.*|.*\\.json', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'E2', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/integrations-vfs-examples', + worktreeSuffix: '.wt-rebase-93', + prTitle: '(rebase PR #93 — strip traits/sandbox from examples)', + mergeAfter: ['D', 'E1'], + externalMergeAfter: [], + specSection: 'Track E2 — rebase #93 (feat/integrations-vfs-examples)', + scope: 'Rebase #93 onto post-Track-D main + strip traits and sandbox blocks from examples/review-agent/persona.json and examples/linear-shipper/persona.json. Verify both type-check against #92 WorkforceCtx.', + allowedDirty: 'examples/.*|packages/.*|.*\\.json', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'E3', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/persona-json-schema', + worktreeSuffix: '.wt-rebase-94', + prTitle: '(rebase PR #94 — regen persona schema)', + mergeAfter: ['D'], + externalMergeAfter: [], + specSection: 'Track E3 — rebase #94 (feat/persona-json-schema)', + scope: 'Rebase #94 + run scripts/emit-schema.mjs to regenerate packages/persona-kit/schemas/persona.schema.json. Verify fixtures still validate.', + allowedDirty: 'packages/persona-kit/.*|.*\\.json', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'E4', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/proactive-bridge', + worktreeSuffix: '.wt-rebase-96', + prTitle: '(rebase PR #96 — bump @agent-assistant/proactive ^0.4.32)', + mergeAfter: ['D'], + externalMergeAfter: [], + specSection: 'Track E4 — rebase #96 (feat/proactive-bridge)', + scope: 'Rebase #96. Drop any remaining expressionFromTraits references. Bump @agent-assistant/proactive ^0.4.31 -> ^0.4.32. Run corepack pnpm install to refresh pnpm-lock.yaml. Verify baseline tests pass.', + allowedDirty: 'packages/.*|.*\\.json|pnpm-lock\\.yaml', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'E5', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/persona-integration-source', + worktreeSuffix: '.wt-rebase-97', + prTitle: '(rebase PR #97 — feat/persona-integration-source)', + mergeAfter: ['D'], + externalMergeAfter: [], + specSection: 'Track E5 — rebase #97 (feat/persona-integration-source)', + scope: 'Rebase #97. Interface name is PersonaIntegrationConfig (verified). No content change beyond rebase.', + allowedDirty: 'packages/.*|.*\\.json', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'F', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/runtime-input-values-resolution', + worktreeSuffix: '.wt-runtime', + prTitle: 'feat(runtime): resolve persona inputs from agents.input_values + expose ctx.agent/ctx.deployment', + mergeAfter: ['A', 'D'], + externalMergeAfter: [], + specSection: 'Track F — Workforce runtime input-values + agent identity wiring', + scope: [ + 'In packages/runtime/src/ctx.ts: read input_values from the agents row (not agent_deployments). resolved[key] = agents.input_values[key] ?? persona.spec.inputs[key].default. Throw on required-without-value.', + 'Update WorkforceCtx.persona.inputs shape: Record resolved values. Add ctx.persona.inputSpecs for consumers needing the spec.', + 'Add ctx.agent (id, deployedName, spawnedByAgentId) and ctx.deployment (id, triggerKind, parentDeploymentId) accessors.', + 'Tests: override wins; default fills; required-missing throws specified error; ctx.persona.inputSpecs exposes defaults; ctx.agent.id + ctx.deployment.id populated.', + ].join(' '), + allowedDirty: 'packages/runtime/src/ctx\\.ts|packages/runtime/src/types\\.ts|packages/runtime/src/ctx\\.test\\.ts|packages/runtime/src/__tests__/.*', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'G', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'feat/persona-bundle-deploy-endpoint', + worktreeSuffix: '.wt-deploy-endpoint', + prTitle: 'feat(deploy): persona+bundle deploy endpoint', + mergeAfter: ['A'], + externalMergeAfter: [ + { repo: 'cloud', pr: 548, description: 'cloud#548 (agent-gateway DO + relaycron-client + registerWatches)' }, + { repo: 'relaycron', pr: 5, description: 'relaycron#5 (WS delivery + cancel API)' }, + ], + specSection: 'Track G — Cloud persona+bundle deploy endpoint', + scope: [ + 'POST /api/v1/workspaces/:workspaceId/deployments taking persona+bundle.', + 'Validate persona via parsePersonaSpec. Insert persona_versions row if spec_hash new. Upsert agents row matched on (workspace_id, persona_id).', + 'Translate persona.integrations.

.triggers[] -> watch glob list (e.g. provider=github trigger.on=pull_request.opened -> /github/pull_requests/opened/**). Persist on agents.watch_globs.', + 'Translate persona.schedules[] -> relaycron registrations via services/agent-gateway/src/relaycron-client.ts:registerCronSchedules. Persist on agents.schedule_ids.', + 'Provision Daytona sandbox + upload bundle via existing POST /api/v1/workspaces/:id/sandboxes infrastructure. Start runner.mjs.', + 'Insert initial agent_deployments row status=running trigger_kind=inbox. Audit-log.', + 'Tests: happy path, re-deploy same persona, invalid persona, trigger translation, schedule registration, sandbox + bundle order, auth.', + ].join(' '), + allowedDirty: 'packages/web/app/api/v1/workspaces/.*|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*persona.*|services/agent-gateway/.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'high', + autoMerge: true, + }, + { + id: 'H', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/deploy-mode-cloud', + worktreeSuffix: '.wt-mode-cloud', + prTitle: 'feat(deploy): --mode cloud (OSS-generic persona+bundle POST)', + mergeAfter: ['D', 'G'], + externalMergeAfter: [], + specSection: 'Track H — Workforce `--mode cloud` (OSS-generic implementation)', + scope: [ + 'Replace stubbed packages/deploy/src/modes/cloud.ts with real implementation that POSTs persona+bundle to a configurable cloud-deploy URL.', + 'URL precedence: --cloud-url flag > WORKFORCE_CLOUD_URL env > persona.cloud.deployUrl > default https://agentrelay.com.', + 'OSS-generic: do not bake agentrelay.com into code paths (only as a default URL).', + 'Auth via packages/deploy/src/login.ts. 401 -> clean error suggesting workforce login. Retry with backoff (3 attempts).', + 'Status polling resolves on active and failed. stop() calls DELETE endpoint.', + ].join(' '), + allowedDirty: 'packages/deploy/src/modes/cloud\\.ts|packages/deploy/src/index\\.ts|packages/deploy/src/login\\.ts|packages/cli/src/cli\\.ts', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'I', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'feat/deploy-input-flags', + worktreeSuffix: '.wt-deploy-inputs', + prTitle: 'feat(deploy): --input = flags across all modes', + mergeAfter: ['A', 'D', 'F'], + externalMergeAfter: [], + specSection: 'Track I — Deploy CLI `--input =` flags', + scope: [ + 'Accept --input = flag in packages/cli/src/cli.ts (repeatable). Parse into Record. Reject malformed flags.', + 'Plumb through packages/deploy/src/index.ts deploy() function as DeployOptions.inputs.', + 'Validate against persona spec at deploy time. Unknown key -> "Unknown input \'\'; persona declares: ".', + 'Forward to each mode: dev (env vars WORKFORCE_INPUT_), sandbox (Daytona envVars), cloud (POST body inputs field).', + 'Update persona spec docs in docs/plans/deploy-v1.md §3.', + ].join(' '), + allowedDirty: 'packages/cli/src/cli\\.ts|packages/deploy/src/index\\.ts|packages/deploy/src/types\\.ts|packages/deploy/src/modes/.*', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'J', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'feat/workflow-invocations-followups', + worktreeSuffix: '.wt-workflow-shim-followups', + prTitle: 'feat(workflows): synthesis policy + scope mint follow-ups for cloud#555', + mergeAfter: [], + externalMergeAfter: [ + { repo: 'cloud', pr: 555, description: 'cloud#555 (workflow-invocations shim) must be merged' }, + ], + specSection: 'Track J — `workflow.run` MCP synthesis + scope mint (cloud#555 follow-ups)', + scope: [ + 'J1: slug -> workflow translation in packages/web/lib/workflows/invocation-registry.ts. Synthesis: s3CodeKey=workflows//latest.tar.gz, sourceFileType=workflow, runtime={id:daytona} from workspace default_runtime, args forwarded as metadata.invocationArgs. Initial registry: { echo }.', + 'J2: scope mint additions. packages/web/app/api/v1/workflows/run/route.ts mints workflow:invoke:write + workflow:invoke:read. Add requireAuthScope checks to new /workspaces/:id/workflows/run + /workspaces/:id/workflows/runs/:runId routes.', + 'Tests: J1 echo round-trip; unknown slug 404; J2 missing scope 403; with scope success.', + ].join(' '), + allowedDirty: 'packages/web/app/api/v1/workspaces/.*workflows/.*|packages/web/lib/workflows/.*|packages/web/lib/auth/.*sandbox.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'high', + autoMerge: true, + }, + { + id: 'L', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'chore/remove-agent-relay-packages', + worktreeSuffix: '.wt-oss-cleanup', + prTitle: 'chore: remove in-tree @agent-relay/{events,agent}; consume from npm', + mergeAfter: [], + externalMergeAfter: [ + { repo: 'cloud', pr: 548, description: 'cloud#548 must be merged first' }, + { repo: 'relay', pr: 843, description: 'relay#843 merged 2026-05-12T21:30:54Z; publish workflow completed 21:49:38Z. @agent-relay/* lockstep-bumped to 6.0.18. Safety check: poll `npm view @agent-relay/sdk version` and ensure >= 6.0.18 before pinning.' }, + ], + specSection: 'Track L — Cloud OSS-scope cleanup (post-#548 merge)', + scope: [ + 'Delete cloud/packages/agent-relay-events/ and cloud/packages/agent-relay-agent/. Add npm deps @agent-relay/{events,agent}: ^ to services/agent-gateway/package.json and other consumers.', + 'Refresh package-lock.json via npm install.', + 'Bump other @agent-relay/* pins to keep the umbrella aligned.', + 'grep .github/workflows/*.yml + Makefile for refs to agent-relay-events / agent-relay-agent needing cleanup.', + ].join(' '), + allowedDirty: 'package(-lock)?\\.json|packages/.*|services/.*|\\.github/workflows/.*|Makefile', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'M', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'chore/bump-relaycron-packages', + worktreeSuffix: '.wt-relaycron-bump', + prTitle: 'chore(deps): bump @relaycron/{server,types} to ^0.1.3', + mergeAfter: [], + externalMergeAfter: [ + { repo: 'relaycron', pr: 5, description: 'relaycron#5 merged + @relaycron/{server,types}@0.1.3 published' }, + ], + specSection: 'Track M — Cloud `@relaycron/*` pin bump', + scope: [ + 'Bump @relaycron/server and @relaycron/types pins from ^0.1.0 -> ^0.1.3 in packages/relaycron/package.json (and packages/relaycron-types/package.json + root package.json if pinned).', + 'Run npm install to refresh package-lock.json. Run npm run typecheck. Run npm run relaycron:test.', + 'grep .github/workflows/*.yml for @relaycron/server or @relaycron/types refs needing bumps.', + ].join(' '), + allowedDirty: 'package(-lock)?\\.json|packages/relaycron/.*|packages/relaycron-types/.*|\\.github/workflows/.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm run relaycron:test', + reasoning: 'low', + autoMerge: true, + }, + { + id: 'N', + repo: 'cloud', + ghRepo: GH_REPOS.cloud, + branch: 'feat/sandbox-token-path-scoped', + worktreeSuffix: '.wt-token-paths', + prTitle: 'feat(sandbox): token path-scoping via POST /v1/tokens/path', + mergeAfter: ['G'], + externalMergeAfter: [ + { repo: 'relayauth', pr: 39, description: 'relayauth#39 (docs contract) must be merged' }, + ], + specSection: 'Track N — Cloud sandbox token path-scoping', + scope: [ + 'Update Track G sandbox-provisioning flow to mint path-scoped tokens via POST /v1/tokens/path (per relayauth#39 contract).', + 'Tests: token mint path-scoping happy path; downstream sandbox bound to scoped path; legacy path still works during rollout.', + ].join(' '), + allowedDirty: 'packages/web/.*|services/agent-gateway/.*', + typecheckCmd: 'npm run typecheck', + testCmd: 'npm test', + reasoning: 'medium', + autoMerge: true, + }, + { + id: 'K', + repo: 'workforce', + ghRepo: GH_REPOS.workforce, + branch: 'test/deploy-v1-e2e-smoke', + worktreeSuffix: '.wt-smoke', + prTitle: 'test(deploy): e2e smoke for weekly-digest --mode cloud', + // Track K depends on every preceding track but does NOT block the cascade. + mergeAfter: ['A', 'B', 'D', 'F', 'G', 'H', 'I', 'J', 'E1', 'E2', 'E3', 'E4', 'E5'], + externalMergeAfter: [], + specSection: 'Track K — End-to-end smoke test', + scope: [ + 'Add packages/deploy/test/e2e/weekly-digest.smoke.test.ts: build bundle, authenticate via WORKFORCE_E2E_STAGING_TOKEN (skip gracefully if missing), deploy via --mode cloud against WORKFORCE_E2E_STAGING_URL, force a cron tick, assert agent posts GitHub issue on AgentWorkforce/deploy-e2e-fixtures within 90s.', + 'Add .github/workflows/deploy-e2e.yml: nightly schedule + manual dispatch. Failures notify #workforce-alerts.', + 'Reports SMOKE_TEST: PASS or SMOKE_TEST: FAIL — does NOT block cascade.', + ].join(' '), + allowedDirty: 'packages/deploy/test/e2e/.*|\\.github/workflows/deploy-e2e\\.yml', + typecheckCmd: 'corepack pnpm run typecheck', + testCmd: 'corepack pnpm -r run test', + reasoning: 'medium', + autoMerge: false, // smoke test reports but does not block + }, +]; + +// --------------------------------------------------------------------------- +// Helper builders +// --------------------------------------------------------------------------- + +function trackById(id: TrackId): TrackDef { + const t = TRACKS.find((x) => x.id === id); + if (!t) throw new Error(`Unknown track ${id}`); + return t; +} + +function workdir(t: TrackDef): string { + if (!t.worktreeSuffix) return REPOS[t.repo]; + return `${REPOS[t.repo]}${t.worktreeSuffix}`; +} + +function implAgentName(t: TrackDef): string { + return `impl-${t.id.toLowerCase()}`; +} +function reflectAgentName(t: TrackDef): string { + return `reflect-${t.id.toLowerCase()}`; +} +function freshEyesAgentName(t: TrackDef): string { + return `fresh-eyes-${t.id.toLowerCase()}`; +} + +// Peer review comes from a DIFFERENT implementer (cross-pollination catches +// blind spots). Rotate through the TRACKS array; skip comment-only tracks. +function peerReviewerName(t: TrackDef): string { + const idx = TRACKS.findIndex((x) => x.id === t.id); + for (let i = 1; i < TRACKS.length; i++) { + const peer = TRACKS[(idx + i) % TRACKS.length]; + if (peer.commentOnly) continue; + if (peer.id === t.id) continue; + return implAgentName(peer); + } + return 'fixer'; +} + +// Loud-hole disclosure required in every PR body (per spec "Loud hole" §). +const LOUD_HOLE_LINES = [ + '## Known gaps after this PR', + '', + ':warning: **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced).', + '', + ':warning: **M3 destroy/list CLI commands** not implemented. Separate workflow.', + '', + ':warning: **`@workforce/daytona-runner` not on npm** under `@workforce` scope. Handled by a separate agent per platform-team OIDC setup; not blocking morning state because cloud consumes via workspace ref.', +]; + +// Preflight: branch checkout, allow-listed dirty tolerance, gh auth check. +function preflightCmd(t: TrackDef): string { + const dir = workdir(t); + const lines: string[] = [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, + ]; + if (t.commentOnly) { + lines.push( + 'gh auth status >/dev/null 2>&1 || (echo "ERROR: gh CLI not authenticated"; exit 1)', + `echo PREFLIGHT_OK_${t.id}`, + ); + return lines.join('\n'); + } + if (t.worktreeSuffix) { + lines.push( + `if [ ! -e "${dir}/.git" ]; then`, + ` cd "${REPOS[t.repo]}" && git worktree add "${dir}" -b ${t.branch} origin/main 2>/dev/null || git worktree add "${dir}" ${t.branch}`, + `fi`, + ); + } + lines.push( + `cd "${dir}" || exit 1`, + 'git fetch origin main >/dev/null 2>&1 || true', + 'git config user.email "ricky@agent-relay.com"', + 'git config user.name "Ricky Schema Cascade"', + `if git rev-parse --verify ${t.branch} >/dev/null 2>&1; then git checkout ${t.branch}; else git checkout -B ${t.branch} origin/main; fi`, + 'mkdir -p .git/info && grep -qxF ".logs/" .git/info/exclude 2>/dev/null || echo ".logs/" >> .git/info/exclude', + `ALLOWED_DIRTY="${t.allowedDirty || 'package(-lock)?\\.json'}"`, + 'DIRTY_TRACKED=$(git diff --name-only | grep -vE "^(${ALLOWED_DIRTY})$" || true)', + 'DIRTY_UNTRACKED=$(git ls-files --others --exclude-standard | grep -vE "^(${ALLOWED_DIRTY})$" || true)', + 'if [ -n "$DIRTY_TRACKED" ] || [ -n "$DIRTY_UNTRACKED" ]; then', + ` echo "ERROR: unexpected drift in track ${t.id} (${t.repo}):"`, + ' [ -n "$DIRTY_TRACKED" ] && echo "tracked: $DIRTY_TRACKED"', + ' [ -n "$DIRTY_UNTRACKED" ] && echo "untracked: $DIRTY_UNTRACKED"', + ' exit 1', + 'fi', + 'if ! git diff --cached --quiet; then echo "ERROR: staging area dirty"; git diff --cached --stat; exit 1; fi', + 'gh auth status >/dev/null 2>&1 || (echo "ERROR: gh CLI not authenticated"; exit 1)', + `echo PREFLIGHT_OK_${t.id}`, + ); + return lines.join('\n'); +} + +// Soft gate — never throws; captures exit code into output. +function softCmd(t: TrackDef, label: string, cmd: string): string { + const dir = workdir(t); + return [ + 'set -e', + `cd "${dir}" || exit 1`, + 'mkdir -p .logs', + 'set +e', + `${cmd} > .logs/${label}.log 2>&1; E=$?`, + `echo "${label.toUpperCase()}_EXIT=$E"`, + `tail -80 .logs/${label}.log`, + 'exit 0', + ].join('\n'); +} + +// Merge-ready check: posts WAITING_FOR_ and exits 0 (soft) when any +// dep is unmerged so the cascade can continue with independent tracks. +function mergeReadyCmd(t: TrackDef): string { + const lines: string[] = ['set -e', 'READY=1', 'REASONS=""']; + for (const dep of t.mergeAfter) { + const depTrack = trackById(dep); + if (depTrack.commentOnly) continue; + lines.push( + `STATE_${dep}=$(cat ${ARTIFACTS}/track-${dep.toLowerCase()}/merge-state.txt 2>/dev/null || echo "UNKNOWN")`, + `if [ "$STATE_${dep}" != "MERGED" ]; then READY=0; REASONS="$REASONS Track-${dep}=$STATE_${dep}"; fi`, + ); + } + for (const ext of t.externalMergeAfter) { + const key = `EXT_${ext.repo.toUpperCase()}_${ext.pr}`; + lines.push( + `${key}=$(gh pr view ${ext.pr} --repo ${GH_REPOS[ext.repo]} --json mergedAt -q '.mergedAt' 2>/dev/null || echo null)`, + `if [ -z "$${key}" ] || [ "$${key}" = "null" ]; then READY=0; REASONS="$REASONS ${ext.repo}#${ext.pr}=unmerged"; fi`, + ); + } + lines.push( + `echo "MERGE_READY_TRACK_${t.id}=$READY"`, + 'echo "REASONS:$REASONS"', + `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, + `echo "$READY" > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-ready.txt`, + 'exit 0', + ); + return lines.join('\n'); +} + +// Auto-merge gate enforces spec's "Gates that BLOCK auto-merge" rules. +function autoMergeCmd(t: TrackDef): string { + if (!t.autoMerge || t.commentOnly) { + return [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, + `echo "Track ${t.id}: auto-merge not authorized — leaving PR as DRAFT."`, + `echo NOT_AUTO_MERGED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + ].join('\n'); + } + return [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, + `READY=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-ready.txt 2>/dev/null || echo 0)`, + `PR_NUM=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/pr-number.txt 2>/dev/null || echo "")`, + `if [ "$READY" != "1" ] || [ -z "$PR_NUM" ]; then`, + ` echo "Track ${t.id}: not merge-ready or PR not opened. Skipping auto-merge."`, + ` echo SKIPPED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + ` exit 0`, + `fi`, + // Verify no CHANGES_REQUESTED from non-bot reviewers. + `CR=$(gh pr view "$PR_NUM" --repo ${t.ghRepo} --json reviews -q '[.reviews[] | select(.state == "CHANGES_REQUESTED" and (.author.login | endswith("[bot]") | not))] | length' 2>/dev/null || echo 0)`, + `if [ "$CR" != "0" ] && [ -n "$CR" ]; then`, + ` echo "Track ${t.id}: CHANGES_REQUESTED present ($CR). Blocking auto-merge."`, + ` echo BLOCKED_CHANGES_REQUESTED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + ` exit 0`, + `fi`, + // Unresolved human review comments (per spec line 113). + // Conservative: any human review comment blocks unless RICKY_TRUST_UNRESOLVED_COMMENTS=1. + `HUMAN_COMMENTS=$(gh api repos/${t.ghRepo}/pulls/$PR_NUM/comments --paginate -q '[.[] | select(.user.type == "User")] | length' 2>/dev/null || echo 0)`, + `if [ "$HUMAN_COMMENTS" -gt 0 ] 2>/dev/null && [ "$RICKY_TRUST_UNRESOLVED_COMMENTS" != "1" ]; then`, + ` echo "Track ${t.id}: $HUMAN_COMMENTS human review comments — blocking auto-merge (set RICKY_TRUST_UNRESOLVED_COMMENTS=1 to override)."`, + ` echo BLOCKED_UNRESOLVED_COMMENTS > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + ` exit 0`, + `fi`, + // Flip draft -> ready then squash --auto. + `gh pr ready "$PR_NUM" --repo ${t.ghRepo} || echo "(already ready)"`, + `gh pr merge "$PR_NUM" --repo ${t.ghRepo} --squash --auto || (echo MERGE_FAILED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt; exit 0)`, + // Poll for the actual merge (auto-merge may queue). Cap at 15 min. + `for i in $(seq 1 30); do`, + ` M=$(gh pr view "$PR_NUM" --repo ${t.ghRepo} --json mergedAt -q '.mergedAt' 2>/dev/null || echo "")`, + ` if [ -n "$M" ] && [ "$M" != "null" ]; then`, + ` echo "Track ${t.id} merged at $M"`, + ` echo MERGED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + ` exit 0`, + ` fi`, + ` sleep 30`, + `done`, + `echo "Track ${t.id}: auto-merge queued but not completed within 15 min."`, + `echo AUTO_MERGE_QUEUED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, + 'exit 0', + ].join('\n'); +} + +async function main() { + const wf = workflow('ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r') + .description( + 'Deploy v1 schema cascade + persona refactor. Hub-spoke conversation: Lead Claude Opus stays on #wf-schema-cascade as architect + ambient reviewer; codex implementers run 14+ tracks across cloud/workforce/relay/relaycron/relayauth with per-track self-reflection, peer review (different implementer), fresh-eyes review (separate Claude), signoff, fix-r2, and auto-merge per the spec Merge DAG. Soft -> fixer -> hard gates throughout. Auto-merge when CI green + no CHANGES_REQUESTED + no unresolved comments.' + ) + .pattern('hub-spoke') + .channel(CHANNEL) + .maxConcurrency(6) + .timeout(28_800_000) // 8h ceiling: 18 tracks × ~14 phases × CI/merge polling, plus external PR waits. Original 4h was tight; reviewer recommended bumping. + + // -- Lead + reviewers + signoff + fixer (Claude Opus, interactive) ------- + .agent('lead', { + cli: 'claude', + role: `Architect on #${CHANNEL}. Owns the Merge DAG. Posts the plan; pings implementers; reads diffs in real time; reconciles cross-repo contracts (cloud<->workforce<->relay<->relaycron<->relayauth); approves tracks for round 1. Exits when ALL_TRACKS_APPROVED is posted.`, + retries: 1, + }) + .agent('reviewer-peer', { + cli: 'claude', + role: 'Formal cross-track peer reviewer. Reads diffs; compares vs per-track acceptance bullets; emits PEER_REVIEW: APPROVED or PEER_REVIEW: CHANGES_REQUESTED with per-track notes (file:line).', + retries: 1, + }) + .agent('signoff', { + cli: 'claude', + role: 'Final signoff agent. Re-reads spec acceptance bullets and verifies every track\'s bullets against actual files. Emits SIGNOFF: COMPLETE or SIGNOFF: INCOMPLETE: .', + retries: 1, + }) + .agent('fixer', { + cli: 'codex', + preset: 'worker', + role: 'Applies targeted fixes from any feedback (reflection, peer review, fresh-eyes review, signoff). Reads feedback + diffs, edits files in the right repo+worktree, exits.', + retries: 2, + }); + + // Per-track codex implementers (interactive on channel so they hear lead feedback). + for (const t of TRACKS) { + if (t.commentOnly) { + wf.agent(implAgentName(t), { + cli: 'claude', + role: `Track ${t.id} (${t.specSection}). Comment-only verification.`, + retries: 1, + }); + } else { + wf.agent(implAgentName(t), { + cli: 'codex', + role: `Track ${t.id} (${t.repo}, branch ${t.branch}): ${t.specSection}. Listens on #${CHANNEL} for lead feedback. Iterates on CHANGES_REQUESTED.`, + retries: 2, + }); + } + } + + // Per-track self-reflection analysts (separate Claude, reads only its own diff). + for (const t of TRACKS) { + wf.agent(reflectAgentName(t), { + cli: 'claude', + preset: 'analyst', + role: `Self-reflection for Track ${t.id}. Reads diff vs the per-track acceptance bullets. Emits REFLECT_GAPS: or REFLECT_GAPS: NONE.`, + retries: 1, + }); + } + + // Per-track fresh-eyes reviewers (separate Claude, NO prior workflow context). + for (const t of TRACKS) { + wf.agent(freshEyesAgentName(t), { + cli: 'claude', + preset: 'reviewer', + role: `Fresh-eyes review for Track ${t.id}. NO prior workflow context. Reads only the spec section + the diff. Catches blind spots the implementer cannot see. Emits FRESH_EYES_REVIEW: APPROVED or FRESH_EYES_REVIEW: CHANGES_REQUESTED.`, + retries: 1, + }); + } + + // ============================================================ + // Phase 0 — Read spec ONCE into context (deterministic). + // ============================================================ + wf.step('read-spec', { + type: 'deterministic', + command: `set -e\nmkdir -p ${ARTIFACTS}\ncat ${SPEC_FILE}`, + captureOutput: true, + failOnError: true, + }); + + // ============================================================ + // Phase 1 — Per-track preflight (parallel where possible). + // ============================================================ + for (const t of TRACKS) { + wf.step(`preflight-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: ['read-spec'], + command: preflightCmd(t), + captureOutput: true, + failOnError: false, + }); + } + + wf.step('preflight-summary', { + type: 'deterministic', + dependsOn: TRACKS.map((t) => `preflight-${t.id.toLowerCase()}`), + command: [ + 'set -e', + `mkdir -p ${ARTIFACTS}`, + 'echo "=== PREFLIGHT SUMMARY ==="', + ...TRACKS.map((t) => + `echo "Track ${t.id}: $(echo \"{{steps.preflight-${t.id.toLowerCase()}.output}}\" | grep -oE 'PREFLIGHT_OK_[A-Z0-9]+|ERROR.*' | head -1)"`, + ), + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + + // ============================================================ + // Phase 2 — Lead coordinate (the hub). Stays on the channel until + // all tracks pass round-1 review, ambient-reviewing diffs as they land. + // ============================================================ + const READY_DEPS = ['read-spec', 'preflight-summary']; + + wf.step('lead-coordinate', { + agent: 'lead', + dependsOn: READY_DEPS, + task: [ + `You are the lead architect on #${CHANNEL}. Deploy-v1 schema cascade + persona refactor.`, + `Spec file: ${SPEC_FILE}`, + '', + 'SPEC (full):', + '{{steps.read-spec.output}}', + '', + `Tracks (${TRACKS.length} total) and their implementers:`, + ...TRACKS.map((t) => ` - @${implAgentName(t)} -- Track ${t.id} in ${t.repo} (${t.specSection})`), + '', + 'Step 1 -- Post the cross-repo plan to the channel. Restate the Merge DAG (spec "Merge DAG -- auto-merge order"):', + ' - Tracks A, D run first (parallel; no upstream deps).', + ' - Track B depends on A; Track F depends on A + D; Track G depends on A + cloud#548 + relaycron#5; Track H depends on D + G.', + ' - E1-E5 fan out after D.', + ' - Track L depends on cloud#548 + relay#843 publish settled. Track M depends on relaycron#5. Track N depends on relayauth#39 + G.', + ' - Track K runs after everything, reports SMOKE_TEST: PASS/FAIL -- does NOT block cascade.', + '', + 'Step 2 -- Require ACK from every implementer before they write code. Re-post + ping if a worker is silent for 3 minutes.', + '', + 'Step 3 -- Every 5 minutes post a status probe naming all implementers. Each replies RUNNING / BLOCKED / DONE.', + '', + 'Step 4 -- As workers post DONE, READ THEIR ACTUAL FILES and post per-track verdict:', + ' "APPROVED Track-" -- track is good for round 1', + ' "CHANGES_REQUESTED Track-: " -- worker iterates', + '', + 'Step 5 -- Cross-repo contract reconciliation. Watch for mismatches between:', + ' - Track A schema columns (agents.watch_globs, agents.schedule_ids) vs Track G consumption', + ' - Track G endpoint contract vs Track H --mode cloud client', + ' - Track A input_values column vs Track F runtime ctx vs Track I --input flags', + ' - Track D persona-kit shape vs E1-E5 rebases', + ' - Track M @relaycron pin vs Track L OSS-scope cleanup post relay#843 publish settling', + 'On mismatch, post @-pings to BOTH owning implementers and reconcile before approving either.', + '', + 'Step 6 -- Exit when all tracks are APPROVED or have CHANGES_REQUESTED in a stable state. Post FINAL: ALL_TRACKS_APPROVED before exiting.', + '', + 'Loud-hole reminders (spec "Loud hole"): every PR body MUST mention memory is not wired in v1. Do NOT let implementers forget this.', + '', + 'Constraints:', + ' - Do NOT write code. You review and coordinate.', + ' - Do NOT commit anything. The workflow handles git.', + ' - Do NOT use exit instructions; the runner self-terminates.', + ].join('\n'), + verification: { type: 'output_contains', value: 'ALL_TRACKS_APPROVED' }, + }); + + // ============================================================ + // Phase 3 — Per-track implementer steps. All share READY_DEPS so they + // start concurrently with the lead (no deadlock, per + // writing-agent-relay-workflows SKILL "DAG Deadlock Anti-Pattern"). + // ============================================================ + for (const t of TRACKS) { + wf.step(`impl-${t.id.toLowerCase()}-work`, { + agent: implAgentName(t), + dependsOn: READY_DEPS, + task: [ + `You are ${implAgentName(t)} on #${CHANNEL}. TRACK ${t.id}.`, + `Repo: ${REPOS[t.repo]}, branch ${t.branch}, workdir ${workdir(t)}.`, + '', + `Wait for the lead's plan on #${CHANNEL}, then ACK with "ACK ${implAgentName(t)}".`, + '', + 'Spec (read fully):', + '{{steps.read-spec.output}}', + '', + `Your section in the spec: "${t.specSection}".`, + '', + 'Scope:', + t.scope, + '', + t.commentOnly + ? [ + 'This track is comment-only. Verify the coordination comment exists on the target PR; re-post if missing.', + 'Do NOT branch, commit, or push.', + ].join('\n') + : [ + 'Implementation rules:', + ` - Stay in ${workdir(t)} on branch ${t.branch}.`, + ` - Allowed-dirty regex: ${t.allowedDirty || '(default: package locks only)'}.`, + ' - No --no-verify. Pre-commit hooks must pass.', + ' - Include the loud-hole note (memory not wired) in any PR body you draft.', + ` - Run typecheck (\`${t.typecheckCmd}\`) and tests (\`${t.testCmd}\`) until they pass.`, + ` - Watch #${CHANNEL}. Iterate on CHANGES_REQUESTED Track-${t.id}: from the lead.`, + ` - Post completion as: "DONE Track-${t.id}: ".`, + '', + 'Constraints:', + ` - Edit ONLY files matching the allow-list: ${t.allowedDirty || '(see spec section)'}.`, + ' - Do NOT touch other tracks files.', + ' - Do NOT commit; the workflow handles git.', + ` - Do NOT exit until DONE Track-${t.id} is posted.`, + ].join('\n'), + '', + 'Quality bar: typecheck + tests pass; every acceptance bullet in the spec section is addressed.', + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 4 — Per-track soft typecheck (never throws). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`tsc-soft-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`impl-${t.id.toLowerCase()}-work`], + command: softCmd(t, `tsc-soft-${t.id.toLowerCase()}`, t.typecheckCmd), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 5 — Per-track typecheck fixer. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`tsc-fix-${t.id.toLowerCase()}`, { + agent: 'fixer', + dependsOn: [`tsc-soft-${t.id.toLowerCase()}`], + task: [ + `Typecheck output for Track ${t.id} (${workdir(t)}):`, + `{{steps.tsc-soft-${t.id.toLowerCase()}.output}}`, + '', + `If TSC_SOFT_${t.id.toUpperCase()}_EXIT=0, exit immediately.`, + `Otherwise fix type errors in ${workdir(t)}. Re-run \`${t.typecheckCmd}\` until it passes.`, + 'Do NOT silence with `as any` or `// @ts-ignore`. Fix the root cause.', + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 6 — Per-track soft tests. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`tests-soft-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`tsc-fix-${t.id.toLowerCase()}`], + command: softCmd(t, `tests-soft-${t.id.toLowerCase()}`, t.testCmd), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 7 — Per-track tests fixer. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`tests-fix-${t.id.toLowerCase()}`, { + agent: 'fixer', + dependsOn: [`tests-soft-${t.id.toLowerCase()}`], + task: [ + `Test output for Track ${t.id} (${workdir(t)}):`, + `{{steps.tests-soft-${t.id.toLowerCase()}.output}}`, + '', + `If TESTS_SOFT_${t.id.toUpperCase()}_EXIT=0, exit immediately.`, + `Otherwise read failures and fix EITHER the test or the source -- whichever is correct. Do NOT skip or delete tests.`, + `Re-run \`${t.testCmd}\` until it passes.`, + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 8 — Per-track HARD typecheck + tests (captured, never throws, + // gates feed into signoff + PR body). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`tsc-hard-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`tests-fix-${t.id.toLowerCase()}`], + command: softCmd(t, `tsc-hard-${t.id.toLowerCase()}`, t.typecheckCmd), + captureOutput: true, + failOnError: false, + }); + wf.step(`tests-hard-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`tests-fix-${t.id.toLowerCase()}`], + command: softCmd(t, `tests-hard-${t.id.toLowerCase()}`, t.testCmd), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 9 — Self-reflection per track (Claude analyst). + // Reads diff vs spec acceptance bullets; emits REFLECT_GAPS. + // ============================================================ + for (const t of TRACKS) { + wf.step(`reflect-${t.id.toLowerCase()}`, { + agent: reflectAgentName(t), + dependsOn: t.commentOnly + ? [`impl-${t.id.toLowerCase()}-work`] + : [`tsc-hard-${t.id.toLowerCase()}`, `tests-hard-${t.id.toLowerCase()}`], + task: [ + `Self-reflection for Track ${t.id}.`, + `Spec section: "${t.specSection}".`, + `Read the diff in ${workdir(t)} (branch ${t.branch}) via:`, + ` cd ${workdir(t)} && git diff origin/main`, + '', + 'Spec (full):', + '{{steps.read-spec.output}}', + '', + 'Re-read your spec section acceptance bullets. For EACH bullet:', + ' - Addressed in the diff? Where (file:line)?', + ' - MISSING or PARTIAL?', + '', + 'Output exactly this format:', + ' REFLECT_GAPS:', + ' - ', + ' - ', + '', + 'If NO gaps, output: REFLECT_GAPS: NONE', + '', + 'Be brutal. Read the actual files; do not trust chat.', + ].join('\n'), + verification: { type: 'output_contains', value: 'REFLECT_GAPS' }, + }); + } + + // ============================================================ + // Phase 10 — Self-reflection fix-loop: the ORIGINAL implementer + // addresses its own track gaps (preserves track context). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`reflect-fix-${t.id.toLowerCase()}`, { + agent: implAgentName(t), + dependsOn: [`reflect-${t.id.toLowerCase()}`], + task: [ + `Self-reflection report for Track ${t.id}:`, + `{{steps.reflect-${t.id.toLowerCase()}.output}}`, + '', + 'If REFLECT_GAPS: NONE, exit immediately.', + `Otherwise address every listed gap. Edit files in ${workdir(t)} only. Stay on branch ${t.branch}.`, + '', + `After fixes, re-run \`${t.typecheckCmd}\` and \`${t.testCmd}\`. They must pass.`, + 'Do not introduce new gaps. Do not edit files outside your allow-list.', + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 11 — FRESH-EYES review per track. Separate Claude with NO + // prior workflow context -- reads only spec section + diff. + // ============================================================ + for (const t of TRACKS) { + wf.step(`fresh-eyes-${t.id.toLowerCase()}`, { + agent: freshEyesAgentName(t), + dependsOn: t.commentOnly + ? [`reflect-${t.id.toLowerCase()}`] + : [`reflect-fix-${t.id.toLowerCase()}`], + task: [ + `FRESH-EYES REVIEW for Track ${t.id}. You have NO prior workflow context.`, + '', + 'Read ONLY two things:', + ` 1. The spec section: "${t.specSection}" in ${SPEC_FILE}`, + ` 2. The diff in ${workdir(t)} via: cd ${workdir(t)} && git diff origin/main`, + '', + 'Do NOT read chat history or other tracks. You are the fresh-eyes safety net.', + '', + 'Reviewer checklist:', + ' - Do changes match the spec section acceptance bullets exactly?', + ' - Are there spec items the implementer might have skipped because they "seemed obvious"?', + ' - Are there cross-references in the spec the diff missed?', + ' - Tests cover acceptance bullets, not just happy path?', + ' - Is the loud-hole note present where required (memory not wired)?', + '', + 'Emit exactly one of:', + ' FRESH_EYES_REVIEW: APPROVED', + 'or', + ' FRESH_EYES_REVIEW: CHANGES_REQUESTED', + ' - ', + ' - ', + '', + 'Be ruthless.', + ].join('\n'), + verification: { type: 'output_contains', value: 'FRESH_EYES_REVIEW' }, + }); + } + + // ============================================================ + // Phase 12 — Apply fresh-eyes fixes (ORIGINAL implementer). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`fresh-eyes-fix-${t.id.toLowerCase()}`, { + agent: implAgentName(t), + dependsOn: [`fresh-eyes-${t.id.toLowerCase()}`], + task: [ + `Fresh-eyes review for Track ${t.id}:`, + `{{steps.fresh-eyes-${t.id.toLowerCase()}.output}}`, + '', + 'If FRESH_EYES_REVIEW: APPROVED, exit immediately.', + `Otherwise address every CHANGES_REQUESTED note in ${workdir(t)} only.`, + '', + `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 13 — PEER REVIEW per track from a DIFFERENT implementer. + // If CHANGES_REQUESTED, the ORIGINAL implementer fixes (Phase 14). + // ============================================================ + for (const t of TRACKS) { + const peer = peerReviewerName(t); + wf.step(`peer-review-${t.id.toLowerCase()}`, { + agent: peer, + dependsOn: t.commentOnly + ? [`fresh-eyes-${t.id.toLowerCase()}`] + : [`fresh-eyes-fix-${t.id.toLowerCase()}`], + task: [ + `PEER REVIEW for Track ${t.id}. You are ${peer}, normally the implementer for a different track.`, + '', + `Read the diff in ${workdir(t)}: cd ${workdir(t)} && git diff origin/main`, + '', + `Spec section: "${t.specSection}".`, + '', + 'Spec (full):', + '{{steps.read-spec.output}}', + '', + 'Review for:', + ' - Correctness vs acceptance bullets', + ' - Tests covering happy path AND edge cases in the spec section', + ' - No regressions in adjacent code', + ' - Cross-repo contracts honored (if your own track contract intersects)', + ' - Loud-hole note is in the PR body draft (if applicable)', + '', + 'Emit exactly one of:', + ' PEER_REVIEW: APPROVED', + 'or', + ' PEER_REVIEW: CHANGES_REQUESTED', + ' - ', + ' - ', + ].join('\n'), + verification: { type: 'output_contains', value: 'PEER_REVIEW' }, + }); + } + + // ============================================================ + // Phase 14 — Apply peer-review fixes (ORIGINAL implementer). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`peer-review-fix-${t.id.toLowerCase()}`, { + agent: implAgentName(t), + dependsOn: [`peer-review-${t.id.toLowerCase()}`], + task: [ + `Peer review for Track ${t.id}:`, + `{{steps.peer-review-${t.id.toLowerCase()}.output}}`, + '', + 'If PEER_REVIEW: APPROVED, exit immediately.', + `Otherwise address every CHANGES_REQUESTED note in ${workdir(t)} only.`, + '', + `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 15 — Per-track final hard gate after all review loops. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`final-gate-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`peer-review-fix-${t.id.toLowerCase()}`], + command: [ + 'set +e', + `cd ${workdir(t)}`, + 'mkdir -p .logs', + `${t.typecheckCmd} > .logs/final-tsc.log 2>&1; T=$?`, + `${t.testCmd} > .logs/final-tests.log 2>&1; X=$?`, + `echo "FINAL_${t.id}_TSC=$T"`, + `echo "FINAL_${t.id}_TESTS=$X"`, + 'tail -40 .logs/final-tsc.log', + 'tail -40 .logs/final-tests.log', + 'exit 0', + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 16 — Per-track SIGNOFF agent verifies acceptance bullets. + // ============================================================ + for (const t of TRACKS) { + wf.step(`signoff-${t.id.toLowerCase()}`, { + agent: 'signoff', + dependsOn: t.commentOnly + ? [`peer-review-${t.id.toLowerCase()}`] + : [`final-gate-${t.id.toLowerCase()}`], + task: [ + `Final signoff for Track ${t.id}.`, + '', + 'Re-read the spec acceptance bullets for this track:', + '{{steps.read-spec.output}}', + '', + `Track section: "${t.specSection}".`, + '', + t.commentOnly + ? 'Verify the comment-only action was completed (gh pr view confirms the comment exists).' + : `Read the diff in ${workdir(t)}: cd ${workdir(t)} && git diff origin/main`, + '', + t.commentOnly + ? '' + : `Final gate status:\n{{steps.final-gate-${t.id.toLowerCase()}.output}}`, + '', + 'For EACH acceptance bullet, mark [x] satisfied or [ ] gap.', + '', + 'Emit exactly one of:', + ` SIGNOFF: COMPLETE Track-${t.id}`, + 'or', + ` SIGNOFF: INCOMPLETE Track-${t.id}`, + ' - ', + ' - ', + '', + 'Read files. Do not trust chat. Even if INCOMPLETE, exit cleanly -- the workflow ships DRAFT with the gap list.', + ].join('\n'), + verification: { type: 'output_contains', value: 'SIGNOFF:' }, + }); + } + + // ============================================================ + // Phase 17 — Per-track router (deterministic): COMPLETE or NEEDS_FIX. + // ============================================================ + for (const t of TRACKS) { + wf.step(`router-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`signoff-${t.id.toLowerCase()}`], + command: [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, + `BODY=${ARTIFACTS}/track-${t.id.toLowerCase()}/signoff.txt`, + `cat <<'SIGNOFF_EOF' > $BODY`, + `{{steps.signoff-${t.id.toLowerCase()}.output}}`, + 'SIGNOFF_EOF', + `if grep -q "^SIGNOFF: COMPLETE Track-${t.id}" $BODY; then`, + ` echo "ROUTE_${t.id}: COMPLETE"`, + ` echo complete > ${ARTIFACTS}/track-${t.id.toLowerCase()}/router.txt`, + 'else', + ` echo "ROUTE_${t.id}: NEEDS_FIX"`, + ` echo needs-fix > ${ARTIFACTS}/track-${t.id.toLowerCase()}/router.txt`, + 'fi', + ].join('\n'), + captureOutput: true, + failOnError: true, + }); + } + + // ============================================================ + // Phase 18 — Round-2 fix agent (no-op if router said COMPLETE). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`fix-r2-${t.id.toLowerCase()}`, { + agent: 'fixer', + dependsOn: [`router-${t.id.toLowerCase()}`], + task: [ + `Router decision for Track ${t.id}:`, + `{{steps.router-${t.id.toLowerCase()}.output}}`, + '', + `Signoff verdict for Track ${t.id}:`, + `{{steps.signoff-${t.id.toLowerCase()}.output}}`, + '', + `If router said "ROUTE_${t.id}: COMPLETE", exit immediately.`, + `If "ROUTE_${t.id}: NEEDS_FIX", read SIGNOFF: INCOMPLETE gaps and address every one in ${workdir(t)}.`, + '', + `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, + '', + 'Do not edit unrelated files. Do not add new TODOs. Exit cleanly.', + ].join('\n'), + verification: { type: 'exit_code' }, + }); + } + + // ============================================================ + // Phase 19 — Final signoff after round-2 fix. + // ============================================================ + for (const t of TRACKS) { + wf.step(`signoff-final-${t.id.toLowerCase()}`, { + agent: 'signoff', + dependsOn: t.commentOnly + ? [`router-${t.id.toLowerCase()}`] + : [`fix-r2-${t.id.toLowerCase()}`], + task: [ + `FINAL signoff for Track ${t.id} after round-2 fix.`, + '', + 'Re-verify acceptance bullets against the ACTUAL files now.', + '', + `Spec section: "${t.specSection}".`, + '', + t.commentOnly ? '' : `Diff: cd ${workdir(t)} && git diff origin/main`, + '', + 'Emit:', + ` SIGNOFF_FINAL: COMPLETE Track-${t.id}`, + 'or', + ` SIGNOFF_FINAL: INCOMPLETE Track-${t.id}`, + ' - ', + '', + 'Even if INCOMPLETE, exit cleanly. The PR will ship as DRAFT with the gap list.', + ].join('\n'), + verification: { type: 'output_contains', value: 'SIGNOFF_FINAL' }, + }); + } + + // ============================================================ + // Phase 20 — Build PR body (deterministic) with loud-hole + + // signoff + gate output + reflection report. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + const id = t.id.toLowerCase(); + wf.step(`build-pr-body-${id}`, { + type: 'deterministic', + dependsOn: [`signoff-final-${id}`], + command: [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${id}`, + `BODY=${ARTIFACTS}/track-${id}/pr-body.md`, + `cat <<'SF_EOF' > ${ARTIFACTS}/track-${id}/signoff-final.txt`, + `{{steps.signoff-final-${id}.output}}`, + 'SF_EOF', + `cat <<'FG_EOF' > ${ARTIFACTS}/track-${id}/final-gate.txt`, + `{{steps.final-gate-${id}.output}}`, + 'FG_EOF', + `cat <<'RF_EOF' > ${ARTIFACTS}/track-${id}/reflect.txt`, + `{{steps.reflect-${id}.output}}`, + 'RF_EOF', + `if grep -q "^SIGNOFF_FINAL: COMPLETE Track-${t.id}" ${ARTIFACTS}/track-${id}/signoff-final.txt; then`, + ` HEADER_STATE="complete"`, + `else`, + ` HEADER_STATE="incomplete"`, + `fi`, + `if [ "$HEADER_STATE" = "complete" ]; then`, + ` printf "%s\\n" "## Summary" "" "- Track ${t.id}: ${t.specSection}" "- Final signoff: COMPLETE." "- Eligible for auto-merge when CI green and upstream deps merged." "" > "$BODY"`, + `else`, + ` printf "%s\\n" "## Summary (DRAFT -- gaps remain)" "" "- Track ${t.id}: ${t.specSection}" "- Final signoff: INCOMPLETE; gap list below." "- PR stays as DRAFT; human review required." "" > "$BODY"`, + `fi`, + 'printf "%s\\n" "## Spec reference" "" "Source spec: workforce/docs/plans/deploy-v1-schema-cascade-spec.md" "" >> "$BODY"', + `printf "%s\\n" "Track section: ${t.specSection}" "" >> "$BODY"`, + 'printf "%s\\n" "## Final signoff" "" "\\`\\`\\`" >> "$BODY"', + `cat ${ARTIFACTS}/track-${id}/signoff-final.txt >> "$BODY"`, + 'printf "%s\\n" "\\`\\`\\`" "" "## Final gate (typecheck + tests)" "" "\\`\\`\\`" >> "$BODY"', + `cat ${ARTIFACTS}/track-${id}/final-gate.txt >> "$BODY"`, + 'printf "%s\\n" "\\`\\`\\`" "" "## Self-reflection report" "" "\\`\\`\\`" >> "$BODY"', + `cat ${ARTIFACTS}/track-${id}/reflect.txt >> "$BODY"`, + 'printf "%s\\n" "\\`\\`\\`" "" >> "$BODY"', + ...LOUD_HOLE_LINES.map((line) => `printf "%s\\n" ${JSON.stringify(line)} >> "$BODY"`), + 'printf "%s\\n" "" "Co-Authored-By: Ricky deploy-v1 schema cascade " >> "$BODY"', + 'echo "=== PR BODY for Track ' + t.id + ' ==="', + 'cat "$BODY"', + ].join('\n'), + captureOutput: true, + failOnError: true, + }); + } + + // ============================================================ + // Phase 21 — Per-track commit + push (deterministic). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + wf.step(`commit-push-${t.id.toLowerCase()}`, { + type: 'deterministic', + dependsOn: [`build-pr-body-${t.id.toLowerCase()}`], + command: [ + 'set -e', + `cd ${workdir(t)}`, + 'git add -A', + 'if git diff --cached --quiet; then', + ` echo "NO_CHANGES_TO_COMMIT_${t.id}"`, + 'else', + ' MSG=$(mktemp)', + ` printf "%s\\n" ${JSON.stringify(t.prTitle)} "" ${JSON.stringify(`Track ${t.id}: ${t.specSection}`)} "" ${JSON.stringify('See workforce/docs/plans/deploy-v1-schema-cascade-spec.md')} > "$MSG"`, + ' git commit -F "$MSG"', + ' rm -f "$MSG"', + 'fi', + `git push -u origin ${t.branch} --force-with-lease`, + 'git log --oneline -1', + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 22 — Open PR as DRAFT (or reuse existing for rebase tracks). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + const id = t.id.toLowerCase(); + wf.step(`open-pr-${id}`, { + type: 'deterministic', + dependsOn: [`commit-push-${id}`], + command: [ + 'set -e', + `mkdir -p ${ARTIFACTS}/track-${id}`, + `EXISTING=$(gh pr list --repo ${t.ghRepo} --head ${t.branch} --state open --json number -q '.[0].number' 2>/dev/null || echo "")`, + 'if [ -n "$EXISTING" ]; then', + ` echo "Track ${t.id}: reusing existing PR #$EXISTING"`, + ` echo "$EXISTING" > ${ARTIFACTS}/track-${id}/pr-number.txt`, + ` gh pr edit "$EXISTING" --repo ${t.ghRepo} --body-file ${ARTIFACTS}/track-${id}/pr-body.md || true`, + 'else', + ` CREATED=$(gh pr create --repo ${t.ghRepo} --head ${t.branch} --base main --draft --title ${JSON.stringify(t.prTitle)} --body-file ${ARTIFACTS}/track-${id}/pr-body.md 2>&1 || echo "FAILED")`, + ' PR_NUM=$(echo "$CREATED" | grep -oE "/pull/[0-9]+" | grep -oE "[0-9]+" | head -1)', + ' if [ -n "$PR_NUM" ]; then', + ` echo "Track ${t.id}: created PR #$PR_NUM"`, + ` echo "$PR_NUM" > ${ARTIFACTS}/track-${id}/pr-number.txt`, + ' else', + ` echo "Track ${t.id}: PR create failed: $CREATED"`, + ` echo "" > ${ARTIFACTS}/track-${id}/pr-number.txt`, + ' fi', + 'fi', + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 23 — Wait for CI green (poll PR checks, 30 min cap). + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + const id = t.id.toLowerCase(); + wf.step(`wait-ci-${id}`, { + type: 'deterministic', + dependsOn: [`open-pr-${id}`], + command: [ + 'set -e', + `PR_NUM=$(cat ${ARTIFACTS}/track-${id}/pr-number.txt 2>/dev/null || echo "")`, + 'if [ -z "$PR_NUM" ]; then', + ` echo "Track ${t.id}: no PR -- skipping CI wait."`, + ' exit 0', + 'fi', + 'for i in $(seq 1 60); do', + ` STATUS=$(gh pr checks "$PR_NUM" --repo ${t.ghRepo} --required --json conclusion -q '[.[] | .conclusion] | unique' 2>/dev/null || echo "[]")`, + ` echo "Track ${t.id} CI status (iter $i): $STATUS"`, + ' case "$STATUS" in', + ` '["SUCCESS"]'|'[]') echo "Track ${t.id} CI: GREEN"; exit 0;;`, + ` *FAILURE*) echo "Track ${t.id} CI: FAILURE -- stopping cascade for this track."; exit 0;;`, + ` *CANCELLED*) echo "Track ${t.id} CI: CANCELLED"; exit 0;;`, + ' esac', + ' sleep 30', + 'done', + `echo "Track ${t.id}: CI did not settle within 30 min."`, + 'exit 0', + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 24 — Merge-ready check (DAG walk: upstream merged?). + // Includes external PR deps via gh pr view --json mergedAt. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + const id = t.id.toLowerCase(); + const upstreamMerges = t.mergeAfter + .filter((dep) => !trackById(dep).commentOnly) + .map((dep) => `auto-merge-${dep.toLowerCase()}`); + wf.step(`merge-ready-${id}`, { + type: 'deterministic', + dependsOn: [`wait-ci-${id}`, ...upstreamMerges], + command: mergeReadyCmd(t), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 25 — Auto-merge gate: flip draft -> ready, squash --auto. + // Blocks on CHANGES_REQUESTED, unresolved comments, upstream not merged. + // ============================================================ + for (const t of TRACKS) { + if (t.commentOnly) continue; + const id = t.id.toLowerCase(); + wf.step(`auto-merge-${id}`, { + type: 'deterministic', + dependsOn: [`merge-ready-${id}`], + command: autoMergeCmd(t), + captureOutput: true, + failOnError: false, + }); + } + + // ============================================================ + // Phase 26 — Final cross-cascade summary (the wake-up report). + // ============================================================ + const finalDeps = TRACKS.filter((t) => !t.commentOnly).map( + (t) => `auto-merge-${t.id.toLowerCase()}`, + ); + wf.step('final-cascade-report', { + type: 'deterministic', + dependsOn: finalDeps.length > 0 ? finalDeps : ['read-spec'], + command: [ + 'set -e', + `mkdir -p ${ARTIFACTS}`, + `REPORT=${ARTIFACTS}/wake-up-report.md`, + 'printf "%s\\n" "# Deploy v1 schema cascade -- wake-up report" "" "Generated by ricky-deploy-v1-schema-cascade workflow." "" "## Per-track merge state" "" > "$REPORT"', + ...TRACKS.filter((t) => !t.commentOnly).map( + (t) => + `STATE=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt 2>/dev/null || echo "UNKNOWN"); PR=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/pr-number.txt 2>/dev/null || echo "?"); printf -- "- Track ${t.id} (${t.repo}): %s -- PR #%s\\n" "$STATE" "$PR" >> "$REPORT"`, + ), + 'printf "\\n%s\\n\\n" "## Comment-only tracks" >> "$REPORT"', + ...TRACKS.filter((t) => t.commentOnly).map( + (t) => `printf -- "- Track ${t.id}: comment-only -- see signoff\\n" >> "$REPORT"`, + ), + 'printf "\\n%s\\n\\n" "## Loud holes still open (intentional)" >> "$REPORT"', + ...LOUD_HOLE_LINES.map((line) => `printf "%s\\n" ${JSON.stringify(line)} >> "$REPORT"`), + 'echo "=== WAKE-UP REPORT ==="', + 'cat "$REPORT"', + ].join('\n'), + captureOutput: true, + failOnError: false, + }); + + // ============================================================ + // Global error policy: retry transient failures up to 2x with 10s + // backoff (writing-agent-relay-workflows SKILL convention). + // ============================================================ + wf.onError('retry', { maxRetries: 2, retryDelayMs: 10_000 }); + + const result = await wf.run({ cwd: process.cwd() }); + console.log('Workflow status:', result.status); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From f40328f38e12a3536830a7f31da3210d1f6f02d4 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 14:22:40 +0200 Subject: [PATCH 4/6] feat(persona-kit): JSON Schema export + fixture personas + lint codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the persona JSON Schema generator from #88 plus the four fixture personas that exercise it. The schema is emitted from `PersonaSpec` during `npm run build` and checked into the repo at `packages/persona-kit/schemas/persona.schema.json` so external editors (VS Code's json.schemas, etc.) can autocomplete + validate personas against the published shape. What changed - `scripts/emit-schema.mjs` — generator using `ts-json-schema-generator`. Emits a draft-2020-12 schema with an `allOf` clause encoding "cloud:true requires onEvent" so editors warn at write time, not just at deploy time. - `schemas/persona.schema.json` — committed artifact, regenerated on every `pnpm run build`. - `src/__fixtures__/personas/{minimal,cron-only,full,invalid-unknown-trigger}.json` — four canonical personas the emit-schema test validates against the freshly-generated schema and also runs through parsePersonaSpec + lintTriggers to lock in current behavior. - `src/emit-schema.test.ts` — new test suite. Walks every fixture, asserts the schema matches its structure (including the if/then branch for cloud personas), and asserts the generator is idempotent (re-running produces a byte-identical schema file). 3 new tests. - `src/triggers.ts` — adds a `TriggerLintCode` (`unknown_provider` | `unknown_trigger`) and a `code` field on `TriggerLintIssue` so consumers can branch on a machine-readable category instead of parsing message strings. Existing tests updated; lint output is otherwise unchanged. - `package.json` — `build` script now runs `emit:schema` after `tsc`; `schemas/` is added to the published `files` list and the `./schemas/persona.schema.json` export so consumers can `require.resolve('@agentworkforce/persona-kit/schemas/persona.schema.json')`. - `ts-json-schema-generator` lands as a devDependency. Tests: 156 pass in persona-kit (up from 153), 378 across the repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/persona-kit/package.json | 10 +- .../persona-kit/schemas/persona.schema.json | 583 ++++++++++++++++++ packages/persona-kit/scripts/emit-schema.mjs | 61 ++ .../src/__fixtures__/personas/cron-only.json | 18 + .../src/__fixtures__/personas/full.json | 100 +++ .../personas/invalid-unknown-trigger.json | 21 + .../src/__fixtures__/personas/minimal.json | 11 + packages/persona-kit/src/emit-schema.test.ts | 183 ++++++ packages/persona-kit/src/index.ts | 1 + packages/persona-kit/src/triggers.test.ts | 2 + packages/persona-kit/src/triggers.ts | 9 + pnpm-lock.yaml | 97 +++ 12 files changed, 1094 insertions(+), 2 deletions(-) create mode 100644 packages/persona-kit/schemas/persona.schema.json create mode 100644 packages/persona-kit/scripts/emit-schema.mjs create mode 100644 packages/persona-kit/src/__fixtures__/personas/cron-only.json create mode 100644 packages/persona-kit/src/__fixtures__/personas/full.json create mode 100644 packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json create mode 100644 packages/persona-kit/src/__fixtures__/personas/minimal.json create mode 100644 packages/persona-kit/src/emit-schema.test.ts diff --git a/packages/persona-kit/package.json b/packages/persona-kit/package.json index 41bd7930..7eb69f32 100644 --- a/packages/persona-kit/package.json +++ b/packages/persona-kit/package.json @@ -10,10 +10,12 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./schemas/persona.schema.json": "./schemas/persona.schema.json" }, "files": [ "dist", + "schemas", "README.md", "package.json" ], @@ -26,13 +28,17 @@ "access": "public" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run emit:schema", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "emit:schema": "node ./scripts/emit-schema.mjs", "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@relayfile/local-mount": "^0.7.0" + }, + "devDependencies": { + "ts-json-schema-generator": "^2.3.0" } } diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json new file mode 100644 index 00000000..9ed02cc6 --- /dev/null +++ b/packages/persona-kit/schemas/persona.schema.json @@ -0,0 +1,583 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/definitions/PersonaSpec", + "definitions": { + "PersonaSpec": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "intent": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaTag" + }, + "description": "Free-form classification labels (from {@link PERSONA_TAGS } ). Every persona has at least one; a persona may carry multiple tags when it spans concerns (e.g. `['testing', 'implementation']`)." + }, + "description": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaSkill" + } + }, + "inputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PersonaInputSpec" + }, + "description": "Prompt-visible runtime inputs. Keys must be env-style names (`OUTPUT_PATH`, `TARGET_DIR`, etc.). Never put secrets here; resolved values are substituted into the persona's system prompt." + }, + "harness": { + "$ref": "#/definitions/Harness", + "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`)." + }, + "model": { + "type": "string", + "description": "Model identifier passed to the harness." + }, + "systemPrompt": { + "type": "string", + "description": "System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time." + }, + "harnessSettings": { + "$ref": "#/definitions/HarnessSettings", + "description": "Harness-level knobs (reasoning, timeout, codex sandbox/approval policy, etc.)." + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables injected into the harness child process. Values may be literal strings or `$VAR` references resolved from the caller's environment at spawn time." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/McpServerSpec" + }, + "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode`: currently warns and skips" + }, + "permissions": { + "$ref": "#/definitions/PersonaPermissions", + "description": "Permission policy (allow/deny lists, mode) for the harness session. Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, `--permission-mode`); other harnesses warn and skip." + }, + "mount": { + "$ref": "#/definitions/PersonaMount", + "description": "Relayfile mount policy for file visibility and writability. Applied by launchers that run the harness inside `@relayfile/local-mount`." + }, + "claudeMd": { + "type": "string", + "description": "Author-supplied path to a `CLAUDE.md` sidecar that should be applied when the persona runs under the claude harness. The path is relative to the JSON file that declared the field; the loader resolves it to an already-absolute path on the parsed spec. Built-in personas inline the content into {@link PersonaSpec.claudeMdContent } at build time." + }, + "claudeMdMode": { + "$ref": "#/definitions/SidecarMdMode", + "description": "Defaults to `overwrite`. See {@link SidecarMdMode } ." + }, + "agentsMd": { + "type": "string", + "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode harness. Same resolution rules as {@link claudeMd } ." + }, + "agentsMdMode": { + "$ref": "#/definitions/SidecarMdMode", + "description": "Defaults to `overwrite`. See {@link SidecarMdMode } ." + }, + "claudeMdContent": { + "type": "string", + "description": "Inlined `CLAUDE.md` content for built-in personas. The catalog generator reads the sibling `.md` at build time and emits its body here so the installed package does not need to ship the file separately. Runtime code prefers this over `claudeMd` when both are set." + }, + "agentsMdContent": { + "type": "string", + "description": "Inlined `AGENTS.md` content for built-in personas." + }, + "cloud": { + "type": "boolean", + "description": "Opt this persona into the `workforce deploy` cloud-agent surface. When `true`, the deploy CLI considers this persona a deployable agent (validates {@link integrations } / {@link schedules } , prompts for integration connect, bundles {@link onEvent } , hands off to the runtime). Local `workforce agent ` flows ignore this flag — non-deploy use keeps working unchanged." + }, + "useSubscription": { + "type": "boolean", + "description": "When `true`, inference for this agent uses the user's connected LLM subscription via `@agent-relay/cloud`'s provider link, rather than workforce-billed tokens. The deploy CLI calls `connectProvider({...})` at deploy time. Only meaningful when {@link cloud } is `true`." + }, + "integrations": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PersonaIntegrationConfig" + }, + "description": "Per-provider integration declarations keyed by Relayfile provider slug (`github`, `linear`, `slack`, `notion`, `jira`). At deploy time the CLI runs `RelayfileSetup.connectIntegration({ allowedIntegrations: [key] })` for each provider not yet connected to the active workspace." + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaSchedule" + }, + "description": "Cron-style schedules. Each `name` is unique within the persona." + }, + "sandbox": { + "$ref": "#/definitions/PersonaSandbox", + "description": "Sandbox preference. `true` (default for cloud personas) means the agent runs inside a Daytona sandbox at deploy time; `false` runs it in the runner process. The object form lets the author tune timeout / env." + }, + "memory": { + "$ref": "#/definitions/PersonaMemory", + "description": "Memory subsystem opt-in. Wires the agent-assistant memory adapter at runtime; the persona spec only declares intent, not implementation details (api keys, adapter type, etc. come from workforce env)." + }, + "traits": { + "$ref": "#/definitions/PersonaTraits", + "description": "Conversational traits, applied only when the agent posts to a chat surface. Omit for headless agents." + }, + "onEvent": { + "type": "string", + "description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required when {@link cloud } is `true` and any trigger is declared; the deploy CLI enforces this at deploy time, the parser keeps it optional so partially- authored specs still parse." + } + }, + "required": [ + "id", + "intent", + "tags", + "description", + "skills", + "harness", + "model", + "systemPrompt", + "harnessSettings" + ], + "allOf": [ + { + "if": { + "type": "object", + "properties": { + "cloud": { + "const": true + } + }, + "required": [ + "cloud" + ] + }, + "then": { + "required": [ + "onEvent" + ] + } + } + ] + }, + "PersonaTag": { + "type": "string", + "enum": [ + "planning", + "implementation", + "review", + "testing", + "debugging", + "documentation", + "release", + "discovery", + "analytics" + ] + }, + "PersonaSkill": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "source", + "description" + ], + "description": "A skill is a named, reusable capability attached to a persona. `source` points to canonical guidance the persona should apply (e.g. a prpm.dev package URL, an internal runbook, a docs page)." + }, + "PersonaInputSpec": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Human-readable explanation shown in docs/catalog UIs." + }, + "env": { + "type": "string", + "description": "Environment variable to read when the launcher did not provide an explicit value. Defaults to the input key itself." + }, + "default": { + "type": "string", + "description": "Literal fallback used when neither an explicit value nor env var exists." + }, + "optional": { + "type": "boolean", + "description": "When true, the input is allowed to resolve to an empty string. The launcher substitutes `$NAME` with `''` rather than throwing `MissingPersonaInputError`. Use for inputs whose absence is meaningful — e.g. an upstream task description that may or may not be forwarded — and prefer non-optional inputs with a `default` for everything else so misconfigured launches surface loudly." + } + }, + "description": "Prompt-visible runtime input declared by a persona. Inputs are for non-secret run configuration such as output paths, target package names, or mode switches. Launchers resolve each input from explicit values, the process environment, or `default`, then substitute `$NAME` / `${NAME}` in the system prompt before spawning the harness." + }, + "Harness": { + "type": "string", + "enum": [ + "opencode", + "codex", + "claude" + ] + }, + "HarnessSettings": { + "type": "object", + "properties": { + "reasoning": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "timeoutSeconds": { + "type": "number" + }, + "sandboxMode": { + "$ref": "#/definitions/CodexSandboxMode", + "description": "Codex CLI sandbox mode for model-generated shell commands. Prefer `workspace-write` with `workspaceWriteNetworkAccess` when network is the only missing capability; `danger-full-access` is the fully unsandboxed fallback." + }, + "approvalPolicy": { + "$ref": "#/definitions/CodexApprovalPolicy", + "description": "Codex CLI approval policy (`--ask-for-approval`)." + }, + "workspaceWriteNetworkAccess": { + "type": "boolean", + "description": "Allow outbound network access inside Codex's workspace-write sandbox (`sandbox_workspace_write.network_access`)." + }, + "webSearch": { + "type": "boolean", + "description": "Enable the Codex live web-search tool for this runtime." + }, + "dangerouslyBypassApprovalsAndSandbox": { + "type": "boolean", + "description": "Emit codex's single `--dangerously-bypass-approvals-and-sandbox` flag, which collapses \"no sandbox + never ask for approval\" and also suppresses codex's interactive \"are you sure?\" startup confirmation. Mutually exclusive with `sandboxMode`, `approvalPolicy`, and `workspaceWriteNetworkAccess` — those translate to the two-flag form which still prompts." + } + }, + "required": [ + "reasoning", + "timeoutSeconds" + ] + }, + "CodexSandboxMode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "CodexApprovalPolicy": { + "type": "string", + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ] + }, + "McpServerSpec": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http", + "sse" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stdio" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "command" + ] + } + ], + "description": "MCP server config, structured to match Claude Code's `--mcp-config` JSON verbatim so the whole object can be passed through untouched. Values inside `headers` / `env` / `args` / `url` / `command` may be literal strings or `$VAR` / `${VAR}` references. Resolution happens in the runner/CLI at spawn time — this package only defines the shape, not the interpolation policy." + }, + "PersonaPermissions": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names/patterns to auto-approve." + }, + "deny": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names/patterns to always block." + }, + "mode": { + "$ref": "#/definitions/PermissionMode", + "description": "Permission mode for the session." + } + }, + "description": "Persona-level permission policy for the harness session. Translates to the harness's native allow/deny/mode flags at spawn time. Tool-pattern syntax is passed through verbatim — `\"mcp__posthog\"` to allow every posthog MCP tool, `\"mcp__posthog__projects-get\"` for a specific one, `\"Bash(git *)\"` for a shell pattern. See the target harness's docs for the exact grammar." + }, + "PermissionMode": { + "type": "string", + "enum": [ + "default", + "acceptEdits", + "bypassPermissions", + "plan" + ] + }, + "PersonaMount": { + "type": "object", + "properties": { + "ignoredPatterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "readonlyPatterns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "Relayfile mount policy for interactive sessions. Patterns use gitignore syntax. `ignoredPatterns` are omitted from the mount entirely; `readonlyPatterns` are copied into the mount but edits do not sync back. Launchers may merge these with project-level `.agentignore` / `.agentreadonly` dotfiles." + }, + "SidecarMdMode": { + "type": "string", + "enum": [ + "overwrite", + "extend" + ] + }, + "PersonaIntegrationConfig": { + "type": "object", + "properties": { + "scope": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "triggers": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaIntegrationTrigger" + } + } + }, + "description": "Per-provider integration configuration. The map key is the Relayfile provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` is provider-specific filter metadata (e.g. `{ repo: \"org/repo\" }` for github, `{ database: \"\" }` for notion). `triggers` are flat — all trigger events for this provider fan into the same `onEvent` handler, which discriminates on `event.source` + `event.type`." + }, + "PersonaIntegrationTrigger": { + "type": "object", + "properties": { + "on": { + "type": "string" + }, + "match": { + "type": "string" + }, + "where": { + "type": "string" + } + }, + "required": [ + "on" + ], + "description": "A single event trigger declared by an integration. `on` is a Relayfile- adapter-normalized event name (e.g. `pull_request.opened`, `issue.created`, `app_mention`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nExamples: { on: \"pull_request.opened\" } { on: \"issue_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" }" + }, + "PersonaSchedule": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": [ + "name", + "cron" + ], + "description": "A cron-style schedule. `name` is unique within the persona and surfaces to the handler as `event.name`. `cron` is a standard 5-field expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps it optional so the spec stays close to what the author wrote)." + }, + "PersonaSandbox": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/PersonaSandboxConfig" + } + ], + "description": "Sandbox can be specified as `true` / `false` shorthand or as the full config object. The parser preserves whichever form the author wrote so round-trips stay lossless; consumers normalize when reading." + }, + "PersonaSandboxConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "timeoutSeconds": { + "type": "number" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "description": "Long-form sandbox configuration. `enabled` defaults to true when the object form is present; supply the boolean shorthand `sandbox: false` to opt out entirely. `timeoutSeconds` caps a single handler invocation (default 1800s in the runtime). `env` is merged on top of auto-injected secrets at sandbox-create time.\n\nImage selection is intentionally not user-configurable in v1 — workforce picks a standard image. Add `image` later if a real demand surfaces." + }, + "PersonaMemory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/PersonaMemoryConfig" + } + ] + }, + "PersonaMemoryConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaMemoryScope" + } + }, + "ttlDays": { + "type": "number" + }, + "autoPromote": { + "type": "boolean" + }, + "dedupMs": { + "type": "number" + } + }, + "description": "Long-form memory configuration. Defaults are applied by the runtime, not the parser — the spec keeps only what the author actually wrote. `enabled` defaults to true when the object form is present." + }, + "PersonaMemoryScope": { + "type": "string", + "enum": [ + "session", + "user", + "workspace", + "org", + "object" + ], + "description": "Memory scope semantics, mirroring" + }, + "PersonaTraits": { + "type": "object", + "properties": { + "voice": { + "type": "string" + }, + "formality": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "proactivity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "riskPosture": { + "type": "string", + "enum": [ + "conservative", + "balanced", + "aggressive" + ] + }, + "domain": { + "type": "string" + }, + "vocabulary": { + "type": "array", + "items": { + "type": "string" + } + }, + "preferMarkdown": { + "type": "boolean" + } + }, + "description": "Conversational traits, applied only when the agent posts to a chat surface (Slack, Relaycast, GitHub PR comment). Headless agents — the paraglide \"Linear issue → PR\" pattern — should omit this field. Mirrors the trait shape in `@agent-assistant/traits`." + } + }, + "$id": "https://agentworkforce.dev/schemas/persona.schema.json" +} diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs new file mode 100644 index 00000000..b5612352 --- /dev/null +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { createGenerator } = require('ts-json-schema-generator'); + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(packageRoot, 'schemas/persona.schema.json'); +const tsconfigPath = resolve(packageRoot, 'tsconfig.json'); +const typesPath = resolve(packageRoot, 'src/types.ts'); + +const generator = createGenerator({ + path: typesPath, + tsconfig: tsconfigPath, + type: 'PersonaSpec', + expose: 'export', + topRef: true, + jsDoc: 'extended', + additionalProperties: true, + sortProps: true, + skipTypeCheck: true +}); + +const schema = generator.createSchema('PersonaSpec'); +schema.$schema = 'https://json-schema.org/draft/2020-12/schema'; +schema.$id = 'https://agentworkforce.dev/schemas/persona.schema.json'; +const personaSpecSchema = schema.definitions?.PersonaSpec; +if (personaSpecSchema) { + personaSpecSchema.allOf = [ + ...(personaSpecSchema.allOf ?? []), + { + if: { + type: 'object', + properties: { + cloud: { const: true } + }, + required: ['cloud'] + }, + then: { + required: ['onEvent'] + } + } + ]; +} + +const serialized = `${JSON.stringify(schema, null, 2)}\n`; +await mkdir(dirname(schemaPath), { recursive: true }); + +let existing = ''; +try { + existing = await readFile(schemaPath, 'utf8'); +} catch { + // First emission. +} + +if (existing !== serialized) { + await writeFile(schemaPath, serialized); +} diff --git a/packages/persona-kit/src/__fixtures__/personas/cron-only.json b/packages/persona-kit/src/__fixtures__/personas/cron-only.json new file mode 100644 index 00000000..04975c56 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/cron-only.json @@ -0,0 +1,18 @@ +{ + "id": "cron-only", + "intent": "documentation", + "tags": ["documentation"], + "description": "A cloud persona with schedules and no integration triggers.", + "skills": [], + "cloud": true, + "schedules": [ + { "name": "weekly-digest", "cron": "0 9 * * 6", "tz": "UTC" }, + { "name": "weekday-check", "cron": "0 9 * * 1-5", "tz": "America/New_York" } + ], + "sandbox": true, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Summarize scheduled work.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/full.json b/packages/persona-kit/src/__fixtures__/personas/full.json new file mode 100644 index 00000000..db2432b8 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/full.json @@ -0,0 +1,100 @@ +{ + "id": "full-deploy", + "intent": "review", + "tags": ["review", "implementation"], + "description": "A full deploy-v1 persona fixture with every deploy field populated.", + "skills": [ + { + "id": "review-rubric", + "source": "https://prpm.dev/packages/@agentworkforce/review-rubric", + "description": "Review code using the team rubric." + } + ], + "inputs": { + "TOPICS": { + "description": "Comma-separated topic list.", + "default": "runtime,deploy,integrations" + }, + "OPTIONAL_CONTEXT": { + "description": "Optional context from the deploy caller.", + "optional": true + } + }, + "cloud": true, + "useSubscription": true, + "integrations": { + "github": { + "scope": { "repo": "AgentWorkforce/workforce" }, + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "issue_comment.created", "match": "@mention" }, + { "on": "check_run.completed", "where": "conclusion=failure" } + ] + }, + "linear": { "triggers": [{ "on": "issue.created" }] }, + "slack": { "triggers": [{ "on": "app_mention" }] }, + "notion": { + "scope": { "database": "db_123" }, + "triggers": [{ "on": "page.updated" }] + }, + "jira": { + "scope": { "project": "ENG" }, + "triggers": [{ "on": "issue.created" }] + } + }, + "schedules": [{ "name": "daily-review", "cron": "0 14 * * 1-5", "tz": "UTC" }], + "sandbox": { + "enabled": true, + "timeoutSeconds": 1800, + "env": { "NODE_ENV": "production" } + }, + "memory": { + "enabled": true, + "scopes": ["session", "user", "workspace"], + "ttlDays": 30, + "autoPromote": true, + "dedupMs": 300000 + }, + "traits": { + "voice": "professional-warm", + "formality": "low", + "proactivity": "medium", + "riskPosture": "conservative", + "domain": "engineering", + "vocabulary": ["PR", "diff", "CI"], + "preferMarkdown": true + }, + "onEvent": "./agent.ts", + "env": { "BRAVE_API_KEY": "$BRAVE_API_KEY" }, + "mcpServers": { + "docs": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer $DOCS_TOKEN" } + } + }, + "permissions": { + "allow": ["Bash(git *)"], + "deny": ["Bash(rm -rf *)"], + "mode": "default" + }, + "mount": { + "ignoredPatterns": ["node_modules/**"], + "readonlyPatterns": ["vendor/**"] + }, + "claudeMd": "CLAUDE.md", + "claudeMdMode": "extend", + "agentsMd": "AGENTS.md", + "agentsMdMode": "overwrite", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Review code and explain risks clearly.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 600, + "sandboxMode": "workspace-write", + "approvalPolicy": "on-request", + "workspaceWriteNetworkAccess": true, + "webSearch": true + } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json b/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json new file mode 100644 index 00000000..dad7f079 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json @@ -0,0 +1,21 @@ +{ + "id": "invalid-unknown-trigger", + "intent": "review", + "tags": ["review"], + "description": "A schema-valid persona fixture with one trigger lint warning.", + "skills": [], + "cloud": true, + "integrations": { + "github": { + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "pull_request.evaporated" } + ] + } + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Review code.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/minimal.json b/packages/persona-kit/src/__fixtures__/personas/minimal.json new file mode 100644 index 00000000..929033f7 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/minimal.json @@ -0,0 +1,11 @@ +{ + "id": "minimal", + "intent": "documentation", + "tags": ["documentation"], + "description": "A minimal persona fixture without deploy fields.", + "skills": [], + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Write clear documentation.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts new file mode 100644 index 00000000..c4ab1119 --- /dev/null +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { parsePersonaSpec } from './parse.js'; +import { lintTriggers } from './triggers.js'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixturesDir = resolve(packageRoot, 'src/__fixtures__/personas'); +const schemaPath = resolve(packageRoot, 'schemas/persona.schema.json'); + +test('persona fixtures validate against generated schema and parse', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + const fixtureNames = (await readdir(fixturesDir)).filter((name) => name.endsWith('.json')).sort(); + + assert.deepEqual(fixtureNames, [ + 'cron-only.json', + 'full.json', + 'invalid-unknown-trigger.json', + 'minimal.json' + ]); + + for (const fixtureName of fixtureNames) { + const fixture = JSON.parse(await readFile(resolve(fixturesDir, fixtureName), 'utf8')); + assertSchema(fixture, schema, schema, fixtureName); + + const parsed = parsePersonaSpec(fixture, fixture.intent); + const triggerIssues = lintTriggers(parsed); + if (fixtureName === 'invalid-unknown-trigger.json') { + assert.equal(triggerIssues.length, 1); + assert.equal(triggerIssues[0].code, 'unknown_trigger'); + } else { + assert.deepEqual(triggerIssues, []); + } + } +}); + +test('emit-schema script is idempotent', async () => { + const before = await readFile(schemaPath, 'utf8'); + execFileSync('node', [resolve(packageRoot, 'scripts/emit-schema.mjs')], { + cwd: packageRoot, + stdio: 'pipe' + }); + const after = await readFile(schemaPath, 'utf8'); + assert.equal(after, before); +}); + +test('generated schema requires onEvent for cloud personas', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + const fixture = JSON.parse(await readFile(resolve(fixturesDir, 'minimal.json'), 'utf8')); + + assertSchema({ ...fixture, cloud: true, onEvent: './agent.ts' }, schema, schema, 'cloud-persona'); + assert.throws( + () => assertSchema({ ...fixture, cloud: true }, schema, schema, 'cloud-persona'), + /cloud-persona\.onEvent is required/ + ); +}); + +type SchemaNode = Record & { + $ref?: string; + allOf?: SchemaNode[]; + anyOf?: SchemaNode[]; + if?: SchemaNode; + then?: SchemaNode; + enum?: unknown[]; + const?: unknown; + type?: string | string[]; + properties?: Record; + additionalProperties?: SchemaNode | boolean; + required?: string[]; + items?: SchemaNode; +}; + +function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path: string): void { + if (schema.$ref) { + return assertSchema(value, resolveRef(schema.$ref, root), root, path); + } + if (schema.allOf) { + for (const candidate of schema.allOf) { + assertSchema(value, candidate, root, path); + } + } + if (schema.if && matchesSchema(value, schema.if, root, path) && schema.then) { + assertSchema(value, schema.then, root, path); + } + if (schema.anyOf) { + const errors = []; + for (const candidate of schema.anyOf) { + try { + assertSchema(value, candidate, root, path); + return; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + throw new Error(`${path} must match one schema in anyOf: ${errors.join('; ')}`); + } + if (schema.enum && !schema.enum.includes(value)) { + throw new Error(`${path} must be one of: ${schema.enum.map((v) => String(v)).join(', ')}`); + } + if (schema.const !== undefined && value !== schema.const) { + throw new Error(`${path} must equal ${JSON.stringify(schema.const)}`); + } + if (schema.type) { + assertType(value, schema.type, path); + } + if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) { + if (!isObject(value)) { + throw new Error(`${path} must be an object`); + } + for (const requiredKey of schema.required ?? []) { + if (!(requiredKey in value)) { + throw new Error(`${path}.${requiredKey} is required`); + } + } + const properties = schema.properties ?? {}; + for (const [key, childValue] of Object.entries(value)) { + const childSchema = properties[key] ?? schema.additionalProperties; + if (childSchema === false) { + throw new Error(`${path}.${key} is not allowed`); + } + if (childSchema && childSchema !== true) { + assertSchema(childValue, childSchema as SchemaNode, root, `${path}.${key}`); + } + } + } + if (schema.type === 'array' || schema.items) { + if (!Array.isArray(value)) { + throw new Error(`${path} must be an array`); + } + if (schema.items) { + value.forEach((item, index) => + assertSchema(item, schema.items as SchemaNode, root, `${path}[${index}]`) + ); + } + } +} + +function matchesSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path: string): boolean { + try { + assertSchema(value, schema, root, path); + return true; + } catch { + return false; + } +} + +function assertType(value: unknown, type: string | string[], path: string): void { + const types = Array.isArray(type) ? type : [type]; + const ok = types.some((candidate) => { + switch (candidate) { + case 'array': + return Array.isArray(value); + case 'integer': + return Number.isInteger(value); + case 'null': + return value === null; + case 'object': + return isObject(value); + default: + return typeof value === candidate; + } + }); + if (!ok) { + throw new Error(`${path} must be ${types.join('|')}`); + } +} + +function resolveRef(ref: string, root: SchemaNode): SchemaNode { + const parts = ref.replace(/^#\//, '').split('/'); + let current: unknown = root; + for (const part of parts) { + current = (current as Record)[part]; + } + return current as SchemaNode; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 97301547..b6dec013 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -86,6 +86,7 @@ export { lintTriggers, type KnownProviderName, type KnownTriggerName, + type TriggerLintCode, type TriggerLintIssue, type TriggerLintLevel } from './triggers.js'; diff --git a/packages/persona-kit/src/triggers.test.ts b/packages/persona-kit/src/triggers.test.ts index 9f741186..c31ef311 100644 --- a/packages/persona-kit/src/triggers.test.ts +++ b/packages/persona-kit/src/triggers.test.ts @@ -59,6 +59,7 @@ test('lintTriggers warns once per unknown provider', () => { ); assert.equal(issues.length, 1); assert.equal(issues[0].level, 'warning'); + assert.equal(issues[0].code, 'unknown_provider'); assert.equal(issues[0].provider, 'mysteryapp'); assert.equal(issues[0].path, 'integrations.mysteryapp'); }); @@ -79,6 +80,7 @@ test('lintTriggers warns per unknown trigger for a known provider', () => { assert.deepEqual(triggers, ['made.up', 'pull_request.really_truly_new_event']); for (const issue of issues) { assert.equal(issue.level, 'warning'); + assert.equal(issue.code, 'unknown_trigger'); assert.equal(issue.provider, 'github'); assert.match(issue.path, /integrations\.github\.triggers\[\d+\]\.on/); } diff --git a/packages/persona-kit/src/triggers.ts b/packages/persona-kit/src/triggers.ts index 3d221e88..cd32bbd4 100644 --- a/packages/persona-kit/src/triggers.ts +++ b/packages/persona-kit/src/triggers.ts @@ -33,8 +33,15 @@ export type KnownTriggerName

= (typeof KNOWN_TRIGGE export type TriggerLintLevel = 'warning'; +/** + * Machine-readable issue category, so callers can branch on + * `issue.code` without parsing the human-readable `message`. + */ +export type TriggerLintCode = 'unknown_provider' | 'unknown_trigger'; + export interface TriggerLintIssue { level: TriggerLintLevel; + code: TriggerLintCode; /** Provider slug the issue was raised under (`github`, `linear`, …). */ provider: string; /** The trigger name that was flagged. */ @@ -69,6 +76,7 @@ export function lintTriggers(persona: PersonaSpec): TriggerLintIssue[] { // catalogued yet. issues.push({ level: 'warning', + code: 'unknown_provider', provider, trigger: '*', path: `integrations.${provider}`, @@ -81,6 +89,7 @@ export function lintTriggers(persona: PersonaSpec): TriggerLintIssue[] { if (!known.includes(trigger.on)) { issues.push({ level: 'warning', + code: 'unknown_trigger', provider, trigger: trigger.on, path: `integrations.${provider}.triggers[${idx}].on`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8c3cbf9..0a18a168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,10 @@ importers: '@relayfile/local-mount': specifier: ^0.7.0 version: 0.7.0 + devDependencies: + ts-json-schema-generator: + specifier: ^2.3.0 + version: 2.9.0 packages/personas-core: {} @@ -958,6 +962,9 @@ packages: resolution: {integrity: sha512-G/gWDykZNL0NVcd1qXkoKm45jxJECp6q53DSomM5QKMsyAMEsGksVq+HwgonqYxfFJEzzHi6ljtWKXVS1pl0/Q==} engines: {node: '>=18.0.0'} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -994,12 +1001,20 @@ packages: axios@1.16.0: resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1053,6 +1068,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1171,6 +1190,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1233,6 +1256,11 @@ packages: peerDependencies: ws: '*' + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -1243,6 +1271,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1267,6 +1299,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1284,6 +1320,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1300,6 +1340,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1351,6 +1395,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + shell-quote@1.8.3: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} @@ -1403,6 +1451,11 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-json-schema-generator@2.9.0: + resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} + engines: {node: '>=22.0.0'} + hasBin: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2663,6 +2716,8 @@ snapshots: '@smithy/core': 3.24.1 tslib: 2.8.1 + '@types/json-schema@7.0.15': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -2697,10 +2752,16 @@ snapshots: transitivePeerDependencies: - debug + balanced-match@4.0.4: {} + base64-js@1.5.1: {} bowser@2.14.1: {} + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2749,6 +2810,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2887,6 +2950,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -2934,6 +3003,8 @@ snapshots: dependencies: ws: 8.20.0 + json5@2.2.3: {} + lodash.camelcase@4.3.0: {} log-symbols@7.0.1: @@ -2943,6 +3014,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.3.6: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -2960,6 +3033,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minipass@7.1.3: {} minizlib@3.1.0: @@ -2972,6 +3049,8 @@ snapshots: node-addon-api@7.1.1: {} + normalize-path@3.0.0: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2991,6 +3070,11 @@ snapshots: path-expression-matcher@1.5.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3046,6 +3130,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + shell-quote@1.8.3: {} signal-exit@4.1.0: {} @@ -3098,6 +3184,17 @@ snapshots: dependencies: is-number: 7.0.0 + ts-json-schema-generator@2.9.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 14.0.3 + glob: 13.0.6 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.5.0 + tslib: 2.8.1 + typescript: 5.9.3 + tslib@2.8.1: {} typescript@5.9.3: {} From 340db0cd2a6d2d164c60dd1e72cb72bb041fa34d Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 01:35:50 +0200 Subject: [PATCH 5/6] fix(persona-kit): align schema fixtures with locked v1 spec --- docs/plans/deploy-v1-schema-cascade-spec.md | 1451 --------------- docs/plans/deploy-v1.md | 79 +- examples/weekly-digest/persona.json | 1 - packages/deploy/src/modes/sandbox.ts | 13 +- .../persona-kit/schemas/persona.schema.json | 95 +- .../src/__fixtures__/personas/cron-only.json | 1 - .../src/__fixtures__/personas/full.json | 16 +- packages/persona-kit/src/emit-schema.test.ts | 15 + packages/persona-kit/src/index.ts | 5 - packages/persona-kit/src/parse.test.ts | 105 +- packages/persona-kit/src/parse.ts | 111 +- packages/persona-kit/src/types.ts | 77 +- packages/runtime/src/index.ts | 3 +- packages/runtime/src/types.ts | 2 +- ...ade-persona-refactor-status-ready-for-r.ts | 1551 ----------------- 15 files changed, 104 insertions(+), 3421 deletions(-) delete mode 100644 docs/plans/deploy-v1-schema-cascade-spec.md delete mode 100644 workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts diff --git a/docs/plans/deploy-v1-schema-cascade-spec.md b/docs/plans/deploy-v1-schema-cascade-spec.md deleted file mode 100644 index 9146bee2..00000000 --- a/docs/plans/deploy-v1-schema-cascade-spec.md +++ /dev/null @@ -1,1451 +0,0 @@ -# Ricky workflow spec — Deploy v1 schema cascade + persona refactor - -**Status:** ready for Ricky to generate + run a workflow. -**Resolves:** locked-in decisions from cloud#553 thread + two May 12 architecture meetings. -**Companion docs:** -- `workforce/docs/plans/deploy-v1.md` (product plan) -- `workforce/docs/plans/deploy-v1-codex-spec.md` -- `workforce/docs/plans/deploy-v1-workflow-spec.md` (reference workflow shape — mirror conventions) - -**Reference workflow file (shape to mirror):** `cloud-proactive-runtime-spec/workflows/proactive-runtime-m1.ts` - -**Hard precondition (cleared):** workforce#95 (`refactor/flatten-persona-tiers`) **MERGED 2026-05-12T21:09:01Z**. All tracks may start. - -**Current state of upstream dependencies (verify at workflow start):** -- workforce#95 — **MERGED 2026-05-12T21:09:01Z**. Hard gate cleared. -- relay#844 — **MERGED 2026-05-12T19:50:04Z**; `@agent-relay/events@6.0.17` + `@agent-relay/agent@6.0.17` published. Track C coordination comment already posted. -- agent-assistant#91 — MERGED; `@agent-assistant/proactive@0.4.32` published. Track E4 picks this up. -- workforce#97 (`feat/persona-integration-source`) — DRAFT, ready for rebase in Track E5 after Track D. -- cloud#548 (now M1-M6, title stale) — open, +33k/-57. Trigger registration code VERIFIED (schedules → relaycron via `services/agent-gateway/src/relaycron-client.ts:registerCronSchedules()`; watches → gateway DO via `packages/agent-relay-agent/src/index.ts:registerWatches()` at agent startup). Missing piece for persona+bundle deploy: persona → watch-glob translation; lives in Track G below. **Track A must rebase on #548's migrations if #548 merges first; review showed #548 is additive on existing `agent_deployments`, so two-table split is layerable.** -- relay#843 — **MERGED 2026-05-12T21:30:54Z**; publish workflow run `25763431116` **completed 21:49:38 UTC**. All `@agent-relay/*` packages now at **`6.0.18`** (lockstep umbrella bump from `6.0.17`). Track L pins to `^6.0.18`. -- relaycron#5 — **MERGED 2026-05-12T21:32:06Z**; `@relaycron/server@0.1.3` and `@relaycron/types@0.1.3` published 21:35 UTC. Track G's preflight on relaycron#5 already cleared; Track M (cloud `@relaycron/*` pin bump) targets `^0.1.3`. -- relayauth#39 — open, docs-only +3/-1. No spec impact. -- cloud#554 (Daytona meter) — draft, platform-team gates only. -- cloud#555 (workflow-invocations shim) — draft, 2 follow-ups in Track J. - ---- - -## How to consume - -Generate one workflow TS file (suggested name `workforce-schema-cascade.ts`) under `cloud-proactive-runtime-spec/workflows/` that orchestrates the six tracks below. - -- Tracks A and C run on cloud; D, E, F on workforce. -- A, C may run in parallel from workflow start. -- B depends on A. -- D depends on workforce#95 merged. -- E depends on D. -- F depends on D merged AND A merged. - -### Run command - -```sh -npx tsx cloud-proactive-runtime-spec/workflows/workforce-schema-cascade.ts -``` - -### Required env - -``` -HOME=/Users/khaliqgant -ROOT=$HOME/Projects/AgentWorkforce - -CLOUD_REPO=$ROOT/cloud -WORKFORCE_REPO=$ROOT/workforce -RELAY_REPO=$ROOT/relay # read-only — verify relay#844 merge state -``` - -### Required secrets - -``` -GITHUB_TOKEN -``` - -### Coordination - -Hub-spoke. Lead Claude Opus stays on `#wf-schema-cascade` as architect + ambient reviewer. - -### Never-fail mechanics (mirror `proactive-runtime-m1.ts`) - -- Every test / typecheck / regression gate runs as **soft → fixer → hard**. -- Two review rounds: peer review → signoff → router → fix-r2 → final signoff. If round 2 still has gaps, the PR opens as DRAFT with the gap list in the body. Workflow exits 0. -- Global `onError`: retry 2× with 10s backoff. -- Per-track self-reflection vs that track's section below. - -### Worktree layout - -``` -$CLOUD_REPO # Track A (issue body + migrations PR) — operates in place -$CLOUD_REPO.wt-resolver # Track B -$WORKFORCE_REPO # Track D (persona-kit refactor — owns primary checkout) -$WORKFORCE_REPO.wt-runtime # Track F (runtime input-values resolution) -$WORKFORCE_REPO.wt-rebase-92 # Track E sub-tracks (one worktree per workforce PR) -$WORKFORCE_REPO.wt-rebase-93 -$WORKFORCE_REPO.wt-rebase-94 -$WORKFORCE_REPO.wt-rebase-96 -$WORKFORCE_REPO.wt-rebase-97 -``` - -Create with `git worktree add -b `. Cleanup: never run `git worktree remove`. Leave worktrees in place for human inspection. - -### PR conventions - -- **PRs open as DRAFT initially. Workflow auto-flips to ready + AUTO-MERGES** when all gates pass (see Auto-merge directive below). Khaliq has explicitly authorized auto-merge for this workflow so when he wakes up everything is wired and ready to test. -- Title format: `():

`. -- Body references this spec file path (`workforce/docs/plans/deploy-v1-schema-cascade-spec.md`) and a per-track acceptance checklist (checked/unchecked). -- Commit trailer: `Co-Authored-By: via Ricky `. -- No `--no-verify`. Fix hook failures and create a new commit. - -### Auto-merge directive (FLIP from prior DRAFT-only posture) - -After each track's PR is opened, the workflow's lead Claude: - -1. **Waits for CI green** on the PR (all required checks passing, no failures, no in-progress). -2. **Runs the soft → fixer → hard gate loop** until tests + typecheck are clean. -3. **Verifies all upstream dependencies are merged** (per the Merge DAG below). -4. **Verifies no merge conflicts** with target base branch. -5. **Verifies no human review has requested changes** (`gh pr view --json reviews` returns no `CHANGES_REQUESTED` from a non-bot reviewer). -6. **Flips PR from draft to ready** (`gh pr ready `). -7. **Merges via squash** (`gh pr merge --squash --auto`) — uses `--auto` so if CI is still settling, GitHub merges as soon as it goes green. -8. **Posts a status line into `#wf-schema-cascade`**: "merged: (#X)". - -**Gates that BLOCK auto-merge** (workflow stops cascade, posts loud alert): -- Any required CI check returns FAILURE after the fixer loop. -- Any human reviewer left `CHANGES_REQUESTED` (don't override). -- **Any unresolved review comment thread** from a human reviewer — query `gh api repos///pulls//comments` and skip auto-merge if any thread has `in_reply_to_id` chains where the last reply is from a non-bot reviewer and the thread isn't marked resolved. ("As long as there are no outstanding review comments" — Khaliq, May 12.) -- Merge conflict that fixer can't resolve. -- A downstream-track PR was already opened and its CI breaks post-merge of an upstream track → STOP, do not merge further. - -**Cross-repo merge ordering:** the workflow walks the Merge DAG (below) topologically. Within a single repo, tracks merge sequentially. Across repos, paired-contract PRs (cloud#548 + relaycron#5) merge as a pair via short polling: workflow merges cloud#548 first, then immediately verifies relaycron#5 still green + merges it; if relaycron#5 breaks in between, the workflow flags it but doesn't roll back cloud#548 (Khaliq handles). - -**What the workflow will NOT auto-merge:** -- workforce#89 (README rewrite — DRAFT by design, docs polish, not blocking). -- workforce#87 (proactive-agent-builder persona) — auto-merge IF #87 still has the `parseInputsShape` `optional: true` regression fix, since Track F's input resolution depends on it. Otherwise skip. -- cloud#554 (Daytona meter) — platform-team gates on meter name + autostop reconciliation; flag for Khaliq's morning review, don't merge. -- Anything in the "Out of scope" list. - -**Rollback policy:** the workflow doesn't auto-revert. If a merge breaks a downstream track, the workflow stops, posts the broken state, and leaves all repos in their merged-so-far state for Khaliq to inspect. This is intentional: incomplete cascade is recoverable; rolling back partial cross-repo merges is not. - ---- - -## Out of scope (DO NOT implement) - -The following decisions were explicitly punted in the May 12 meetings. **Ricky must NOT enact any of these.** If an implementer agent proposes changes in these areas, fail the soft-gate. - -1. **Multi-persona collaboration team table.** `agent_teams` or similar grouping table is NOT in v1. RelayCast workspace IS the de facto grouping; the only multi-agent observability is the `spawned_by_agent_id` back-pointer in Track A. -2. **Persona-spec timeout fields.** Timeouts are runtime-managed for v1 with sensible defaults per `trigger_kind`. Don't add `timeout_seconds` to `PersonaSpec`. -3. **`workforce deployments destroy/list` CLI commands.** M3 milestone — separate workflow file. -4. **Persona-personality-builder tool.** Future package; not part of persona-kit v1. -5. **Trait → expression auto-mapping** in the proactive bridge. Traits removed entirely from persona spec (Track D); no replacement in v1. -6. **LLM-judge timeout resumption logic.** Khaliq mentioned as "an option for later" — runtime layer, not schema. -7. **`@workforce/daytona-runner` npm publish.** A separate agent is handling publishing under the `@workforce` OIDC trusted-publisher scope. Do NOT touch the daytona-runner package or its workspace ref in this workflow. - -### Loud hole: memory wiring (intentionally out of scope, intentionally loud) - -Memory is NOT wired end-to-end after this workflow completes. The schema has the supermemory pointer in External state, and `PersonaSpec.memory` declares `scopes` + `ttlDays`, but **the runtime does not inject the supermemory API key, does not call save/recall, and `ctx.memory` returns a stub.** - -This is a deliberate hole. Memory architecture is being worked through separately. After this workflow lands: - -- `ctx.memory.save(...)` will type-check and compile, but at runtime it will log a warning and no-op. -- `ctx.memory.recall(...)` returns `[]`. -- A deployed agent that calls memory APIs runs cleanly but has no persistence. - -**When memory IS wired** (separate follow-up spec), the locked decisions from the May 12 diagrams are: - -- `enabled: bool` -- `scopes: 'workspace' | 'user' | 'global'` (per image 1 of the whimsical diagram — note: **no `session` scope, and a `global` scope is added** vs the old deploy-v1.md prose). -- `ttl: number` - -Track D's persona-kit refactor MUST keep `PersonaSpec.memory.scopes` accepting `'workspace' | 'user' | 'global'` (drop `session` from the accepted union if it's there from pre-flatten code). - -**Document the hole prominently:** every track's PR body MUST include the following line in a "Known gaps after this PR" section: - -> ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). - -A separate spec — `docs/plans/deploy-v1-memory-spec.md` — will own the wiring. Out of scope here. - -### Terminology notes (diagram ↔ schema) - -The May 12 whimsical diagrams use a few names that differ from the locked schema. Ricky must use the **schema** names in code and migrations; diagram names are informal aliases. - -| Diagram term | Schema term | Notes | -|---|---|---| -| `harnesses` table | `provider_credentials` | The "user-owned llm credential" row — `(user_id, model_provider, auth_type, label)`. The diagram's "harness" is the runner program (claude code, codex, opencode); the row is the credential to run it. Schema name `provider_credentials` stays. | -| `harnessShare` field | `provider_credentials.label` (or N/A) | The diagram's right-side table sketch was lossy here; treat as informal. | -| "Listeners" (image 1, item 4) + "schedule" (image 1, item 5) | `PersonaSpec.integrations.

.triggers[]` + `PersonaSpec.schedules[]` | Listed adjacently in image 1; spec keeps the existing shape with listeners as the unifying narrative (Track D JSDoc). | -| "Setup relaycast environment + agents.md with relaycast credentials" (image 3) | Runtime concern in cloud#548's agent-gateway, NOT a persona-spec field | Every deployed agent gets relaycast wired so it can communicate. Doesn't require the persona to declare an `inbox` listener. | - ---- - -## Track A — Cloud #553 schema lock-ins (issue body + migrations PR) - -**Repo:** `$CLOUD_REPO` (operates in place — single track on this repo at a time) -**Implementer model:** codex (high reasoning). -**Working branch:** `chore/db1-schema-lockin` -**PR title:** `feat(db): DB1 schema lock-ins per cloud#553 thread` - -**Allowed-dirty regex:** `package(-lock)?\.json|packages/web/drizzle/.*|packages/web/lib/db/.*|packages/web/lib/proactive-runtime/.*|docs/.*` - -### A1 — Update issue body of cloud#553 - -Read the current issue body first: -```bash -gh issue view 553 --repo AgentWorkforce/cloud --json body -q .body > /tmp/553-current.md -``` - -Edit the body to reflect ALL of the following lock-ins. If a lock-in is already in the body (Will has applied some already), leave it; only add what's missing. - -#### Two-table agent model (multi-instance per persona) - -Replace the existing single `agent_deployments` definition with a two-table model: - -**`agents`** — persona-level, addressable identity. One row per `(workspace_id, persona_id)` not yet destroyed. - -| Column | Notes | -|---|---| -| `id` uuid PK | The addressable agent ID — used in inter-agent communication, billing, observability grouping | -| `workspace_id` uuid FK→workspaces | | -| `persona_id` uuid FK→personas | | -| `deployed_name` text | denorm of `persona.slug` at deploy time | -| `deployed_by_user_id` uuid FK→users | | -| `credential_selections` jsonb | per-provider credential pick | -| `input_values` jsonb | per-deployment overrides for `persona.spec.inputs` | -| `pinned_version_id` uuid NULL FK→persona_versions | when NULL, agent tracks persona's latest version | -| `spec_hash_at_deploy` text | for "agent is behind persona" UI | -| `status` enum | `active \| disabled \| error \| destroyed` | -| `destroyed_at` timestamptz NULL | | -| `destroyed_by_user_id` uuid NULL | | -| `spawned_by_agent_id` uuid NULL FK→agents(id) | observability when one agent spawns another | -| `last_used_at`, `last_error` | | - -`UNIQUE (workspace_id, persona_id) WHERE status != 'destroyed'` -`UNIQUE (workspace_id, deployed_name) WHERE status != 'destroyed'` - -**`agent_deployments`** — per-running-instance row (a "head"). Many rows per `agents.id`. Two simultaneous Linear-ticket triggers for the same agent fan out to two `agent_deployments` rows under one `agents` row. - -| Column | Notes | -|---|---| -| `id` uuid PK | per-instance ID | -| `agent_id` uuid FK→agents | | -| `trigger_kind` text | `'inbox' \| 'clock' \| 'radio'` | -| `trigger_payload` jsonb | what fired this deployment (cron name, integration event envelope, inbox message id, etc.) | -| `started_at`, `last_active_at` timestamptz | | -| `status` enum | `running \| idle \| timed_out \| completed \| failed` | -| `spec_hash_at_run` text | snapshot of which spec version this instance executed | -| `timed_out_at` timestamptz NULL | set when this deployment times out | -| `compaction_summary` text NULL | LLM-summarized conversation written when this deployment compacts | -| `parent_deployment_id` uuid NULL FK→agent_deployments(id) | chain to prior compaction so the "thread" of a conversation is reconstructable | - -Add a `## Multi-instance + compaction semantics` section in the issue body: - -> A single `agents` row can have N concurrent `agent_deployments`. Two simultaneous triggers (e.g. two Linear tickets arriving for the same MSD agent) fan out to two `agent_deployments` rows. Each deployment has its own conversation context. -> -> **Timeouts are runtime-managed**, per `trigger_kind`: human DM ≈ 5 min idle, GitHub review ≈ 24h, etc. (not in persona spec for v1.) -> -> **On timeout: compaction.** Runtime runs a compaction step — LLM summarizes the conversation; `compaction_summary` written; `timed_out_at` set; status moves to `timed_out`. The next trigger creates a new `agent_deployments` row with `parent_deployment_id` pointing at the timed-out row; the new row's system prompt is seeded from the parent's `compaction_summary`. - -#### Integrations — two-table model - -Already in body per Will's earlier edits — verify: -- `user_integrations` + `workspace_integrations`, nullable `name`, partial-unique indexes. `workspace_service_accounts` absorbed via `name IS NOT NULL`. - -Add if missing: -- **`adapter` column on both integration tables** — `text NOT NULL DEFAULT 'nango'`, values `'nango' | 'composio' | 'pipedream'`. Will explicitly: "There should be adapter." Cloud already brokers via Composio (`packages/web/lib/integrations/composio-service.ts`); Pipedream is in the picture too. - -#### `integration_scopes` generic table — replaces `slack_channel_configs` - -``` -integration_scopes - id uuid PK - user_integration_id uuid NULL FK→user_integrations(id) - workspace_integration_id uuid NULL FK→workspace_integrations(id) - scope_kind text -- 'slack_channel' | 'github_repo' | 'jira_project' | 'notion_database' | … - scope_id text -- provider-side id (channel id, repo full_name, project key, …) - config_json jsonb -- per-kind extras (enabled flag, mode, etc.), zod-validated by scope_kind - created_at, updated_at - CHECK ((user_integration_id IS NULL) <> (workspace_integration_id IS NULL)) - UNIQUE (user_integration_id, scope_kind, scope_id) WHERE user_integration_id IS NOT NULL - UNIQUE (workspace_integration_id, scope_kind, scope_id) WHERE workspace_integration_id IS NOT NULL -``` - -Mirrors the two-table integration pattern via two nullable FKs + CHECK. - -#### `persona_versions` table — in v1 - -``` -persona_versions - id uuid PK - persona_id uuid FK→personas - version int - spec jsonb - spec_hash text - created_at timestamptz - UNIQUE (persona_id, version) - UNIQUE (persona_id, spec_hash) -``` - -Add authoring note: "The persona-maker authoring agent writes a new `persona_versions` row on each persona edit. No separate version-management UI in v1." - -`agents.spec_snapshot jsonb` is removed; replaced by `agents.pinned_version_id uuid NULL FK→persona_versions(id)`. When NULL, agent tracks persona's latest version. - -#### `cli_auth_sessions` split - -Rename existing table → `cloud_cli_bootstrap_sessions` (preserves Daytona + SSH bootstrap shape). - -Add new: -``` -workforce_cli_auth_sessions - id uuid PK - user_id uuid FK→users - code_challenge text - code_challenge_method text - state text - redirect_uri text - token_hash text NULL -- set on successful exchange; nulled on revoke - issued_at, exchanged_at, expires_at, revoked_at timestamptz -``` - -#### Sharing rule prose - -Replace any "OAuth credentials cannot be shared org-wide" language with: - -> A persona can be shared org-wide regardless of credential type. The persona itself is shareable; credentials are deployer-scoped. Deploys fail with a clear error when the deploying user hasn't connected the required credential. - -#### GitHub App + user OAuth combine (resolution flow doc) - -Add to §"Resolution at deployment-run time": - -> For provider `github`, `source: { kind: 'deployer_user' }` loads the deployer's `user_integrations` row **and** the workspace's matching `workspace_integrations` row (matching workspace + provider, `name IS NULL`). Both are required at runtime: the App install gates repo access (workspace `installation_id`); the user OAuth identifies the actor. - -#### Sub-agents / teams note - -Add to schema doc: - -> **Harness sub-instances** inside a handler invocation are captured in `session_events`, not new `agents` or `agent_deployments` rows. -> -> **Multi-persona teams.** When agent A spawns agent B (a different persona), B gets its own `agents` row. RelayCast workspace IS the de facto team grouping in v1; no new `agent_teams` table. `agents.spawned_by_agent_id NULL` is the observability back-pointer. - -#### External state: sandbox-minute metering - -Add row to the External state table: - -| Concern | Stored in | How DB1 references it | -|---|---|---| -| Sandbox-minute usage events | platform metering pipeline (emitted via structured `logger.info` from `packages/web/app/api/v1/workspaces/[workspaceId]/sandboxes/workforce-sandbox-meter.ts`) | events carry `agent_id`, `workspace_id`, `sandbox_id`; no DB1 row; reconcile in billing dashboard | - -#### Lock-in revision history - -Add a `## Lock-in revision history` section at the bottom of the issue body referencing this workflow run + the May 12 transcripts + the date. - -Apply via: -```bash -gh issue edit 553 --repo AgentWorkforce/cloud --body-file /tmp/553-updated.md -``` - -### A2 — Open migrations PR - -Branch `chore/db1-schema-lockin` off `origin/main` in `$CLOUD_REPO`. Generate Drizzle migrations in `packages/web/drizzle/`: - -**New tables:** -- `agents` — the persona-level identity (see schema in A1) -- `persona_versions` -- `integration_scopes` -- `user_integrations` (if not already shipped — verify) -- `workspace_integrations` (if not already shipped — absorb `workspace_service_accounts` if it exists) -- `workforce_cli_auth_sessions` - -**Renames:** -- `cli_auth_sessions` → `cloud_cli_bootstrap_sessions` - -**Repurpose `agent_deployments`:** the existing table moves from "persona-level deployment" semantics to "per-instance run" semantics. This is largely additive: keep `agent_deployments.id` as the per-instance ID; move persona-level columns (deployed_name, credential_selections, input_values, status, destroyed_at, etc.) to the new `agents` table. Add: -- `agent_deployments.agent_id uuid NOT NULL FK→agents(id)` -- `agent_deployments.trigger_kind text NOT NULL DEFAULT 'inbox'` (back-fill for existing rows) -- `agent_deployments.trigger_payload jsonb NULL` -- `agent_deployments.started_at`, `last_active_at` timestamptz -- `agent_deployments.timed_out_at timestamptz NULL` -- `agent_deployments.compaction_summary text NULL` -- `agent_deployments.parent_deployment_id uuid NULL FK→agent_deployments(id)` -- `agent_deployments.spec_hash_at_run text` -- `agent_deployments.status` enum updated to `running | idle | timed_out | completed | failed` - -**Back-fill migration for existing `agent_deployments` rows:** for each existing row: -1. Create an `agents` row, copy persona-level columns. -2. Point the original row's new `agent_id` at it. -3. Translate the old status enum (`active | disabled | error | destroyed`) → new statuses (`running | timed_out | failed | completed`) using a best-effort mapping (active→running, disabled→completed, error→failed, destroyed→completed with destroyed_at copied to agents). - -**Column adds on existing tables:** -- `user_integrations.adapter text NOT NULL DEFAULT 'nango'` -- `workspace_integrations.adapter text NOT NULL DEFAULT 'nango'` - -**Data migrations:** -- `slack_channel_configs` → `integration_scopes` with `scope_kind = 'slack_channel'`. After move, drop `slack_channel_configs`. -- `workspace_service_accounts` → `workspace_integrations` with `name = `. After move, drop `workspace_service_accounts`. - -**Constraint updates:** -- `agents` unique indexes filtered `WHERE status != 'destroyed'`. - -**Codegen:** -- Run Drizzle codegen so the TypeScript schema (`packages/web/lib/db/schema.ts`) matches. - -### Track A acceptance - -- [ ] Issue body of cloud#553 reflects every bullet above. -- [ ] `agents` table created; `agent_deployments` repurposed for per-instance rows. -- [ ] All migrations + back-fill steps land in the same PR. -- [ ] Migrations PR opens as DRAFT. -- [ ] `npm run typecheck` clean. -- [ ] `npm test` passes (existing tests; new tables not yet exercised — that's Track B). -- [ ] No `--no-verify`; all hooks pass. - -**Effort estimate:** ~5h (back-fill migration is the bulk of the work). - ---- - -## Track B — Cloud resolver: dispatch on `source` + `adapter` - -**Repo:** `$CLOUD_REPO.wt-resolver` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `feat/integration-resolver-source-dispatch` -**Base:** Track A's branch (or `main` if Track A merges first). -**Depends on:** Track A's migrations PR merged (or mergeable + schema types stable). - -**Allowed-dirty regex:** `packages/web/lib/integrations/.*|packages/web/lib/proactive-runtime/deploy-manager\.ts|packages/web/app/api/v1/integrations/.*` - -### Implementation - -Update cloud's integration resolver (find it under `packages/web/lib/integrations/` and `packages/web/lib/proactive-runtime/deploy-manager.ts`). - -1. **Read `source` from persona spec.** Persona-side `PersonaIntegrationConfig.source` ships in workforce#97 (rebased in Track E5). For each declared integration: - - `source.kind === 'deployer_user'` → query `user_integrations WHERE user_id = $deployer AND provider = $p AND name IS NULL`. - - `source.kind === 'workspace'` → query `workspace_integrations WHERE workspace_id = $ws AND provider = $p AND name IS NULL`. - - `source.kind === 'workspace_service_account'` → query `workspace_integrations WHERE workspace_id = $ws AND provider = $p AND name = $source.name`. - - Missing/undefined `source` → default `{ kind: 'deployer_user' }`. Mirror persona-kit's parser default-injection. - -2. **GitHub App combine.** When provider is `github` AND `source.kind === 'deployer_user'`, ALSO load the workspace's `workspace_integrations` row (`name IS NULL`) for the installation_id. Return a combined resolved-integration object: - ```ts - { user_oauth: UserIntegrationRow, workspace_install: WorkspaceIntegrationRow } - ``` - If the workspace install is missing, deploy must fail with: `GitHub deploys require both a user OAuth and a workspace GitHub App install. Workspace install missing.` - -3. **`adapter` dispatch.** When invoking the connection's token-refresh / introspection logic, branch on `integration.adapter`: - - `'nango'` → existing Nango path (unchanged). - - `'composio'` → existing Composio path in `packages/web/lib/integrations/composio-service.ts`. - - `'pipedream'` → throw `Adapter 'pipedream' not yet wired` (stub for future). - -4. **Default `source` injection on the cloud side.** Mirror persona-kit's behavior: any spec arriving without `source` gets `{ kind: 'deployer_user' }` injected at resolver entry. - -### Track B tests - -Add resolver test fixtures (vitest): -- [ ] deployer_user happy path -- [ ] workspace happy path -- [ ] workspace_service_account happy path (named) -- [ ] GitHub combine: both rows present → success -- [ ] GitHub combine: workspace install missing → clear error -- [ ] Missing user_integrations row → clear error -- [ ] Unknown `adapter` → clean "not yet wired" error -- [ ] Default source injection when persona spec omits it -- [ ] Adapter dispatch routes correctly (Nango / Composio) - -### Track B acceptance - -- [ ] Resolver dispatches by `source.kind` without inference. -- [ ] GitHub combine returns both rows when both required. -- [ ] Adapter dispatch routes to existing Nango + Composio paths. -- [ ] All new tests green; existing tests unchanged. -- [ ] `npm run typecheck && npm test` clean. - -**Effort estimate:** ~4h. - ---- - -## Track C — Cloud #548 OSS-scope rebase coordination - -**Repo:** `$CLOUD_REPO` (no worktree — comment-only) -**Implementer model:** claude (medium reasoning). -**No branch.** Comment-only via `gh pr comment`. - -**Note:** relay#844 already merged at 2026-05-12T19:50:04Z. `@agent-relay/events@6.0.17` and `@agent-relay/agent@6.0.17` are published on npm. A coordination comment has already been posted on cloud#548 (see comment `4434762449`). Track C is effectively **already done** at workflow start; Ricky should verify the comment exists and skip if so. - -### Preflight - -```bash -COMMENT_EXISTS=$(gh pr view 548 --repo AgentWorkforce/cloud --json comments \ - -q '.comments[] | select(.body | test("@agent-relay/events@6\\.0\\.17")) | .id' | head -1) - -if [ -n "$COMMENT_EXISTS" ]; then - echo "SKIP: Track C already posted via comment $COMMENT_EXISTS" - exit 0 -fi -``` - -If somehow the comment is missing (rolled back, etc.), re-post it: - -```bash -gh pr comment 548 --repo AgentWorkforce/cloud -F -``` - -with the same contents as comment `4434762449` (relay#844 merged, versions live, rebase recommendation, alternative cleanup-PR option). - -### Track C acceptance - -- [ ] Coordination comment exists on cloud#548 referencing `@agent-relay/{events,agent}@6.0.17`. - -**Effort estimate:** ~5min. - ---- - -## Track D — Workforce persona-kit refactor (traits-out, sandbox-out, listeners doc) - -**Repo:** `$WORKFORCE_REPO` (operates in place — Track D owns the primary checkout; E/F use worktrees) -**Implementer model:** codex (high reasoning). -**Working branch:** `refactor/persona-kit-schema-lockin` -**Base:** `origin/main` AFTER workforce#95 merges. - -**Hard precondition:** -```bash -MERGED_AT=$(gh pr view 95 --repo AgentWorkforce/workforce --json mergedAt -q '.mergedAt') -if [ -z "$MERGED_AT" ] || [ "$MERGED_AT" = "null" ]; then - echo "WAITING: workforce#95 not merged"; exit 0 -fi -``` - -**Allowed-dirty regex:** `packages/persona-kit/.*|packages/runtime/src/proactive\.ts|packages/runtime/src/types\.ts|packages/runtime/src/ctx\.ts|packages/deploy/src/.*|examples/.*|docs/plans/.*` - -### Implementation - -1. **Remove `traits` from `PersonaSpec`.** - - Delete `Traits` type from `packages/persona-kit/src/types.ts`. - - Delete `spec.traits` parsing logic from `packages/persona-kit/src/parse.ts`. - - Update all persona fixtures in `packages/persona-kit/src/__fixtures__/` and `examples/*/persona.json` to remove any `traits` block. - - Remove `traits`-related re-exports / imports from `packages/runtime/src/proactive.ts`. If `expressionFromTraits` (or similar) is still referenced, remove it. - - Parser must REJECT personas containing a `traits` key with a clear error: `traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md`. - -2. **Remove `sandbox` from `PersonaSpec`.** - - Delete `SandboxConfig` type and `spec.sandbox` parsing. - - Update fixtures and examples removing any `sandbox` blocks. - - Verify `@agentworkforce/deploy` (`packages/deploy/src/index.ts` and `packages/deploy/src/modes/sandbox.ts`) reads sandbox config from deploy options (the `--mode sandbox` CLI flag and any defaults baked into the deploy package), NOT from `persona.spec`. If any code reads `spec.sandbox`, refactor. - - Parser must REJECT personas containing a `sandbox` key with a clear error: `sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md`. - -3. **Listeners section rename (DOCS + COMMENTS ONLY — keep current SHAPE).** - Khaliq explicitly: "I don't know if we have to be so literal with inbox, clock, radio, current shape is probably fine, but can use listeners." - - Add JSDoc comments on `PersonaIntegrationConfig` and `Schedule` describing them as the "radio listener" and "clock listener" parts of a persona's listener surface. - - Top-level JSDoc on `PersonaSpec`: - > A persona listens for events. Three listener kinds: **clock** (cron schedules — `schedules[]`), **radio** (RelayFile integration events — `integrations..triggers[]`), **inbox** (RelayCast targeted messages — not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent. - - Update `docs/plans/deploy-v1.md` §3 prose with the listeners narrative (recover from git if untracked: `git show 11ed713:docs/plans/deploy-v1.md > docs/plans/deploy-v1.md`). - - Do NOT restructure JSON schema. Do NOT rename existing types. - -4. **Regenerate persona JSON schema if applicable.** - - If `packages/persona-kit/scripts/emit-schema.mjs` exists on the branch: run it, commit the regenerated `packages/persona-kit/schemas/persona.schema.json`. - - If not, skip and note in PR body that #94 will pick it up on rebase. - -5. **Update tests.** - - Remove tests asserting on `traits`/`sandbox` fields. - - Add tests asserting parse FAILURE (with the specific error messages) when `traits` or `sandbox` keys appear. - - Verify the 14 personas in `packages/personas-core` still validate via `corepack pnpm -r --filter @agentworkforce/personas-core run lint`. - -6. **Examples cleanup.** Strip `traits` + `sandbox` from `examples/weekly-digest/persona.json`, `examples/review-agent/persona.json`, `examples/linear-shipper/persona.json` if they exist on this branch. - -### Track D acceptance - -- [ ] `traits` and `sandbox` types removed from persona-kit `types.ts` and `parse.ts`. -- [ ] Parser rejects `traits` and `sandbox` with the specified errors. -- [ ] All persona fixtures + 14 core personas parse without errors. -- [ ] Listeners JSDoc + `deploy-v1.md` §3 narrative updated. -- [ ] Persona JSON schema regenerated if emit-schema is on the branch. -- [ ] `corepack pnpm -r run build && corepack pnpm run typecheck && corepack pnpm -r run test` green. -- [ ] PR opens as DRAFT. - -**Effort estimate:** ~2.5h. - ---- - -## Track E — Workforce queue rebase (#92, #93, #94, #96, #97) - -**Repo:** `$WORKFORCE_REPO.wt-rebase-` (one worktree per PR) -**Implementer model:** codex (medium reasoning). -**Depends on:** Track D merged. - -For each PR in the workforce queue, rebase its branch onto post-Track-D `main`. Resolve conflicts from traits/sandbox removal. Do NOT introduce new functionality. Push with `git push --force-with-lease`. - -### Sub-tracks - -| ID | PR | Branch | Worktree | Rebase action | -|---|---|---|---|---| -| **E1** | #92 | `feat/integrations-vfs` | `wt-rebase-92` | Rebase. VFS substrate doesn't touch traits/sandbox; conflicts should be minimal. | -| **E2** | #93 | `feat/integrations-vfs-examples` | `wt-rebase-93` | Rebase + strip `traits` and `sandbox` blocks from `examples/review-agent/persona.json` and `examples/linear-shipper/persona.json`. Verify both still type-check against #92's `WorkforceCtx`. | -| **E3** | #94 | `feat/persona-json-schema` | `wt-rebase-94` | Rebase + run `scripts/emit-schema.mjs` to regenerate `packages/persona-kit/schemas/persona.schema.json`. Verify fixtures still validate. | -| **E4** | #96 | `feat/proactive-bridge` | `wt-rebase-96` | Rebase. Drop any remaining `expressionFromTraits` references. Bump `@agent-assistant/proactive ^0.4.31 → ^0.4.32` per agent-assistant#91 publish; run `corepack pnpm install` to refresh `pnpm-lock.yaml`. Verify the existing test baseline passes. | -| **E5** | #97 | `feat/persona-integration-source` | `wt-rebase-97` | Rebase. Interface name is `PersonaIntegrationConfig` (verified in #97). No content change beyond rebase. | - -### Per-sub-track gates (soft → fixer → hard) - -```bash -corepack pnpm -r run build -corepack pnpm run typecheck -corepack pnpm -r run test -``` - -### Track E acceptance (per sub-track) - -- [ ] Rebased branch pushes successfully with `--force-with-lease`. -- [ ] CI on the PR is green after rebase. -- [ ] No functional regression vs the PR's original acceptance bullets. -- [ ] If conflicts unresolvable: open `-rebased`, post a comment on the original linking it, STOP that sub-track. Others continue. - -**Effort estimate:** ~1h per sub-track; E1–E5 can run in parallel after Track D. - ---- - -## Track F — Workforce runtime input-values + agent identity wiring - -**Repo:** `$WORKFORCE_REPO.wt-runtime` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `feat/runtime-input-values-resolution` -**Base:** post-Track-D `main`. -**Depends on:** Track D merged AND Track A's migrations PR merged (need `agents.input_values` column). - -**Allowed-dirty regex:** `packages/runtime/src/ctx\.ts|packages/runtime/src/types\.ts|packages/runtime/src/ctx\.test\.ts|packages/runtime/src/__tests__/.*` - -### Implementation - -In `packages/runtime/src/ctx.ts`: - -1. **Read `input_values` from the `agents` row** (NOT `agent_deployments` — input values are agent-level, not per-instance). - ``` - resolved[key] = agents.input_values[key] ?? persona.spec.inputs[key].default - ``` - When a required input has no value from either source, throw before the handler runs: - ``` - Required input '' has no value (no deployment override, no spec default). Set it via 'workforce deploy --input =' or by editing the agent record. - ``` - -2. **Update `WorkforceCtx.persona.inputs` shape** (`types.ts`): - - Currently exposes `Record` (defaults). - - New: expose `Record` (resolved values). - - Add `ctx.persona.inputSpecs: Record` for consumers that need the spec. - -3. **Add `ctx.agent` and `ctx.deployment` accessors** to mirror the schema: - ```ts - ctx.agent: { id: string; deployedName: string; spawnedByAgentId: string | null; ... } - ctx.deployment: { id: string; triggerKind: 'inbox' | 'clock' | 'radio'; parentDeploymentId: string | null; ... } - ``` - The runtime injects these from the agent + agent_deployment rows that fired this handler. - -4. **Tests:** - - [ ] Override wins over default. - - [ ] Default fills when override absent. - - [ ] Required input with no value → throws specified error. - - [ ] `ctx.persona.inputSpecs` still exposes the spec defaults. - - [ ] `ctx.agent.id` + `ctx.deployment.id` correctly populated. - -### Track F acceptance - -- [ ] `ctx.persona.inputs` returns resolved values. -- [ ] Required-but-missing inputs throw with the specified error. -- [ ] `ctx.persona.inputSpecs` accessor added. -- [ ] `ctx.agent` and `ctx.deployment` accessors added. -- [ ] `corepack pnpm -r run build && corepack pnpm -r run test` green. -- [ ] PR title: `feat(runtime): resolve persona inputs from agents.input_values + expose ctx.agent/ctx.deployment` -- [ ] Opens as DRAFT. - -**Effort estimate:** ~2h. - ---- - ---- - -# Phase 2 — Deploy enablement tracks - -Phase 1 (Tracks A–F) lands the schema, persona-kit refactor, runtime accessors, and queue rebase. **Phase 2 lights up end-to-end deploy** — cloud accepts a persona+bundle payload, workforce CLI speaks that contract OSS-generically, deploy-time inputs are wired, and the MCP `workflow.run` tool actually returns results. - -Phase 2 tracks depend on Phase 1 tracks being merged. Order: G → H (workforce-side consumer of G's contract); I depends on A (schema) + D (persona-kit); J depends on cloud#555 being live on main. - -## Track G — Cloud persona+bundle deploy endpoint - -**Repo:** `$CLOUD_REPO.wt-deploy-endpoint` (worktree) -**Implementer model:** codex (high reasoning). -**Working branch:** `feat/persona-bundle-deploy-endpoint` -**Base:** Track A merged (`agents` + `agent_deployments` schema live); cloud#548 ideally merged (for agent-gateway + DO infra) but the endpoint can ship as a stub that queues for the gateway if #548 is still in flight. - -**Depends on:** -- Track A merged. -- cloud#548 merged (for agent-gateway DO + `relaycron-client.ts` + `registerWatches` infra). -- **relaycron#5 merged** — without this, the WS-delivery + cancel API in relaycron isn't live, and schedule registration via cloud's `relaycron-client.ts` returns errors at runtime. Preflight check: - ```bash - RC5_MERGED=$(gh pr view 5 --repo AgentWorkforce/relaycron --json mergedAt -q '.mergedAt') - if [ -z "$RC5_MERGED" ] || [ "$RC5_MERGED" = "null" ]; then - echo "WAITING: relaycron#5 not merged"; exit 0 - fi - ``` - -**Allowed-dirty regex:** `packages/web/app/api/v1/workspaces/\[workspaceId\]/deployments/.*|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*persona.*|services/agent-gateway/.*` - -### Why this exists - -cloud#548's `/api/v1/deploy` takes `{ entrypoint, source }` — single-file TS. workforce's deploy CLI is built to upload a persona+bundle. The decision (Khaliq): **cloud adds a new endpoint for the persona+bundle contract.** Single-file `/api/v1/deploy` stays for power users; persona+bundle is the workforce-CLI surface. - -### Endpoint contract - -``` -POST /api/v1/workspaces/:workspaceId/deployments -Auth: workspace token (mirror sandbox endpoint auth scopes) -Body: -{ - persona: PersonaSpec, // full persona JSON, validated via @agentworkforce/persona-kit - bundle: { - runner: string, // contents of runner.mjs - agent: string, // contents of agent.bundle.mjs (esbuild output) - packageJson: object // contents of package.json - }, - inputs?: Record, // initial input values for agents.input_values - pinnedVersion?: { version: number } // optional; if set, pin to that persona_versions row -} -Returns 201: -{ - agentId: string, // agents.id - workspaceId: string, - status: 'starting' | 'active' | 'failed', - deploymentId: string // first agent_deployments row created at boot -} -``` - -### Implementation - -1. **Validate** `persona` via `@agentworkforce/persona-kit`'s `parsePersonaSpec`. Fail with field-pointed errors on schema problems. - -2. **Persist `persona_versions` row.** Compute `spec_hash`; insert a new `persona_versions` row if no existing row matches (`UNIQUE (persona_id, spec_hash)`). Set `pinned_version_id` on the agent row to this new version. - -3. **Upsert `agents` row.** Match on `(workspace_id, persona_id)` where `status != 'destroyed'`: - - If exists → update `pinned_version_id`, `input_values`, `spec_hash_at_deploy`, bump `last_used_at`. - - If not → insert new row with `status='active'`. - -4. **Translate `persona.integrations..triggers[]` → watch glob list.** The convention the agent-gateway DO and `@agent-relay/agent`'s `registerWatches` expect is glob paths under provider namespaces (e.g. `/github/pull_requests/**`). Read `services/agent-gateway/src/durable-object.ts` + `packages/agent-relay-agent/src/index.ts` to confirm the exact glob format. Translation rule: - - ``` - provider=github, trigger.on='pull_request.opened' → /github/pull_requests/opened/** - provider=linear, trigger.on='issue.created' → /linear/issues/created/** - provider=slack, trigger.on='app_mention' → /slack/app_mention/** - ``` - - Build a lookup table from RelayFile adapter docs (`relayfile-adapters/packages/*/docs/` if present). For unknown provider/trigger combinations, fail deploy with a clear error. - -5. **Persist watch globs** on the `agents` row OR in a sidecar — depends on how cloud#548's deploy-manager stores them. Most likely: store on `agents.watch_globs text[] NULL` (add to Track A if not already there) so the agent-gateway can pull them at agent boot. **Update Track A's migrations to add this column.** - -6. **Translate `persona.schedules[]` → relaycron registrations.** Call `services/agent-gateway/src/relaycron-client.ts:registerCronSchedules()` with each schedule, scoped by `agentId`. Persist returned `gatewayScheduleId`s on `agents.schedule_ids text[]` (add to Track A migrations). - -7. **Provision Daytona sandbox + upload bundle.** Use the existing `POST /api/v1/workspaces/:id/sandboxes` (cloud#543) infrastructure. Write the bundle files (`runner.mjs`, `agent.bundle.mjs`, `persona.json`, `package.json`) to the sandbox via the existing files-proxy route. - -8. **Start the runner.** Call the sandbox's exec route with `node runner.mjs`. The runner internally calls `agent({...})` which calls `registerWatches` against the gateway, completing the watch subscription. - -9. **Insert initial `agent_deployments` row** with `status='running'`, `trigger_kind='inbox'` (or the trigger that launched it), `started_at=now`. - -10. **Audit-log** every deployment creation (mirror sandbox endpoint audit pattern). - -### Track G tests - -- [ ] Happy path: valid persona+bundle → 201 with agentId -- [ ] Re-deploy same persona → agentId stable; persona_versions has new row only if spec_hash differs -- [ ] Invalid persona (e.g. has `traits`) → 400 with field-pointed error -- [ ] Trigger translation: known github/linear/slack/notion/jira triggers map correctly -- [ ] Unknown trigger → 400 with clear error -- [ ] Cron schedules registered with relaycron (mock relaycron client) -- [ ] Daytona sandbox creation + bundle upload happen in order -- [ ] Auth: missing workspace token → 401; wrong scope → 403 - -### Track G acceptance - -- [ ] Endpoint added at `POST /api/v1/workspaces/:workspaceId/deployments`. -- [ ] Persona validation, version persistence, agent upsert, trigger translation, schedule registration all wired. -- [ ] Sandbox provisioning + bundle upload + runner start work end-to-end against a test workspace. -- [ ] All new tests green; no regressions on cloud#548's existing `/api/v1/deploy` endpoint. -- [ ] PR opens as DRAFT. -- [ ] Required Track A schema additions (`agents.watch_globs`, `agents.schedule_ids`) included in Track A's migration PR. - -**Effort estimate:** ~6h. - ---- - -## Track H — Workforce `--mode cloud` (OSS-generic implementation) - -**Repo:** `$WORKFORCE_REPO.wt-mode-cloud` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `feat/deploy-mode-cloud` -**Base:** post-Track-D `main` (after persona-kit refactor). -**Depends on:** Track D merged. Track G's endpoint contract STABLE (need not be merged on cloud; can stub against the spec). - -**Allowed-dirty regex:** `packages/deploy/src/modes/cloud\.ts|packages/deploy/src/index\.ts|packages/deploy/src/login\.ts|packages/cli/src/cli\.ts` - -### OSS / cloud split rationale - -The workforce deploy CLI is OSS. Anyone running a workforce-compatible runtime (their own AWS, on-prem, anything) speaks the **persona+bundle contract** with whatever cloud endpoint URL is configured. The deploy CLI does NOT bake in `agentrelay.com`. The CLI ships generic; cloud (the proprietary side) implements the endpoint. - -- **workforce (OSS)** — Track H: this track. Replace the stubbed `packages/deploy/src/modes/cloud.ts` with a real implementation that POSTs persona+bundle to a configurable cloud-deploy URL. -- **cloud (proprietary)** — Track G: cloud-specific endpoint implementation (above). - -### Decision-tree mapping (image 2) - -Track H implements the full deploy decision tree from image 2 of the May 12 whimsical diagram. Each step in the tree maps to a stage in the CLI flow: - -``` -agentworkforce deploy - │ - ├─► STAGE 1: Choose runtime - │ ├─ --cloud-url flag → use that - │ ├─ WORKFORCE_CLOUD_URL env → use that - │ ├─ persona.cloud.deployUrl → use that - │ └─ default → https://agentrelay.com (note: "build your own" docs link printed when default is overridden) - │ - ├─► STAGE 2: Logged in? - │ ├─ no → open browser to /cli-auth (relayauth PKCE flow) - │ │ save returned token to OS keychain - │ └─ yes → use token saved on machine - │ - ├─► STAGE 3: Harness availability check - │ For each harness the persona declares (claude/codex/opencode): - │ Query GET /api/v1/users/me/provider_credentials?model_provider= - │ ├─ have a connected credential → continue - │ └─ none → - │ Prompt: "Do you want to set up your harness's subscription? (Y/n)" - │ ├─ yes → trigger provider_oauth flow (existing /provider_credentials/auth-session endpoint) - │ └─ no → - │ Prompt: "AgentRelay plan or BYOK?" - │ ├─ plan → set auth_type='relay_managed' (cloud uses its key, tracks spend, charges markup) - │ └─ BYOK → prompt for API key; save encrypted via cloud /provider_credentials POST (auth_type='byo_api_key') - │ - ├─► STAGE 4: Review listeners, determine required integrations - │ For each persona.integrations.: - │ Query GET /api/v1/workspaces/:id/integrations?provider=

- │ ├─ connected → continue - │ └─ missing → open browser to /integrations?provider=

&workspace=&return_to= - │ block until OAuth callback completes - │ - ├─► STAGE 5: Persona exists? - │ Query GET /api/v1/workspaces/:id/agents?persona_slug= - │ ├─ no → continue to deploy - │ └─ yes → - │ Prompt: "This persona is already deployed as agent (status: ). - │ Update existing, destroy and create new, or cancel?" - │ ├─ update → continue to deploy (UNIQUE constraint will UPSERT) - │ ├─ destroy → POST /api/v1/workspaces/:id/agents/:agentId/destroy (M3 endpoint, may not be wired — if missing, exit with "destroy not yet wired; cancel and run with --force-replace later") - │ └─ cancel → exit 0 - │ - └─► STAGE 6: POST persona+bundle to Track G's endpoint - See implementation below. -``` - -For non-interactive use (CI / scripts), the CLI accepts flag overrides for every interactive prompt: -- `--no-prompt` — fail fast on any decision that would normally prompt (instead of asking). -- `--harness-source plan|byok|oauth` — pre-answer Stage 3 decisions. -- `--byok-key ` — pre-answer BYOK prompt. -- `--on-exists update|destroy|cancel` — pre-answer Stage 5 decision (default: `cancel`). - -### Implementation - -In `packages/deploy/src/modes/cloud.ts`: - -1. **Resolve cloud-deploy URL** as Stage 1 above. - -2. **Load workspace token** from keychain via `packages/deploy/src/login.ts` (the relayauth PKCE flow already shipped in workforce#90). If absent and not `--no-prompt`, trigger login as Stage 2. - -3. **Run Stages 3-5** with the prompt logic above (or flag overrides for non-interactive mode). - -4. **POST persona+bundle (Stage 6):** - ```ts - const res = await fetch(`${cloudUrl}/api/v1/workspaces/${workspaceId}/deployments`, { - method: 'POST', - headers: { - authorization: `Bearer ${workspaceToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - persona, - bundle: { - runner: await fs.readFile(bundle.runnerPath, 'utf8'), - agent: await fs.readFile(bundle.bundlePath, 'utf8'), - packageJson: JSON.parse(await fs.readFile(bundle.packageJsonPath, 'utf8')), - }, - inputs: input.inputs, // populated by Track I's --input flags - }), - }); - if (!res.ok) throw new Error(`Cloud deploy failed: ${res.status} ${await res.text()}`); - const { agentId, status, deploymentId } = await res.json(); - ``` - -5. **Status polling.** After POST returns `status: 'starting'`, poll `GET /api/v1/workspaces/:id/agents/:agentId` until `status='active'` or `'failed'` (60s timeout). Stream updates via `onLog`. - -6. **Return a `CloudRunHandle`** that exposes `{ agentId, stop(): Promise, done: Promise<...> }`. `stop()` calls the M3 destroy endpoint; if not wired, throw cleanly. - -7. **Remove the "not yet available" stub** from `packages/deploy/src/index.ts`. - -8. **Add the `--cloud-url`, `--no-prompt`, `--harness-source`, `--byok-key`, `--on-exists` CLI flags** to `packages/cli/src/cli.ts`'s `deploy` case. - -### Track H tests - -- [ ] Happy path: persona + bundle POST → returns CloudRunHandle with agentId. -- [ ] Cloud URL override via flag, env, persona field, default — precedence tested. -- [ ] 401 from cloud → clean error suggesting `workforce login`. -- [ ] Network error → retry with backoff (3 attempts). -- [ ] Status polling resolves on `active` and `failed`. -- [ ] `stop()` calls DELETE endpoint. - -### Track H acceptance - -- [ ] `workforce deploy --mode cloud` no longer prints "not yet available." -- [ ] Posts to the configured cloud URL with persona+bundle contract. -- [ ] OSS-generic: no `agentrelay.com` baked into code paths (only as a default URL). -- [ ] PR opens as DRAFT. - -**Effort estimate:** ~3h. - ---- - -## Track I — Deploy CLI `--input =` flags - -**Repo:** `$WORKFORCE_REPO.wt-deploy-inputs` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `feat/deploy-input-flags` -**Base:** post-Track-D `main`. -**Depends on:** Track D merged. Track A merged (need `agents.input_values` column). Track F merged (runtime reads from `input_values`). - -**Allowed-dirty regex:** `packages/cli/src/cli\.ts|packages/deploy/src/index\.ts|packages/deploy/src/types\.ts|packages/deploy/src/modes/.*` - -### Implementation - -1. **Accept `--input =` flag in `packages/cli/src/cli.ts`** (repeatable). Parse into `Record`. Reject malformed flags with a clear error. - -2. **Plumb through `packages/deploy/src/index.ts`'s `deploy()` function** as `DeployOptions.inputs?: Record`. - -3. **Validate against persona spec at deploy time.** For each provided input key: - - Must be declared in `persona.spec.inputs` — else fail with `Unknown input ''; persona declares: `. - - Value must be a string (basic type check; persona-kit may add more later). - -4. **Forward to each mode:** - - `--mode dev`: pass as env vars to the spawned child process (`WORKFORCE_INPUT_=`). - - `--mode sandbox`: pass as env vars to the Daytona sandbox (`envVars` arg). - - `--mode cloud`: include in the POST body's `inputs` field (Track H consumes this). - -5. **Update persona spec docs in `docs/plans/deploy-v1.md` §3** to mention `--input` as the deploy-time override mechanism. - -### Track I tests - -- [ ] Single `--input` parses and forwards. -- [ ] Multiple `--input` flags accumulate. -- [ ] Malformed flag (`--input foo`) → clean error. -- [ ] Undeclared input key → clean error citing persona's declared inputs. -- [ ] `--mode dev` env vars actually reach the child process. -- [ ] `--mode cloud` POST body includes the `inputs` field. - -### Track I acceptance - -- [ ] `workforce deploy --input topic=AI --input region=us-east-1 ./persona.json` works against all three modes. -- [ ] Undeclared inputs fail fast with a clear error. -- [ ] PR opens as DRAFT. - -**Effort estimate:** ~1.5h. - ---- - -## Track J — `workflow.run` MCP synthesis + scope mint (cloud#555 follow-ups) - -**Repo:** `$CLOUD_REPO.wt-workflow-shim-followups` (worktree) -**Implementer model:** codex (high reasoning). -**Working branch:** `feat/workflow-invocations-followups` -**Base:** cloud#555 merged (the URL surface). -**Depends on:** cloud#555 merged. - -**Allowed-dirty regex:** `packages/web/app/api/v1/workspaces/\[workspaceId\]/workflows/.*|packages/web/lib/workflows/.*|packages/web/lib/auth/.*sandbox.*` - -### Why this exists - -cloud#555 shipped `POST /api/v1/workspaces/:id/workflows/run` taking `{ name, args }`, but it returns 501 for any registered slug — because the heavy `/api/v1/workflows/run` requires `s3CodeKey`/`sourceFileType`/`runtime` fields that can't be derived from `{ name, args }`. Two follow-ups to actually light it up: - -### J1 — Synthesis policy + named-workflow registry - -Implement a slug → workflow translation in `packages/web/lib/workflows/invocation-registry.ts` (created in #555). Convention: - -- **Named workflows live at a known S3 prefix.** Every named workflow has a pre-staged tarball at `s3://workflows//latest.tar.gz` (or similar — match what the heavy workflow engine expects). The synthesis fills in `s3CodeKey: 'workflows//latest.tar.gz'`. -- **`sourceFileType` defaults to `'workflow'`** unless the slug's registry entry overrides it. -- **`runtime` defaults to `{ id: 'daytona' }`** from the workspace's `default_runtime` column (the cloud-side dispatch target Will explained earlier). -- **`args`** from the MCP tool call is forwarded as `metadata.invocationArgs` to the heavy engine, since the heavy engine doesn't have a first-class args field. - -Add an initial registry of named workflows. Start with one slug (e.g. `'echo'` — a minimal workflow that just echoes args back) so the round-trip can be smoke-tested. - -Implementer should read the existing heavy `/api/v1/workflows/run/route.ts` to confirm the exact `RunRequestBody` synthesis. If a required field genuinely can't be synthesized, surface in PR body. - -### J2 — Scope mint additions - -The sandbox-token mint flow at `packages/web/app/api/v1/workflows/run/route.ts` currently mints `workflow:runs:read`, `workflow:logs:read`, `workflow:runs:events:write`. The MCP server expects to call the new lightweight endpoints, which require `workflow:invoke:write` (for `workflow.run`) and `workflow:invoke:read` (for `workflow.status`). - -Add these scopes to the mint: -- `workflow:invoke:write` — minted on sandbox creation for any workspace running a proactive runtime agent. -- `workflow:invoke:read` — same. - -Ensure `requireAuthScope` checks in the new `/workspaces/:id/workflows/run` and `/workspaces/:id/workflows/runs/:runId` routes accept these scopes. - -### Track J tests - -- [ ] J1: `POST /workspaces/:id/workflows/run` with `name='echo', args={foo:1}` returns a runId; the heavy engine receives a synthesized RunRequestBody. -- [ ] J1: Unknown slug → 404 with list of known slugs. -- [ ] J2: A sandbox token without `workflow:invoke:write` → 403 on POST. -- [ ] J2: Token with the right scope → success path. -- [ ] End-to-end: MCP `workflow.run` call from a Daytona sandbox actually returns a runId, no longer 501. - -### Track J acceptance - -- [ ] `workflow.run` MCP tool returns a real runId for at least one registered slug (`echo` is fine for v1). -- [ ] Scope mint includes the two new scopes for sandbox tokens. -- [ ] PR opens as DRAFT. -- [ ] cloud#555's `Status: Ready for Review` note updated to reflect that J1+J2 lit it up. - -**Effort estimate:** ~3.5h. - ---- - -## Acceptance contract (workflow-level) - -After ALL tracks (Phase 1 + Phase 2) complete: - -### Phase 1 -1. cloud#553 issue body reflects every lock-in (Track A1). -2. Cloud migrations PR (Track A2) is open as DRAFT, CI green; `agents` table created, `agent_deployments` repurposed for per-instance rows. -3. Cloud resolver PR (Track B) is open as DRAFT, CI green; dispatches on `source` + `adapter`. -4. cloud#548 has the relay#844 coordination comment (Track C — already posted). -5. Workforce persona-kit PR (Track D) is open as DRAFT, CI green; traits + sandbox removed. -6. Workforce queue (#92, #93, #94, #96, #97) is all rebased + green. -7. Workforce runtime PR (Track F) is open as DRAFT, CI green; ctx.agent + ctx.deployment + resolved inputs. - -### Phase 2 -8. Cloud persona+bundle endpoint PR (Track G) is open as DRAFT, CI green; validates persona, persists version, upserts agent, registers schedules, translates triggers, provisions sandbox. -9. Workforce `--mode cloud` PR (Track H) is open as DRAFT, CI green; speaks Track G's contract OSS-generically. -10. Workforce `--input` flags PR (Track I) is open as DRAFT, CI green; flows through all three modes. -11. Workflow-invocations follow-ups PR (Track J) is open as DRAFT, CI green; `workflow.run` MCP tool returns real runIds. - -### Loud holes after this workflow - -- ⚠️ **Memory is not wired.** `ctx.memory` is a stub. Follow-up workflow needed. -- ⚠️ **M3 destroy/list CLI commands** not implemented. Out of scope; M3 milestone workflow. -- ⚠️ **`@workforce/daytona-runner` not on npm** under `@workforce` scope. Handled by a separate agent per platform-team OIDC setup; not blocking morning state because cloud consumes via workspace ref. - ---- - -## Track K — End-to-end smoke test - -**Repo:** `$WORKFORCE_REPO.wt-smoke` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `test/deploy-v1-e2e-smoke` -**Base:** post-everything-merged `main`. -**Depends on:** All Phase 1 + Phase 2 tracks merged. cloud#548 + relaycron#5 + relay#843 merged. - -**Allowed-dirty regex:** `packages/deploy/test/e2e/.*|\.github/workflows/deploy-e2e\.yml` - -### Why this exists - -When Khaliq wakes up, the workflow should have proved that everything actually works end-to-end, not just compiled. This track runs a real deploy against staging cloud and asserts the agent fires on a real trigger. - -### Implementation - -Add `packages/deploy/test/e2e/weekly-digest.smoke.test.ts`: - -1. **Build the bundle locally** for `examples/weekly-digest/persona.json`: - ```ts - const bundle = await stageBundle({ - personaPath: path.resolve('examples/weekly-digest/persona.json'), - persona: parsePersonaSpec(/* loaded */), - outDir: '.workforce/build/smoke-weekly-digest', - }); - ``` - -2. **Authenticate** using `WORKFORCE_E2E_STAGING_TOKEN` from env (CI secret). Skip the test gracefully if missing. - -3. **Deploy via Track H's `--mode cloud`** against the staging cloud URL (`WORKFORCE_E2E_STAGING_URL`). - -4. **Force a cron tick** by directly POSTing to the runtime test hook (`POST /api/v1/workspaces/:id/agents/:agentId/_test/tick`, mirror what cloud#548 exposes — if no hook, skip the trigger and assert deployment was created + status='active' instead). - -5. **Assert** the agent posts a GitHub issue on the fixture repo `AgentWorkforce/deploy-e2e-fixtures` within 90s, with title pattern `Weekly digest — *`. - -6. **Cleanup**: close the issue, optionally destroy the agent (skip if M3 destroy isn't wired). - -7. **Add `.github/workflows/deploy-e2e.yml`** running this on nightly schedule + manual dispatch. Failures notify `#workforce-alerts`. - -### Run during workflow - -The workflow runs Track K's smoke test ONCE after all upstream tracks have merged — but does NOT block the cascade on it. The smoke test result is reported separately as `SMOKE_TEST: PASS` or `SMOKE_TEST: FAIL — see logs`. If it fails for environmental reasons (staging Daytona down, OAuth tokens missing, fixture repo unreachable), the workflow logs but doesn't unwind any merges. - -### Track K acceptance - -- [ ] Smoke test file added. -- [ ] Test passes when run against staging (or skipped cleanly if `WORKFORCE_E2E_STAGING_TOKEN` is unset). -- [ ] GitHub Actions workflow added. -- [ ] PR title: `test(deploy): e2e smoke for weekly-digest --mode cloud`. - -**Effort estimate:** ~3h. - ---- - -## Workforce PR queue triage (existing PRs the workflow handles) - -The workflow operates on these existing workforce PRs in addition to the new tracks above. Each is either rebased + auto-merged in Track E, or explicitly skipped. - -| PR | Branch | Track in this workflow | Auto-merge? | -|---|---|---|---| -| #97 | feat/persona-integration-source | Track E5 (rebase) | YES | -| #96 | feat/proactive-bridge | Track E4 (rebase + agent-assistant bump) | YES | -| #94 | feat/persona-json-schema | Track E3 (rebase + schema regen) | YES | -| #93 | feat/integrations-vfs-examples | Track E2 (rebase + strip traits/sandbox) | YES | -| #92 | feat/integrations-vfs | Track E1 (rebase) | YES | -| #91 | feat/mcp-workforce | Track E (rebase; stacks on #92) | YES | -| **#87** | feat/proactive-agent-builder-persona | NEW: auto-merge — contains `parseInputsShape` `optional: true` regression fix that Track F depends on; the new persona JSON is additive | YES (verify fix still in branch first) | -| **#89** | codex/deploy-v1-readme | NEW: AUTO-MERGE for docs alignment | YES (nice-to-have; merges if green) | - -Open cloud PRs handled: - -| PR | Handled by | Auto-merge? | -|---|---|---| -| cloud#548 | Verified for trigger registration; paired with relaycron#5 | YES (after architectural items resolved — see below) | -| cloud#551 | Phase 3 dispatcher, already unblocked | YES | -| cloud#554 | Daytona meter | NO — platform-team gates on meter name; flag for Khaliq | -| cloud#555 | Workflow-invocations shim; Track J adds follow-ups | Merge #555 first, then merge Track J's follow-ups on top | - -Open chain-branch PRs: - -| PR | Repo | Auto-merge? | -|---|---|---| -| relay#843 | relay | YES | -| relaycron#5 | relaycron | YES (pair with cloud#548) | -| relayauth#39 | relayauth | YES (docs-only, low risk) | - -### cloud#548 special handling - -cloud#548 still has my three architectural items unaddressed (deploy payload shape, URL scoping, OSS scope governance). Track C's coordination comment is posted. **The workflow's lead Claude must verify before auto-merging cloud#548**: - -1. **Payload shape resolved.** Track G adds a new endpoint at `/api/v1/workspaces/:id/deployments` taking persona+bundle, separate from #548's `/api/v1/deploy` taking single-file. Both coexist. ✅ Resolved by Track G shipping in parallel. -2. **URL scoping** — #548 has top-level `/api/v1/deploy` while reads are workspace-scoped. Track G's new endpoint is workspace-scoped. The mixed shape is acceptable for v1 (legacy `/api/v1/deploy` deprecates later); proceed. -3. **OSS scope governance** — relay#844 merged, packages live. Once relay#843 merges too, the cleanup PR to remove cloud/packages/agent-relay-{events,agent} can land. **The workflow should land cloud#548 as-is** (with the OSS packages still in cloud), then run a follow-up cleanup PR (Track L below) that removes them and pins to `^6.0.17`. - ---- - -## Track N — Cloud sandbox token path-scoping (use `POST /v1/tokens/path`) - -**Repo:** `$CLOUD_REPO.wt-token-paths` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `feat/sandbox-token-path-scoped` -**Base:** post-relayauth#39 merged `main` (officially documented contract). cloud#548 ideally merged so agent-gateway is the consumer; can ship against `main` if #548 still in flight. -**Depends on:** relayauth#39 merged. Track G merged (Track N updates Track G's sandbox-provisioning flow to use path-scoped tokens). - -**Allowed-dirty regex:** `packages/core/src/relayfile/client\.ts|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*sandbox.*|services/agent-gateway/.*` - -### Why this exists - -Today's `mintRelayfileToken` in `packages/core/src/relayfile/client.ts` issues sandbox tokens via the two-step `POST /v1/identities` + `POST /v1/tokens` flow with broad `relayfile:fs:read:*` / `relayfile:fs:write:*` scopes. Every deployed agent's sandbox can therefore read/write **the entire workspace VFS mount**, including paths unrelated to the persona's declared listeners. - -relayauth implemented `POST /v1/tokens/path` in M1 (relayauth#38) for path-scoped token issuance — workspace-token auth in, `relay_pa_*` token out with scopes intersected to the requested path list. Documentation lands in relayauth#39. Cloud doesn't consume it yet. - -This is **least-privilege hardening, not a functional blocker.** First deploy works without it; production hardening wants it. - -### Implementation - -1. **Add a new helper `mintPathScopedRelayfileToken`** in `packages/core/src/relayfile/client.ts`: - - ```ts - export interface MintPathScopedRelayfileTokenOptions { - workspaceId: string; - relayAuthUrl: string; - workspaceToken: string; // user/workspace token authorizing the mint (NOT the relayAuthApiKey) - paths: string[]; // e.g. ['/github/pull_requests/**', '/linear/issues/**'] - ttlSeconds?: number; - agentName?: string; // for token labeling/audit - } - - export async function mintPathScopedRelayfileToken( - opts: MintPathScopedRelayfileTokenOptions, - ): Promise { - const url = normalizeBaseUrl(opts.relayAuthUrl); - const res = await fetch(`${url}/v1/tokens/path`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${opts.workspaceToken}`, - }, - body: JSON.stringify({ - workspaceId: opts.workspaceId, - paths: opts.paths, - ttlSeconds: opts.ttlSeconds ?? 3600, - agentName: opts.agentName ?? 'cloud-orchestrator', - }), - }); - if (!res.ok) { - throw new Error(`relayauth path-token mint failed: ${res.status} ${await res.text()}`); - } - const { accessToken } = await res.json() as { accessToken: string }; - if (!accessToken?.startsWith('relay_pa_')) { - throw new Error('relayauth returned token without expected relay_pa_ prefix'); - } - return accessToken; - } - ``` - - Keep the existing `mintRelayfileToken` (broad-scoped) for legacy call sites — don't remove until all consumers migrated. - -2. **Update Track G's sandbox-provisioning flow** to use the new helper. When provisioning the Daytona sandbox in the persona+bundle endpoint: - - Derive `paths` from `persona.integrations..triggers[]` → watch globs (the same translation Track G already does). - - Call `mintPathScopedRelayfileToken({ workspaceId, relayAuthUrl, workspaceToken, paths, agentName: persona.id })`. - - Inject the returned token as `RELAYFILE_TOKEN` (or whatever the runner expects) into the sandbox env vars instead of the broad-scoped token. - -3. **Migrate `services/agent-gateway/`** if it has its own minting path (verify via grep). The gateway likely uses `mintRelayfileToken` to bootstrap; if so, route through `mintPathScopedRelayfileToken` when the consumer is a deployed agent (not the workflow orchestrator). - -4. **Audit log** every path-token mint with `workspaceId`, `agentId`, `paths`, `requester`. Mirror the existing relayfile-token audit pattern. - -### Track N tests - -- [ ] `mintPathScopedRelayfileToken` happy path: paths array → 200 with `relay_pa_*` token. -- [ ] Mock relayauth returning 403 / 4xx → wraps clean error. -- [ ] Mock relayauth returning malformed token (no `relay_pa_` prefix) → throws. -- [ ] Track G integration test: persona declaring `github.triggers[pull_request.opened]` results in a sandbox token whose scopes are `/github/pull_requests/**` only (not `*`). -- [ ] Legacy `mintRelayfileToken` callers still work (no regression). - -### Track N acceptance - -- [ ] `mintPathScopedRelayfileToken` exported from `packages/core/src/relayfile/client.ts`. -- [ ] Track G's sandbox provisioning uses path-scoped tokens. -- [ ] Audit logging on mint. -- [ ] Legacy broad-scope helper still works for orchestrator-internal calls. -- [ ] PR title: `feat(security): mint path-scoped relayfile tokens for sandbox agents`. -- [ ] Auto-merge on gates green. - -**Effort estimate:** ~2h. - ---- - -## Track M — Cloud `@relaycron/*` pin bump (post-relaycron#5 merge) - -**Repo:** `$CLOUD_REPO.wt-relaycron-bump` (worktree) -**Implementer model:** codex (low reasoning — pure dep bump). -**Working branch:** `chore/bump-relaycron-packages` -**Base:** post-relaycron#5 merged `main`. -**Depends on:** relaycron#5 merged (✅ 2026-05-12T21:32:06Z); `@relaycron/server@0.1.3` and `@relaycron/types@0.1.3` published (✅ 21:35 UTC). - -### Why this exists - -Cloud's `packages/relaycron-cloud/` (workspace path `packages/relaycron/`) and `packages/relaycron-types/` consume `@relaycron/server` and `@relaycron/types` as npm deps pinned at `^0.1.0`. relaycron#5 merged at 21:32 UTC and published `0.1.3` of both packages with the WS-delivery + cancel API + buffered-ticks changes. The lockfile is pinned to the pre-#5 version, so the bump is needed for cloud to actually consume the new code. - -This is separate from cloud#548's agent-gateway consumption — agent-gateway talks to relaycron over WS/HTTP via `services/agent-gateway/src/relaycron-client.ts`, not via the npm package, so it doesn't need this bump. - -### Preflight (already cleared at spec authoring time) - -```bash -# Both should pass; included for re-runs / future workflows. -RC5_MERGED=$(gh pr view 5 --repo AgentWorkforce/relaycron --json mergedAt -q '.mergedAt') -SERVER_VER=$(npm view @relaycron/server version) -TYPES_VER=$(npm view @relaycron/types version) - -if [ -z "$RC5_MERGED" ] || [ "$RC5_MERGED" = "null" ]; then - echo "WAITING: relaycron#5 not merged"; exit 0 -fi -if [ "$SERVER_VER" != "0.1.3" ]; then - echo "NOTE: @relaycron/server resolved to $SERVER_VER, expected 0.1.3 — proceed with $SERVER_VER"; -fi -``` - -### Implementation - -1. Bump these pins to `^0.1.3`: - - | File | Pin | From | To | - |---|---|---|---| - | `packages/relaycron/package.json` | `@relaycron/server` | `^0.1.0` | `^0.1.3` | - | `packages/relaycron/package.json` | `@relaycron/types` | `^0.1.0` | `^0.1.3` | - | `packages/relaycron-types/package.json` (if pinned there too — verify via grep) | `@relaycron/types` | as-is | `^0.1.3` | - | root `package.json` (if pinned there — verify via grep) | both | as-is | `^0.1.3` | - -2. Run `npm install` to refresh `package-lock.json`. -3. Run `npm run typecheck` — should stay clean (WS API additions are additive within `0.1.x`). -4. Run `npm run relaycron:test` — passes. -5. **Per workforce-publish-workflow memory: grep `.github/workflows/*.yml` for any references to `@relaycron/server` or `@relaycron/types` that need version bumps** (most likely none, but check). - -### Track M acceptance - -- [ ] All `@relaycron/*` pins on the new published version. -- [ ] Lockfile refreshed. -- [ ] Typecheck + tests green. -- [ ] PR title: `chore(deps): bump @relaycron/{server,types} to `. -- [ ] Auto-merge on gates green. - -**Effort estimate:** ~20min (mechanical bump). - ---- - -## Track L — Cloud OSS-scope cleanup (post-#548 merge) - -**Repo:** `$CLOUD_REPO.wt-oss-cleanup` (worktree) -**Implementer model:** codex (medium reasoning). -**Working branch:** `chore/remove-agent-relay-packages` -**Base:** post-cloud#548 merged `main`. - -### Preflight — already cleared at spec authoring time - -```bash -# relay#843 merged + publish run 25763431116 completed at 21:49:38 UTC. -# All @agent-relay/* packages are at 6.0.18 on npm. -# This block is left for re-runs / future workflows. - -LATEST=$(npm view @agent-relay/sdk version 2>/dev/null) -if [ -z "$LATEST" ] || [ "$LATEST" = "6.0.17" ]; then - echo "WAITING: @agent-relay/sdk publish hasn't propagated"; exit 0 -fi -echo "OK: @agent-relay/sdk at $LATEST" -``` - -### Implementation - -1. Delete `cloud/packages/agent-relay-events/` and `cloud/packages/agent-relay-agent/` directories. -2. Add `"@agent-relay/events": "^6.0.18"` and `"@agent-relay/agent": "^6.0.18"` to `services/agent-gateway/package.json` and any other consumer (verify via `grep -rln "agent-relay-events\|agent-relay-agent" services/ packages/`). -3. Refresh `package-lock.json` via `npm install`. -4. **Also bump other `@agent-relay/*` pins** in the workspace to `^6.0.18` for umbrella alignment — `@agent-relay/{config,credential-proxy,sdk}` on `main` are at `^6.0.13`; the chain branch already moved them to `^6.0.17`. Take them to `^6.0.18` so the workspace is consistent. -5. Run typecheck + tests; verify agent-gateway service still builds against the OSS packages. -6. **Per workforce-publish-workflow memory: grep `.github/workflows/*.yml` + `Makefile`** for any references to `agent-relay-events` / `agent-relay-agent` that need cleanup. -7. PR title: `chore: remove in-tree @agent-relay/{events,agent}; consume from npm @ ^6.0.18`. - -**Auto-merge?** YES on gates green. - -**Effort estimate:** ~1h. - ---- - -## What Khaliq sees when waking up - -After the workflow completes (assuming no aborts), morning state: - -**Merged on `main`:** -- Workforce: #87 (with input fix), #91, #92, #93, #94, #96, #97, plus 6 new Track D/F/H/I/K branches, plus #89 README (optional). -- Cloud: #548, #551, #555, plus 5 new Track A/B/G/J/L branches. -- Relay: #843. -- Relaycron: #5. -- Relayauth: #39. - -**Open (intentional holds):** -- cloud#554 (Daytona meter — platform-team gates). -- Anything from "Out of scope" list. - -**Ready for testing:** -- ✅ `workforce deploy ./examples/weekly-digest/persona.json --mode cloud` should work end-to-end against staging. -- ✅ Cloud deploy endpoint accepts persona+bundle. -- ✅ Schedules registered with relaycron; watches registered at agent startup with gateway DO. -- ✅ Sandbox provisions; runner executes; handler runs. -- ⚠️ Memory calls no-op (stub). -- ⚠️ Workflow.run MCP tool returns runIds for registered slugs (Track J's `echo` registered as proof-of-life). - -**Smoke test result** in workflow log: -- `SMOKE_TEST: PASS` — weekly-digest deployed against staging; cron tick posted GitHub issue within 90s. -- OR `SMOKE_TEST: FAIL — ` with logs. - -**Loud holes (documented in every track PR body):** -- ⚠️ Memory not wired (`ctx.memory` is a stub). -- ⚠️ M3 destroy/list commands missing. - -**What Khaliq does in the morning:** -1. Read the workflow's final summary comment on cloud#553 (lists every merged PR + smoke test result). -2. If smoke test passed: run `workforce deploy ./examples/review-agent/persona.json --mode cloud` against a personal GitHub repo, force-open a PR, watch the agent post a review. -3. If smoke test failed: inspect logs, decide whether to revert or push fix. - -### What this workflow does NOT deliver - -- Memory wiring (loud hole). -- M3 destroy/list CLI commands. -- `@workforce/daytona-runner` npm publish (separate agent). -- cloud#554 Daytona meter flip-to-ready (platform-team gates). - ---- - -## Merge DAG — auto-merge order - -The workflow's lead Claude walks this DAG topologically. Each node auto-merges when (a) it's opened/exists, (b) all its dependencies are merged, (c) CI green, (d) no `CHANGES_REQUESTED` reviews, (e) no merge conflicts. - -``` - ┌───────────────────────────────┐ - │ workforce#95 (already merged) │ - └─────────────┬─────────────────┘ - │ - ┌──────────────────────────┴───────────────────────────┐ - │ │ - ▼ ▼ - ┌──────────────────────┐ ┌──────────────────────────────┐ - │ Track A (cloud) │ │ Track D (workforce) │ - │ #553 body + DB │ │ persona-kit refactor │ - │ migrations │ │ traits-out + sandbox-out │ - └──────────┬───────────┘ └──────────┬───────────────────┘ - │ │ - ┌─────────┼──────────┐ ┌──────────┼─────────────┐ - ▼ ▼ ▼ ▼ ▼ ▼ - Track B Track G Track F Track E1 Track E2 ... E5 - resolver endpoint runtime (deps on A + D) rebase rebase - (cloud) (cloud) (workforce) #92 #93/#94/#96/#97 - │ │ - ▼ ▼ - Track H (workforce) (queue rebased) - --mode cloud │ - │ ▼ - ▼ Track I (workforce) - Track K (smoke test) --input flags - - ┌────────────────────────────────────────────────────────────┐ - │ Chain branch (paired contracts — verify both merged): │ - │ cloud#548 ─── relaycron#5 ─── relay#843 ─── relayauth#39 │ - │ ↓ │ - │ Track L (cloud) — remove in-tree @agent-relay/* packages │ - │ Track C (already done) — coordination comment │ - └────────────────────────────────────────────────────────────┘ - - Track J (cloud) ← depends on cloud#555 merged -``` - -**Concrete merge sequence the lead Claude executes:** - -1. workforce#87 (auto-merge with input fix verified) -2. Track A opens + auto-merges (cloud schema) -3. cloud#551 auto-merges (already unblocked; orthogonal) -4. Track D opens + auto-merges (workforce persona-kit) -5. Track E1–E5 (rebase #92, #93, #94, #96, #97) — parallel; each auto-merges on green -6. Chain branch group — most already merged; only cloud#548 + relayauth#39 remain. - - **relaycron#5** — ✅ **merged 2026-05-12T21:32:06Z**; `@relaycron/{server,types}@0.1.3` published 21:35 UTC. Skip. - - **relay#843** — ✅ **merged 2026-05-12T21:30:54Z**; publish workflow `25763431116` ✅ **completed 21:49:38 UTC** — `@agent-relay/*` lockstep-bumped to `6.0.18`. Track L's preflight already satisfied; skip the polling step. - - **relayauth#39** — still open, docs-only, +3/-1. Merge first (lowest risk). - - **cloud#548** — still open, last (highest blast radius). - - **Release behavior of remaining merges:** - - relayauth#39: docs-only, no release. - - cloud#548: ⚠️ **merge triggers cloud's SST production deploy pipeline**. Lead Claude posts a `#wf-schema-cascade` confirmation **immediately before** merging #548 so Khaliq can intercept if awake. If no intercept within 5 minutes, proceed with merge. Post-merge, monitor the preview + production deploy job; if it fails, log the failure but don't try to revert (rollback is manual). -7. Track G opens + auto-merges (cloud deploy endpoint; depends on Track A + cloud#548 + relaycron#5) -8. Track B opens + auto-merges (cloud resolver) -9. Track F opens + auto-merges (workforce runtime; depends on Track D + Track A) -10. Track H opens + auto-merges (workforce --mode cloud; depends on Track D + Track G) -11. Track I opens + auto-merges (workforce --input flags; depends on Track D + Track A) -12. Track L opens + auto-merges (cloud OSS-scope cleanup; depends on cloud#548 + relay#843 merged + relay#843's npm publish settled) -13. Track M opens + auto-merges (cloud `@relaycron/*` pin bump; depends on relaycron#5 merged + npm publish settled — waits ~5 min after relaycron#5 merge for npm propagation) -14. Track N opens + auto-merges (cloud sandbox token path-scoping; depends on relayauth#39 merged + Track G merged) -15. cloud#555 auto-merges if green + Track J's follow-ups open + auto-merge -16. workforce#89 (README, nice-to-have) auto-merges last -17. Track K runs (smoke test); reports result; does NOT block any merge. - -**Failure handling per step:** if any node breaks the cascade (CI red after fixer loop, conflict-resolution fails), the workflow: -- Posts a loud failure to `#wf-schema-cascade`. -- Annotates the broken PR with a comment explaining the failure. -- Leaves all previously-merged work merged. -- Continues with INDEPENDENT downstream nodes (e.g. Track J doesn't depend on Track F; if F breaks, J can still proceed). -- Stops dependent nodes (e.g. if Track A breaks, Track G can't run). - -## When Ricky is blocked - -- **workforce#95 not merged at start?** Tracks D/E/F exit immediately with `WAITING: workforce#95`. A/B/C may proceed. -- **Track A back-fill migration breaks on existing prod-shaped data?** Open the migrations PR as DRAFT with the failing back-fill rows listed; don't try to skip them. Human resolves. -- **Track E sub-track has unmergeable conflicts?** Open `-rebased` as a separate PR, comment on original linking it, STOP that sub-track. Others continue. -- **A persona fixture had a `traits` block that consumers depend on?** Don't add traits back. Surface in PR body: `TODO(human): consumer X expected traits.; recommend extracting to persona-personality-builder (out of scope for v1).` -- **`agent-assistant/proactive@0.4.32` introduces a breaking change in `fromContext`?** Pin to `0.4.31` in #96 with a `TODO(human): bump after consuming 0.4.32 breaking changes` note. Don't modify proactive bridge logic. - ---- - -## Notes for the workflow author - -- Use `proactive-runtime-m1.ts` as the structural reference for `dependsOn` edges, soft/hard gates, and review rounds. -- Tracks A/B/C run on cloud; D/E/F on workforce. Repo isolation prevents cross-track conflicts. -- Track E sub-tracks are independent of each other — generate parallel `dependsOn` edges (all five depend only on Track D). -- Lead Claude posts a per-track summary into `#wf-schema-cascade` at each gate transition. -- Final run report: post a summary comment on cloud#553 linking every PR + the migration plan. diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index a11ab435..ec94c524 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -38,7 +38,7 @@ One file. One command. One contract. ### In -- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `sandbox`, `memory`, `traits`, `onEvent`. +- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `memory`, `onEvent`. - New package `@agentworkforce/runtime` — thin facade exposing `handler(...)` that wraps `agent({...})` from `@agent-relay/agent` (cloud proactive-runtime M1 SDK). - New package `@agentworkforce/deploy` — the deploy CLI logic; the existing `cli.ts` gets a `deploy` case that dispatches to it. - Daytona sandbox launcher used in the `--sandbox` run mode. @@ -64,7 +64,7 @@ One file. One command. One contract. ## 3. Persona JSON schema diff -All new fields are optional. A persona that does not set any of them continues to behave exactly as today — `workforce agent ` works unchanged. Set `cloud: true` and at least one trigger to opt into the new deploy surface. +All new fields are optional. A persona that does not set any of them continues to behave exactly as today — `workforce agent ` works unchanged. Set `cloud: true` and at least one listener to opt into the new deploy surface. A persona listens for events through three listener kinds: **clock** (`schedules[]` cron ticks), **radio** (`integrations..triggers[]` RelayFile events), and **inbox** (RelayCast targeted messages, not yet modeled in v1). The JSON shape predates the listeners framing; the semantics are equivalent. ### 3.1 Top-level additions @@ -72,14 +72,14 @@ All new fields are optional. A persona that does not set any of them continues t |---|---|---|---| | `cloud` | `boolean` | always (default `false`) | When `true`, this persona is deployable. `workforce deploy` only operates on personas where this is `true`. | | `useSubscription` | `boolean` | optional | When `true`, inference uses the user's connected LLM subscription via `@agent-relay/cloud`'s provider link (no workforce-billed tokens). Triggers a `connectProvider` step at deploy time. | -| `integrations` | `Record` | when persona has event triggers | Declares which Relayfile providers this agent needs and what events fire its handler. See §3.2. | -| `schedules` | `Schedule[]` | when persona runs on cron | One or more cron triggers, registered with the runtime's `ctx.schedule.every(...)`. Each schedule has a `name` echoed back to the handler. See §3.3. | -| `sandbox` | `boolean \| SandboxConfig` | optional | `true` (default) means agent runs inside a Daytona sandbox. `false` means the runner process owns its own filesystem. Object form lets you tune env / timeout. See §3.4. | -| `memory` | `boolean \| MemoryConfig` | optional | Enables the agent-assistant memory subsystem. Scopes and TTL configurable. See §3.5. | -| `traits` | `Traits` | optional, **only meaningful for interactive agents** | Mirrors `@agent-assistant/traits`: voice, formality, proactivity, etc. Applied when the agent posts to a chat surface (Slack, Relaycast). Headless agents (paraglide-style "Linear issue → ship") may omit this. See §3.6. | +| `integrations` | `Record` | when persona has radio listeners | Declares which RelayFile providers this agent needs and what radio events fire its handler. See §3.2. | +| `schedules` | `Schedule[]` | when persona has clock listeners | One or more cron listeners, registered with the runtime's `ctx.schedule.every(...)`. Each schedule has a `name` echoed back to the handler. See §3.3. | +| `memory` | `boolean \| MemoryConfig` | optional | Declares memory intent. `ctx.memory` is a v1 stub until the follow-up memory wiring workflow lands. See §3.4. | | `onEvent` | `string` | when `cloud: true` and any trigger declared | Path to a TS file (relative to the persona JSON) whose default export is the event handler. Sub-file references like `./agent.ts` and `./handlers/index.ts` are supported. See §4. | -### 3.2 `integrations` shape +`traits` and `sandbox` were removed from the persona spec in v1. Personality belongs in the persona's prompt/sidecar and the persona-personality-builder flow. Sandbox behavior is deploy-time runtime configuration: sandbox mode is on by default for deploys, with opt-out handled by deploy flags or runtime config rather than persona JSON. + +### 3.2 `integrations` radio listener shape ```jsonc "integrations": { @@ -100,13 +100,13 @@ All new fields are optional. A persona that does not set any of them continues t Key choices: - **Key is the Relayfile provider slug.** `github`, `linear`, `slack`, `notion`, `jira`. The deploy step calls `RelayfileSetup.connectIntegration({ allowedIntegrations: [key] })` for any provider not yet connected to the user's workspace. -- **`triggers[]` is a flat list per provider** — multiple events from the same provider all fan into the same `onEvent`. The handler discriminates on `event.source` + `event.type`. +- **`triggers[]` is a flat radio listener list per provider** — multiple events from the same provider all fan into the same `onEvent`. The handler discriminates on `event.source` + `event.type`. - **`match` and `where` are sugars** — `match: "@mention"` is shorthand for "filter to events that mention the deployed agent." The deploy CLI lints them against a known set; unknown values warn but don't fail. We can always upgrade the runtime to enforce them later. - **`scope` is optional and provider-specific.** Validated by the deploy CLI against a small provider-schema map. For v1, supported keys are documented per provider in the examples. The act of stacking integrations is just declaring multiple keys. The act of linking them ("when GitHub fires, post to Slack") is code in `onEvent`. We considered a declarative `links:` block — see §11.4 for why we deferred it. -### 3.3 `schedules` shape +### 3.3 `schedules` clock listener shape ```jsonc "schedules": [ @@ -119,56 +119,26 @@ The act of stacking integrations is just declaring multiple keys. The act of lin - `cron` is a standard 5-field expression. `tz` defaults to `UTC`. - Multiple schedules are allowed. The runtime registers each with `ctx.schedule.every(cron, { tz, payload: { name } })`. -### 3.4 `sandbox` shape - -```jsonc -"sandbox": true // default -"sandbox": { "enabled": true, "timeoutSeconds": 1800, "env": { "FOO": "bar" } } -"sandbox": false // run in the runner process's fs -``` - -- Image is **not** user-configurable in v1. Workforce picks a standard image (`node-22` baseline) for the default Daytona sandbox. We can add `image` later if a real demand surfaces; eliminating the field keeps the v1 contract small. -- `timeoutSeconds` caps a single handler invocation. Default 1800s. -- `env` adds env vars on top of the auto-injected secrets (Relayfile connection tokens, harness inference creds, etc.). -- When `sandbox: false`, the agent's `ctx.sandbox` still exists but points at the runner's own process — useful for `--dev` iteration, **not** what we recommend for production. - -### 3.5 `memory` shape +### 3.4 `memory` shape ```jsonc "memory": true // sensible defaults "memory": { "enabled": true, - "scopes": ["session", "user", "workspace"], + "scopes": ["workspace", "user", "global"], "ttlDays": 30, "autoPromote": true, "dedupMs": 300000 } ``` -- Implementation: the runtime wires `@agent-assistant/memory` with the supermemory adapter (matching sage today). API key is pulled from workforce-managed env, not declared in the persona. -- `scopes` is the only field with real semantic weight: session-only memory is wiped per handler; user-scope persists across the user's invocations of this agent; workspace persists across all users. +- ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). +- When memory is wired, the runtime will use the supermemory adapter. API keys come from workforce-managed env, not from persona JSON. +- `scopes` is the only field with real semantic weight: workspace memory persists across users in a workspace, user memory follows an individual user's invocations, and global memory is shared across the deployed agent. - `autoPromote` flips on the sage turn-recorder pattern — agent decides if session content is worth promoting. -- **No `memoryMd` file.** Memory is config, not prose. Personality goes in `traits` and `description`. - -### 3.6 `traits` shape - -Direct mapping to `@agent-assistant/traits`: - -```jsonc -"traits": { - "voice": "professional-warm", - "formality": "low", - "proactivity": "medium", - "riskPosture": "conservative", - "domain": "engineering", - "vocabulary": ["PR", "diff", "CI"], - "preferMarkdown": true -} -``` - -Only used when the runtime renders into a conversational surface (Slack message, Relaycast post, GitHub PR comment). Skip the field entirely for headless agents — saves the runtime a subsystem registration. +- **No `memoryMd` file.** Memory is config, not prose. Personality goes in prompt/sidecar content and the persona-personality-builder flow. -### 3.7 Trigger-name registry +### 3.5 Trigger-name registry `packages/persona-kit/src/triggers.ts` (new) ships a small registry of known trigger names per provider so the deploy CLI can lint them: @@ -217,7 +187,7 @@ interface WorkforceCtx { notion?: NotionClient; jira?: JiraClient; - // Daytona sandbox (or process fs if sandbox:false) + // Daytona sandbox or runtime-provided process fs sandbox: { cwd: string; // absolute path inside the sandbox exec(cmd: string, opts?: { cwd?: string; env?: Record }): Promise; @@ -242,7 +212,7 @@ interface WorkforceCtx { cancel(name: string): Promise; }; - // Persona metadata (id, traits, harness tier defaults, etc.) — read-only + // Persona metadata (id, harness defaults, listeners, etc.) — read-only persona: PersonaSpec; } @@ -254,7 +224,7 @@ export function handler( Implementation notes: - `handler(...)` reads the persona JSON adjacent to the entrypoint (workforce bundles them together). At cold-start it: 1. Calls `agent({ workspace, schedule, watch, inbox, onEvent: shim })` from `@agent-relay/agent`, mapping `persona.integrations` to `watch` and `persona.schedules` to `schedule`. - 2. Builds `ctx` once per agent boot: opens Daytona handle (if `sandbox: true`), wires Relayfile-derived clients, attaches memory adapter. + 2. Builds `ctx` once per agent boot: opens Daytona handle when deploy runs in sandbox mode, wires Relayfile-derived clients, attaches memory adapter. 3. The `shim` reshapes the raw envelope from `@agent-relay/agent` into the `WorkforceEvent` discriminated union and invokes the user's `fn(ctx, event)`. - The user never imports `@agent-relay/agent` directly. Workforce owns the ergonomics. If the underlying SDK churns, we absorb the diff here. - The SDK doors stay open for power users: we re-export `agent` from `@agentworkforce/runtime/raw` so anyone who wants the lower-level surface can drop down. This matters for nightcto-shaped projects that outgrow the persona contract. @@ -354,7 +324,6 @@ Direct port of the proactive-agents weekly-digest pattern. "cloud": true, "integrations": { "github": { "scope": { "repo": "AgentWorkforce/weekly-digest" } } }, "schedules": [{ "name": "weekly", "cron": "0 9 * * 6", "tz": "UTC" }], - "sandbox": true, "memory": { "enabled": true, "scopes": ["workspace"], "ttlDays": 90 }, "onEvent": "./agent.ts", "tiers": { ... standard codex/opencode tiers ... } @@ -385,9 +354,7 @@ Direct port of the proactive-agents weekly-digest pattern. }, "slack": { "triggers": [{ "on": "app_mention" }] } }, - "sandbox": true, - "memory": { "enabled": true, "scopes": ["session", "workspace"] }, - "traits": { "voice": "professional-warm", "formality": "low", "preferMarkdown": true }, + "memory": { "enabled": true, "scopes": ["user", "workspace"] }, "onEvent": "./agent.ts", "tiers": { ... } } @@ -409,7 +376,7 @@ workforce/ │ ├── cli/ # add `deploy`, `login` cases │ ├── persona-kit/ # extend PersonaSpec schema (§3) │ │ └── src/ -│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Sandbox, +Memory, +Traits +│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Memory │ │ ├── parse.ts # extend parsePersonaSpec to read new fields │ │ └── triggers.ts # NEW — known triggers registry (§3.7) │ ├── harness-kit/ # no changes for v1 @@ -494,7 +461,7 @@ If a track slips, §10's fallback applies: ship `--dev` end-to-end with `weekly- Tasks that are mechanical, well-specified, and don't gate on my decisions — perfect for a codex agent spawned via `workforce agent code-implementer` or a similar persona: 1. **Trigger registry expansion** — fill out `packages/persona-kit/src/triggers.ts` with the full set of known trigger names per Tier-1 provider (Linear, GitHub, Slack, Notion, Jira) by reading the Relayfile provider docs in `/Users/khaliqgant/Projects/AgentWorkforce/relayfile/docs/`. -2. **Test fixtures** — generate sample `persona.json` files exercising every optional combination (with/without traits, sandbox false, multi-schedule, etc.) into `packages/persona-kit/src/__fixtures__/`. +2. **Test fixtures** — generate sample `persona.json` files exercising deploy optional combinations (memory, multi-schedule, integrations, etc.) into `packages/persona-kit/src/__fixtures__/`. 3. **JSON Schema export** — emit a JSON Schema from the extended `PersonaSpec` for editor autocomplete. New script: `packages/persona-kit/scripts/emit-schema.mjs`. Wire to `pnpm run build` so it ships with the package. 4. **Example expansion** — write a third example, `examples/linear-shipper/` (the paraglide pattern: Linear issue created → drive to PR), purely against the runtime substrate I land in §9.1. 5. **README polish** — once the deploy command is real, codex agent rewrites the workforce README to lead with the deploy story. diff --git a/examples/weekly-digest/persona.json b/examples/weekly-digest/persona.json index 8f028f79..a127b252 100644 --- a/examples/weekly-digest/persona.json +++ b/examples/weekly-digest/persona.json @@ -35,7 +35,6 @@ "tz": "UTC" } ], - "sandbox": true, "memory": { "enabled": true, "scopes": [ diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 56d7f47a..02f64576 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -59,8 +59,6 @@ export const sandboxLauncher: ModeLauncher = { throw err; } - const sandboxTimeoutSeconds = resolveTimeoutSeconds(input.persona.sandbox); - let stopping = false; const stop = async (): Promise => { if (stopping) return; @@ -77,8 +75,7 @@ export const sandboxLauncher: ModeLauncher = { const done = (async () => { try { const result = await client.exec(handle, 'node runner.mjs', { - cwd: SANDBOX_BUNDLE_DIR, - timeoutSeconds: sandboxTimeoutSeconds + cwd: SANDBOX_BUNDLE_DIR }); const output = result.output.trim(); if (output.length > 0) input.io.info(`[sandbox] ${output}`); @@ -149,14 +146,6 @@ export function resolveSandboxClient( }); } -function resolveTimeoutSeconds(sandbox: ModeLaunchInput['persona']['sandbox']): number | undefined { - if (sandbox === undefined || sandbox === true || sandbox === false) return undefined; - if (typeof sandbox.timeoutSeconds === 'number' && sandbox.timeoutSeconds > 0) { - return sandbox.timeoutSeconds; - } - return undefined; -} - // Re-exported for tests + power users wanting to compose the client manually. export { SANDBOX_BUNDLE_DIR, diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 9ed02cc6..6927f120 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -116,20 +116,12 @@ "items": { "$ref": "#/definitions/PersonaSchedule" }, - "description": "Cron-style schedules. Each `name` is unique within the persona." - }, - "sandbox": { - "$ref": "#/definitions/PersonaSandbox", - "description": "Sandbox preference. `true` (default for cloud personas) means the agent runs inside a Daytona sandbox at deploy time; `false` runs it in the runner process. The object form lets the author tune timeout / env." + "description": "Cron-style clock listeners. Each `name` is unique within the persona." }, "memory": { "$ref": "#/definitions/PersonaMemory", "description": "Memory subsystem opt-in. Wires the agent-assistant memory adapter at runtime; the persona spec only declares intent, not implementation details (api keys, adapter type, etc. come from workforce env)." }, - "traits": { - "$ref": "#/definitions/PersonaTraits", - "description": "Conversational traits, applied only when the agent posts to a chat surface. Omit for headless agents." - }, "onEvent": { "type": "string", "description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required when {@link cloud } is `true` and any trigger is declared; the deploy CLI enforces this at deploy time, the parser keeps it optional so partially- authored specs still parse." @@ -146,6 +138,7 @@ "systemPrompt", "harnessSettings" ], + "description": "A persona listens for events. Three listener kinds: clock (cron schedules through `schedules[]`), radio (RelayFile integration events through `integrations..triggers[]`), and inbox (RelayCast targeted messages, not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent.", "allOf": [ { "if": { @@ -420,7 +413,7 @@ } } }, - "description": "Per-provider integration configuration. The map key is the Relayfile provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` is provider-specific filter metadata (e.g. `{ repo: \"org/repo\" }` for github, `{ database: \"\" }` for notion). `triggers` are flat — all trigger events for this provider fan into the same `onEvent` handler, which discriminates on `event.source` + `event.type`." + "description": "Radio listener configuration for a RelayFile provider. The map key is the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` is provider-specific filter metadata (e.g. `{ repo: \"org/repo\" }` for github, `{ database: \"\" }` for notion). `triggers` are flat — all radio listener events for this provider fan into the same `onEvent` handler, which discriminates on `event.source` + `event.type`." }, "PersonaIntegrationTrigger": { "type": "object", @@ -457,36 +450,7 @@ "name", "cron" ], - "description": "A cron-style schedule. `name` is unique within the persona and surfaces to the handler as `event.name`. `cron` is a standard 5-field expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps it optional so the spec stays close to what the author wrote)." - }, - "PersonaSandbox": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/PersonaSandboxConfig" - } - ], - "description": "Sandbox can be specified as `true` / `false` shorthand or as the full config object. The parser preserves whichever form the author wrote so round-trips stay lossless; consumers normalize when reading." - }, - "PersonaSandboxConfig": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "timeoutSeconds": { - "type": "number" - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "description": "Long-form sandbox configuration. `enabled` defaults to true when the object form is present; supply the boolean shorthand `sandbox: false` to opt out entirely. `timeoutSeconds` caps a single handler invocation (default 1800s in the runtime). `env` is merged on top of auto-injected secrets at sandbox-create time.\n\nImage selection is intentionally not user-configurable in v1 — workforce picks a standard image. Add `image` later if a real demand surfaces." + "description": "Clock listener configuration. `name` is unique within the persona and surfaces to the handler as `event.name`. `cron` is a standard 5-field expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps it optional so the spec stays close to what the author wrote)." }, "PersonaMemory": { "anyOf": [ @@ -525,58 +489,11 @@ "PersonaMemoryScope": { "type": "string", "enum": [ - "session", - "user", "workspace", - "org", - "object" + "user", + "global" ], "description": "Memory scope semantics, mirroring" - }, - "PersonaTraits": { - "type": "object", - "properties": { - "voice": { - "type": "string" - }, - "formality": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "proactivity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "riskPosture": { - "type": "string", - "enum": [ - "conservative", - "balanced", - "aggressive" - ] - }, - "domain": { - "type": "string" - }, - "vocabulary": { - "type": "array", - "items": { - "type": "string" - } - }, - "preferMarkdown": { - "type": "boolean" - } - }, - "description": "Conversational traits, applied only when the agent posts to a chat surface (Slack, Relaycast, GitHub PR comment). Headless agents — the paraglide \"Linear issue → PR\" pattern — should omit this field. Mirrors the trait shape in `@agent-assistant/traits`." } }, "$id": "https://agentworkforce.dev/schemas/persona.schema.json" diff --git a/packages/persona-kit/src/__fixtures__/personas/cron-only.json b/packages/persona-kit/src/__fixtures__/personas/cron-only.json index 04975c56..568a70a5 100644 --- a/packages/persona-kit/src/__fixtures__/personas/cron-only.json +++ b/packages/persona-kit/src/__fixtures__/personas/cron-only.json @@ -9,7 +9,6 @@ { "name": "weekly-digest", "cron": "0 9 * * 6", "tz": "UTC" }, { "name": "weekday-check", "cron": "0 9 * * 1-5", "tz": "America/New_York" } ], - "sandbox": true, "onEvent": "./agent.ts", "harness": "codex", "model": "gpt-5", diff --git a/packages/persona-kit/src/__fixtures__/personas/full.json b/packages/persona-kit/src/__fixtures__/personas/full.json index db2432b8..5b5ebb23 100644 --- a/packages/persona-kit/src/__fixtures__/personas/full.json +++ b/packages/persona-kit/src/__fixtures__/personas/full.json @@ -43,27 +43,13 @@ } }, "schedules": [{ "name": "daily-review", "cron": "0 14 * * 1-5", "tz": "UTC" }], - "sandbox": { - "enabled": true, - "timeoutSeconds": 1800, - "env": { "NODE_ENV": "production" } - }, "memory": { "enabled": true, - "scopes": ["session", "user", "workspace"], + "scopes": ["workspace", "user", "global"], "ttlDays": 30, "autoPromote": true, "dedupMs": 300000 }, - "traits": { - "voice": "professional-warm", - "formality": "low", - "proactivity": "medium", - "riskPosture": "conservative", - "domain": "engineering", - "vocabulary": ["PR", "diff", "CI"], - "preferMarkdown": true - }, "onEvent": "./agent.ts", "env": { "BRAVE_API_KEY": "$BRAVE_API_KEY" }, "mcpServers": { diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index c4ab1119..35b2b414 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -59,10 +59,25 @@ test('generated schema requires onEvent for cloud personas', async () => { ); }); +test('generated schema reflects locked v1 persona fields', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')) as SchemaNode; + const definitions = schema.definitions as Record; + const personaSpec = definitions.PersonaSpec; + const properties = personaSpec.properties ?? {}; + + assert.equal('sandbox' in properties, false); + assert.equal('traits' in properties, false); + assert.deepEqual(definitions.PersonaMemoryScope.enum, ['workspace', 'user', 'global']); + assert.equal('PersonaSandbox' in definitions, false); + assert.equal('PersonaSandboxConfig' in definitions, false); + assert.equal('PersonaTraits' in definitions, false); +}); + type SchemaNode = Record & { $ref?: string; allOf?: SchemaNode[]; anyOf?: SchemaNode[]; + definitions?: Record; if?: SchemaNode; then?: SchemaNode; enum?: unknown[]; diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index b6dec013..9498068b 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -32,14 +32,11 @@ export type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode, SkillInstall, SkillMaterializationOptions, @@ -69,13 +66,11 @@ export { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, parseTags, - parseTraits, resolveSidecar, sidecarSelectionFields } from './parse.js'; diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e201c443..2901b91a 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -13,13 +13,11 @@ import { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, - parseTags, - parseTraits + parseTags } from './parse.js'; function validSpec(over: Record = {}): Record { @@ -66,9 +64,7 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { } }, schedules: [{ name: 'weekly', cron: '0 9 * * 6', tz: 'UTC' }], - sandbox: { enabled: true, timeoutSeconds: 1800, env: { NODE_ENV: 'production' } }, memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, - traits: { voice: 'professional-warm', preferMarkdown: true }, onEvent: './agent.ts' }), 'documentation' @@ -77,16 +73,27 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { assert.equal(spec.cloud, true); assert.equal(spec.integrations?.github.triggers?.[0].on, 'pull_request.opened'); assert.equal(spec.schedules?.[0].name, 'weekly'); - assert.deepEqual(spec.sandbox, { - enabled: true, - timeoutSeconds: 1800, - env: { NODE_ENV: 'production' } - }); assert.deepEqual(spec.memory, { enabled: true, scopes: ['workspace'], ttlDays: 30 }); - assert.equal(spec.traits?.preferMarkdown, true); assert.equal(spec.onEvent, './agent.ts'); }); +test('parsePersonaSpec rejects removed deploy-v1 traits and sandbox keys', () => { + assert.throws( + () => parsePersonaSpec(validSpec({ traits: { voice: 'warm' } }), 'documentation'), + { + message: + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + } + ); + assert.throws( + () => parsePersonaSpec(validSpec({ sandbox: true }), 'documentation'), + { + message: + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + } + ); +}); + test('parsePersonaSpec throws when intent does not match the expected intent', () => { assert.throws( () => parsePersonaSpec(validSpec({ intent: 'review' }), 'documentation'), @@ -347,45 +354,18 @@ test('parsePersonaSpec rejects a non-object spec', () => { // --- deploy-v1 schema additions ---------------------------------------------- -test('parseSandbox accepts boolean shorthand and round-trips both forms', () => { - assert.equal(parseSandbox(true, 'sandbox'), true); - assert.equal(parseSandbox(false, 'sandbox'), false); - assert.equal(parseSandbox(undefined, 'sandbox'), undefined); - const obj = parseSandbox( - { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }, - 'sandbox' - ); - assert.deepEqual(obj, { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }); -}); - -test('parseSandbox rejects malformed objects with field-pointed errors', () => { - assert.throws(() => parseSandbox('on', 'sandbox'), /sandbox must be a boolean or an object/); - assert.throws( - () => parseSandbox({ enabled: 'yes' }, 'sandbox'), - /sandbox\.enabled must be a boolean/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: -1 }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: Number.POSITIVE_INFINITY }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); -}); - test('parseMemory accepts boolean + object forms and validates scopes', () => { assert.equal(parseMemory(true, 'memory'), true); assert.equal(parseMemory(false, 'memory'), false); assert.equal(parseMemory(undefined, 'memory'), undefined); const m = parseMemory( - { enabled: true, scopes: ['user', 'user', 'workspace'], ttlDays: 7, autoPromote: true, dedupMs: 0 }, + { enabled: true, scopes: ['user', 'user', 'workspace', 'global'], ttlDays: 7, autoPromote: true, dedupMs: 0 }, 'memory' ); // Duplicates are deduped while preserving first-seen order. assert.deepEqual(m, { enabled: true, - scopes: ['user', 'workspace'], + scopes: ['user', 'workspace', 'global'], ttlDays: 7, autoPromote: true, dedupMs: 0 @@ -395,47 +375,17 @@ test('parseMemory accepts boolean + object forms and validates scopes', () => { test('parseMemory rejects unknown scopes and non-positive ttl', () => { assert.throws( () => parseMemory({ scopes: ['planet'] }, 'memory'), - /memory\.scopes\[0\] must be one of: session, user, workspace, org, object/ + /memory\.scopes\[0\] must be one of: workspace, user, global/ + ); + assert.throws( + () => parseMemory({ scopes: ['session'] }, 'memory'), + /memory\.scopes\[0\] must be one of: workspace, user, global/ ); assert.throws(() => parseMemory({ scopes: [] }, 'memory'), /scopes must be a non-empty array/); assert.throws(() => parseMemory({ ttlDays: 0 }, 'memory'), /ttlDays must be a positive number/); assert.throws(() => parseMemory({ dedupMs: -1 }, 'memory'), /dedupMs must be a non-negative number/); }); -test('parseTraits keeps only supplied fields and validates enums', () => { - assert.equal(parseTraits(undefined, 'traits'), undefined); - assert.equal(parseTraits({}, 'traits'), undefined); // empty object collapses to undefined - const t = parseTraits( - { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }, - 'traits' - ); - assert.deepEqual(t, { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }); - assert.throws( - () => parseTraits({ formality: 'extreme' }, 'traits'), - /traits\.formality must be one of: low, medium, high/ - ); - assert.throws( - () => parseTraits({ riskPosture: 'wild' }, 'traits'), - /traits\.riskPosture must be one of: conservative, balanced, aggressive/ - ); -}); - test('parseSchedules validates cron, requires unique names, preserves tz when set', () => { const s = parseSchedules( [ @@ -568,11 +518,10 @@ test('parsePersonaSpec rejects non-boolean cloud / useSubscription', () => { ); }); -test('parsePersonaSpec keeps boolean shorthand sandbox / memory through round-trip', () => { +test('parsePersonaSpec keeps boolean shorthand memory through round-trip', () => { const spec = parsePersonaSpec( - validSpec({ cloud: true, sandbox: true, memory: false }), + validSpec({ cloud: true, memory: false }), 'documentation' ); - assert.equal(spec.sandbox, true); assert.equal(spec.memory, false); }); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index d5c12094..0a308c9d 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -23,14 +23,11 @@ import type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode } from './types.js'; @@ -388,16 +385,11 @@ export function parseMcpServers( } const MEMORY_SCOPE_VALUES: readonly PersonaMemoryScope[] = [ - 'session', - 'user', 'workspace', - 'org', - 'object' + 'user', + 'global' ]; -const TRAIT_LEVEL_VALUES = ['low', 'medium', 'high'] as const; -const TRAIT_RISK_VALUES = ['conservative', 'balanced', 'aggressive'] as const; - const ONEVENT_EXT_RE = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/i; // Standard 5-field cron: minute hour day-of-month month day-of-week. Each @@ -572,39 +564,6 @@ export function parseSchedules( return out; } -export function parseSandbox(value: unknown, context: string): PersonaSandbox | undefined { - if (value === undefined) return undefined; - if (typeof value === 'boolean') return value; - if (!isObject(value)) { - throw new Error(`${context} must be a boolean or an object if provided`); - } - const { enabled, timeoutSeconds, env } = value; - const out: PersonaSandboxConfig = {}; - if (enabled !== undefined) { - if (typeof enabled !== 'boolean') { - throw new Error(`${context}.enabled must be a boolean if provided`); - } - out.enabled = enabled; - } - if (timeoutSeconds !== undefined) { - if ( - typeof timeoutSeconds !== 'number' || - !Number.isFinite(timeoutSeconds) || - timeoutSeconds <= 0 - ) { - throw new Error(`${context}.timeoutSeconds must be a positive number if provided`); - } - out.timeoutSeconds = timeoutSeconds; - } - if (env !== undefined) { - const parsedEnv = parseStringMap(env, `${context}.env`); - if (parsedEnv && Object.keys(parsedEnv).length > 0) { - out.env = parsedEnv; - } - } - return out; -} - export function parseMemory(value: unknown, context: string): PersonaMemory | undefined { if (value === undefined) return undefined; if (typeof value === 'boolean') return value; @@ -658,56 +617,6 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un return out; } -export function parseTraits(value: unknown, context: string): PersonaTraits | undefined { - if (value === undefined) return undefined; - if (!isObject(value)) { - throw new Error(`${context} must be an object if provided`); - } - const { voice, formality, proactivity, riskPosture, domain, vocabulary, preferMarkdown } = value; - const out: PersonaTraits = {}; - if (voice !== undefined) { - if (typeof voice !== 'string' || !voice.trim()) { - throw new Error(`${context}.voice must be a non-empty string if provided`); - } - out.voice = voice; - } - if (formality !== undefined) { - if (typeof formality !== 'string' || !TRAIT_LEVEL_VALUES.includes(formality as 'low')) { - throw new Error(`${context}.formality must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.formality = formality as PersonaTraits['formality']; - } - if (proactivity !== undefined) { - if (typeof proactivity !== 'string' || !TRAIT_LEVEL_VALUES.includes(proactivity as 'low')) { - throw new Error(`${context}.proactivity must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.proactivity = proactivity as PersonaTraits['proactivity']; - } - if (riskPosture !== undefined) { - if (typeof riskPosture !== 'string' || !TRAIT_RISK_VALUES.includes(riskPosture as 'balanced')) { - throw new Error(`${context}.riskPosture must be one of: ${TRAIT_RISK_VALUES.join(', ')}`); - } - out.riskPosture = riskPosture as PersonaTraits['riskPosture']; - } - if (domain !== undefined) { - if (typeof domain !== 'string' || !domain.trim()) { - throw new Error(`${context}.domain must be a non-empty string if provided`); - } - out.domain = domain; - } - if (vocabulary !== undefined) { - const parsed = parseStringList(vocabulary, `${context}.vocabulary`); - if (parsed) out.vocabulary = parsed; - } - if (preferMarkdown !== undefined) { - if (typeof preferMarkdown !== 'boolean') { - throw new Error(`${context}.preferMarkdown must be a boolean if provided`); - } - out.preferMarkdown = preferMarkdown; - } - return Object.keys(out).length > 0 ? out : undefined; -} - export function parseOnEvent(value: unknown, context: string): string | undefined { if (value === undefined) return undefined; return assertOnEventPath(value, context); @@ -717,6 +626,16 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): if (!isObject(value)) { throw new Error(`persona[${expectedIntent}] must be an object`); } + if ('traits' in value) { + throw new Error( + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + ); + } + if ('sandbox' in value) { + throw new Error( + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + ); + } const { id, @@ -743,9 +662,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): useSubscription, integrations, schedules, - sandbox, memory, - traits, onEvent } = value; @@ -826,9 +743,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): `persona[${expectedIntent}].integrations` ); const parsedSchedules = parseSchedules(schedules, `persona[${expectedIntent}].schedules`); - const parsedSandbox = parseSandbox(sandbox, `persona[${expectedIntent}].sandbox`); const parsedMemory = parseMemory(memory, `persona[${expectedIntent}].memory`); - const parsedTraits = parseTraits(traits, `persona[${expectedIntent}].traits`); const parsedOnEvent = parseOnEvent(onEvent, `persona[${expectedIntent}].onEvent`); return { @@ -856,9 +771,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): ...(typeof useSubscription === 'boolean' ? { useSubscription } : {}), ...(parsedIntegrations ? { integrations: parsedIntegrations } : {}), ...(parsedSchedules ? { schedules: parsedSchedules } : {}), - ...(parsedSandbox !== undefined ? { sandbox: parsedSandbox } : {}), ...(parsedMemory !== undefined ? { memory: parsedMemory } : {}), - ...(parsedTraits ? { traits: parsedTraits } : {}), ...(parsedOnEvent !== undefined ? { onEvent: parsedOnEvent } : {}) }; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index fff25a5a..b231c827 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -153,12 +153,12 @@ export interface PersonaIntegrationTrigger { } /** - * Per-provider integration configuration. The map key is the Relayfile - * provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` - * is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` for - * github, `{ database: "" }` for notion). `triggers` are flat — all - * trigger events for this provider fan into the same `onEvent` handler, - * which discriminates on `event.source` + `event.type`. + * Radio listener configuration for a RelayFile provider. The map key is + * the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). + * `scope` is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` + * for github, `{ database: "" }` for notion). `triggers` are flat — + * all radio listener events for this provider fan into the same `onEvent` + * handler, which discriminates on `event.source` + `event.type`. */ export interface PersonaIntegrationConfig { scope?: Record; @@ -166,10 +166,10 @@ export interface PersonaIntegrationConfig { } /** - * A cron-style schedule. `name` is unique within the persona and surfaces - * to the handler as `event.name`. `cron` is a standard 5-field expression. - * `tz` defaults to `UTC` at the runtime layer (the parser keeps it - * optional so the spec stays close to what the author wrote). + * Clock listener configuration. `name` is unique within the persona and + * surfaces to the handler as `event.name`. `cron` is a standard 5-field + * expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps + * it optional so the spec stays close to what the author wrote). */ export interface PersonaSchedule { name: string; @@ -177,31 +177,8 @@ export interface PersonaSchedule { tz?: string; } -/** - * Long-form sandbox configuration. `enabled` defaults to true when the - * object form is present; supply the boolean shorthand `sandbox: false` - * to opt out entirely. `timeoutSeconds` caps a single handler invocation - * (default 1800s in the runtime). `env` is merged on top of auto-injected - * secrets at sandbox-create time. - * - * Image selection is intentionally not user-configurable in v1 — workforce - * picks a standard image. Add `image` later if a real demand surfaces. - */ -export interface PersonaSandboxConfig { - enabled?: boolean; - timeoutSeconds?: number; - env?: Record; -} - -/** - * Sandbox can be specified as `true` / `false` shorthand or as the full - * config object. The parser preserves whichever form the author wrote so - * round-trips stay lossless; consumers normalize when reading. - */ -export type PersonaSandbox = boolean | PersonaSandboxConfig; - /** Memory scope semantics, mirroring @agent-assistant/memory. */ -export type PersonaMemoryScope = 'session' | 'user' | 'workspace' | 'org' | 'object'; +export type PersonaMemoryScope = 'workspace' | 'user' | 'global'; /** * Long-form memory configuration. Defaults are applied by the runtime, @@ -219,21 +196,12 @@ export interface PersonaMemoryConfig { export type PersonaMemory = boolean | PersonaMemoryConfig; /** - * Conversational traits, applied only when the agent posts to a chat - * surface (Slack, Relaycast, GitHub PR comment). Headless agents — the - * paraglide "Linear issue → PR" pattern — should omit this field. Mirrors - * the trait shape in `@agent-assistant/traits`. + * A persona listens for events. Three listener kinds: clock (cron schedules + * through `schedules[]`), radio (RelayFile integration events through + * `integrations..triggers[]`), and inbox (RelayCast targeted + * messages, not yet modeled in v1). The current shape predates the + * listeners framing; semantics are equivalent. */ -export interface PersonaTraits { - voice?: string; - formality?: 'low' | 'medium' | 'high'; - proactivity?: 'low' | 'medium' | 'high'; - riskPosture?: 'conservative' | 'balanced' | 'aggressive'; - domain?: string; - vocabulary?: string[]; - preferMarkdown?: boolean; -} - export interface PersonaSpec { id: string; intent: string; @@ -333,25 +301,14 @@ export interface PersonaSpec { * for each provider not yet connected to the active workspace. */ integrations?: Record; - /** Cron-style schedules. Each `name` is unique within the persona. */ + /** Cron-style clock listeners. Each `name` is unique within the persona. */ schedules?: PersonaSchedule[]; - /** - * Sandbox preference. `true` (default for cloud personas) means the - * agent runs inside a Daytona sandbox at deploy time; `false` runs it in - * the runner process. The object form lets the author tune timeout / env. - */ - sandbox?: PersonaSandbox; /** * Memory subsystem opt-in. Wires the agent-assistant memory adapter at * runtime; the persona spec only declares intent, not implementation * details (api keys, adapter type, etc. come from workforce env). */ memory?: PersonaMemory; - /** - * Conversational traits, applied only when the agent posts to a chat - * surface. Omit for headless agents. - */ - traits?: PersonaTraits; /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b4473cb0..eb2df1c2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -53,6 +53,5 @@ export type { PersonaIntegrationTrigger, PersonaMemoryScope, PersonaSchedule, - PersonaSpec, - PersonaTraits + PersonaSpec } from '@agentworkforce/persona-kit'; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index b4c7cd8e..501b4e80 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -175,7 +175,7 @@ export interface IntegrationClients { * integration fields undefined. */ export interface WorkforceCtx extends IntegrationClients { - /** Read-only persona metadata, useful for branching on traits. */ + /** Read-only persona metadata for handler decisions. */ readonly persona: PersonaSpec; /** Workspace the agent is deployed into. */ readonly workspaceId: string; diff --git a/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts b/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts deleted file mode 100644 index 5518a9c3..00000000 --- a/workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts +++ /dev/null @@ -1,1551 +0,0 @@ -import { workflow } from '@agent-relay/sdk/workflows'; - -// ============================================================================= -// Ricky workflow: deploy-v1 schema cascade + persona refactor -// ============================================================================= -// Source spec: workforce/docs/plans/deploy-v1-schema-cascade-spec.md -// Reference shape: cloud-proactive-runtime-spec/workflows/proactive-runtime-m1.ts -// -// SWARM PATTERN: hub-spoke (per spec line 64; per choosing-swarm-patterns SKILL). -// -// Why hub-spoke and not pipeline/dag/fan-out: -// - Lead Claude needs to STAY ALIVE on #wf-schema-cascade and adapt in real -// time (ambient peer review, cross-repo contract reconciliation, status -// probes every 5 min). That is the canonical hub-spoke use case -// (choosing-swarm-patterns SKILL.md "Quick Decision Framework": -// "Does a coordinator need to stay alive and adapt? YES -> hub-spoke"). -// - 14+ tracks across 5 repos (cloud, workforce, relay, relaycron, -// relayauth) with cross-repo contracts (paired cloud#548 + relaycron#5 -// + relay#843 merges). Workers must coordinate via the channel; the -// lead picks up contract drift and pings both owning implementers. -// - pipeline is wrong: most tracks fan out in parallel from a shared -// readiness gate (Tracks A and D run concurrently; E1-E5 run as five -// parallel rebases after D). -// - pure dag is wrong: there is no live coordinator. The spec REQUIRES a -// live coordinator ("Lead Claude Opus stays on #wf-schema-cascade as -// architect + ambient reviewer" - spec line 64). -// - fan-out would lose the bidirectional lead<->worker conversation -// needed for CHANGES_REQUESTED iteration. -// -// IMPLEMENTATION_WORKFLOW_CONTRACT: every track produces source changes, -// tests, non-empty diff evidence, and PR/result reporting. Auto-merge after -// CI green + no CHANGES_REQUESTED + no unresolved review comments. -// -// 80-to-100 contract: child track work performs fix-loop work; the lead and -// signoff perform final-review evidence checks before flipping draft -> ready -// and squash-merging. PRs only flip to ready and auto-merge when: -// - CI is green on the PR -// - typecheck + tests pass after soft -> fixer -> hard loop -// - upstream dependencies in the Merge DAG are merged -// - no human reviewer has CHANGES_REQUESTED -// - no unresolved review comments -// Otherwise the PR stays as DRAFT with the loud-hole gap list templated into -// the body. Workflow exits 0 either way. -// -// Never-fail mechanics (mirror proactive-runtime-m1.ts): -// - Every test / typecheck / regression gate runs as soft -> fixer -> hard. -// - Per-track self-reflection vs the spec's per-track acceptance bullets. -// - Two self-review passes per track: normal review + fresh-eyes review -// (different reviewer, no prior context). -// - Peer review by a DIFFERENT implementer; if CHANGES_REQUESTED, the -// ORIGINAL implementer fixes (preserves track context). -// - Final signoff agent verifies acceptance bullets; on INCOMPLETE, route -// to fix-r2 then back through signoff-final. -// - Lead Claude does ambient peer review on the channel during impl. -// - Global onError: retry 2x, 10s backoff. -// -// Run: -// npx tsx workflows/generated/ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r.ts -// ============================================================================= - -const HOME = process.env.HOME ?? '/Users/khaliqgant'; -const ROOT = `${HOME}/Projects/AgentWorkforce`; - -const REPOS = { - cloud: process.env.CLOUD_REPO ?? `${ROOT}/cloud`, - workforce: process.env.WORKFORCE_REPO ?? `${ROOT}/workforce`, - relay: process.env.RELAY_REPO ?? `${ROOT}/relay`, - relaycron: process.env.RELAYCRON_REPO ?? `${ROOT}/relaycron`, - relayauth: process.env.RELAYAUTH_REPO ?? `${ROOT}/relayauth`, -} as const; - -const GH_REPOS = { - cloud: 'AgentWorkforce/cloud', - workforce: 'AgentWorkforce/workforce', - relay: 'AgentWorkforce/relay', - relaycron: 'AgentWorkforce/relaycron', - relayauth: 'AgentWorkforce/relayauth', -} as const; - -const CHANNEL = 'wf-schema-cascade'; -const SPEC_FILE = `${REPOS.workforce}/docs/plans/deploy-v1-schema-cascade-spec.md`; -const ARTIFACTS = `${REPOS.workforce}/.workflow-artifacts/deploy-v1-schema-cascade`; - -type TrackId = - | 'A' | 'B' | 'C' | 'D' - | 'E1' | 'E2' | 'E3' | 'E4' | 'E5' - | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N'; - -interface TrackDef { - id: TrackId; - repo: keyof typeof REPOS; - ghRepo: string; - branch: string; - // Worktree directory ('' means operate in place in REPOS[repo]). - worktreeSuffix: string; - prTitle: string; - // Tracks whose PRs must be merged before this track auto-merges - // (per spec "Merge DAG - auto-merge order"). - mergeAfter: TrackId[]; - // External PR numbers that must be merged first (paired-contract handling). - externalMergeAfter: { repo: keyof typeof REPOS; pr: number; description: string }[]; - // Spec section heading for self-reflection prompts. - specSection: string; - // Brief per-track scope summary used in implementer prompts. - scope: string; - // Allow-list of files that may be dirty on entry (for preflight tolerance). - allowedDirty: string; - typecheckCmd: string; - testCmd: string; - reasoning: 'low' | 'medium' | 'high'; - autoMerge: boolean; - commentOnly?: boolean; -} - -const TRACKS: TrackDef[] = [ - { - id: 'A', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'chore/db1-schema-lockin', - worktreeSuffix: '', - prTitle: 'feat(db): DB1 schema lock-ins per cloud#553 thread', - mergeAfter: [], - externalMergeAfter: [], - specSection: 'Track A — Cloud #553 schema lock-ins (issue body + migrations PR)', - scope: [ - 'Update cloud#553 issue body to reflect every lock-in (two-table agent model, integration_scopes, persona_versions, cli_auth_sessions split, sharing rule prose, sub-agents note, sandbox-minute metering, lock-in revision history).', - 'Open migrations PR on branch chore/db1-schema-lockin. New tables: agents, persona_versions, integration_scopes, user_integrations, workspace_integrations, workforce_cli_auth_sessions. Rename cli_auth_sessions -> cloud_cli_bootstrap_sessions. Repurpose agent_deployments for per-instance rows with back-fill migration.', - 'Add agents.watch_globs text[] NULL and agents.schedule_ids text[] NULL columns for Track G consumption.', - 'Add adapter text NOT NULL DEFAULT \'nango\' columns to user_integrations and workspace_integrations.', - 'Run drizzle codegen so packages/web/lib/db/schema.ts matches.', - ].join(' '), - allowedDirty: 'package(-lock)?\\.json|packages/web/drizzle/.*|packages/web/lib/db/.*|packages/web/lib/proactive-runtime/.*|docs/.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'high', - autoMerge: true, - }, - { - id: 'B', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'feat/integration-resolver-source-dispatch', - worktreeSuffix: '.wt-resolver', - prTitle: 'feat(integrations): resolver dispatches on source + adapter', - mergeAfter: ['A'], - externalMergeAfter: [], - specSection: 'Track B — Cloud resolver: dispatch on `source` + `adapter`', - scope: [ - 'Update cloud integration resolver in packages/web/lib/integrations/ and packages/web/lib/proactive-runtime/deploy-manager.ts.', - 'Read source from persona spec: deployer_user / workspace / workspace_service_account. Default missing source to { kind: deployer_user }.', - 'GitHub combine: provider=github AND source.kind=deployer_user loads workspace_integrations row. Fail clearly if workspace install missing.', - 'Adapter dispatch: nango (existing) / composio (existing) / pipedream (throw not-yet-wired).', - 'Add resolver test fixtures covering all source kinds, GitHub combine paths, default injection, adapter dispatch.', - ].join(' '), - allowedDirty: 'packages/web/lib/integrations/.*|packages/web/lib/proactive-runtime/deploy-manager\\.ts|packages/web/app/api/v1/integrations/.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'C', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: '', - worktreeSuffix: '', - prTitle: '(no PR — comment-only)', - mergeAfter: [], - externalMergeAfter: [], - specSection: 'Track C — Cloud #548 OSS-scope rebase coordination', - scope: 'Verify the coordination comment already exists on cloud#548 referencing @agent-relay/{events,agent}@6.0.18 (the version that landed when relay#843 publish completed 2026-05-12T21:49:38Z). If missing, re-post the comment body.', - allowedDirty: '', - typecheckCmd: 'true', - testCmd: 'true', - reasoning: 'medium', - autoMerge: false, - commentOnly: true, - }, - { - id: 'D', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'refactor/persona-kit-schema-lockin', - worktreeSuffix: '', - prTitle: 'refactor(persona-kit): remove traits + sandbox, add listeners JSDoc (deploy-v1)', - mergeAfter: [], - externalMergeAfter: [], - specSection: 'Track D — Workforce persona-kit refactor (traits-out, sandbox-out, listeners doc)', - scope: [ - 'Remove Traits type and spec.traits parsing from packages/persona-kit. Update fixtures + examples. Parser REJECTS personas containing a traits key with the specified error.', - 'Remove SandboxConfig type and spec.sandbox parsing. Verify @agentworkforce/deploy reads sandbox config from deploy options, NOT persona.spec. Parser REJECTS personas containing a sandbox key.', - 'Add listeners JSDoc on PersonaIntegrationConfig, Schedule, and top-level PersonaSpec (clock/radio/inbox narrative).', - 'Keep PersonaSpec.memory.scopes accepting workspace | user | global (drop session if present).', - 'Regenerate persona JSON schema via packages/persona-kit/scripts/emit-schema.mjs if present.', - 'Add parse-failure tests with specific error messages.', - 'Verify 14 core personas validate via corepack pnpm -r --filter @agentworkforce/personas-core run lint.', - ].join(' '), - allowedDirty: 'packages/persona-kit/.*|packages/runtime/src/proactive\\.ts|packages/runtime/src/types\\.ts|packages/runtime/src/ctx\\.ts|packages/deploy/src/.*|examples/.*|docs/plans/.*', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'high', - autoMerge: true, - }, - { - id: 'E1', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/integrations-vfs', - worktreeSuffix: '.wt-rebase-92', - prTitle: '(rebase PR #92 onto post-Track-D main)', - mergeAfter: ['D'], - externalMergeAfter: [], - specSection: 'Track E1 — rebase #92 (feat/integrations-vfs)', - scope: 'Rebase #92 onto post-Track-D main. VFS substrate doesn\'t touch traits/sandbox; conflicts should be minimal. Push with --force-with-lease.', - allowedDirty: 'packages/.*|.*\\.json', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'E2', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/integrations-vfs-examples', - worktreeSuffix: '.wt-rebase-93', - prTitle: '(rebase PR #93 — strip traits/sandbox from examples)', - mergeAfter: ['D', 'E1'], - externalMergeAfter: [], - specSection: 'Track E2 — rebase #93 (feat/integrations-vfs-examples)', - scope: 'Rebase #93 onto post-Track-D main + strip traits and sandbox blocks from examples/review-agent/persona.json and examples/linear-shipper/persona.json. Verify both type-check against #92 WorkforceCtx.', - allowedDirty: 'examples/.*|packages/.*|.*\\.json', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'E3', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/persona-json-schema', - worktreeSuffix: '.wt-rebase-94', - prTitle: '(rebase PR #94 — regen persona schema)', - mergeAfter: ['D'], - externalMergeAfter: [], - specSection: 'Track E3 — rebase #94 (feat/persona-json-schema)', - scope: 'Rebase #94 + run scripts/emit-schema.mjs to regenerate packages/persona-kit/schemas/persona.schema.json. Verify fixtures still validate.', - allowedDirty: 'packages/persona-kit/.*|.*\\.json', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'E4', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/proactive-bridge', - worktreeSuffix: '.wt-rebase-96', - prTitle: '(rebase PR #96 — bump @agent-assistant/proactive ^0.4.32)', - mergeAfter: ['D'], - externalMergeAfter: [], - specSection: 'Track E4 — rebase #96 (feat/proactive-bridge)', - scope: 'Rebase #96. Drop any remaining expressionFromTraits references. Bump @agent-assistant/proactive ^0.4.31 -> ^0.4.32. Run corepack pnpm install to refresh pnpm-lock.yaml. Verify baseline tests pass.', - allowedDirty: 'packages/.*|.*\\.json|pnpm-lock\\.yaml', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'E5', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/persona-integration-source', - worktreeSuffix: '.wt-rebase-97', - prTitle: '(rebase PR #97 — feat/persona-integration-source)', - mergeAfter: ['D'], - externalMergeAfter: [], - specSection: 'Track E5 — rebase #97 (feat/persona-integration-source)', - scope: 'Rebase #97. Interface name is PersonaIntegrationConfig (verified). No content change beyond rebase.', - allowedDirty: 'packages/.*|.*\\.json', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'F', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/runtime-input-values-resolution', - worktreeSuffix: '.wt-runtime', - prTitle: 'feat(runtime): resolve persona inputs from agents.input_values + expose ctx.agent/ctx.deployment', - mergeAfter: ['A', 'D'], - externalMergeAfter: [], - specSection: 'Track F — Workforce runtime input-values + agent identity wiring', - scope: [ - 'In packages/runtime/src/ctx.ts: read input_values from the agents row (not agent_deployments). resolved[key] = agents.input_values[key] ?? persona.spec.inputs[key].default. Throw on required-without-value.', - 'Update WorkforceCtx.persona.inputs shape: Record resolved values. Add ctx.persona.inputSpecs for consumers needing the spec.', - 'Add ctx.agent (id, deployedName, spawnedByAgentId) and ctx.deployment (id, triggerKind, parentDeploymentId) accessors.', - 'Tests: override wins; default fills; required-missing throws specified error; ctx.persona.inputSpecs exposes defaults; ctx.agent.id + ctx.deployment.id populated.', - ].join(' '), - allowedDirty: 'packages/runtime/src/ctx\\.ts|packages/runtime/src/types\\.ts|packages/runtime/src/ctx\\.test\\.ts|packages/runtime/src/__tests__/.*', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'G', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'feat/persona-bundle-deploy-endpoint', - worktreeSuffix: '.wt-deploy-endpoint', - prTitle: 'feat(deploy): persona+bundle deploy endpoint', - mergeAfter: ['A'], - externalMergeAfter: [ - { repo: 'cloud', pr: 548, description: 'cloud#548 (agent-gateway DO + relaycron-client + registerWatches)' }, - { repo: 'relaycron', pr: 5, description: 'relaycron#5 (WS delivery + cancel API)' }, - ], - specSection: 'Track G — Cloud persona+bundle deploy endpoint', - scope: [ - 'POST /api/v1/workspaces/:workspaceId/deployments taking persona+bundle.', - 'Validate persona via parsePersonaSpec. Insert persona_versions row if spec_hash new. Upsert agents row matched on (workspace_id, persona_id).', - 'Translate persona.integrations.

.triggers[] -> watch glob list (e.g. provider=github trigger.on=pull_request.opened -> /github/pull_requests/opened/**). Persist on agents.watch_globs.', - 'Translate persona.schedules[] -> relaycron registrations via services/agent-gateway/src/relaycron-client.ts:registerCronSchedules. Persist on agents.schedule_ids.', - 'Provision Daytona sandbox + upload bundle via existing POST /api/v1/workspaces/:id/sandboxes infrastructure. Start runner.mjs.', - 'Insert initial agent_deployments row status=running trigger_kind=inbox. Audit-log.', - 'Tests: happy path, re-deploy same persona, invalid persona, trigger translation, schedule registration, sandbox + bundle order, auth.', - ].join(' '), - allowedDirty: 'packages/web/app/api/v1/workspaces/.*|packages/web/lib/proactive-runtime/.*|packages/web/lib/.*persona.*|services/agent-gateway/.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'high', - autoMerge: true, - }, - { - id: 'H', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/deploy-mode-cloud', - worktreeSuffix: '.wt-mode-cloud', - prTitle: 'feat(deploy): --mode cloud (OSS-generic persona+bundle POST)', - mergeAfter: ['D', 'G'], - externalMergeAfter: [], - specSection: 'Track H — Workforce `--mode cloud` (OSS-generic implementation)', - scope: [ - 'Replace stubbed packages/deploy/src/modes/cloud.ts with real implementation that POSTs persona+bundle to a configurable cloud-deploy URL.', - 'URL precedence: --cloud-url flag > WORKFORCE_CLOUD_URL env > persona.cloud.deployUrl > default https://agentrelay.com.', - 'OSS-generic: do not bake agentrelay.com into code paths (only as a default URL).', - 'Auth via packages/deploy/src/login.ts. 401 -> clean error suggesting workforce login. Retry with backoff (3 attempts).', - 'Status polling resolves on active and failed. stop() calls DELETE endpoint.', - ].join(' '), - allowedDirty: 'packages/deploy/src/modes/cloud\\.ts|packages/deploy/src/index\\.ts|packages/deploy/src/login\\.ts|packages/cli/src/cli\\.ts', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'I', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'feat/deploy-input-flags', - worktreeSuffix: '.wt-deploy-inputs', - prTitle: 'feat(deploy): --input = flags across all modes', - mergeAfter: ['A', 'D', 'F'], - externalMergeAfter: [], - specSection: 'Track I — Deploy CLI `--input =` flags', - scope: [ - 'Accept --input = flag in packages/cli/src/cli.ts (repeatable). Parse into Record. Reject malformed flags.', - 'Plumb through packages/deploy/src/index.ts deploy() function as DeployOptions.inputs.', - 'Validate against persona spec at deploy time. Unknown key -> "Unknown input \'\'; persona declares: ".', - 'Forward to each mode: dev (env vars WORKFORCE_INPUT_), sandbox (Daytona envVars), cloud (POST body inputs field).', - 'Update persona spec docs in docs/plans/deploy-v1.md §3.', - ].join(' '), - allowedDirty: 'packages/cli/src/cli\\.ts|packages/deploy/src/index\\.ts|packages/deploy/src/types\\.ts|packages/deploy/src/modes/.*', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'J', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'feat/workflow-invocations-followups', - worktreeSuffix: '.wt-workflow-shim-followups', - prTitle: 'feat(workflows): synthesis policy + scope mint follow-ups for cloud#555', - mergeAfter: [], - externalMergeAfter: [ - { repo: 'cloud', pr: 555, description: 'cloud#555 (workflow-invocations shim) must be merged' }, - ], - specSection: 'Track J — `workflow.run` MCP synthesis + scope mint (cloud#555 follow-ups)', - scope: [ - 'J1: slug -> workflow translation in packages/web/lib/workflows/invocation-registry.ts. Synthesis: s3CodeKey=workflows//latest.tar.gz, sourceFileType=workflow, runtime={id:daytona} from workspace default_runtime, args forwarded as metadata.invocationArgs. Initial registry: { echo }.', - 'J2: scope mint additions. packages/web/app/api/v1/workflows/run/route.ts mints workflow:invoke:write + workflow:invoke:read. Add requireAuthScope checks to new /workspaces/:id/workflows/run + /workspaces/:id/workflows/runs/:runId routes.', - 'Tests: J1 echo round-trip; unknown slug 404; J2 missing scope 403; with scope success.', - ].join(' '), - allowedDirty: 'packages/web/app/api/v1/workspaces/.*workflows/.*|packages/web/lib/workflows/.*|packages/web/lib/auth/.*sandbox.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'high', - autoMerge: true, - }, - { - id: 'L', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'chore/remove-agent-relay-packages', - worktreeSuffix: '.wt-oss-cleanup', - prTitle: 'chore: remove in-tree @agent-relay/{events,agent}; consume from npm', - mergeAfter: [], - externalMergeAfter: [ - { repo: 'cloud', pr: 548, description: 'cloud#548 must be merged first' }, - { repo: 'relay', pr: 843, description: 'relay#843 merged 2026-05-12T21:30:54Z; publish workflow completed 21:49:38Z. @agent-relay/* lockstep-bumped to 6.0.18. Safety check: poll `npm view @agent-relay/sdk version` and ensure >= 6.0.18 before pinning.' }, - ], - specSection: 'Track L — Cloud OSS-scope cleanup (post-#548 merge)', - scope: [ - 'Delete cloud/packages/agent-relay-events/ and cloud/packages/agent-relay-agent/. Add npm deps @agent-relay/{events,agent}: ^ to services/agent-gateway/package.json and other consumers.', - 'Refresh package-lock.json via npm install.', - 'Bump other @agent-relay/* pins to keep the umbrella aligned.', - 'grep .github/workflows/*.yml + Makefile for refs to agent-relay-events / agent-relay-agent needing cleanup.', - ].join(' '), - allowedDirty: 'package(-lock)?\\.json|packages/.*|services/.*|\\.github/workflows/.*|Makefile', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'M', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'chore/bump-relaycron-packages', - worktreeSuffix: '.wt-relaycron-bump', - prTitle: 'chore(deps): bump @relaycron/{server,types} to ^0.1.3', - mergeAfter: [], - externalMergeAfter: [ - { repo: 'relaycron', pr: 5, description: 'relaycron#5 merged + @relaycron/{server,types}@0.1.3 published' }, - ], - specSection: 'Track M — Cloud `@relaycron/*` pin bump', - scope: [ - 'Bump @relaycron/server and @relaycron/types pins from ^0.1.0 -> ^0.1.3 in packages/relaycron/package.json (and packages/relaycron-types/package.json + root package.json if pinned).', - 'Run npm install to refresh package-lock.json. Run npm run typecheck. Run npm run relaycron:test.', - 'grep .github/workflows/*.yml for @relaycron/server or @relaycron/types refs needing bumps.', - ].join(' '), - allowedDirty: 'package(-lock)?\\.json|packages/relaycron/.*|packages/relaycron-types/.*|\\.github/workflows/.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm run relaycron:test', - reasoning: 'low', - autoMerge: true, - }, - { - id: 'N', - repo: 'cloud', - ghRepo: GH_REPOS.cloud, - branch: 'feat/sandbox-token-path-scoped', - worktreeSuffix: '.wt-token-paths', - prTitle: 'feat(sandbox): token path-scoping via POST /v1/tokens/path', - mergeAfter: ['G'], - externalMergeAfter: [ - { repo: 'relayauth', pr: 39, description: 'relayauth#39 (docs contract) must be merged' }, - ], - specSection: 'Track N — Cloud sandbox token path-scoping', - scope: [ - 'Update Track G sandbox-provisioning flow to mint path-scoped tokens via POST /v1/tokens/path (per relayauth#39 contract).', - 'Tests: token mint path-scoping happy path; downstream sandbox bound to scoped path; legacy path still works during rollout.', - ].join(' '), - allowedDirty: 'packages/web/.*|services/agent-gateway/.*', - typecheckCmd: 'npm run typecheck', - testCmd: 'npm test', - reasoning: 'medium', - autoMerge: true, - }, - { - id: 'K', - repo: 'workforce', - ghRepo: GH_REPOS.workforce, - branch: 'test/deploy-v1-e2e-smoke', - worktreeSuffix: '.wt-smoke', - prTitle: 'test(deploy): e2e smoke for weekly-digest --mode cloud', - // Track K depends on every preceding track but does NOT block the cascade. - mergeAfter: ['A', 'B', 'D', 'F', 'G', 'H', 'I', 'J', 'E1', 'E2', 'E3', 'E4', 'E5'], - externalMergeAfter: [], - specSection: 'Track K — End-to-end smoke test', - scope: [ - 'Add packages/deploy/test/e2e/weekly-digest.smoke.test.ts: build bundle, authenticate via WORKFORCE_E2E_STAGING_TOKEN (skip gracefully if missing), deploy via --mode cloud against WORKFORCE_E2E_STAGING_URL, force a cron tick, assert agent posts GitHub issue on AgentWorkforce/deploy-e2e-fixtures within 90s.', - 'Add .github/workflows/deploy-e2e.yml: nightly schedule + manual dispatch. Failures notify #workforce-alerts.', - 'Reports SMOKE_TEST: PASS or SMOKE_TEST: FAIL — does NOT block cascade.', - ].join(' '), - allowedDirty: 'packages/deploy/test/e2e/.*|\\.github/workflows/deploy-e2e\\.yml', - typecheckCmd: 'corepack pnpm run typecheck', - testCmd: 'corepack pnpm -r run test', - reasoning: 'medium', - autoMerge: false, // smoke test reports but does not block - }, -]; - -// --------------------------------------------------------------------------- -// Helper builders -// --------------------------------------------------------------------------- - -function trackById(id: TrackId): TrackDef { - const t = TRACKS.find((x) => x.id === id); - if (!t) throw new Error(`Unknown track ${id}`); - return t; -} - -function workdir(t: TrackDef): string { - if (!t.worktreeSuffix) return REPOS[t.repo]; - return `${REPOS[t.repo]}${t.worktreeSuffix}`; -} - -function implAgentName(t: TrackDef): string { - return `impl-${t.id.toLowerCase()}`; -} -function reflectAgentName(t: TrackDef): string { - return `reflect-${t.id.toLowerCase()}`; -} -function freshEyesAgentName(t: TrackDef): string { - return `fresh-eyes-${t.id.toLowerCase()}`; -} - -// Peer review comes from a DIFFERENT implementer (cross-pollination catches -// blind spots). Rotate through the TRACKS array; skip comment-only tracks. -function peerReviewerName(t: TrackDef): string { - const idx = TRACKS.findIndex((x) => x.id === t.id); - for (let i = 1; i < TRACKS.length; i++) { - const peer = TRACKS[(idx + i) % TRACKS.length]; - if (peer.commentOnly) continue; - if (peer.id === t.id) continue; - return implAgentName(peer); - } - return 'fixer'; -} - -// Loud-hole disclosure required in every PR body (per spec "Loud hole" §). -const LOUD_HOLE_LINES = [ - '## Known gaps after this PR', - '', - ':warning: **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced).', - '', - ':warning: **M3 destroy/list CLI commands** not implemented. Separate workflow.', - '', - ':warning: **`@workforce/daytona-runner` not on npm** under `@workforce` scope. Handled by a separate agent per platform-team OIDC setup; not blocking morning state because cloud consumes via workspace ref.', -]; - -// Preflight: branch checkout, allow-listed dirty tolerance, gh auth check. -function preflightCmd(t: TrackDef): string { - const dir = workdir(t); - const lines: string[] = [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, - ]; - if (t.commentOnly) { - lines.push( - 'gh auth status >/dev/null 2>&1 || (echo "ERROR: gh CLI not authenticated"; exit 1)', - `echo PREFLIGHT_OK_${t.id}`, - ); - return lines.join('\n'); - } - if (t.worktreeSuffix) { - lines.push( - `if [ ! -e "${dir}/.git" ]; then`, - ` cd "${REPOS[t.repo]}" && git worktree add "${dir}" -b ${t.branch} origin/main 2>/dev/null || git worktree add "${dir}" ${t.branch}`, - `fi`, - ); - } - lines.push( - `cd "${dir}" || exit 1`, - 'git fetch origin main >/dev/null 2>&1 || true', - 'git config user.email "ricky@agent-relay.com"', - 'git config user.name "Ricky Schema Cascade"', - `if git rev-parse --verify ${t.branch} >/dev/null 2>&1; then git checkout ${t.branch}; else git checkout -B ${t.branch} origin/main; fi`, - 'mkdir -p .git/info && grep -qxF ".logs/" .git/info/exclude 2>/dev/null || echo ".logs/" >> .git/info/exclude', - `ALLOWED_DIRTY="${t.allowedDirty || 'package(-lock)?\\.json'}"`, - 'DIRTY_TRACKED=$(git diff --name-only | grep -vE "^(${ALLOWED_DIRTY})$" || true)', - 'DIRTY_UNTRACKED=$(git ls-files --others --exclude-standard | grep -vE "^(${ALLOWED_DIRTY})$" || true)', - 'if [ -n "$DIRTY_TRACKED" ] || [ -n "$DIRTY_UNTRACKED" ]; then', - ` echo "ERROR: unexpected drift in track ${t.id} (${t.repo}):"`, - ' [ -n "$DIRTY_TRACKED" ] && echo "tracked: $DIRTY_TRACKED"', - ' [ -n "$DIRTY_UNTRACKED" ] && echo "untracked: $DIRTY_UNTRACKED"', - ' exit 1', - 'fi', - 'if ! git diff --cached --quiet; then echo "ERROR: staging area dirty"; git diff --cached --stat; exit 1; fi', - 'gh auth status >/dev/null 2>&1 || (echo "ERROR: gh CLI not authenticated"; exit 1)', - `echo PREFLIGHT_OK_${t.id}`, - ); - return lines.join('\n'); -} - -// Soft gate — never throws; captures exit code into output. -function softCmd(t: TrackDef, label: string, cmd: string): string { - const dir = workdir(t); - return [ - 'set -e', - `cd "${dir}" || exit 1`, - 'mkdir -p .logs', - 'set +e', - `${cmd} > .logs/${label}.log 2>&1; E=$?`, - `echo "${label.toUpperCase()}_EXIT=$E"`, - `tail -80 .logs/${label}.log`, - 'exit 0', - ].join('\n'); -} - -// Merge-ready check: posts WAITING_FOR_ and exits 0 (soft) when any -// dep is unmerged so the cascade can continue with independent tracks. -function mergeReadyCmd(t: TrackDef): string { - const lines: string[] = ['set -e', 'READY=1', 'REASONS=""']; - for (const dep of t.mergeAfter) { - const depTrack = trackById(dep); - if (depTrack.commentOnly) continue; - lines.push( - `STATE_${dep}=$(cat ${ARTIFACTS}/track-${dep.toLowerCase()}/merge-state.txt 2>/dev/null || echo "UNKNOWN")`, - `if [ "$STATE_${dep}" != "MERGED" ]; then READY=0; REASONS="$REASONS Track-${dep}=$STATE_${dep}"; fi`, - ); - } - for (const ext of t.externalMergeAfter) { - const key = `EXT_${ext.repo.toUpperCase()}_${ext.pr}`; - lines.push( - `${key}=$(gh pr view ${ext.pr} --repo ${GH_REPOS[ext.repo]} --json mergedAt -q '.mergedAt' 2>/dev/null || echo null)`, - `if [ -z "$${key}" ] || [ "$${key}" = "null" ]; then READY=0; REASONS="$REASONS ${ext.repo}#${ext.pr}=unmerged"; fi`, - ); - } - lines.push( - `echo "MERGE_READY_TRACK_${t.id}=$READY"`, - 'echo "REASONS:$REASONS"', - `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, - `echo "$READY" > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-ready.txt`, - 'exit 0', - ); - return lines.join('\n'); -} - -// Auto-merge gate enforces spec's "Gates that BLOCK auto-merge" rules. -function autoMergeCmd(t: TrackDef): string { - if (!t.autoMerge || t.commentOnly) { - return [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, - `echo "Track ${t.id}: auto-merge not authorized — leaving PR as DRAFT."`, - `echo NOT_AUTO_MERGED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - ].join('\n'); - } - return [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, - `READY=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-ready.txt 2>/dev/null || echo 0)`, - `PR_NUM=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/pr-number.txt 2>/dev/null || echo "")`, - `if [ "$READY" != "1" ] || [ -z "$PR_NUM" ]; then`, - ` echo "Track ${t.id}: not merge-ready or PR not opened. Skipping auto-merge."`, - ` echo SKIPPED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - ` exit 0`, - `fi`, - // Verify no CHANGES_REQUESTED from non-bot reviewers. - `CR=$(gh pr view "$PR_NUM" --repo ${t.ghRepo} --json reviews -q '[.reviews[] | select(.state == "CHANGES_REQUESTED" and (.author.login | endswith("[bot]") | not))] | length' 2>/dev/null || echo 0)`, - `if [ "$CR" != "0" ] && [ -n "$CR" ]; then`, - ` echo "Track ${t.id}: CHANGES_REQUESTED present ($CR). Blocking auto-merge."`, - ` echo BLOCKED_CHANGES_REQUESTED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - ` exit 0`, - `fi`, - // Unresolved human review comments (per spec line 113). - // Conservative: any human review comment blocks unless RICKY_TRUST_UNRESOLVED_COMMENTS=1. - `HUMAN_COMMENTS=$(gh api repos/${t.ghRepo}/pulls/$PR_NUM/comments --paginate -q '[.[] | select(.user.type == "User")] | length' 2>/dev/null || echo 0)`, - `if [ "$HUMAN_COMMENTS" -gt 0 ] 2>/dev/null && [ "$RICKY_TRUST_UNRESOLVED_COMMENTS" != "1" ]; then`, - ` echo "Track ${t.id}: $HUMAN_COMMENTS human review comments — blocking auto-merge (set RICKY_TRUST_UNRESOLVED_COMMENTS=1 to override)."`, - ` echo BLOCKED_UNRESOLVED_COMMENTS > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - ` exit 0`, - `fi`, - // Flip draft -> ready then squash --auto. - `gh pr ready "$PR_NUM" --repo ${t.ghRepo} || echo "(already ready)"`, - `gh pr merge "$PR_NUM" --repo ${t.ghRepo} --squash --auto || (echo MERGE_FAILED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt; exit 0)`, - // Poll for the actual merge (auto-merge may queue). Cap at 15 min. - `for i in $(seq 1 30); do`, - ` M=$(gh pr view "$PR_NUM" --repo ${t.ghRepo} --json mergedAt -q '.mergedAt' 2>/dev/null || echo "")`, - ` if [ -n "$M" ] && [ "$M" != "null" ]; then`, - ` echo "Track ${t.id} merged at $M"`, - ` echo MERGED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - ` exit 0`, - ` fi`, - ` sleep 30`, - `done`, - `echo "Track ${t.id}: auto-merge queued but not completed within 15 min."`, - `echo AUTO_MERGE_QUEUED > ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt`, - 'exit 0', - ].join('\n'); -} - -async function main() { - const wf = workflow('ricky-ricky-workflow-spec-deploy-v1-schema-cascade-persona-refactor-status-ready-for-r') - .description( - 'Deploy v1 schema cascade + persona refactor. Hub-spoke conversation: Lead Claude Opus stays on #wf-schema-cascade as architect + ambient reviewer; codex implementers run 14+ tracks across cloud/workforce/relay/relaycron/relayauth with per-track self-reflection, peer review (different implementer), fresh-eyes review (separate Claude), signoff, fix-r2, and auto-merge per the spec Merge DAG. Soft -> fixer -> hard gates throughout. Auto-merge when CI green + no CHANGES_REQUESTED + no unresolved comments.' - ) - .pattern('hub-spoke') - .channel(CHANNEL) - .maxConcurrency(6) - .timeout(28_800_000) // 8h ceiling: 18 tracks × ~14 phases × CI/merge polling, plus external PR waits. Original 4h was tight; reviewer recommended bumping. - - // -- Lead + reviewers + signoff + fixer (Claude Opus, interactive) ------- - .agent('lead', { - cli: 'claude', - role: `Architect on #${CHANNEL}. Owns the Merge DAG. Posts the plan; pings implementers; reads diffs in real time; reconciles cross-repo contracts (cloud<->workforce<->relay<->relaycron<->relayauth); approves tracks for round 1. Exits when ALL_TRACKS_APPROVED is posted.`, - retries: 1, - }) - .agent('reviewer-peer', { - cli: 'claude', - role: 'Formal cross-track peer reviewer. Reads diffs; compares vs per-track acceptance bullets; emits PEER_REVIEW: APPROVED or PEER_REVIEW: CHANGES_REQUESTED with per-track notes (file:line).', - retries: 1, - }) - .agent('signoff', { - cli: 'claude', - role: 'Final signoff agent. Re-reads spec acceptance bullets and verifies every track\'s bullets against actual files. Emits SIGNOFF: COMPLETE or SIGNOFF: INCOMPLETE: .', - retries: 1, - }) - .agent('fixer', { - cli: 'codex', - preset: 'worker', - role: 'Applies targeted fixes from any feedback (reflection, peer review, fresh-eyes review, signoff). Reads feedback + diffs, edits files in the right repo+worktree, exits.', - retries: 2, - }); - - // Per-track codex implementers (interactive on channel so they hear lead feedback). - for (const t of TRACKS) { - if (t.commentOnly) { - wf.agent(implAgentName(t), { - cli: 'claude', - role: `Track ${t.id} (${t.specSection}). Comment-only verification.`, - retries: 1, - }); - } else { - wf.agent(implAgentName(t), { - cli: 'codex', - role: `Track ${t.id} (${t.repo}, branch ${t.branch}): ${t.specSection}. Listens on #${CHANNEL} for lead feedback. Iterates on CHANGES_REQUESTED.`, - retries: 2, - }); - } - } - - // Per-track self-reflection analysts (separate Claude, reads only its own diff). - for (const t of TRACKS) { - wf.agent(reflectAgentName(t), { - cli: 'claude', - preset: 'analyst', - role: `Self-reflection for Track ${t.id}. Reads diff vs the per-track acceptance bullets. Emits REFLECT_GAPS: or REFLECT_GAPS: NONE.`, - retries: 1, - }); - } - - // Per-track fresh-eyes reviewers (separate Claude, NO prior workflow context). - for (const t of TRACKS) { - wf.agent(freshEyesAgentName(t), { - cli: 'claude', - preset: 'reviewer', - role: `Fresh-eyes review for Track ${t.id}. NO prior workflow context. Reads only the spec section + the diff. Catches blind spots the implementer cannot see. Emits FRESH_EYES_REVIEW: APPROVED or FRESH_EYES_REVIEW: CHANGES_REQUESTED.`, - retries: 1, - }); - } - - // ============================================================ - // Phase 0 — Read spec ONCE into context (deterministic). - // ============================================================ - wf.step('read-spec', { - type: 'deterministic', - command: `set -e\nmkdir -p ${ARTIFACTS}\ncat ${SPEC_FILE}`, - captureOutput: true, - failOnError: true, - }); - - // ============================================================ - // Phase 1 — Per-track preflight (parallel where possible). - // ============================================================ - for (const t of TRACKS) { - wf.step(`preflight-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: ['read-spec'], - command: preflightCmd(t), - captureOutput: true, - failOnError: false, - }); - } - - wf.step('preflight-summary', { - type: 'deterministic', - dependsOn: TRACKS.map((t) => `preflight-${t.id.toLowerCase()}`), - command: [ - 'set -e', - `mkdir -p ${ARTIFACTS}`, - 'echo "=== PREFLIGHT SUMMARY ==="', - ...TRACKS.map((t) => - `echo "Track ${t.id}: $(echo \"{{steps.preflight-${t.id.toLowerCase()}.output}}\" | grep -oE 'PREFLIGHT_OK_[A-Z0-9]+|ERROR.*' | head -1)"`, - ), - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - - // ============================================================ - // Phase 2 — Lead coordinate (the hub). Stays on the channel until - // all tracks pass round-1 review, ambient-reviewing diffs as they land. - // ============================================================ - const READY_DEPS = ['read-spec', 'preflight-summary']; - - wf.step('lead-coordinate', { - agent: 'lead', - dependsOn: READY_DEPS, - task: [ - `You are the lead architect on #${CHANNEL}. Deploy-v1 schema cascade + persona refactor.`, - `Spec file: ${SPEC_FILE}`, - '', - 'SPEC (full):', - '{{steps.read-spec.output}}', - '', - `Tracks (${TRACKS.length} total) and their implementers:`, - ...TRACKS.map((t) => ` - @${implAgentName(t)} -- Track ${t.id} in ${t.repo} (${t.specSection})`), - '', - 'Step 1 -- Post the cross-repo plan to the channel. Restate the Merge DAG (spec "Merge DAG -- auto-merge order"):', - ' - Tracks A, D run first (parallel; no upstream deps).', - ' - Track B depends on A; Track F depends on A + D; Track G depends on A + cloud#548 + relaycron#5; Track H depends on D + G.', - ' - E1-E5 fan out after D.', - ' - Track L depends on cloud#548 + relay#843 publish settled. Track M depends on relaycron#5. Track N depends on relayauth#39 + G.', - ' - Track K runs after everything, reports SMOKE_TEST: PASS/FAIL -- does NOT block cascade.', - '', - 'Step 2 -- Require ACK from every implementer before they write code. Re-post + ping if a worker is silent for 3 minutes.', - '', - 'Step 3 -- Every 5 minutes post a status probe naming all implementers. Each replies RUNNING / BLOCKED / DONE.', - '', - 'Step 4 -- As workers post DONE, READ THEIR ACTUAL FILES and post per-track verdict:', - ' "APPROVED Track-" -- track is good for round 1', - ' "CHANGES_REQUESTED Track-: " -- worker iterates', - '', - 'Step 5 -- Cross-repo contract reconciliation. Watch for mismatches between:', - ' - Track A schema columns (agents.watch_globs, agents.schedule_ids) vs Track G consumption', - ' - Track G endpoint contract vs Track H --mode cloud client', - ' - Track A input_values column vs Track F runtime ctx vs Track I --input flags', - ' - Track D persona-kit shape vs E1-E5 rebases', - ' - Track M @relaycron pin vs Track L OSS-scope cleanup post relay#843 publish settling', - 'On mismatch, post @-pings to BOTH owning implementers and reconcile before approving either.', - '', - 'Step 6 -- Exit when all tracks are APPROVED or have CHANGES_REQUESTED in a stable state. Post FINAL: ALL_TRACKS_APPROVED before exiting.', - '', - 'Loud-hole reminders (spec "Loud hole"): every PR body MUST mention memory is not wired in v1. Do NOT let implementers forget this.', - '', - 'Constraints:', - ' - Do NOT write code. You review and coordinate.', - ' - Do NOT commit anything. The workflow handles git.', - ' - Do NOT use exit instructions; the runner self-terminates.', - ].join('\n'), - verification: { type: 'output_contains', value: 'ALL_TRACKS_APPROVED' }, - }); - - // ============================================================ - // Phase 3 — Per-track implementer steps. All share READY_DEPS so they - // start concurrently with the lead (no deadlock, per - // writing-agent-relay-workflows SKILL "DAG Deadlock Anti-Pattern"). - // ============================================================ - for (const t of TRACKS) { - wf.step(`impl-${t.id.toLowerCase()}-work`, { - agent: implAgentName(t), - dependsOn: READY_DEPS, - task: [ - `You are ${implAgentName(t)} on #${CHANNEL}. TRACK ${t.id}.`, - `Repo: ${REPOS[t.repo]}, branch ${t.branch}, workdir ${workdir(t)}.`, - '', - `Wait for the lead's plan on #${CHANNEL}, then ACK with "ACK ${implAgentName(t)}".`, - '', - 'Spec (read fully):', - '{{steps.read-spec.output}}', - '', - `Your section in the spec: "${t.specSection}".`, - '', - 'Scope:', - t.scope, - '', - t.commentOnly - ? [ - 'This track is comment-only. Verify the coordination comment exists on the target PR; re-post if missing.', - 'Do NOT branch, commit, or push.', - ].join('\n') - : [ - 'Implementation rules:', - ` - Stay in ${workdir(t)} on branch ${t.branch}.`, - ` - Allowed-dirty regex: ${t.allowedDirty || '(default: package locks only)'}.`, - ' - No --no-verify. Pre-commit hooks must pass.', - ' - Include the loud-hole note (memory not wired) in any PR body you draft.', - ` - Run typecheck (\`${t.typecheckCmd}\`) and tests (\`${t.testCmd}\`) until they pass.`, - ` - Watch #${CHANNEL}. Iterate on CHANGES_REQUESTED Track-${t.id}: from the lead.`, - ` - Post completion as: "DONE Track-${t.id}: ".`, - '', - 'Constraints:', - ` - Edit ONLY files matching the allow-list: ${t.allowedDirty || '(see spec section)'}.`, - ' - Do NOT touch other tracks files.', - ' - Do NOT commit; the workflow handles git.', - ` - Do NOT exit until DONE Track-${t.id} is posted.`, - ].join('\n'), - '', - 'Quality bar: typecheck + tests pass; every acceptance bullet in the spec section is addressed.', - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 4 — Per-track soft typecheck (never throws). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`tsc-soft-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`impl-${t.id.toLowerCase()}-work`], - command: softCmd(t, `tsc-soft-${t.id.toLowerCase()}`, t.typecheckCmd), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 5 — Per-track typecheck fixer. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`tsc-fix-${t.id.toLowerCase()}`, { - agent: 'fixer', - dependsOn: [`tsc-soft-${t.id.toLowerCase()}`], - task: [ - `Typecheck output for Track ${t.id} (${workdir(t)}):`, - `{{steps.tsc-soft-${t.id.toLowerCase()}.output}}`, - '', - `If TSC_SOFT_${t.id.toUpperCase()}_EXIT=0, exit immediately.`, - `Otherwise fix type errors in ${workdir(t)}. Re-run \`${t.typecheckCmd}\` until it passes.`, - 'Do NOT silence with `as any` or `// @ts-ignore`. Fix the root cause.', - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 6 — Per-track soft tests. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`tests-soft-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`tsc-fix-${t.id.toLowerCase()}`], - command: softCmd(t, `tests-soft-${t.id.toLowerCase()}`, t.testCmd), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 7 — Per-track tests fixer. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`tests-fix-${t.id.toLowerCase()}`, { - agent: 'fixer', - dependsOn: [`tests-soft-${t.id.toLowerCase()}`], - task: [ - `Test output for Track ${t.id} (${workdir(t)}):`, - `{{steps.tests-soft-${t.id.toLowerCase()}.output}}`, - '', - `If TESTS_SOFT_${t.id.toUpperCase()}_EXIT=0, exit immediately.`, - `Otherwise read failures and fix EITHER the test or the source -- whichever is correct. Do NOT skip or delete tests.`, - `Re-run \`${t.testCmd}\` until it passes.`, - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 8 — Per-track HARD typecheck + tests (captured, never throws, - // gates feed into signoff + PR body). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`tsc-hard-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`tests-fix-${t.id.toLowerCase()}`], - command: softCmd(t, `tsc-hard-${t.id.toLowerCase()}`, t.typecheckCmd), - captureOutput: true, - failOnError: false, - }); - wf.step(`tests-hard-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`tests-fix-${t.id.toLowerCase()}`], - command: softCmd(t, `tests-hard-${t.id.toLowerCase()}`, t.testCmd), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 9 — Self-reflection per track (Claude analyst). - // Reads diff vs spec acceptance bullets; emits REFLECT_GAPS. - // ============================================================ - for (const t of TRACKS) { - wf.step(`reflect-${t.id.toLowerCase()}`, { - agent: reflectAgentName(t), - dependsOn: t.commentOnly - ? [`impl-${t.id.toLowerCase()}-work`] - : [`tsc-hard-${t.id.toLowerCase()}`, `tests-hard-${t.id.toLowerCase()}`], - task: [ - `Self-reflection for Track ${t.id}.`, - `Spec section: "${t.specSection}".`, - `Read the diff in ${workdir(t)} (branch ${t.branch}) via:`, - ` cd ${workdir(t)} && git diff origin/main`, - '', - 'Spec (full):', - '{{steps.read-spec.output}}', - '', - 'Re-read your spec section acceptance bullets. For EACH bullet:', - ' - Addressed in the diff? Where (file:line)?', - ' - MISSING or PARTIAL?', - '', - 'Output exactly this format:', - ' REFLECT_GAPS:', - ' - ', - ' - ', - '', - 'If NO gaps, output: REFLECT_GAPS: NONE', - '', - 'Be brutal. Read the actual files; do not trust chat.', - ].join('\n'), - verification: { type: 'output_contains', value: 'REFLECT_GAPS' }, - }); - } - - // ============================================================ - // Phase 10 — Self-reflection fix-loop: the ORIGINAL implementer - // addresses its own track gaps (preserves track context). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`reflect-fix-${t.id.toLowerCase()}`, { - agent: implAgentName(t), - dependsOn: [`reflect-${t.id.toLowerCase()}`], - task: [ - `Self-reflection report for Track ${t.id}:`, - `{{steps.reflect-${t.id.toLowerCase()}.output}}`, - '', - 'If REFLECT_GAPS: NONE, exit immediately.', - `Otherwise address every listed gap. Edit files in ${workdir(t)} only. Stay on branch ${t.branch}.`, - '', - `After fixes, re-run \`${t.typecheckCmd}\` and \`${t.testCmd}\`. They must pass.`, - 'Do not introduce new gaps. Do not edit files outside your allow-list.', - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 11 — FRESH-EYES review per track. Separate Claude with NO - // prior workflow context -- reads only spec section + diff. - // ============================================================ - for (const t of TRACKS) { - wf.step(`fresh-eyes-${t.id.toLowerCase()}`, { - agent: freshEyesAgentName(t), - dependsOn: t.commentOnly - ? [`reflect-${t.id.toLowerCase()}`] - : [`reflect-fix-${t.id.toLowerCase()}`], - task: [ - `FRESH-EYES REVIEW for Track ${t.id}. You have NO prior workflow context.`, - '', - 'Read ONLY two things:', - ` 1. The spec section: "${t.specSection}" in ${SPEC_FILE}`, - ` 2. The diff in ${workdir(t)} via: cd ${workdir(t)} && git diff origin/main`, - '', - 'Do NOT read chat history or other tracks. You are the fresh-eyes safety net.', - '', - 'Reviewer checklist:', - ' - Do changes match the spec section acceptance bullets exactly?', - ' - Are there spec items the implementer might have skipped because they "seemed obvious"?', - ' - Are there cross-references in the spec the diff missed?', - ' - Tests cover acceptance bullets, not just happy path?', - ' - Is the loud-hole note present where required (memory not wired)?', - '', - 'Emit exactly one of:', - ' FRESH_EYES_REVIEW: APPROVED', - 'or', - ' FRESH_EYES_REVIEW: CHANGES_REQUESTED', - ' - ', - ' - ', - '', - 'Be ruthless.', - ].join('\n'), - verification: { type: 'output_contains', value: 'FRESH_EYES_REVIEW' }, - }); - } - - // ============================================================ - // Phase 12 — Apply fresh-eyes fixes (ORIGINAL implementer). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`fresh-eyes-fix-${t.id.toLowerCase()}`, { - agent: implAgentName(t), - dependsOn: [`fresh-eyes-${t.id.toLowerCase()}`], - task: [ - `Fresh-eyes review for Track ${t.id}:`, - `{{steps.fresh-eyes-${t.id.toLowerCase()}.output}}`, - '', - 'If FRESH_EYES_REVIEW: APPROVED, exit immediately.', - `Otherwise address every CHANGES_REQUESTED note in ${workdir(t)} only.`, - '', - `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 13 — PEER REVIEW per track from a DIFFERENT implementer. - // If CHANGES_REQUESTED, the ORIGINAL implementer fixes (Phase 14). - // ============================================================ - for (const t of TRACKS) { - const peer = peerReviewerName(t); - wf.step(`peer-review-${t.id.toLowerCase()}`, { - agent: peer, - dependsOn: t.commentOnly - ? [`fresh-eyes-${t.id.toLowerCase()}`] - : [`fresh-eyes-fix-${t.id.toLowerCase()}`], - task: [ - `PEER REVIEW for Track ${t.id}. You are ${peer}, normally the implementer for a different track.`, - '', - `Read the diff in ${workdir(t)}: cd ${workdir(t)} && git diff origin/main`, - '', - `Spec section: "${t.specSection}".`, - '', - 'Spec (full):', - '{{steps.read-spec.output}}', - '', - 'Review for:', - ' - Correctness vs acceptance bullets', - ' - Tests covering happy path AND edge cases in the spec section', - ' - No regressions in adjacent code', - ' - Cross-repo contracts honored (if your own track contract intersects)', - ' - Loud-hole note is in the PR body draft (if applicable)', - '', - 'Emit exactly one of:', - ' PEER_REVIEW: APPROVED', - 'or', - ' PEER_REVIEW: CHANGES_REQUESTED', - ' - ', - ' - ', - ].join('\n'), - verification: { type: 'output_contains', value: 'PEER_REVIEW' }, - }); - } - - // ============================================================ - // Phase 14 — Apply peer-review fixes (ORIGINAL implementer). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`peer-review-fix-${t.id.toLowerCase()}`, { - agent: implAgentName(t), - dependsOn: [`peer-review-${t.id.toLowerCase()}`], - task: [ - `Peer review for Track ${t.id}:`, - `{{steps.peer-review-${t.id.toLowerCase()}.output}}`, - '', - 'If PEER_REVIEW: APPROVED, exit immediately.', - `Otherwise address every CHANGES_REQUESTED note in ${workdir(t)} only.`, - '', - `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 15 — Per-track final hard gate after all review loops. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`final-gate-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`peer-review-fix-${t.id.toLowerCase()}`], - command: [ - 'set +e', - `cd ${workdir(t)}`, - 'mkdir -p .logs', - `${t.typecheckCmd} > .logs/final-tsc.log 2>&1; T=$?`, - `${t.testCmd} > .logs/final-tests.log 2>&1; X=$?`, - `echo "FINAL_${t.id}_TSC=$T"`, - `echo "FINAL_${t.id}_TESTS=$X"`, - 'tail -40 .logs/final-tsc.log', - 'tail -40 .logs/final-tests.log', - 'exit 0', - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 16 — Per-track SIGNOFF agent verifies acceptance bullets. - // ============================================================ - for (const t of TRACKS) { - wf.step(`signoff-${t.id.toLowerCase()}`, { - agent: 'signoff', - dependsOn: t.commentOnly - ? [`peer-review-${t.id.toLowerCase()}`] - : [`final-gate-${t.id.toLowerCase()}`], - task: [ - `Final signoff for Track ${t.id}.`, - '', - 'Re-read the spec acceptance bullets for this track:', - '{{steps.read-spec.output}}', - '', - `Track section: "${t.specSection}".`, - '', - t.commentOnly - ? 'Verify the comment-only action was completed (gh pr view confirms the comment exists).' - : `Read the diff in ${workdir(t)}: cd ${workdir(t)} && git diff origin/main`, - '', - t.commentOnly - ? '' - : `Final gate status:\n{{steps.final-gate-${t.id.toLowerCase()}.output}}`, - '', - 'For EACH acceptance bullet, mark [x] satisfied or [ ] gap.', - '', - 'Emit exactly one of:', - ` SIGNOFF: COMPLETE Track-${t.id}`, - 'or', - ` SIGNOFF: INCOMPLETE Track-${t.id}`, - ' - ', - ' - ', - '', - 'Read files. Do not trust chat. Even if INCOMPLETE, exit cleanly -- the workflow ships DRAFT with the gap list.', - ].join('\n'), - verification: { type: 'output_contains', value: 'SIGNOFF:' }, - }); - } - - // ============================================================ - // Phase 17 — Per-track router (deterministic): COMPLETE or NEEDS_FIX. - // ============================================================ - for (const t of TRACKS) { - wf.step(`router-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`signoff-${t.id.toLowerCase()}`], - command: [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${t.id.toLowerCase()}`, - `BODY=${ARTIFACTS}/track-${t.id.toLowerCase()}/signoff.txt`, - `cat <<'SIGNOFF_EOF' > $BODY`, - `{{steps.signoff-${t.id.toLowerCase()}.output}}`, - 'SIGNOFF_EOF', - `if grep -q "^SIGNOFF: COMPLETE Track-${t.id}" $BODY; then`, - ` echo "ROUTE_${t.id}: COMPLETE"`, - ` echo complete > ${ARTIFACTS}/track-${t.id.toLowerCase()}/router.txt`, - 'else', - ` echo "ROUTE_${t.id}: NEEDS_FIX"`, - ` echo needs-fix > ${ARTIFACTS}/track-${t.id.toLowerCase()}/router.txt`, - 'fi', - ].join('\n'), - captureOutput: true, - failOnError: true, - }); - } - - // ============================================================ - // Phase 18 — Round-2 fix agent (no-op if router said COMPLETE). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`fix-r2-${t.id.toLowerCase()}`, { - agent: 'fixer', - dependsOn: [`router-${t.id.toLowerCase()}`], - task: [ - `Router decision for Track ${t.id}:`, - `{{steps.router-${t.id.toLowerCase()}.output}}`, - '', - `Signoff verdict for Track ${t.id}:`, - `{{steps.signoff-${t.id.toLowerCase()}.output}}`, - '', - `If router said "ROUTE_${t.id}: COMPLETE", exit immediately.`, - `If "ROUTE_${t.id}: NEEDS_FIX", read SIGNOFF: INCOMPLETE gaps and address every one in ${workdir(t)}.`, - '', - `After fixes: cd ${workdir(t)} && ${t.typecheckCmd} && ${t.testCmd}.`, - '', - 'Do not edit unrelated files. Do not add new TODOs. Exit cleanly.', - ].join('\n'), - verification: { type: 'exit_code' }, - }); - } - - // ============================================================ - // Phase 19 — Final signoff after round-2 fix. - // ============================================================ - for (const t of TRACKS) { - wf.step(`signoff-final-${t.id.toLowerCase()}`, { - agent: 'signoff', - dependsOn: t.commentOnly - ? [`router-${t.id.toLowerCase()}`] - : [`fix-r2-${t.id.toLowerCase()}`], - task: [ - `FINAL signoff for Track ${t.id} after round-2 fix.`, - '', - 'Re-verify acceptance bullets against the ACTUAL files now.', - '', - `Spec section: "${t.specSection}".`, - '', - t.commentOnly ? '' : `Diff: cd ${workdir(t)} && git diff origin/main`, - '', - 'Emit:', - ` SIGNOFF_FINAL: COMPLETE Track-${t.id}`, - 'or', - ` SIGNOFF_FINAL: INCOMPLETE Track-${t.id}`, - ' - ', - '', - 'Even if INCOMPLETE, exit cleanly. The PR will ship as DRAFT with the gap list.', - ].join('\n'), - verification: { type: 'output_contains', value: 'SIGNOFF_FINAL' }, - }); - } - - // ============================================================ - // Phase 20 — Build PR body (deterministic) with loud-hole + - // signoff + gate output + reflection report. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - const id = t.id.toLowerCase(); - wf.step(`build-pr-body-${id}`, { - type: 'deterministic', - dependsOn: [`signoff-final-${id}`], - command: [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${id}`, - `BODY=${ARTIFACTS}/track-${id}/pr-body.md`, - `cat <<'SF_EOF' > ${ARTIFACTS}/track-${id}/signoff-final.txt`, - `{{steps.signoff-final-${id}.output}}`, - 'SF_EOF', - `cat <<'FG_EOF' > ${ARTIFACTS}/track-${id}/final-gate.txt`, - `{{steps.final-gate-${id}.output}}`, - 'FG_EOF', - `cat <<'RF_EOF' > ${ARTIFACTS}/track-${id}/reflect.txt`, - `{{steps.reflect-${id}.output}}`, - 'RF_EOF', - `if grep -q "^SIGNOFF_FINAL: COMPLETE Track-${t.id}" ${ARTIFACTS}/track-${id}/signoff-final.txt; then`, - ` HEADER_STATE="complete"`, - `else`, - ` HEADER_STATE="incomplete"`, - `fi`, - `if [ "$HEADER_STATE" = "complete" ]; then`, - ` printf "%s\\n" "## Summary" "" "- Track ${t.id}: ${t.specSection}" "- Final signoff: COMPLETE." "- Eligible for auto-merge when CI green and upstream deps merged." "" > "$BODY"`, - `else`, - ` printf "%s\\n" "## Summary (DRAFT -- gaps remain)" "" "- Track ${t.id}: ${t.specSection}" "- Final signoff: INCOMPLETE; gap list below." "- PR stays as DRAFT; human review required." "" > "$BODY"`, - `fi`, - 'printf "%s\\n" "## Spec reference" "" "Source spec: workforce/docs/plans/deploy-v1-schema-cascade-spec.md" "" >> "$BODY"', - `printf "%s\\n" "Track section: ${t.specSection}" "" >> "$BODY"`, - 'printf "%s\\n" "## Final signoff" "" "\\`\\`\\`" >> "$BODY"', - `cat ${ARTIFACTS}/track-${id}/signoff-final.txt >> "$BODY"`, - 'printf "%s\\n" "\\`\\`\\`" "" "## Final gate (typecheck + tests)" "" "\\`\\`\\`" >> "$BODY"', - `cat ${ARTIFACTS}/track-${id}/final-gate.txt >> "$BODY"`, - 'printf "%s\\n" "\\`\\`\\`" "" "## Self-reflection report" "" "\\`\\`\\`" >> "$BODY"', - `cat ${ARTIFACTS}/track-${id}/reflect.txt >> "$BODY"`, - 'printf "%s\\n" "\\`\\`\\`" "" >> "$BODY"', - ...LOUD_HOLE_LINES.map((line) => `printf "%s\\n" ${JSON.stringify(line)} >> "$BODY"`), - 'printf "%s\\n" "" "Co-Authored-By: Ricky deploy-v1 schema cascade " >> "$BODY"', - 'echo "=== PR BODY for Track ' + t.id + ' ==="', - 'cat "$BODY"', - ].join('\n'), - captureOutput: true, - failOnError: true, - }); - } - - // ============================================================ - // Phase 21 — Per-track commit + push (deterministic). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - wf.step(`commit-push-${t.id.toLowerCase()}`, { - type: 'deterministic', - dependsOn: [`build-pr-body-${t.id.toLowerCase()}`], - command: [ - 'set -e', - `cd ${workdir(t)}`, - 'git add -A', - 'if git diff --cached --quiet; then', - ` echo "NO_CHANGES_TO_COMMIT_${t.id}"`, - 'else', - ' MSG=$(mktemp)', - ` printf "%s\\n" ${JSON.stringify(t.prTitle)} "" ${JSON.stringify(`Track ${t.id}: ${t.specSection}`)} "" ${JSON.stringify('See workforce/docs/plans/deploy-v1-schema-cascade-spec.md')} > "$MSG"`, - ' git commit -F "$MSG"', - ' rm -f "$MSG"', - 'fi', - `git push -u origin ${t.branch} --force-with-lease`, - 'git log --oneline -1', - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 22 — Open PR as DRAFT (or reuse existing for rebase tracks). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - const id = t.id.toLowerCase(); - wf.step(`open-pr-${id}`, { - type: 'deterministic', - dependsOn: [`commit-push-${id}`], - command: [ - 'set -e', - `mkdir -p ${ARTIFACTS}/track-${id}`, - `EXISTING=$(gh pr list --repo ${t.ghRepo} --head ${t.branch} --state open --json number -q '.[0].number' 2>/dev/null || echo "")`, - 'if [ -n "$EXISTING" ]; then', - ` echo "Track ${t.id}: reusing existing PR #$EXISTING"`, - ` echo "$EXISTING" > ${ARTIFACTS}/track-${id}/pr-number.txt`, - ` gh pr edit "$EXISTING" --repo ${t.ghRepo} --body-file ${ARTIFACTS}/track-${id}/pr-body.md || true`, - 'else', - ` CREATED=$(gh pr create --repo ${t.ghRepo} --head ${t.branch} --base main --draft --title ${JSON.stringify(t.prTitle)} --body-file ${ARTIFACTS}/track-${id}/pr-body.md 2>&1 || echo "FAILED")`, - ' PR_NUM=$(echo "$CREATED" | grep -oE "/pull/[0-9]+" | grep -oE "[0-9]+" | head -1)', - ' if [ -n "$PR_NUM" ]; then', - ` echo "Track ${t.id}: created PR #$PR_NUM"`, - ` echo "$PR_NUM" > ${ARTIFACTS}/track-${id}/pr-number.txt`, - ' else', - ` echo "Track ${t.id}: PR create failed: $CREATED"`, - ` echo "" > ${ARTIFACTS}/track-${id}/pr-number.txt`, - ' fi', - 'fi', - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 23 — Wait for CI green (poll PR checks, 30 min cap). - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - const id = t.id.toLowerCase(); - wf.step(`wait-ci-${id}`, { - type: 'deterministic', - dependsOn: [`open-pr-${id}`], - command: [ - 'set -e', - `PR_NUM=$(cat ${ARTIFACTS}/track-${id}/pr-number.txt 2>/dev/null || echo "")`, - 'if [ -z "$PR_NUM" ]; then', - ` echo "Track ${t.id}: no PR -- skipping CI wait."`, - ' exit 0', - 'fi', - 'for i in $(seq 1 60); do', - ` STATUS=$(gh pr checks "$PR_NUM" --repo ${t.ghRepo} --required --json conclusion -q '[.[] | .conclusion] | unique' 2>/dev/null || echo "[]")`, - ` echo "Track ${t.id} CI status (iter $i): $STATUS"`, - ' case "$STATUS" in', - ` '["SUCCESS"]'|'[]') echo "Track ${t.id} CI: GREEN"; exit 0;;`, - ` *FAILURE*) echo "Track ${t.id} CI: FAILURE -- stopping cascade for this track."; exit 0;;`, - ` *CANCELLED*) echo "Track ${t.id} CI: CANCELLED"; exit 0;;`, - ' esac', - ' sleep 30', - 'done', - `echo "Track ${t.id}: CI did not settle within 30 min."`, - 'exit 0', - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 24 — Merge-ready check (DAG walk: upstream merged?). - // Includes external PR deps via gh pr view --json mergedAt. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - const id = t.id.toLowerCase(); - const upstreamMerges = t.mergeAfter - .filter((dep) => !trackById(dep).commentOnly) - .map((dep) => `auto-merge-${dep.toLowerCase()}`); - wf.step(`merge-ready-${id}`, { - type: 'deterministic', - dependsOn: [`wait-ci-${id}`, ...upstreamMerges], - command: mergeReadyCmd(t), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 25 — Auto-merge gate: flip draft -> ready, squash --auto. - // Blocks on CHANGES_REQUESTED, unresolved comments, upstream not merged. - // ============================================================ - for (const t of TRACKS) { - if (t.commentOnly) continue; - const id = t.id.toLowerCase(); - wf.step(`auto-merge-${id}`, { - type: 'deterministic', - dependsOn: [`merge-ready-${id}`], - command: autoMergeCmd(t), - captureOutput: true, - failOnError: false, - }); - } - - // ============================================================ - // Phase 26 — Final cross-cascade summary (the wake-up report). - // ============================================================ - const finalDeps = TRACKS.filter((t) => !t.commentOnly).map( - (t) => `auto-merge-${t.id.toLowerCase()}`, - ); - wf.step('final-cascade-report', { - type: 'deterministic', - dependsOn: finalDeps.length > 0 ? finalDeps : ['read-spec'], - command: [ - 'set -e', - `mkdir -p ${ARTIFACTS}`, - `REPORT=${ARTIFACTS}/wake-up-report.md`, - 'printf "%s\\n" "# Deploy v1 schema cascade -- wake-up report" "" "Generated by ricky-deploy-v1-schema-cascade workflow." "" "## Per-track merge state" "" > "$REPORT"', - ...TRACKS.filter((t) => !t.commentOnly).map( - (t) => - `STATE=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/merge-state.txt 2>/dev/null || echo "UNKNOWN"); PR=$(cat ${ARTIFACTS}/track-${t.id.toLowerCase()}/pr-number.txt 2>/dev/null || echo "?"); printf -- "- Track ${t.id} (${t.repo}): %s -- PR #%s\\n" "$STATE" "$PR" >> "$REPORT"`, - ), - 'printf "\\n%s\\n\\n" "## Comment-only tracks" >> "$REPORT"', - ...TRACKS.filter((t) => t.commentOnly).map( - (t) => `printf -- "- Track ${t.id}: comment-only -- see signoff\\n" >> "$REPORT"`, - ), - 'printf "\\n%s\\n\\n" "## Loud holes still open (intentional)" >> "$REPORT"', - ...LOUD_HOLE_LINES.map((line) => `printf "%s\\n" ${JSON.stringify(line)} >> "$REPORT"`), - 'echo "=== WAKE-UP REPORT ==="', - 'cat "$REPORT"', - ].join('\n'), - captureOutput: true, - failOnError: false, - }); - - // ============================================================ - // Global error policy: retry transient failures up to 2x with 10s - // backoff (writing-agent-relay-workflows SKILL convention). - // ============================================================ - wf.onError('retry', { maxRetries: 2, retryDelayMs: 10_000 }); - - const result = await wf.run({ cwd: process.cwd() }); - console.log('Workflow status:', result.status); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); From 3e95dfcec9f5ad8d7c650011326a35f20b0e50e0 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 08:30:31 +0200 Subject: [PATCH 6/6] fix(persona-kit,deploy): address PR #94 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore explicit sandbox exec timeoutSeconds from harnessSettings.timeoutSeconds so deploys cannot hang indefinitely on a runner that does not exit. - Align PersonaSpec.onEvent JSDoc with the schema rule (required whenever cloud is true, not only when triggers are declared) and regenerate persona.schema.json. - Expand truncated PersonaMemoryScope description (the @-tag was swallowed by the JSDoc extractor) and regenerate. - deploy-v1.md: replace "specced" with "specified" and reconcile the memory-wiring note at boot with the v1-stub statement in §3.4. --- docs/plans/deploy-v1.md | 4 ++-- packages/deploy/src/modes/sandbox.ts | 3 ++- packages/persona-kit/schemas/persona.schema.json | 4 ++-- packages/persona-kit/src/types.ts | 16 +++++++++++----- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index ec94c524..704e9b6b 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -132,7 +132,7 @@ The act of stacking integrations is just declaring multiple keys. The act of lin } ``` -- ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). +- ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specified). - When memory is wired, the runtime will use the supermemory adapter. API keys come from workforce-managed env, not from persona JSON. - `scopes` is the only field with real semantic weight: workspace memory persists across users in a workspace, user memory follows an individual user's invocations, and global memory is shared across the deployed agent. - `autoPromote` flips on the sage turn-recorder pattern — agent decides if session content is worth promoting. @@ -224,7 +224,7 @@ export function handler( Implementation notes: - `handler(...)` reads the persona JSON adjacent to the entrypoint (workforce bundles them together). At cold-start it: 1. Calls `agent({ workspace, schedule, watch, inbox, onEvent: shim })` from `@agent-relay/agent`, mapping `persona.integrations` to `watch` and `persona.schedules` to `schedule`. - 2. Builds `ctx` once per agent boot: opens Daytona handle when deploy runs in sandbox mode, wires Relayfile-derived clients, attaches memory adapter. + 2. Builds `ctx` once per agent boot: opens Daytona handle when deploy runs in sandbox mode, wires Relayfile-derived clients. (Memory adapter wiring is deferred — `ctx.memory` is a stub in v1; see §3.4.) 3. The `shim` reshapes the raw envelope from `@agent-relay/agent` into the `WorkforceEvent` discriminated union and invokes the user's `fn(ctx, event)`. - The user never imports `@agent-relay/agent` directly. Workforce owns the ergonomics. If the underlying SDK churns, we absorb the diff here. - The SDK doors stay open for power users: we re-export `agent` from `@agentworkforce/runtime/raw` so anyone who wants the lower-level surface can drop down. This matters for nightcto-shaped projects that outgrow the persona contract. diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 02f64576..eae3967b 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -75,7 +75,8 @@ export const sandboxLauncher: ModeLauncher = { const done = (async () => { try { const result = await client.exec(handle, 'node runner.mjs', { - cwd: SANDBOX_BUNDLE_DIR + cwd: SANDBOX_BUNDLE_DIR, + timeoutSeconds: input.persona.harnessSettings.timeoutSeconds }); const output = result.output.trim(); if (output.length > 0) input.io.info(`[sandbox] ${output}`); diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 6927f120..1e2b5e1a 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -124,7 +124,7 @@ }, "onEvent": { "type": "string", - "description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required when {@link cloud } is `true` and any trigger is declared; the deploy CLI enforces this at deploy time, the parser keeps it optional so partially- authored specs still parse." + "description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required by the JSON Schema whenever {@link cloud } is `true` (any cloud persona needs an entrypoint, regardless of whether triggers are declared); the deploy CLI enforces the same rule. The parser itself keeps the field optional so partially-authored specs still parse." } }, "required": [ @@ -493,7 +493,7 @@ "user", "global" ], - "description": "Memory scope semantics, mirroring" + "description": "Memory scope semantics, mirroring the agent-assistant memory adapter: `workspace` memory persists across users in a workspace, `user` memory follows an individual user's invocations, and `global` memory is shared across every invocation of the deployed agent." } }, "$id": "https://agentworkforce.dev/schemas/persona.schema.json" diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index b231c827..fb5c4bcb 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -177,7 +177,12 @@ export interface PersonaSchedule { tz?: string; } -/** Memory scope semantics, mirroring @agent-assistant/memory. */ +/** + * Memory scope semantics, mirroring the agent-assistant memory adapter: + * `workspace` memory persists across users in a workspace, `user` memory + * follows an individual user's invocations, and `global` memory is shared + * across every invocation of the deployed agent. + */ export type PersonaMemoryScope = 'workspace' | 'user' | 'global'; /** @@ -312,10 +317,11 @@ export interface PersonaSpec { /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved - * relative to the persona JSON's directory at deploy time. Required when - * {@link cloud} is `true` and any trigger is declared; the deploy CLI - * enforces this at deploy time, the parser keeps it optional so partially- - * authored specs still parse. + * relative to the persona JSON's directory at deploy time. Required by + * the JSON Schema whenever {@link cloud} is `true` (any cloud persona + * needs an entrypoint, regardless of whether triggers are declared); the + * deploy CLI enforces the same rule. The parser itself keeps the field + * optional so partially-authored specs still parse. */ onEvent?: string; }