From 61f28fc2a08cb1085bd9cc5eab35874d14a108fd Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Mon, 4 May 2026 12:03:34 -0700 Subject: [PATCH] Add orchestrate plugin --- orchestrate/.cursor-plugin/plugin.json | 30 + orchestrate/.gitignore | 10 + orchestrate/LICENSE | 21 + orchestrate/README.md | 70 + orchestrate/skills/orchestrate/SKILL.md | 47 + .../skills/orchestrate/prompts/andon-block.md | 7 + .../prompts/empty-error-handoff.md | 10 + .../orchestrate/prompts/failure-handoff.md | 24 + .../prompts/finished-no-handoff.md | 21 + .../orchestrate/prompts/loop-hygiene.md | 15 + .../skills/orchestrate/prompts/root.md | 11 + .../skills/orchestrate/prompts/slack-block.md | 11 + .../skills/orchestrate/prompts/subplanner.md | 59 + .../skills/orchestrate/prompts/verifier.md | 74 + .../skills/orchestrate/prompts/worker.md | 70 + .../orchestrate/references/dispatcher.md | 50 + .../skills/orchestrate/references/handoffs.md | 194 ++ .../skills/orchestrate/references/planner.md | 155 ++ .../skills/orchestrate/references/spawning.md | 79 + .../orchestrate/schemas/plan.schema.json | 598 +++++ .../orchestrate/schemas/state.schema.json | 297 +++ .../agent-manager-slack-mirror.test.ts | 558 +++++ .../__tests__/andon-root-cache.test.ts | 401 ++++ .../__tests__/checkpoint-restart.test.ts | 195 ++ .../scripts/__tests__/comment-cli.test.ts | 270 +++ .../__tests__/comment-retry-queue.test.ts | 392 ++++ .../scripts/__tests__/exit-on-error.test.ts | 171 ++ .../scripts/__tests__/failure-handoff.test.ts | 207 ++ .../__tests__/handoff-branch-parser.test.ts | 106 + .../handoff-verification-parser.test.ts | 120 + .../handoff-verification-record.test.ts | 171 ++ .../scripts/__tests__/kickoff-dedupe.test.ts | 168 ++ .../__tests__/measurements-compare.test.ts | 201 ++ .../__tests__/measurements-mismatch.test.ts | 332 +++ .../__tests__/measurements-parser.test.ts | 113 + .../scripts/__tests__/models-catalog.test.ts | 94 + .../__tests__/operator-boundary.test.ts | 111 + .../scripts/__tests__/probe-models.test.ts | 71 + .../__tests__/prompt-plan-validation.test.ts | 89 + .../scripts/__tests__/redact-body.test.ts | 52 + .../scripts/__tests__/schemas.test.ts | 321 +++ .../scripts/__tests__/slack-adapter.test.ts | 353 +++ .../__tests__/slack-channel-boundary.test.ts | 75 + .../__tests__/slack-message-format.test.ts | 88 + .../__tests__/slack-prompt-shape.test.ts | 237 ++ .../__tests__/support/slack-web-api-mock.ts | 99 + .../verifier-startingref-reconcile.test.ts | 282 +++ .../__tests__/wait-handoff-failure.test.ts | 319 +++ .../__tests__/watchdog-inspect.test.ts | 98 + .../worker-branch-discipline.test.ts | 125 + .../orchestrate/scripts/adapters/README.md | 23 + .../orchestrate/scripts/adapters/index.ts | 9 + .../scripts/adapters/slack/client.ts | 19 + .../scripts/adapters/slack/index.ts | 296 +++ .../orchestrate/scripts/adapters/types.ts | 62 + .../skills/orchestrate/scripts/biome.json | 26 + .../skills/orchestrate/scripts/bun.lock | 414 ++++ orchestrate/skills/orchestrate/scripts/cli.ts | 4 + .../skills/orchestrate/scripts/cli/andon.ts | 123 + .../orchestrate/scripts/cli/comments.ts | 290 +++ .../orchestrate/scripts/cli/forensics.ts | 166 ++ .../skills/orchestrate/scripts/cli/index.ts | 31 + .../skills/orchestrate/scripts/cli/inspect.ts | 206 ++ .../skills/orchestrate/scripts/cli/task.ts | 604 +++++ .../skills/orchestrate/scripts/cli/util.ts | 595 +++++ .../orchestrate/scripts/core/agent-manager.ts | 2090 +++++++++++++++++ .../skills/orchestrate/scripts/core/andon.ts | 251 ++ .../orchestrate/scripts/core/branches.ts | 31 + .../scripts/core/comment-retry-queue.ts | 327 +++ .../scripts/core/failure-handoff.ts | 204 ++ .../orchestrate/scripts/core/handoff.ts | 221 ++ .../skills/orchestrate/scripts/core/loop.ts | 299 +++ .../orchestrate/scripts/core/prompts.ts | 318 +++ .../orchestrate/scripts/core/redact-body.ts | 72 + .../skills/orchestrate/scripts/errors.ts | 2 + .../orchestrate/scripts/measurements.ts | 395 ++++ .../skills/orchestrate/scripts/models.ts | 196 ++ .../skills/orchestrate/scripts/package.json | 28 + .../skills/orchestrate/scripts/schemas.ts | 715 ++++++ .../scripts/tools/generate-json-schemas.ts | 56 + .../orchestrate/scripts/tools/nudge-root.ts | 259 ++ .../orchestrate/scripts/tools/probe-models.ts | 101 + .../skills/orchestrate/scripts/tsconfig.json | 16 + 83 files changed, 16121 insertions(+) create mode 100644 orchestrate/.cursor-plugin/plugin.json create mode 100644 orchestrate/.gitignore create mode 100644 orchestrate/LICENSE create mode 100644 orchestrate/README.md create mode 100644 orchestrate/skills/orchestrate/SKILL.md create mode 100644 orchestrate/skills/orchestrate/prompts/andon-block.md create mode 100644 orchestrate/skills/orchestrate/prompts/empty-error-handoff.md create mode 100644 orchestrate/skills/orchestrate/prompts/failure-handoff.md create mode 100644 orchestrate/skills/orchestrate/prompts/finished-no-handoff.md create mode 100644 orchestrate/skills/orchestrate/prompts/loop-hygiene.md create mode 100644 orchestrate/skills/orchestrate/prompts/root.md create mode 100644 orchestrate/skills/orchestrate/prompts/slack-block.md create mode 100644 orchestrate/skills/orchestrate/prompts/subplanner.md create mode 100644 orchestrate/skills/orchestrate/prompts/verifier.md create mode 100644 orchestrate/skills/orchestrate/prompts/worker.md create mode 100644 orchestrate/skills/orchestrate/references/dispatcher.md create mode 100644 orchestrate/skills/orchestrate/references/handoffs.md create mode 100644 orchestrate/skills/orchestrate/references/planner.md create mode 100644 orchestrate/skills/orchestrate/references/spawning.md create mode 100644 orchestrate/skills/orchestrate/schemas/plan.schema.json create mode 100644 orchestrate/skills/orchestrate/schemas/state.schema.json create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/agent-manager-slack-mirror.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/andon-root-cache.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/checkpoint-restart.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/comment-retry-queue.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/exit-on-error.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/failure-handoff.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/handoff-branch-parser.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-parser.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-record.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/kickoff-dedupe.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/measurements-compare.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/measurements-mismatch.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/measurements-parser.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/operator-boundary.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/probe-models.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/prompt-plan-validation.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/schemas.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/slack-channel-boundary.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/slack-message-format.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/slack-prompt-shape.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/support/slack-web-api-mock.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/verifier-startingref-reconcile.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/wait-handoff-failure.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/watchdog-inspect.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/__tests__/worker-branch-discipline.test.ts create mode 100644 orchestrate/skills/orchestrate/scripts/adapters/README.md create mode 100644 orchestrate/skills/orchestrate/scripts/adapters/index.ts create mode 100644 orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts create mode 100644 orchestrate/skills/orchestrate/scripts/adapters/slack/index.ts create mode 100644 orchestrate/skills/orchestrate/scripts/adapters/types.ts create mode 100644 orchestrate/skills/orchestrate/scripts/biome.json create mode 100644 orchestrate/skills/orchestrate/scripts/bun.lock create mode 100644 orchestrate/skills/orchestrate/scripts/cli.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/andon.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/comments.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/forensics.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/index.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/inspect.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/task.ts create mode 100644 orchestrate/skills/orchestrate/scripts/cli/util.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/agent-manager.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/andon.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/branches.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/comment-retry-queue.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/failure-handoff.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/handoff.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/loop.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/prompts.ts create mode 100644 orchestrate/skills/orchestrate/scripts/core/redact-body.ts create mode 100644 orchestrate/skills/orchestrate/scripts/errors.ts create mode 100644 orchestrate/skills/orchestrate/scripts/measurements.ts create mode 100644 orchestrate/skills/orchestrate/scripts/models.ts create mode 100644 orchestrate/skills/orchestrate/scripts/package.json create mode 100644 orchestrate/skills/orchestrate/scripts/schemas.ts create mode 100644 orchestrate/skills/orchestrate/scripts/tools/generate-json-schemas.ts create mode 100644 orchestrate/skills/orchestrate/scripts/tools/nudge-root.ts create mode 100644 orchestrate/skills/orchestrate/scripts/tools/probe-models.ts create mode 100644 orchestrate/skills/orchestrate/scripts/tsconfig.json diff --git a/orchestrate/.cursor-plugin/plugin.json b/orchestrate/.cursor-plugin/plugin.json new file mode 100644 index 0000000..3f82843 --- /dev/null +++ b/orchestrate/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "orchestrate", + "displayName": "Orchestrate", + "version": "1.0.0", + "description": "Fan a large task out across parallel Cursor cloud agents via the Cursor SDK: planners publish tasks, workers hand off back up, and a script reconciles the tree from disk and git.", + "author": { + "name": "Cursor", + "email": "plugins@cursor.com" + }, + "homepage": "https://github.com/cursor/plugins/tree/main/orchestrate", + "repository": "https://github.com/cursor/plugins", + "license": "MIT", + "keywords": [ + "orchestrate", + "cursor", + "cursor-sdk", + "agents", + "cloud-agents", + "parallel", + "automation" + ], + "category": "developer-tools", + "tags": [ + "agents", + "automation", + "developer-tools", + "sdk" + ], + "skills": "./skills/" +} diff --git a/orchestrate/.gitignore b/orchestrate/.gitignore new file mode 100644 index 0000000..86c7d8c --- /dev/null +++ b/orchestrate/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.DS_Store + +# Runtime artifacts written by the orchestrate CLI in the user's workspace +# and by `bun test` when fixtures don't override cwd. +bc-*/ +state.json +plan.json +handoffs/ +comment-retry-queue.json diff --git a/orchestrate/LICENSE b/orchestrate/LICENSE new file mode 100644 index 0000000..ca2bba7 --- /dev/null +++ b/orchestrate/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cursor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/orchestrate/README.md b/orchestrate/README.md new file mode 100644 index 0000000..2cdece7 --- /dev/null +++ b/orchestrate/README.md @@ -0,0 +1,70 @@ +# Orchestrate + +Fan a large task out across parallel Cursor cloud agents via the Cursor SDK. Planners publish tasks, workers hand off back up, and a script reconciles the tree from disk and git, so the spawn / wait / handoff loop keeps converging without long-running agent state. + +The skill itself lives in [`skills/orchestrate/SKILL.md`](./skills/orchestrate/SKILL.md). Read that for the full operating manual; this README only covers what to set up before you invoke it. + +## Prerequisites + +- `bun` on PATH. +- A Cursor API key in `CURSOR_API_KEY`. +- Optional Slack app and bot token if you want a Slack thread mirroring the run. + +## Cursor API key + +1. Open [https://cursor.com/dashboard/integrations](https://cursor.com/dashboard/integrations). +2. Create a personal user API key. The value starts with `cursor_`. +3. Export it: `export CURSOR_API_KEY="cursor_..."`. + +Team service-account keys (Team Settings → Service accounts) also work for both local and cloud runs. See the [`cursor-sdk` plugin](https://github.com/cursor/plugins/tree/main/cursor-sdk) for the full auth model. + +## Slack app (optional) + +Slack visibility is opt-in. When the token is unset, the script logs once and runs without Slack; correctness does not change. To enable it: + +1. Create a Slack app at [https://api.slack.com/apps](https://api.slack.com/apps) → **From scratch**. Pick a name and a workspace. +2. Under **OAuth & Permissions** → **Bot Token Scopes**, add: + + | Scope | Why | + | --- | --- | + | `chat:write` | Post and edit messages. | + | `chat:write.customize` | Set the bot username and icon on each post. | + | `chat:write.public` | Post in public channels without inviting the bot first. | + | `files:write` | Upload handoff artifacts to the run thread. | + | `files:read` | Paired with `files:write` for the upload v2 flow. | + | `reactions:read` | Watch the Andon `:rotating_light:` reaction on the kickoff message. | + | `channels:history` | Read thread replies. Use `groups:history` instead if your run channel is private. | + + Optional but recommended: + + | Scope | Why | + | --- | --- | + | `users:read.email` | Resolve the dispatcher's first name from `git config user.email`. Without it, pass `--dispatcher-name` explicitly. | + +3. **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`). +4. Export it: `export SLACK_BOT_TOKEN="xoxb-..."`. +5. Invite the bot to the channel where you want runs to thread (`/invite @your-bot`). Public channels with `chat:write.public` skip this; private channels require the invite. +6. Grab the channel ID. In Slack: right-click the channel → **View channel details** → bottom of the dialog. Pass it via `--slack-channel ` on `kickoff` (or set `SLACK_CHANNEL_ID`). The first kickoff persists the id on the plan; subplanners and later `run` invocations inherit it. + +## Install + +```bash +cd skills/orchestrate/scripts +bun install +``` + +The scripts live outside the host repo's package manager workspace on purpose. + +## Invoke + +```bash +bun skills/orchestrate/scripts/cli.ts kickoff "" \ + [--repo ] [--ref main] [--model claude-opus-4-7] \ + [--slack-channel ] [--dispatcher-name ""] +``` + +The CLI prints `{ agentId, runId, status, url }`; from there the cloud root planner self-drives. See the skill for `run`, `spawn`, `respawn`, `kill`, `tail`, `comment`, and `andon` subcommands. + +## License + +MIT. See [`LICENSE`](./LICENSE). diff --git a/orchestrate/skills/orchestrate/SKILL.md b/orchestrate/skills/orchestrate/SKILL.md new file mode 100644 index 0000000..ff7fc8e --- /dev/null +++ b/orchestrate/skills/orchestrate/SKILL.md @@ -0,0 +1,47 @@ +--- +name: orchestrate +description: Use only when the user explicitly types `/orchestrate ` to decompose a large task, spawn a tree of parallel cloud-agent workers/subplanners/verifiers via the Cursor SDK, and collect structured handoffs; do not invoke autonomously. +disable-model-invocation: true +--- + +# Orchestrate + +An explicit `/orchestrate ` fans out a large task across parallel Cursor cloud agents. Workers don't talk to each other; they talk up through structured handoffs. The spawn, wait, and handoff loop lives in `scripts/cli.ts`. The planner writes `plan.json`, the script executes it, and the planner reads handoffs to decide what comes next. Long-running agent loops drift; a script with a JSON state file keeps its footing. + +**Required reading: the `cursor-sdk` skill ([cursor/plugins/cursor-sdk](https://github.com/cursor/plugins/tree/main/cursor-sdk)).** Spawning, auth, and the error taxonomy live there. Don't reimplement what that skill already documents. + +## Setup + +- `CURSOR_API_KEY` must be a personal/user key. Create it from [Cursor Dashboard > Integrations](https://cursor.com/dashboard/integrations), then read `cursor-sdk` Auth before using it. +- `SLACK_BOT_TOKEN` is optional. When set, pass `--slack-channel ` to `kickoff` or the first `run --root`, or set `SLACK_CHANNEL_ID`. The script stores the channel in `plan.slackChannel`, posts the kickoff thread there, mirrors task status, and reads Andon reactions. When the token is unset, the script logs once and runs without Slack visibility; correctness does not change. + +## Core principles + +These rules make the tree self-converging without global coordination. + +1. **Planners own scopes and publish tasks. They do no coding.** Writing `plan.json`, reading handoffs, and deciding what's next are planner work. Editing files, running `git merge`, and fixing conflicts inline are not. If a planner feels the urge to code, it publishes a task for a worker instead. +2. **Planners don't know who picks up their tasks.** The script routes each task to a cloud agent. The planner's mental model stays at the task level. +3. **Workers are isolated.** One task, one clone of the repo, no channel to any other agent. One handoff when done. +4. **Subplanners are recursive planners.** A planner publishes a "subplan this slice" task; the subplanner fully owns that slice and hands back an aggregated handoff. +5. **Continuous motion via handoffs.** A planner that thought it was done can receive a late handoff and replan. No "finished" state until the planner decides to stop publishing. +6. **Propagation, not synchronization.** No cross-talk between siblings. No shared state between levels. Each level sees only its children's handoffs. + +## Node types + +| Node | Runs the loop? | Scope | Output | +| -------------- | -------------- | -------------------------------- | --------------------------------------- | +| Planner | yes | Entire user goal | User-facing message + optional PR | +| Subplanner (↻) | yes | One slice of parent's scope | Handoff to parent | +| Worker | no | One concrete task | Handoff to spawning planner | +| Verifier | no | One target's acceptance criteria | Verdict handoff to spawning planner | +| Git | n/a | Shared medium | Branches (code) + handoffs/ (meaning) | + +## Role + +Two roles, one skill. Read your role's reference file and skip the other. + +**Dispatcher.** You're in a local IDE session and the user typed `/orchestrate `. Your job is to kick off a cloud root planner and return its URL. See `references/dispatcher.md`. One-shot; you are not the planner. + +**Planner (root or sub).** You were spawned with a structured prompt that opens with "You are the root planner for:" or "You are a subplanner for:". Or the user chose to run the planning loop locally. You own a scope, publish tasks, read handoffs, decide what's next. See `references/planner.md`. + +`disable-model-invocation: true` means this skill loads only on explicit invocation. diff --git a/orchestrate/skills/orchestrate/prompts/andon-block.md b/orchestrate/skills/orchestrate/prompts/andon-block.md new file mode 100644 index 0000000..a885888 --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/andon-block.md @@ -0,0 +1,7 @@ + +### Andon +Halts new spawns across the whole tree. Raise only with concrete evidence that continued spawning produces garbage: upstream output is wrong and downstream tasks will fail against it, verifier cascade shows acceptance was wrong, auth or infra is unrecoverable. A task hitting its own snag is a `Status: blocked` handoff, not Andon. + + bun /scripts/cli.ts andon raise --reason ""{{agentIdFlag}} --workspace + +`--reason` is required and posts to the run thread so the tree can see why orchestration paused. The reaction is the cheap gate children poll. `--agent-id` adds a footer link back to the agent that raised it. diff --git a/orchestrate/skills/orchestrate/prompts/empty-error-handoff.md b/orchestrate/skills/orchestrate/prompts/empty-error-handoff.md new file mode 100644 index 0000000..9e469b8 --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/empty-error-handoff.md @@ -0,0 +1,10 @@ +Cloud run errored before producing a final message. + +agent: https://cursor.com/agents/{{agentId}} +agentId: {{agentId}} +runId: {{runId}} +resultStatus: {{resultStatus}} +result: +```json +{{resultDataJson}} +``` diff --git a/orchestrate/skills/orchestrate/prompts/failure-handoff.md b/orchestrate/skills/orchestrate/prompts/failure-handoff.md new file mode 100644 index 0000000..b44ec75 --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/failure-handoff.md @@ -0,0 +1,24 @@ + + +# {{taskName}} failure handoff + +Status: error (cloud agent terminated without writing a handoff) +Failure mode: {{failureMode}} +Cloud agent: {{agentId}} +Started: {{startedAt}} +Terminated: {{terminatedAt}} +Duration: {{duration}} +Last activity: {{lastActivityLine}} +Last tool call: {{lastToolCall}} +Branch: {{branch}} +SDK error: {{sdkError}} + +## Suggested next steps +{{suggestions}} diff --git a/orchestrate/skills/orchestrate/prompts/finished-no-handoff.md b/orchestrate/skills/orchestrate/prompts/finished-no-handoff.md new file mode 100644 index 0000000..d05e28b --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/finished-no-handoff.md @@ -0,0 +1,21 @@ + + +# {{taskName}} finished without handoff + +Status: {{resultStatus}} (cloud agent ended cleanly but never wrote a `## Status` handoff) +Cloud agent: {{agentId}} +Run: {{runId}} +Branch: {{branch}} +Terminated: {{terminatedAt}} + +## Suggested next steps +- Inspect the raw handoff at `handoffs/{{taskName}}.md` to see what the worker actually emitted. +- Retry as-is if this looks like a prompt-misfire (worker produced prose but not the structured template). +- Abandon: skip task, replan around it if the goal genuinely has no acceptable output.{{rawSnippetBlock}} diff --git a/orchestrate/skills/orchestrate/prompts/loop-hygiene.md b/orchestrate/skills/orchestrate/prompts/loop-hygiene.md new file mode 100644 index 0000000..6c3db4a --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/loop-hygiene.md @@ -0,0 +1,15 @@ +Loop hygiene: +- Run `bun cli.ts run{{rootFlag}} ` in the foreground. The Shell default backgrounds the loop and breaks the heartbeat when your turn ends. +- Exit code 100 is a planned checkpoint restart, not an error. Rerun the same command immediately; it resumes from committed `state.json`. +- Exit code 1 on a non-empty error set is your turn. The loop exited because a task crashed; the script already wrote a synthetic `handoffs/-failure.md` for each dead worker and any `handoffs/-finished-no-handoff.md` for workers that ended without a structured handoff. In-flight workers keep running; the next `run` reattaches via `recoverRunning`. +- After `run` returns, call `tree`. If any task is still `pending` or `running`, loop again. +- Don't end your turn while this workspace has non-terminal tasks. + +Reacting to failure handoffs: +For each task with `status: "error"` and a matching `handoffs/-failure.md`, read the `Failure mode` line and decide: +- `cap-hit` or `oom`: retry with smaller scope (split into narrower tasks, tighter `pathsAllowed`, leaner `scopedGoal`). +- `network-drop`: retry as-is; treat as transient. +- `tool-error`: retry with a different `model`. +- `unknown`: read the `Last activity` and `SDK error` lines; if no signal, treat as transient and retry as-is; abandon if it fails again. +For `-finished-no-handoff.md`, read the raw snippet at `handoffs/.md` and decide whether the worker's intent was recoverable; retry or abandon. +Each retry costs another cloud-agent run; budget your decisions. After 2 retries on the same task, prefer abandon (drop the task from `plan.json`, replan around it) over a 3rd attempt unless you have specific evidence the next retry will succeed. Update `plan.json`, then re-run `bun cli.ts run{{rootFlag}} ` to continue. diff --git a/orchestrate/skills/orchestrate/prompts/root.md b/orchestrate/skills/orchestrate/prompts/root.md new file mode 100644 index 0000000..adde883 --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/root.md @@ -0,0 +1,11 @@ +You are the root planner for: {{goal}} + +Read this skill's `SKILL.md` and follow it. + +Your cloud agent id is `{{agentId}}`. Set `plan.selfAgentId` in plan.json to that string so spawns record `parentAgentId` and `kill-tree --agent-id` can target this planner. + +Write `plan.summary` as a one-line orientation for the human in the Slack thread (e.g. `"smoke test of the new orchestrate substrate"`). Kickoff posts the summary; without it, kickoff falls back to a truncated `goal`.{{dispatcherInstruction}}{{slackChannelInstruction}} + +Discover here before you publish tasks. Bootstrap workers hold reference material for descendants, not one-off discovery. + +{{loopHygiene}} diff --git a/orchestrate/skills/orchestrate/prompts/slack-block.md b/orchestrate/skills/orchestrate/prompts/slack-block.md new file mode 100644 index 0000000..c943cfa --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/slack-block.md @@ -0,0 +1,11 @@ + +Slack visibility: +- Write like a human typing in Slack. Short, terse, intent-first. No bot-speak ("I have completed", "Successfully executed", "Please find attached"), no filler emoji, no em-dashes. Show data over narration: "subplan-glint-23 → handed-off (4m12s)" beats a paragraph saying the same. +- The script mirrors task status in `{{channel}}` / `{{threadTs}}`. Don't edit those messages. +- Don't post to the channel root or open another kickoff. Stay in the run thread. +- Post a Slack note when silence would hide useful context: blocked work, changed assumptions, surprising findings, review request. Otherwise stay quiet. +- Default to autonomous. Don't @-mention humans; the dispatcher is already following the run thread and gets channel-level notifications. Posting in-thread is enough. +- For non-Slack follow-up (Linear ticket, GitHub issue, on-call page) call the relevant MCP directly. Orchestrate's structured plumbing is Slack-only; runtime MCPs are not. +- `bun /scripts/cli.ts comment "" --thread-ts {{threadTs}} --sender {{taskName}}{{agentIdFlag}} --workspace `. `--agent-id` adds a footer link back to your cursor.com page. +- File attachments (repro/fix videos): `bun cli.ts comment --thread-ts {{threadTs}} --file --comment "" --sender {{taskName}}{{agentIdFlag}} --workspace `. Lands in the run thread alongside the status mirror. +- Add `--criticality required` for messages that must land. Default is best-effort. diff --git a/orchestrate/skills/orchestrate/prompts/subplanner.md b/orchestrate/skills/orchestrate/prompts/subplanner.md new file mode 100644 index 0000000..ea4814b --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/subplanner.md @@ -0,0 +1,59 @@ +You are a subplanner for: {{scopedGoal}} + +Read this skill's `SKILL.md` and follow it. + +**You fully own this slice.** Your parent gave you a goal, path boundaries, and acceptance, not a sub-plan. Decide your own decomposition. If `scopedGoal` below includes hints about how to split the work, treat them as weak hints at most; you are authoritative on your subtree's structure. + +**Recursion.** You are a planner: use workers for leaf-sized slices; add `subplanner` tasks when the slice still needs internal structure or merge/verify passes. No depth limit. Use judgment on count: one worker can carry a multi-file, multi-step slice. Default to fewer, broader workers; see `references/planner.md` "Planning rules" for the spawn-scope tradeoff. + +**Child tasks.** Write child tasks as `plan.tasks[]` entries in your own plan.json. For code-editing children, propagate per the code discipline: no narrative comments. Comment only non-obvious *why*. + +**Workspace convention.** Put your orchestrate workspace at `.orchestrate/{{name}}/` and set `plan.rootSlug = "{{name}}"`. The parent records your actual cloud-agent branch after handoff; use the branch already checked out and don't create or rename one to match a planned name. Set `plan.repoUrl` to `{{repoUrl}}` so descendants stay in the parent's repo. When `{{andonStateRef}}` and `{{andonStatePath}}` are non-empty, copy them so Andon state stays shared. Omit any field whose placeholder renders empty. + +**Lineage.** If `{{selfAgentId}}` is set, put `plan.selfAgentId = "{{selfAgentId}}"` in plan.json (SDK does not surface it elsewhere). Details: `references/spawning.md`. + +{{loopHygiene}} + +Overall goal (parent's framing, context only): + +{{goal}} + +Your scoped sub-goal: + +{{scopedGoal}} + +Paths you may MODIFY (read any file in the repo): +{{allow}} + +Paths you must NOT modify (owned by siblings): +{{forbid}} + +Acceptance criteria for your subtree: +{{accept}}{{verifyPlan}}{{upstream}} +Model selection: pick `tasks[].model` per task by capability. Available models: + +{{modelCatalog}} + +Your **final message** is your handoff to your parent. Use exactly this structure: + +## Status +success | partial | blocked + +## Branch +`` + +## What my subtree did +- + +## Verification + + +Aggregate the strongest claim your subtree's evidence actually supports for the deliverable on `## Branch`. Definitions live in `prompts/verifier.md`. Pass `verifier-blocked` through unchanged rather than rounding up to a thinner verified value. Use `not-verified` only when no verifier ran and your workers didn't self-report a stronger claim. + +## Notes, concerns, deviations, findings, thoughts, feedback +- + +## Suggested follow-ups +- + +Do not open a PR. Your parent decides what to do with your branch. diff --git a/orchestrate/skills/orchestrate/prompts/verifier.md b/orchestrate/skills/orchestrate/prompts/verifier.md new file mode 100644 index 0000000..060d3fe --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/verifier.md @@ -0,0 +1,74 @@ +You are a verifier in an orchestrated task. You do not communicate with any other agents. You produce one verdict handoff when done. + +Overall goal (context only; don't try to own it): + +{{goal}} + +Your verifier task: + +{{scopedGoal}} + +You are verifying target task `{{targetName}}` (type: `{{targetType}}`) on branch `{{targetBranch}}`. + +Target scoped task (verbatim): + +{{targetGoal}} + +Target acceptance criteria (verbatim): +{{targetAccept}}{{targetVerifyPlan}} + +Verifier-specific acceptance criteria: +{{accept}}{{ownVerifyPlan}}{{upstream}} +Execution mandate: +- Run the code. Reading the diff is not verification. +- Reproduce each acceptance criterion by observable behavior: run the test suite and paste output; invoke the CLI with real inputs; start the service and hit the endpoint; start the UI (dev server), click through the flow, inspect DOM/localStorage/network; build/typecheck. +- `## Verification` is the only structured signal the planner gets about your evidence. A planner that reads `live-ui-verified` and the underlying truth was `verifier-blocked` ships a broken fix. +- When environment failures (Docker rate limit, port conflicts, missing creds, broken harness) prevent the verification, set `verifier-blocked`. Don't report `type-check-only` for a check you didn't run end-to-end; that disguises an env failure as a thin verification. +- If you're tempted to write a verdict without running anything, set `verifier-blocked` and say why. +- UI / interactive bugs: capture a screen recording of the repro or fix and mention the artifact path in your handoff. + +Branch discipline: +- Your repo starts from `{{startingRef}}` (target branch: `{{targetBranch}}`). +- Commit verifier artifacts (repro scripts, audit notes, log captures if useful) to the branch already checked out for this cloud agent and push it. +- Do not create or rename a branch solely to match a planned branch name. +- Do NOT modify target source files. +- Do NOT merge, rebase, or open a PR. The planner owns integration. +- Your branch is never merged back; the planner reads your handoff and decides follow-ups. + +Your **final message** is your verifier handoff; the planner reads nothing else. Use exactly this structure: + +## Verification + + +Pick the strongest claim your `## Execution` evidence supports: + +- `live-ui-verified`: you reproduced the bug live (real browser, real binary, real CLI) and confirmed the fix removes it. Required for UI or interactive bugs when the env permits. +- `unit-test-verified`: a targeted unit or integration test exercises the changed code path and passes. No live confirmation. +- `type-check-only`: only type-check / build passes. No tests for the fix itself. Pick this only when the change is typing-only or compile-only. +- `verifier-blocked`: environment failures (Docker rate limit, port conflicts, missing creds, broken harness) prevented you from running the verification. The fix may be correct but you couldn't prove it. Use this rather than misrepresenting a thinner check. +- `verifier-failed`: you ran the verification and the fix did not resolve the bug. + +## Target +`{{targetName}}` on branch `{{targetBranch}}` + +## Branch +`` (or "(no branch)" if you committed nothing) + +## Execution +- +- +- +(list every meaningful thing you actually ran; this section is what distinguishes a real verification from pattern-matching) + +## Findings +Per acceptance criterion: +- [x] : (met | not met | n/a) +Other findings (severity-ordered): +- (high) : evidence +- (med) : evidence +- (low) : evidence + +## Notes & suggestions +- + +Put everything important here. The planner doesn't see your intermediate output. diff --git a/orchestrate/skills/orchestrate/prompts/worker.md b/orchestrate/skills/orchestrate/prompts/worker.md new file mode 100644 index 0000000..f16fe38 --- /dev/null +++ b/orchestrate/skills/orchestrate/prompts/worker.md @@ -0,0 +1,70 @@ +You are a worker in an orchestrated task. You do not communicate with any other agents. You produce one handoff when done. + +Overall goal (context only; don't try to own it): + +{{goal}} + +Your scoped task: + +{{scopedGoal}} + +Paths you may MODIFY (read any file in the repo): +{{allow}} + +Do NOT modify: +{{forbid}} + +Acceptance criteria: +{{accept}}{{verifyPlan}}{{upstream}} +Branch discipline: +- Your repo starts from `{{startingRef}}`. +- Push exactly `{{branch}}` and report it in your handoff.{{mergeDiscipline}} +{{prDiscipline}} + +Quality floor: +- No placeholder TODOs. Every public function gets a real implementation. +- No `throw new Error("not implemented")` except in deliberate assertion helpers. +- Per the code discipline: no narrative comments. Comment only non-obvious *why*. +- UI / interactive bugs: capture a screen recording of the fix or before/after state and mention the artifact path in your handoff. + +If you crash, OOM, or hit the wall-time cap, the orchestrator script writes a postmortem handoff on your behalf. Don't burn cycles on defensive last-gasp writes; focus on the real work and write the normal handoff when you finish cleanly. + +Your **final message** is your handoff; the planner reads nothing else. Use exactly this structure: + +## Status +success | partial | blocked + +## Branch +`` (or "(no branch)" if you produced no code) + +## What I did +- + +## Measurements +- : + +One line per quantitative acceptance criterion. `` is one of `→`, `<=`, `<`, `>`, `>=`, `==`. Example lines: + +- `LOC(packages/ui/src/Settings.tsx): 412 → 354` +- `pnpm test --filter @example/foo: 84 passing → 84 passing` +- `bundle size: 2.41 MB → 2.39 MB` + +If your task has no quantitative acceptance criteria, write `(none)` on its own line. The script re-runs declared `measurements` on your branch and flags >10% drift or unit mismatches (e.g. MB vs KB) in `attention.log`. + +## Verification + + +Self-report the strongest evidence you produced for the fix itself, not for the code compiling. A verifier may override this later; without one, this value is what the planner uses to bucket your work: + +- `live-ui-verified`: you reproduced the bug live and confirmed the fix removes it. +- `unit-test-verified`: a targeted test exercises the changed code path and passes. +- `type-check-only`: only type-check / build passes. No test or repro for the fix. +- `not-verified`: you didn't verify the fix end-to-end (e.g. refactor with no behavioral target, or env blocked you and a verifier still has to run). + +## Notes, concerns, deviations, findings, thoughts, feedback +- + +## Suggested follow-ups +- + +Put everything important here. The planner doesn't see your intermediate output. diff --git a/orchestrate/skills/orchestrate/references/dispatcher.md b/orchestrate/skills/orchestrate/references/dispatcher.md new file mode 100644 index 0000000..314469d --- /dev/null +++ b/orchestrate/skills/orchestrate/references/dispatcher.md @@ -0,0 +1,50 @@ +Operating manual for the dispatcher. If you were spawned with a prompt starting "You are the root planner for:" or "You are a subplanner for:", read `planner.md` instead. + +# Dispatcher + +The dispatcher is one-shot. Take the user's goal, launch a cloud root planner via the CLI, return the URL, stop. + +## The job + +1. Take the user's goal. Ask for clarification only if the goal is missing or ambiguous. The user chose parallel cloud orchestration deliberately; push back only if the task is genuinely trivial. +2. Run the kickoff CLI with that goal and any user-specified constraints (model override, repo override). +3. Return the URL from the CLI output to the user. Stop. The planner self-drives. + +One-time setup: run `bun install` inside this skill's `scripts/` directory if `node_modules/` is missing. The scripts live outside the host repo's package manager workspace on purpose. + +```bash +bun cli.ts kickoff "" [--repo ] [--ref main] [--model claude-opus-4-7] [--slack-channel C123] [--dispatcher-name "Alex"] +``` + +The CLI reads `CURSOR_API_KEY`, auto-detects the repo from `git config --get remote.origin.url`, builds the spawn prompt, spawns via `cursor-sdk`, and prints `{ agentId, runId, status, url, dispatcherFirstName }` JSON. Slack is optional. If `SLACK_BOT_TOKEN` is set, also pass `--slack-channel ` or set `SLACK_CHANNEL_ID`; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled. + +## Dispatcher identity + +Kickoff bot username is `'s bot` when the first name resolves, otherwise `orchestrate`. Resolution order: + +1. `--dispatcher-name "Alex"` flag. +2. Slack `users.lookupByEmail` against `git config user.email` (best-effort; missing scope or no match leaves it unset). + +The CLI passes the resolved name to the root planner via the kickoff prompt; the planner writes `plan.dispatcher = { firstName: "" }` into `plan.json`. Child tasks keep their own task name as bot identity. + +## Run summary + +The root planner writes `plan.summary` as a one-line orientation for the human in the Slack thread. Kickoff posts `: `; without `summary` it truncates `goal` to ~200 chars. `summary` is for the human; `goal` stays as the agent-facing full text. + +## Minimal-goal discipline + +Pass the user's goal through without expanding it. Don't add planning heuristics, subplanner counts, or structural prescriptions. The planner reads the orchestrate skill and decides its own decomposition. Over-prescribing leaks dispatcher context into the planner's window and invalidates the skill as a realistic test of the planner's judgment. + +## Auth + +`CURSOR_API_KEY` must be a user API key, not a team key. Auth sourcing precedence is documented in the `cursor-sdk` skill (https://github.com/cursor/plugins/tree/main/cursor-sdk). Don't bake keychain lookup into the kickoff CLI itself; cloud-agent VMs have no keychain. + +## Observability after kickoff + +Progress is observable after dispatch: + +- `bun cli.ts crawl ` for a deep tree view. +- `bun cli.ts status` for top-level state. +- The Slack kickoff thread in `plan.slackChannel`, when `SLACK_BOT_TOKEN` is set. + +`syncStateToGit` defaults to true. Set `syncStateToGit: false` on the root plan when goals or handoffs should not be committed. diff --git a/orchestrate/skills/orchestrate/references/handoffs.md b/orchestrate/skills/orchestrate/references/handoffs.md new file mode 100644 index 0000000..15c7c2c --- /dev/null +++ b/orchestrate/skills/orchestrate/references/handoffs.md @@ -0,0 +1,194 @@ +# Handoffs + +Handoffs are the only way information moves between nodes. Workers produce one; planners read them and decide. No shared branch, no status API, no cross-sibling chatter. That uniformity is what keeps the tree in motion without global coordination. + +The script instructs every spawned agent to end with a structured final message (status, branch, summary, notes, follow-ups). Exact format lives in the templates at `prompts/*.md`. The script saves the final message verbatim to `/handoffs/.md` with a traceability header. Don't enrich or sanitize; the planner needs the worker's words unfiltered. + +## Measurements (worker handoff) + +Workers self-report under `## Measurements`; format in `prompts/worker.md`. When a task declares `measurements[]`, the script re-runs each command on the worker's branch and flags numeric drift >10% or unit mismatches to `attention.log`; the worker still hands off and the planner decides whether to respawn. Authoring details in `references/planner.md` → `measurements[]`. + +## Reading handoffs + +For each new `handoffs/*.md`: + +1. **Status** other than `success`: decide whether to retry, repair, or clarify via a follow-up task. +2. **Branch**: note it; reference it if another task needs to build on it. +3. **What I did**: treat as fact, but skim for claims that don't match your expectations. +4. **Notes / concerns / deviations / findings / thoughts / feedback**: the richest section. Each bullet may become a new task. Worker feedback about scoping or task clarity is especially valuable: it tells you whether your plan's prompts are pulling their weight. +5. **Suggested follow-ups**: candidate tasks. Accept, reject, or consolidate. + +`Status: blocked` is a single task's dead end; the planner retries, repairs, or clarifies and the tree keeps moving. Use an Andon instead (see `references/planner.md` → Failure recovery) only when continued spawning across the tree would waste effort. + +## Synthetic failure handoffs + +When a worker dies without writing its own handoff (cap-hit, OOM, tool-error, network drop, uncaught SDK error), the script writes `handoffs/-failure.md` so the planner sees a postmortem instead of silence. The loop then returns exit code 1 with a checkpoint sync so the planner can react immediately. + +```markdown + + +# — failure handoff + +Status: error (cloud agent terminated without writing a handoff) +Failure mode: cap-hit | oom | network-drop | tool-error | unknown +Cloud agent: bc-... +Started: +Terminated: +Duration: +Last activity: +Last tool call: +Branch: +SDK error: + +## Suggested next steps +- +``` + +Classifier heuristics: +- `cap-hit` when duration is 70–80 minutes and the run is terminal-error +- `oom` when output or SDK error contains `out of memory` / `OOMKilled` / `exit code 137` +- `network-drop` when the SDK error matches `fetch failed` / `ETIMEDOUT` / `ECONN` / `socket` / `dns` / `disconnect` +- `tool-error` when the SDK error mentions `tool_use_failed` or `tool-error` +- `unknown` otherwise + +Default retry strategy by mode: +- `cap-hit` / `oom`: retry with smaller scope +- `network-drop`: retry as-is (treat as transient) +- `tool-error`: retry with a different `model` +- `unknown`: retry as-is once, then abandon + +After 2 retries on the same task, prefer abandon (drop from `plan.json`, replan around it) over a 3rd attempt unless you have specific evidence the next retry will succeed. + +## Finished-without-handoff sidecar + +When a run ends with `status=finished` but the body has no `## Status` heading, the script writes `handoffs/-finished-no-handoff.md` alongside the raw `.md`. Treat the raw body as the worker's intent; retry if it looks recoverable, abandon if not. + +## Upstream handoffs in downstream prompts + +Workers live on sibling branches and **cannot read each other's branches at runtime**. If a downstream task depends on an upstream task's output, the planner must relay it. + +The script handles the relay. When spawning a task whose `dependsOn` includes handed-off tasks, it pastes each upstream handoff body into the downstream prompt. Preview with `bun cli.ts prompt ` before spawning. + +Consequences: + +- `dependsOn` is semantically meaningful, not just a scheduling gate. Use it whenever a downstream task needs upstream findings, even if Git-level ordering doesn't strictly require it. +- Undeclared `dependsOn` + needed upstream context = the worker guesses. +- Long fan-ins inflate prompt size. If it gets unwieldy, push summarization down into upstream handoffs rather than bloating the downstream prompt. +- Handoffs render verbatim: sloppy `## What I did` sections pollute every downstream task. The format is a shared-context commons; respect it. + +## Producing your own handoff (subplanner) + +A subplanner's final message is its handoff to its parent. Aggregate children upward; don't forward raw child handoffs. The parent has more global context but less local detail. + +| Field | Rule | +|-------|------| +| `Status` | `success` only if every acceptance criterion is met. `partial` if any child was partial. `blocked` if any hard blocker remains. | +| `Branch` | The actual deliverable branch you are handing up, not your bookkeeping branch (`orch//`). Usually it is the last merge-task's output within your subtree. After orphan recovery, or if an integration worker merged into a child's branch, the deliverable may be that child branch instead. Downstream tasks build on whatever you name here, so name the real deliverable explicitly. | +| `What my subtree did` | One bullet per meaningful slice, not per child. The parent cares about work, not your org chart. | +| `Notes / concerns` | Surface anything a sibling subtree or the root might collide with. Silence on real risk is worse than redundant trivia. | +| `Suggested follow-ups` | Tasks for your parent's scope, not yours. | + +## Verifier handoffs + +A verifier's final message is a verdict on one target task's acceptance criteria. It is not an implementation summary. + +```markdown +## Verification + + +## Target +`` on branch `` + +## Branch +`` (or "(no branch)" if you committed nothing) + +## Execution +- +- +- +(list every meaningful thing you actually ran; this section is what distinguishes a real verification from pattern-matching) + +## Findings +Per acceptance criterion: +- [x] : (met | not met | n/a) +Other findings (severity-ordered): +- (high) : evidence +- (med) : evidence +- (low) : evidence + +## Notes & suggestions +- +``` + +`## Verification` is parsed by the script and persisted on the *target* task's state row (`tasks[].verification` in `state.json`) so post-run classifiers bucket "fixed-and-verified" by quality instead of treating every non-failure as equivalent. Authoritative definitions live in `prompts/verifier.md`. Short version: + +| Value | Meaning | Planner response | +|---|---|---| +| `live-ui-verified` | Verifier reproduced the bug live and confirmed the fix removes it. | Trust as shipped; no follow-up unless other findings surfaced. | +| `unit-test-verified` | Targeted test exercises the changed code path and passes. | Acceptable for non-UI bugs. For UI bugs, follow up with a `live-ui-verified` pass once env permits. | +| `type-check-only` | Only type-check / build passes. | Weak; only sufficient for typing-only changes. Anything behavioral needs a stronger verifier. | +| `verifier-blocked` | Verifier hit env failures (Docker rate limit, ports, missing creds). | Fix may be correct but unproven. Re-spawn the verifier once the env is healthy, or escalate. Don't count as verified. | +| `verifier-failed` | Verifier ran and the fix did not resolve the bug. | Follow-up fix task, not auto-respawn. | + +Workers and subplanners may also write a `## Verification` line to self-report their own evidence. A later verifier overrides that self-report on the same target row. + +The script also accepts the legacy `## Verdict pass | fail | inconclusive` shape and migrates it to the most conservative new value: `pass` → `type-check-only`, `fail` → `verifier-failed`, `inconclusive` → `verifier-blocked`. New verifier prompts emit `## Verification` directly. + +Publish verifiers explicitly in `plan.json`: + +```json +{ + "name": "frontend-toggle", + "type": "worker", + "scopedGoal": "Add a Settings → Appearance toggle that persists `editor.experimentalDarkMode` through the existing settings service.", + "pathsAllowed": ["packages/ui/src/settings/**"], + "acceptance": [ + "Toggle renders in Settings → Appearance", + "Toggling on persists `editor.experimentalDarkMode=true`", + "Reloading Settings shows the persisted toggle state" + ], + "verify": "## Setup\n- Start the Settings UI dev environment with the existing repo workflow.\n\n## Automated\n- Run the focused Settings UI test that covers Appearance settings persistence.\n\n## Manual\n- Open Settings → Appearance, toggle dark mode on, reload Settings, and confirm the toggle remains on.\n\n## Gotchas\n- Make sure the test account starts with no existing `editor.experimentalDarkMode` override." +}, +{ + "name": "verify-frontend-toggle", + "type": "verifier", + "verifies": "frontend-toggle", + "scopedGoal": "Verify the Settings → Appearance toggle works against every acceptance criterion by running the UI test or manually exercising the screen.", + "acceptance": ["Verification section includes execution evidence for all frontend-toggle acceptance criteria"] +} +``` + +## Merges are tasks + +Because planners don't code, merges happen via tasks. Publish a worker whose `scopedGoal` names both branches and the resolution policy, with `dependsOn` gating both siblings: + +```json +{ + "name": "merge-frontend-and-theme", + "type": "worker", + "scopedGoal": "Merge `orch/dark-mode/frontend-toggle` into the current branch. On conflict in `packages/ui/src/settings/Settings.tsx`, prefer frontend-toggle's hook wiring. After merge, verify `pnpm -w typecheck` passes.", + "startingRef": "orch/dark-mode/theme-system", + "dependsOn": ["frontend-toggle", "theme-system"], + "acceptance": ["Merge committed with both parents in history", "pnpm -w typecheck passes"] +} +``` + +Its handoff tells you whether the merge succeeded, whether conflicts were non-obvious, and whether acceptance held. Treat it like any other handoff. + +## Continuous motion + +A planner isn't strictly "done" while children might still produce handoffs. If one arrives after you've summarized or sent your handoff up: + +- Read it. +- If it changes your conclusion, say so: "one more worker just came back with X, revising". +- Publish follow-ups if needed. +- Produce a fresh handoff / summary. + +Hard stop only when you've decided to stop publishing and every in-flight task is terminal. diff --git a/orchestrate/skills/orchestrate/references/planner.md b/orchestrate/skills/orchestrate/references/planner.md new file mode 100644 index 0000000..d8ea8fe --- /dev/null +++ b/orchestrate/skills/orchestrate/references/planner.md @@ -0,0 +1,155 @@ +Operating manual for root and subplanners. Dispatchers read `dispatcher.md`. + +# Planner + +Root and subplanners behave the same way. The root reports to the user; a subplanner reports to its parent. + +## Prerequisites + +Load before acting: + +1. Load the [cursor-sdk plugin](https://github.com/cursor/plugins/tree/main/cursor-sdk) for auth, spawning, and `CursorAgentError` vs. `RunResult.status === "error"`. + +Scripts expect `bun` on PATH. Install dependencies with `bun install` inside this skill's `scripts/` directory. + +Regenerate `schemas/*.json` from `scripts/schemas.ts` with `bun run generate-schemas` in `scripts/` after plan or state shape changes. + +Slack visibility uses `SLACK_BOT_TOKEN`. Required scopes: + +- `chat:write` — post and edit messages. +- `chat:write.customize` — set custom username and icon on bot messages. +- `chat:write.public` — post in public channels without joining first. +- `files:write` — upload handoff artifacts. +- `files:read` — paired with `files:write` for the upload v2 flow. +- `reactions:read` — watch the Andon `:rotating_light:` reaction on the kickoff message. +- `channels:history` — read thread replies via `conversations.replies`. Add `groups:history` instead if the run thread lives in a private channel. + +Optional: + +- `users:read.email` — best-effort first-name lookup against the dispatcher's git email. Without it, pass `--dispatcher-name` explicitly. + +Until those scopes land, Slack calls fail with Slack's `missing_scope` error in `attention.log`. The run still proceeds because git and disk are authoritative. + +## Source of truth + +Git and disk are the substrate. + +- `plan.json` carries the task graph, Slack config, repo URL, and model choices. +- `state.json` carries task status, agent/run ids, branch names, and Slack message timestamps. +- `handoffs/*.md` carries worker and verifier output. +- `attention.log` carries operator-visible failures and decisions. + +Slack is human visibility, not task state. The script posts one kickoff message, mirrors task status in that thread, and reads `:rotating_light:` on the kickoff message for Andon. After kickoff, Slack writes stay in the run thread; the adapter requires `threadTs` for those writes. If Slack is down, orchestration correctness does not change. + +Orchestrate owns Slack status mirrors, Andon, and the comment retry queue. Agents can still call MCPs directly for Linear, GitHub, Slack, Notion, and other ad-hoc external work. Those systems are not orchestrate destinations. + +## Phase 1: publish tasks + +Write `plan.json` at ``; the default workspace is `.orchestrate//`. + +```json +{ + "$schema": "/schemas/plan.schema.json", + "goal": "", + "summary": "ship the dark-mode toggle end to end", + "rootSlug": "dark-mode", + "baseBranch": "main", + "repoUrl": "https://github.com/example-org/example-repo", + "tasks": [ + { + "name": "frontend-toggle", + "type": "worker", + "scopedGoal": "Add a Settings UI toggle that flips `useDarkMode` in localStorage.", + "pathsAllowed": ["packages/ui/src/settings/**"], + "acceptance": ["Toggle renders in Settings > Appearance"] + } + ] +} +``` + +`summary` is for the human in the Slack thread; `goal` is the agent's full context. Kickoff falls back to a truncated `goal` when `summary` is unset. + +On the first `run --root`, the script uses `plan.slackChannel` for the Slack kickoff and writes `plan.slackKickoffRef`. The root plan gets `slackChannel` from `kickoff --slack-channel`, `run --root --slack-channel`, or `SLACK_CHANNEL_ID`. Subplanners inherit both fields so the whole tree mirrors into one thread. + +Planning rules: + +- Merges are tasks. Publish a worker whose `scopedGoal` says which branches to merge, conflict intent, and verification. +- Prefer a worker unless you can name the decomposition a subplanner would do. +- One worker can carry a lot. Workers and verifiers are full cloud agents with hours of runtime: multi-file slices, multi-step refactors, full repro/fix/test cycles all fit in a single spawn. Each spawn costs cloud-agent runtime, Slack noise, and your own coordination overhead. Default to fewer, broader workers; reach for finer granularity only when a slice is genuinely independent or has real contention risk. +- Default to verifiers. Use `type: "verifier"` and `verifies: ""`. +- Use `verify` for the concrete check recipe. Workers read it as target behavior; verifiers inherit it from their target. +- Set `openPR: true` only for independent worker tasks you want shipped as their own draft PRs. +- Add `measurements[]` for quantitative claims. The script reruns each command on the worker branch after handoff and logs drift. +- Keep fan-in small. If a task needs many upstream handoffs, publish an aggregation worker first. +- Minimize path overlap. List forbidden paths when sibling ownership matters. +- Put task specs in `plan.tasks[]`. Put shared artifacts in git and reference them by path. +- Use the `comment` CLI for Slack notes routed through the retry queue. Use `--criticality required` only for messages that must land. For non-Slack destinations, agents call the relevant MCP directly. + +## Phase 2: drive the workspace + +All operator actions go through `scripts/cli.ts`. + +```bash +bun /scripts/cli.ts [...] +``` + +`run` spawns pending tasks whose dependencies are satisfied, waits for handoffs, writes handoffs, and repeats until no more progress is possible. Exit code `0` means clean completion. Exit code `100` means a planned checkpoint restart; rerun the same command. Other nonzero codes mean read `state.json` and `attention.log`. + +Do not detach `run`. The script is the heartbeat for state, handoffs, Slack mirrors, retry-queue draining, and Andon polling. When it exits, call `tree`. If any task is still `pending` or `running`, run the loop again. + +`state.json` is the source of truth. Inspect with `tree`, `list`, and `status`. + +## Comments + +The `comment` CLI is Slack-only and never posts the kickoff. Pass `--task ` to validate task context and resolve the run thread, or pass `--thread-ts ` explicitly. + +Examples: + +```bash +bun cli.ts comment "worker-one is blocked on auth" --task worker-one --workspace .orchestrate/root +bun cli.ts comment "no-repro on the upstream report; need a Linear ticket filed before retrying" --thread-ts 1714500000.000100 --criticality required --workspace .orchestrate/root +``` + +`--workspace` is required with `--task` and for non-operator file uploads. Operators outside a run enable operator mode with a current-user-owned `~/.orchestrate/operator-mode` file set to `0600`. Workers are assumed unable to write the operator's OS home directory. + +Required comments use `comment-retry-queue.json` with the existing backoff schedule. + +For external trackers (Linear, GitHub, on-call paging), agents call the relevant MCP directly. Orchestrate does not route those systems. + +## Failure recovery + +Script handles mechanical liveness. Planner handles meaning. + +- Transient spawn failures retry inside `spawnTask`. +- Restarted loops reattach to running tasks via `recoverRunning`. +- `RunResult.status === "error"` or a blocked handoff is a planner decision: respawn, split, escalate, or drop. +- Downstream tasks stay `pending` when an upstream fails. Fix the upstream and rerun, or `kill` abandoned downstream work. +- Subplanner respawn clones from its own branch after the first attempt so committed child state and handoffs survive. +- `maxAttempts` caps automatic spawning. Bump it in the task definition only when another attempt is intentional. +- Planned checkpoint restarts commit state and handoffs before exiting `100`; rerun the same command. + +### Andon + +Andon pauses new spawns across the tree. The root polls the Slack kickoff message for `:rotating_light:`. Children read the cached root state through git via `plan.andonStateRef` and `plan.andonStatePath`. + +```bash +bun /scripts/cli.ts andon raise --reason "" --workspace +bun /scripts/cli.ts andon clear --workspace [--note ""] +``` + +`--reason` is required on `raise`. The root polls the Slack kickoff message for `:rotating_light:` and scans the most recent matching `🚨 ANDON RAISED ...: ` thread reply, then writes the truncated reason into `state.andon.reason`. Children read that cached state via git, so they see *why* orchestration paused without calling Slack themselves. Andon state is operator-typed, capped at 500 chars, and lives in the same trust circle as the rest of `state.json`. + +Raise Andon only when continued spawning will produce garbage for the tree: bad upstream output, broken acceptance, or unrecoverable auth/infra. A task's own snag belongs in its handoff, not Andon. + +## Finding agents + +Use `bun cli.ts tree ` and `bun cli.ts list ` for lineage, status, and agent IDs. Do not rely on cloud-agent display titles. + +`syncStateToGit` defaults on so remote observers can read `state.json` and handoffs from git. Set it to `false` when those artifacts should stay local. + +```bash +bun cli.ts crawl +bun cli.ts kill-tree [-y] [--agent-id ] +``` + +Both commands walk `.orchestrate//state.json`; every subplanner row recurses into `orch//`. diff --git a/orchestrate/skills/orchestrate/references/spawning.md b/orchestrate/skills/orchestrate/references/spawning.md new file mode 100644 index 0000000..5412a44 --- /dev/null +++ b/orchestrate/skills/orchestrate/references/spawning.md @@ -0,0 +1,79 @@ +# Spawning tasks + +Contract between `plan.json` entries and the cloud agents the script spawns. Mechanics (auth, `CursorAgentError` vs `RunResult.status === "error"`) live in `cursor-sdk/SKILL.md`. Read that first. + +## Branch naming + +Cloud agents own their working branch. `state.json` starts with a deterministic placeholder (`orch//`) so pre-spawn state is readable, then replaces it with the branch reported by `Run.git.branches[].branch` after handoff. Kebab-case is enforced (`TASK_NAME_RE` in `orchestrate.ts`) so task names still feed filesystem paths and prompt text without escaping. No auto-managed integration branch. Branches live independently until a merge task consolidates them (see `handoffs.md` → "Merges are tasks"). + +Do not ask workers to create or rename branches to match the placeholder. If a downstream task needs an upstream task's code, depend on that upstream task so the script can wait for handoff and use the recorded actual branch. + +## Agent naming + +Cloud agents are given a `name` at `Agent.create` time so the Cursor agent list (`cursor.com/agents`, IDE agent list) groups a single orchestrate run together and stays readable across dozens of concurrent children. + +| Spawn site | Agent name | +|------------|------------| +| Root planner (`cli.ts kickoff`) | `` | +| Worker / subplanner / verifier (`spawnTask`) | `/` — echoes the task's branch without the `orch/` prefix | +| Model catalog probe (`probe-models`) | `probe: ` | + +The server caps names at 100 chars and rejects empty/whitespace-only values; the helpers handle both. When `name` is omitted the cloud backend auto-generates one from the first prompt, so this is purely a readability upgrade — dropping a name doesn't change behavior. + +## Starting refs and dependencies + +| Field | Controls | Default | Pair with | +|-------|----------|---------|-----------| +| `startingRef` | Which branch the cloud agent clones from | `plan.baseBranch` | `dependsOn` when depending on another task's work | +| `dependsOn` | When the task is allowed to spawn | `[]` | The script records the upstream branch from `Run.git` after handoff | + +`startingRef` without `dependsOn` gives a point-in-time snapshot: whatever commits exist on that branch at spawn time, which may be nothing. Pair them unless you really mean "start from the current tip, even if empty". + +Verifiers default `startingRef` to their target's branch and auto-include the target in `dependsOn`; the planner doesn't need to wire either explicitly. + +Any task can set `verify`: a Markdown-formatted plan (setup, automated, manual, gotchas). Workers see it as a target spec; verifiers inherit the target's `verify` as their recipe. + +## Spawn design decisions + +The script calls `Agent.create` with two deliberate defaults: + +- **PRs are opt-in per task.** `autoCreatePR` on the cloud-agent create call mirrors the task's `openPR` flag (default false). The server-side `cloud_agent_pr_control` gate makes that flag a no-op today, so the real mechanism is the worker prompt: when `openPR: true`, the worker is instructed to open a draft PR against `plan.prBase ?? plan.baseBranch` via the ManagePullRequest tool after pushing. Subplanners and verifiers never open PRs; they hand off to their parent. Task-driven PRs give the planner a specific guarantee: this task ships as its own pull request. +- **PR base vs. worker starting ref.** `plan.baseBranch` is the starting ref for workers that don't specify their own. `plan.prBase` (optional, defaults to `baseBranch`) is where openPR workers aim their PRs. Split when you want workers to inherit planner-side setup from the planner's branch but still open PRs against `main` (so each worker PR is mergeable without the planner's branch landing first). Leave `prBase` unset for the classic pattern where worker PRs stack on the planner's branch. +- **No shared integration branch.** Each task is its own island until a merge task consolidates them. An auto-managed integration branch would smuggle planner-level coding decisions into infrastructure, violating Core Principle #1. + +## Task prompt contract + +The script renders prompts from `scopedGoal`, `pathsAllowed`, `pathsForbidden`, `acceptance`, `startingRef`, `type`, and `openPR`. Fix the plan entry if the prompt doesn't match your intent. Don't patch the prompt template. Every spawned prompt tells the agent: + +- It's isolated. No communication with other agents. +- Commit to the current cloud-agent branch and push. No branch renames, merges, or rebases. Open a draft PR only if the task sets `openPR: true` (workers only; subplanners and verifiers never open PRs). +- Final message is the handoff in the structure from `handoffs.md`. That's the only thing the planner reads. + +Subplanner prompts also start with `/orchestrate` so the skill loads automatically when the agent boots. + +Workers cannot ask clarifying questions mid-run. Under-specified `scopedGoal` produces silent drift. Write each task as if you'll never get another chance to steer it. + +Cloud-agent VMs may redact environment variable values as a prompt-injection defense, so do not rely on env vars for data multiple agents must share. Use the planner-authored artifact pattern instead: commit a file to the base branch and reference it by path in `scopedGoal` so clones pick it up. Never paste credentials into `scopedGoal`; it is sent to the model provider and may end up in git history when state sync is on. + +## Slack visibility + +When `SLACK_BOT_TOKEN` is set, Slack traffic is owned by the script. Agents do not drive lifecycle. The script posts the kickoff thread to `plan.slackChannel`, records the result in `plan.slackKickoffRef`, mirrors task status messages in that thread, and reads `:rotating_light:` on the kickoff message for Andon. + +Spawn prompts still include a Slack block because workers may need to leave notes: + +- `comment "" --thread-ts --workspace ` posts a note through the retry queue when silence would hide useful context. `--task ` with `--workspace` is also accepted; the CLI validates the task and posts in the run thread. +- For Linear, GitHub, or other external systems, call the relevant MCP directly. Orchestrate's `comment` CLI is Slack-only. + +If Slack comments fail, keep working and say what happened in the handoff. Disk handoffs are still authoritative for downstream prompt assembly. + +## Tracking & recovery + +After each successful spawn the script persists `agentId`, `runId`, and `parentAgentId` to `state.json`, so a later rerun can re-attach via `Agent.getRun` and read lineage from disk. A row with partial identity (exactly one of `agentId` / `runId`) on restart is marked `error` with an explanatory note. Rename and respawn, or prune. + +After every handoff the script reconciles dependent verifiers' `startingRef`. The actual worker branch is sourced from the handoff body's `## Branch` line — the SDK leaves `Run.git.branches[].branch` empty for worker runs, so the body is authoritative. Any verifier whose `verifies` points at the just-handed-off task and whose `startingRef` is still the `orch//` placeholder is updated to that real branch. Planner-authored `startingRef` overrides win; each propagation logs to `attention.log`. Load also sweeps over already-handed-off rows so state recovered from disk converges before the next spawn. + +## Lineage + +Each `state.tasks[]` row's `parentAgentId` is the spawning planner's cloud agent id from `plan.selfAgentId` at spawn. `kill-tree --agent-id` walks those parent links downward. If the planner never set `selfAgentId`, children get `parentAgentId: null` and that subtree is skipped for a scoped kill; omit `--agent-id` to cancel the whole tree. + +Spawn templates use the child's id as `{{selfAgentId}}` and the parent's as `{{parentAgentId}}`. The id from `Agent.create` is valid before `send()`; the cloud client sends it at create time and rejects a mismatched server response. diff --git a/orchestrate/skills/orchestrate/schemas/plan.schema.json b/orchestrate/skills/orchestrate/schemas/plan.schema.json new file mode 100644 index 0000000..9bf9b39 --- /dev/null +++ b/orchestrate/skills/orchestrate/schemas/plan.schema.json @@ -0,0 +1,598 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor/orchestrate/plan.schema.json", + "title": "orchestrate plan.json", + "description": "Input to scripts/orchestrate.ts: planner-authored JSON consumed by the loop script.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Optional editor validation schema path." + }, + "goal": { + "type": "string", + "minLength": 1, + "description": "User goal, verbatim at every planner depth. Agent-facing full context." + }, + "summary": { + "type": "string", + "minLength": 1, + "description": "One-line orientation for the human reading the Slack run thread. Kickoff falls back to a truncated `goal` when unset." + }, + "dispatcher": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "minLength": 1, + "description": "Kickoff bot username (`'s bot`). Resolved by the dispatcher CLI from --dispatcher-name or Slack lookupByEmail; planners don't author it." + } + }, + "required": [ + "firstName" + ], + "additionalProperties": false, + "description": "Who launched this run. Set by the dispatcher CLI." + }, + "rootSlug": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Kebab-case ASCII slug used in branch names." + }, + "baseBranch": { + "type": "string", + "minLength": 1, + "description": "Default startingRef for tasks that don't specify their own." + }, + "prBase": { + "type": "string", + "minLength": 1, + "description": "PR base for tasks with openPR (defaults to baseBranch)." + }, + "repoUrl": { + "type": "string", + "format": "uri", + "description": "GitHub URL of the repo cloud agents should operate on." + }, + "acceptanceCriteria": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Planner-level acceptance checklist." + }, + "syncStateToGit": { + "type": "boolean", + "default": true, + "description": "Commit and push plan/state/handoffs on status transitions." + }, + "slackChannel": { + "type": "string", + "minLength": 1, + "description": "Slack channel id for run visibility. Set from --slack-channel or SLACK_CHANNEL_ID by kickoff or the first root run." + }, + "slackKickoffRef": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "minLength": 1 + }, + "ts": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "channel", + "ts" + ], + "additionalProperties": false, + "description": "Root Slack message for the run thread. Set by the script after the first kickoff post; planners do not author it." + }, + "andonStateRef": { + "type": "string", + "description": "Git ref whose state.json carries the root-polled Andon state." + }, + "andonStatePath": { + "type": "string", + "description": "Repo-relative path to the root state.json carrying Andon state." + }, + "selfAgentId": { + "type": "string", + "description": "This planner's cloud agent id." + }, + "tasks": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Kebab-case ASCII. Used in branch and agent-title." + }, + "scopedGoal": { + "type": "string", + "minLength": 1, + "description": "Outcome for this task; write it as the only steering signal." + }, + "brief": { + "type": "string", + "description": "Markdown spec inlined into the spawn prompt." + }, + "pathsAllowed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns the task may touch." + }, + "pathsForbidden": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns owned by siblings." + }, + "acceptance": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-task acceptance checklist." + }, + "verify": { + "type": "string", + "description": "Optional markdown verification plan." + }, + "startingRef": { + "type": "string", + "description": "Branch the spawned cloud agent clones from." + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "description": "Task names to wait on before spawning." + }, + "model": { + "type": "string", + "description": "Model id for the spawned cloud agent." + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "description": "Max logical spawn attempts." + }, + "openPR": { + "type": "boolean", + "description": "Open a PR when the task completes." + }, + "measurements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Identifier matched against the worker's `## Measurements` block, e.g. `LOC(packages/ui/src/Settings.tsx)` or `bundle size`. Must match the line prefix verbatim." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Shell command executed under `bash -c` in a fresh checkout of the worker's branch." + }, + "parser": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "wc-l" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regex" + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "JavaScript regex applied to stdout. Capture group 1 is the value; if the regex has no capture group, the full match is used." + }, + "flags": { + "type": "string", + "pattern": "^[gimsuy]*$", + "description": "RegExp flags (default: empty)." + } + }, + "required": [ + "kind", + "pattern" + ], + "additionalProperties": false + } + ], + "description": "How to extract a value from the command's stdout. Defaults to `wc-l` (count non-empty lines)." + }, + "toleranceFraction": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fractional drift tolerated for numeric comparisons (e.g. 0.10 = 10%). Defaults to 0.10. String values must match exactly." + } + }, + "required": [ + "name", + "command" + ], + "additionalProperties": false + }, + "description": "Quantitative checks the script re-runs against the worker's branch after handoff to catch drift between the worker's `## Measurements` self-report and the actual artifact." + }, + "slackTs": { + "type": "string", + "description": "Slack thread message ts for this task, set by the script." + }, + "type": { + "type": "string", + "const": "worker" + }, + "verifies": { + "not": {} + } + }, + "required": [ + "name", + "scopedGoal", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Kebab-case ASCII. Used in branch and agent-title." + }, + "scopedGoal": { + "type": "string", + "minLength": 1, + "description": "Outcome for this task; write it as the only steering signal." + }, + "brief": { + "type": "string", + "description": "Markdown spec inlined into the spawn prompt." + }, + "pathsAllowed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns the task may touch." + }, + "pathsForbidden": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns owned by siblings." + }, + "acceptance": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-task acceptance checklist." + }, + "verify": { + "type": "string", + "description": "Optional markdown verification plan." + }, + "startingRef": { + "type": "string", + "description": "Branch the spawned cloud agent clones from." + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "description": "Task names to wait on before spawning." + }, + "model": { + "type": "string", + "description": "Model id for the spawned cloud agent." + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "description": "Max logical spawn attempts." + }, + "openPR": { + "type": "boolean", + "description": "Open a PR when the task completes." + }, + "measurements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Identifier matched against the worker's `## Measurements` block, e.g. `LOC(packages/ui/src/Settings.tsx)` or `bundle size`. Must match the line prefix verbatim." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Shell command executed under `bash -c` in a fresh checkout of the worker's branch." + }, + "parser": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "wc-l" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regex" + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "JavaScript regex applied to stdout. Capture group 1 is the value; if the regex has no capture group, the full match is used." + }, + "flags": { + "type": "string", + "pattern": "^[gimsuy]*$", + "description": "RegExp flags (default: empty)." + } + }, + "required": [ + "kind", + "pattern" + ], + "additionalProperties": false + } + ], + "description": "How to extract a value from the command's stdout. Defaults to `wc-l` (count non-empty lines)." + }, + "toleranceFraction": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fractional drift tolerated for numeric comparisons (e.g. 0.10 = 10%). Defaults to 0.10. String values must match exactly." + } + }, + "required": [ + "name", + "command" + ], + "additionalProperties": false + }, + "description": "Quantitative checks the script re-runs against the worker's branch after handoff to catch drift between the worker's `## Measurements` self-report and the actual artifact." + }, + "slackTs": { + "type": "string", + "description": "Slack thread message ts for this task, set by the script." + }, + "type": { + "type": "string", + "const": "subplanner" + }, + "verifies": { + "not": {} + } + }, + "required": [ + "name", + "scopedGoal", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Kebab-case ASCII. Used in branch and agent-title." + }, + "scopedGoal": { + "type": "string", + "minLength": 1, + "description": "Outcome for this task; write it as the only steering signal." + }, + "brief": { + "type": "string", + "description": "Markdown spec inlined into the spawn prompt." + }, + "pathsAllowed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns the task may touch." + }, + "pathsForbidden": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns owned by siblings." + }, + "acceptance": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Per-task acceptance checklist." + }, + "verify": { + "type": "string", + "description": "Optional markdown verification plan." + }, + "startingRef": { + "type": "string", + "description": "Branch the spawned cloud agent clones from." + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "description": "Task names to wait on before spawning." + }, + "model": { + "type": "string", + "description": "Model id for the spawned cloud agent." + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "description": "Max logical spawn attempts." + }, + "openPR": { + "type": "boolean", + "description": "Open a PR when the task completes." + }, + "measurements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Identifier matched against the worker's `## Measurements` block, e.g. `LOC(packages/ui/src/Settings.tsx)` or `bundle size`. Must match the line prefix verbatim." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Shell command executed under `bash -c` in a fresh checkout of the worker's branch." + }, + "parser": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "wc-l" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regex" + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "JavaScript regex applied to stdout. Capture group 1 is the value; if the regex has no capture group, the full match is used." + }, + "flags": { + "type": "string", + "pattern": "^[gimsuy]*$", + "description": "RegExp flags (default: empty)." + } + }, + "required": [ + "kind", + "pattern" + ], + "additionalProperties": false + } + ], + "description": "How to extract a value from the command's stdout. Defaults to `wc-l` (count non-empty lines)." + }, + "toleranceFraction": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fractional drift tolerated for numeric comparisons (e.g. 0.10 = 10%). Defaults to 0.10. String values must match exactly." + } + }, + "required": [ + "name", + "command" + ], + "additionalProperties": false + }, + "description": "Quantitative checks the script re-runs against the worker's branch after handoff to catch drift between the worker's `## Measurements` self-report and the actual artifact." + }, + "slackTs": { + "type": "string", + "description": "Slack thread message ts for this task, set by the script." + }, + "type": { + "type": "string", + "const": "verifier" + }, + "verifies": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Name of the task this verifier checks." + } + }, + "required": [ + "name", + "scopedGoal", + "type", + "verifies" + ], + "additionalProperties": false + } + ] + }, + "description": "Planner-authored task definitions." + } + }, + "required": [ + "goal", + "rootSlug", + "baseBranch", + "repoUrl" + ], + "additionalProperties": false +} diff --git a/orchestrate/skills/orchestrate/schemas/state.schema.json b/orchestrate/skills/orchestrate/schemas/state.schema.json new file mode 100644 index 0000000..5bd7781 --- /dev/null +++ b/orchestrate/skills/orchestrate/schemas/state.schema.json @@ -0,0 +1,297 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor/orchestrate/state.schema.json", + "title": "orchestrate state.json", + "description": "Written by scripts/orchestrate.ts. Live task rows; read-only unless you must edit by hand to unstick state.", + "type": "object", + "properties": { + "rootSlug": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "type": { + "type": "string", + "enum": [ + "worker", + "subplanner", + "verifier" + ] + }, + "branch": { + "type": "string" + }, + "startingRef": { + "type": "string" + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + } + }, + "agentId": { + "type": [ + "string", + "null" + ], + "default": null + }, + "runId": { + "type": [ + "string", + "null" + ], + "default": null + }, + "parentAgentId": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Planner agent id that spawned this row." + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "handed-off", + "error", + "cancelled", + "pruned" + ], + "description": "pending -> running -> handed-off; error on hard failure; cancelled by operator; pruned if removed from plan.json." + }, + "resultStatus": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "The cloud run's RunResult.status." + }, + "handoffPath": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Relative path to the collected handoff markdown." + }, + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "default": null + }, + "finishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "default": null + }, + "lastUpdate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "default": null + }, + "note": { + "type": [ + "string", + "null" + ], + "default": null + }, + "adHoc": { + "type": "boolean", + "description": "True if this task was added outside plan.json." + }, + "attempts": { + "type": "integer", + "minimum": 0, + "description": "Count of logical spawn attempts." + }, + "slackTs": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Slack task message ts." + }, + "slackRendered": { + "type": "object", + "properties": { + "emoji": { + "type": "string" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "emoji", + "summary" + ], + "additionalProperties": false, + "description": "Last rendered Slack status tuple for no-op update guards." + }, + "prNumber": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pull request number opened by this task, when known." + }, + "failureMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "cap-hit", + "oom", + "tool-error", + "network-drop", + "unknown" + ] + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parsed terminal failure class for Slack and triage." + }, + "verification": { + "anyOf": [ + { + "type": "string", + "enum": [ + "live-ui-verified", + "unit-test-verified", + "type-check-only", + "verifier-blocked", + "verifier-failed", + "not-verified" + ] + }, + { + "type": "null" + } + ], + "default": null, + "description": "Verification quality claimed for this task's deliverable. Parsed from the handoff body's `## Verification` line on handoff (verifiers set this for their target's deliverable; workers and subplanners may self-report). Null until set." + } + }, + "required": [ + "name", + "type", + "branch", + "startingRef", + "dependsOn", + "status" + ], + "additionalProperties": false + } + }, + "attention": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "message": { + "type": "string" + } + }, + "required": [ + "at", + "message" + ], + "additionalProperties": false + } + }, + "andon": { + "type": "object", + "properties": { + "raisedAt": { + "type": "string", + "format": "date-time" + }, + "raisedBy": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "cleared": { + "type": "boolean" + }, + "clearedAt": { + "type": "string", + "format": "date-time" + }, + "clearedBy": { + "type": "string" + }, + "clearNote": { + "type": "string" + }, + "lastCheckedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "lastCheckedAt" + ], + "additionalProperties": false, + "description": "Current state of the Andon cord." + } + }, + "required": [ + "rootSlug", + "tasks", + "attention" + ], + "additionalProperties": false +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/agent-manager-slack-mirror.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/agent-manager-slack-mirror.test.ts new file mode 100644 index 0000000..7556ad9 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/agent-manager-slack-mirror.test.ts @@ -0,0 +1,558 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_SLACK_CHANNEL = "C123TEST"; +import type { State } from "../schemas.ts"; +import { + installSlackWebApiMock, + resetSlackWebApiMock, + slackWebApiCalls, +} from "./support/slack-web-api-mock.ts"; + +installSlackWebApiMock(); + +const { AgentManager } = await import("../core/agent-manager.ts"); + +const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY; +const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN; +process.env.CURSOR_API_KEY = "test-key"; +process.env.SLACK_BOT_TOKEN = "xoxb-test"; + +afterAll(() => { + if (ORIGINAL_API_KEY === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = ORIGINAL_API_KEY; + if (ORIGINAL_SLACK_TOKEN === undefined) { + delete process.env.SLACK_BOT_TOKEN; + } else { + process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN; + } +}); + +function readState(workspace: string): State { + return JSON.parse(readFileSync(join(workspace, "state.json"), "utf8")); +} + +function readPlan(workspace: string): Record { + return JSON.parse(readFileSync(join(workspace, "plan.json"), "utf8")); +} + +function requireTask( + task: State["tasks"][number] | undefined, + name: string +): State["tasks"][number] { + if (!task) throw new Error(`missing task: ${name}`); + return task; +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + for (let i = 0; i < 50; i++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error(`timed out waiting for ${label}`); +} + +describe("AgentManager Slack status mirror", () => { + test("Creates a task thread message then edits it in place", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-mirror-")); + resetSlackWebApiMock((method, args) => ({ + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "222.333", + })); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "mirror status", + rootSlug: "mirror-status", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: TEST_SLACK_CHANNEL, + slackKickoffRef: { channel: TEST_SLACK_CHANNEL, ts: "111.222" }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + const task = requireTask(mgr.getTask("worker-one"), "worker-one"); + + mgr.touch(task, { + agentId: "bc-child", + status: "running", + startedAt: new Date(Date.now() - 2 * 60_000).toISOString(), + }); + await waitFor( + () => readState(workspace).tasks[0]?.slackTs === "222.333", + "initial Slack mirror" + ); + + mgr.touch(task, { status: "handed-off" }); + await waitFor( + () => slackWebApiCalls().some(call => call.method === "chat.update"), + "Slack edit" + ); + + const calls = slackWebApiCalls(); + expect(calls.map(call => call.method)).toEqual([ + "chat.postMessage", + "chat.update", + ]); + expect(calls[0].args).toMatchObject({ + channel: TEST_SLACK_CHANNEL, + thread_ts: "111.222", + username: "worker-one", + text: "▶︎ running\nstarted 2m ago · ", + }); + expect(calls[0].args.icon_emoji).toBeUndefined(); + expect(calls[1].args).toMatchObject({ + channel: TEST_SLACK_CHANNEL, + ts: "222.333", + text: "✓ completed\n", + }); + expect(readState(workspace).tasks[0]?.slackTs).toBe("222.333"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("First-time kickoff posts to TEST_SLACK_CHANNEL with summary, dispatcher username, and agent footer", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-kickoff-")); + resetSlackWebApiMock((_method, args) => ({ + ok: true, + channel: args.channel, + ts: "100.001", + })); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "fresh kickoff: long agent-facing description that should not show up in slack verbatim", + summary: "smoke test of the new orchestrate substrate", + rootSlug: "fresh-kickoff", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + selfAgentId: "bc-root-planner", + dispatcher: { firstName: "Alex" }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + await AgentManager.load(workspace, { slackChannel: TEST_SLACK_CHANNEL }); + + const kickoff = slackWebApiCalls().find( + call => call.method === "chat.postMessage" + ); + expect(kickoff?.args.channel).toBe(TEST_SLACK_CHANNEL); + expect(typeof kickoff?.args.client_msg_id).toBe("string"); + expect(kickoff?.args.username).toBe("Alex's bot"); + expect(kickoff?.args.icon_url).toBeUndefined(); + expect(kickoff?.args.icon_emoji).toBeUndefined(); + expect(kickoff?.args.text).toBe( + "`fresh-kickoff`: smoke test of the new orchestrate substrate " + ); + expect(readPlan(workspace).slackKickoffRef).toEqual({ + channel: TEST_SLACK_CHANNEL, + ts: "100.001", + }); + expect(readPlan(workspace).slackChannel).toBe(TEST_SLACK_CHANNEL); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Child planner load does not create a top-level kickoff", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-child-no-kickoff-")); + resetSlackWebApiMock(() => { + throw new Error("child planner should not post kickoff"); + }); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "child planner", + rootSlug: "child-planner", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + andonStateRef: "main", + andonStatePath: ".orchestrate/root/state.json", + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + + await AgentManager.load(workspace); + + expect(slackWebApiCalls()).toHaveLength(0); + expect(readPlan(workspace).slackKickoffRef).toEqual({ + channel: "C123", + ts: "111.222", + }); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Child planner load fails when kickoff ref is missing", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-child-missing-ref-")); + resetSlackWebApiMock(() => { + throw new Error("child planner should not post kickoff"); + }); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "child planner", + rootSlug: "child-planner", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + andonStateRef: "main", + andonStatePath: ".orchestrate/root/state.json", + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + + await expect(AgentManager.load(workspace)).rejects.toThrow( + /child planner plan missing slackKickoffRef/ + ); + expect(slackWebApiCalls()).toHaveLength(0); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Re-mirrors when agentId lands after the initial running transition", async () => { + const workspace = mkdtempSync( + join(tmpdir(), "orch-slack-mirror-late-agentid-") + ); + let messageTs = 0; + resetSlackWebApiMock((method, args) => ({ + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : `666.${++messageTs}`, + })); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "late agentid", + rootSlug: "late-agentid", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { name: "worker-one", type: "worker", scopedGoal: "Do work." }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + const task = mgr.getTask("worker-one"); + if (!task) throw new Error("worker-one missing"); + + // Mimic spawnTask: status:"running" with agentId:null first, then a + // separate touch sets agentId:"bc-child" without changing status. + mgr.touch(task, { agentId: null, status: "running" }); + await waitFor( + () => + slackWebApiCalls().some(call => call.method === "chat.postMessage"), + "initial running mirror" + ); + mgr.touch(task, { agentId: "bc-child" }); + await waitFor(() => { + const update = slackWebApiCalls().find( + call => call.method === "chat.update" + ); + return Boolean( + update && String(update.args.text ?? "").includes("bc-child") + ); + }, "agentId-landed re-mirror"); + + const update = slackWebApiCalls().find( + call => call.method === "chat.update" + ); + expect(update?.args.text).toBe( + "▶︎ running\nstarted 0m ago · " + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Status mirror text includes the child agent's cursor.com footer", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-mirror-footer-")); + resetSlackWebApiMock((method, args) => ({ + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "555.666", + })); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "footer test", + rootSlug: "footer-test", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + const task = mgr.getTask("worker-one"); + if (!task) throw new Error("worker-one missing"); + // Pretend the spawn succeeded enough to have an agentId. + mgr.touch(task, { agentId: "bc-child", status: "running" }); + await waitFor( + () => + slackWebApiCalls().some(call => call.method === "chat.postMessage"), + "initial mirror" + ); + + const mirror = slackWebApiCalls().find( + call => call.method === "chat.postMessage" + ); + expect(mirror?.args.text).toBe( + "▶︎ running\nstarted 0m ago · " + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("No token: load works, slackAdapter undefined, attention log + console.error once", async () => { + const original = process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_BOT_TOKEN; + const errors: string[] = []; + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }) as typeof console.error; + resetSlackWebApiMock(() => { + throw new Error("unexpected Slack call when token unset"); + }); + + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-no-token-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "slack disabled", + rootSlug: "slack-disabled", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + expect(mgr.slackAdapter).toBeUndefined(); + expect(slackWebApiCalls()).toHaveLength(0); + expect( + errors.filter(line => line.includes("SLACK_BOT_TOKEN not set")) + ).toHaveLength(1); + expect(readState(workspace).attention).toEqual([]); + + const task = requireTask(mgr.getTask("worker-one"), "worker-one"); + mgr.touch(task, { status: "running" }); + mgr.touch(task, { status: "handed-off" }); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(slackWebApiCalls()).toHaveLength(0); + await mgr.andon.drainEvents(); + expect(mgr.andon.isActive()).toBe(false); + } finally { + rmSync(workspace, { recursive: true, force: true }); + console.error = originalConsoleError; + if (original === undefined) { + delete process.env.SLACK_BOT_TOKEN; + } else { + process.env.SLACK_BOT_TOKEN = original; + } + } + }); + + test("Serializes rapid status mirrors for one Slack task message", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-mirror-race-")); + resetSlackWebApiMock(async (method, args) => { + if (method === "chat.postMessage") { + await new Promise(resolve => setTimeout(resolve, 25)); + } + return { + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "222.333", + }; + }); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "mirror status", + rootSlug: "mirror-status", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + const task = requireTask(mgr.getTask("worker-one"), "worker-one"); + + mgr.touch(task, { status: "running" }); + mgr.touch(task, { status: "handed-off" }); + await waitFor( + () => slackWebApiCalls().some(call => call.method === "chat.update"), + "Slack edit after rapid transitions" + ); + + const calls = slackWebApiCalls(); + expect(calls.map(call => call.method)).toEqual([ + "chat.postMessage", + "chat.update", + ]); + expect(readState(workspace).tasks[0]?.slackTs).toBe("222.333"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Skips Slack update when rendered status tuple is unchanged", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-mirror-noop-")); + resetSlackWebApiMock((method, args) => ({ + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "222.333", + })); + + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "mirror status", + rootSlug: "mirror-status", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + const mgr = await AgentManager.load(workspace); + const task = requireTask(mgr.getTask("worker-one"), "worker-one"); + + mgr.touch(task, { + agentId: "bc-child", + status: "running", + startedAt: new Date().toISOString(), + }); + await waitFor( + () => + slackWebApiCalls().some(call => call.method === "chat.postMessage"), + "initial mirror" + ); + resetSlackWebApiMock((method, args) => ({ + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "222.333", + })); + + await ( + mgr as unknown as { + mirrorTaskToSlack(task: State["tasks"][number]): Promise; + } + ).mirrorTaskToSlack(task); + + expect(slackWebApiCalls()).toEqual([]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/andon-root-cache.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/andon-root-cache.test.ts new file mode 100644 index 0000000..9a38019 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/andon-root-cache.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { SlackAdapter, SlackMessageRef } from "../adapters/types.ts"; +import { AndonPoller, SlackReactionAndonSource } from "../core/andon.ts"; +import type { State } from "../schemas.ts"; + +function slackWithReactions( + reactions: { name: string; users: string[] }[], + threadReplies: { ts: string; text: string }[] = [] +): SlackAdapter { + return { + async postRunKickoff(): Promise { + throw new Error("not used"); + }, + async lookupFirstNameByEmail(): Promise { + return undefined; + }, + async postInThread(): Promise { + throw new Error("not used"); + }, + async editThreadMessage(): Promise { + throw new Error("not used"); + }, + async uploadFileToThread(): Promise<{ fileId: string; permalink: string }> { + throw new Error("not used"); + }, + async getReactions() { + return { reactions }; + }, + async getThreadReplies() { + return { messages: threadReplies }; + }, + async postCommentInThread(): Promise { + throw new Error("not used"); + }, + async addReaction(): Promise { + throw new Error("not used"); + }, + async removeReaction(): Promise { + throw new Error("not used"); + }, + }; +} + +describe("SlackReactionAndonSource", () => { + test("Reaction present returns active state", async () => { + const source = new SlackReactionAndonSource( + slackWithReactions( + [{ name: "rotating_light", users: ["U123"] }], + [ + { ts: "111.222", text: "orchestrate started" }, + { ts: "111.333", text: "unrelated" }, + { + ts: "111.444", + text: "🚨 ANDON RAISED by operator: upstream verifier is wrong", + }, + ] + ), + { channel: "C123", ts: "111.222" } + ); + + const state = await source.snapshot(); + + expect(state.active).toBe(true); + if (state.active) { + expect(state.raisedBy).toBe("U123"); + expect(state.reason).toBe("upstream verifier is wrong"); + expect(state.raisedAt).toBeTruthy(); + expect(state.lastCheckedAt).toBeTruthy(); + } + }); + + test("Reaction absent returns inactive snapshot", async () => { + const source = new SlackReactionAndonSource( + slackWithReactions([{ name: "eyes", users: ["U123"] }]), + { channel: "C123", ts: "111.222" } + ); + + await expect(source.snapshot()).resolves.toMatchObject({ + active: false, + lastCheckedAt: expect.any(String), + }); + }); + + test("Uses the newest Andon reason reply", async () => { + const calls: { limit: number; latest?: string }[] = []; + const source = new SlackReactionAndonSource( + { + ...slackWithReactions([{ name: "rotating_light", users: ["U123"] }]), + async getThreadReplies(args) { + calls.push({ limit: args.limit, latest: args.latest }); + return { + messages: [ + { ts: "111.222", text: "orchestrate started" }, + { + ts: "111.250", + text: "🚨 ANDON RAISED by older: first reason", + }, + { + ts: "111.260", + text: ":rotating_light: ANDON RAISED by newer: latest reason", + }, + ...Array.from({ length: 18 }, (_, index) => ({ + ts: `111.${200 + index}`, + text: `older reply ${index}`, + })), + ], + }; + }, + }, + { channel: "C123", ts: "111.222" } + ); + + const state = await source.snapshot(); + + expect(state).toMatchObject({ + active: true, + reason: "latest reason", + }); + expect(calls).toEqual([{ limit: 20, latest: expect.any(String) }]); + }); + + test("Keeps Andon active when reason reply fetch fails", async () => { + const source = new SlackReactionAndonSource( + { + ...slackWithReactions([{ name: "rotating_light", users: ["U123"] }]), + async getThreadReplies() { + throw new Error("slack_replies_unavailable"); + }, + }, + { channel: "C123", ts: "111.222" } + ); + + await expect(source.snapshot()).resolves.toMatchObject({ + active: true, + raisedBy: "U123", + }); + }); + + test("Strips the cursor.com observability footer from the parsed reason", async () => { + const source = new SlackReactionAndonSource( + slackWithReactions( + [{ name: "rotating_light", users: ["U123"] }], + [ + { ts: "111.222", text: "orchestrate started" }, + { + ts: "111.444", + text: "🚨 ANDON RAISED by operator: upstream verifier is wrong\n", + }, + ] + ), + { channel: "C123", ts: "111.222" } + ); + + const state = await source.snapshot(); + + if (!state.active) throw new Error("expected active andon snapshot"); + expect(state.reason).toBe("upstream verifier is wrong"); + expect(state.reason).not.toContain("cursor.com"); + }); +}); + +describe("AndonPoller root cache", () => { + test("Root polling refreshes lastCheckedAt while Andon stays raised", async () => { + const state: State = { + rootSlug: "root", + tasks: [], + attention: [], + }; + let saves = 0; + const saveReasons: (string | undefined)[] = []; + let checks = 0; + const poller = new AndonPoller({ + source: { + async snapshot() { + checks++; + return { + active: true, + raisedAt: "2026-04-30T00:00:00.000Z", + raisedBy: "U123", + lastCheckedAt: `2026-04-30T00:00:0${checks}.000Z`, + }; + }, + }, + getState: () => state, + saveState: reason => { + saves++; + saveReasons.push(reason); + }, + logAttention: () => {}, + pollSource: true, + }); + + await poller.drainEvents(); + const firstRaisedAt = state.andon?.raisedAt; + await poller.drainEvents(); + + expect(saves).toBe(2); + expect(saveReasons).toEqual(["andon state changed", undefined]); + expect(state.andon?.raisedAt).toBe(firstRaisedAt); + expect(state.andon?.lastCheckedAt).toBe("2026-04-30T00:00:02.000Z"); + }); + + test("Root polling refreshes lastCheckedAt after Andon is cleared", async () => { + const state: State = { + rootSlug: "root", + tasks: [], + attention: [], + andon: { + raisedAt: "2026-04-30T00:00:00.000Z", + raisedBy: "U123", + cleared: true, + clearedAt: "2026-04-30T00:00:01.000Z", + lastCheckedAt: "2026-04-30T00:00:01.000Z", + }, + }; + let saves = 0; + const poller = new AndonPoller({ + source: { + async snapshot() { + return { + active: false, + lastCheckedAt: "2026-04-30T00:00:02.000Z", + }; + }, + }, + getState: () => state, + saveState: () => { + saves++; + }, + logAttention: () => {}, + pollSource: true, + }); + + await poller.drainEvents(); + + expect(saves).toBe(1); + expect(state.andon?.clearedAt).toBe("2026-04-30T00:00:01.000Z"); + expect(state.andon?.lastCheckedAt).toBe("2026-04-30T00:00:02.000Z"); + }); + + test("Subplanners read cached state without polling Slack", async () => { + const state: State = { + rootSlug: "child", + tasks: [], + attention: [], + andon: { + raisedAt: "2026-04-30T00:00:00.000Z", + raisedBy: "root", + reason: "bad upstream", + cleared: false, + lastCheckedAt: "2026-04-30T00:00:00.000Z", + }, + }; + const poller = new AndonPoller({ + source: new SlackReactionAndonSource( + slackWithReactions([{ name: "rotating_light", users: ["U123"] }]), + { channel: "C123", ts: "111.222" } + ), + getState: () => state, + saveState: () => {}, + logAttention: () => {}, + pollSource: false, + }); + + await poller.drainEvents(); + + expect(poller.isActive()).toBe(true); + }); + + test("Subplanners sync cached root Andon state from git", async () => { + const repo = mkdtempSync(join(tmpdir(), "orch-andon-cache-")); + const origin = mkdtempSync(join(tmpdir(), "orch-andon-origin-")); + const workspace = join(repo, ".orchestrate", "child"); + const rootStatePath = join(repo, ".orchestrate", "root", "state.json"); + const git = (args: string[], cwd = repo) => + execFileSync("git", args, { cwd, stdio: "pipe" }); + try { + git(["init", "-b", "main"]); + git(["config", "user.email", "orchestrate@example.com"]); + git(["config", "user.name", "Orchestrate Test"]); + execFileSync("git", ["init", "--bare"], { cwd: origin, stdio: "pipe" }); + git(["remote", "add", "origin", origin]); + mkdirSync(join(repo, ".orchestrate", "root"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + writeFileSync( + rootStatePath, + JSON.stringify( + { + rootSlug: "root", + tasks: [], + attention: [], + andon: { + raisedAt: "2026-04-30T00:00:00.000Z", + raisedBy: "root", + reason: "stop", + cleared: false, + lastCheckedAt: "2026-04-30T00:00:00.000Z", + }, + }, + null, + 2 + ) + ); + git(["add", ".orchestrate/root/state.json"]); + git(["commit", "-m", "root state"]); + git(["push", "-u", "origin", "main"]); + + const state: State = { + rootSlug: "child", + tasks: [], + attention: [], + }; + const poller = new AndonPoller({ + getState: () => state, + saveState: () => {}, + logAttention: line => + state.attention.push({ at: new Date().toISOString(), message: line }), + pollSource: false, + cachedState: { + workspace, + ref: "main", + path: ".orchestrate/root/state.json", + }, + }); + + await poller.drainEvents(); + + expect(poller.isActive()).toBe(true); + expect(state.andon?.reason).toBe("stop"); + expect(state.attention).toHaveLength(0); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(origin, { recursive: true, force: true }); + } + }); + + test("Rejects malformed cached Andon state instead of marking active", async () => { + const repo = mkdtempSync(join(tmpdir(), "orch-andon-malformed-")); + const origin = mkdtempSync(join(tmpdir(), "orch-andon-malformed-origin-")); + const workspace = join(repo, ".orchestrate", "child"); + const rootStatePath = join(repo, ".orchestrate", "root", "state.json"); + const git = (args: string[], cwd = repo) => + execFileSync("git", args, { cwd, stdio: "pipe" }); + try { + git(["init", "-b", "main"]); + git(["config", "user.email", "orchestrate@example.com"]); + git(["config", "user.name", "Orchestrate Test"]); + execFileSync("git", ["init", "--bare"], { cwd: origin, stdio: "pipe" }); + git(["remote", "add", "origin", origin]); + mkdirSync(join(repo, ".orchestrate", "root"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + writeFileSync( + rootStatePath, + JSON.stringify({ + rootSlug: "root", + tasks: [], + attention: [], + andon: { + raisedAt: 1234567890, + cleared: "no", + lastCheckedAt: "2026-04-30T00:00:00.000Z", + }, + }) + ); + git(["add", ".orchestrate/root/state.json"]); + git(["commit", "-m", "malformed state"]); + git(["push", "-u", "origin", "main"]); + + const state: State = { + rootSlug: "child", + tasks: [], + attention: [], + }; + const poller = new AndonPoller({ + getState: () => state, + saveState: () => {}, + logAttention: line => + state.attention.push({ at: new Date().toISOString(), message: line }), + pollSource: false, + cachedState: { + workspace, + ref: "main", + path: ".orchestrate/root/state.json", + }, + }); + + await poller.drainEvents(); + + expect(poller.isActive()).toBe(false); + expect(state.andon).toBeUndefined(); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(origin, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/checkpoint-restart.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/checkpoint-restart.test.ts new file mode 100644 index 0000000..0824f86 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/checkpoint-restart.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { AgentManager } from "../core/agent-manager.ts"; +import { + PLANNED_CHECKPOINT_EXIT_CODE, + runOrchestrateLoop, +} from "../core/loop.ts"; +import type { TaskState } from "../schemas.ts"; + +const SCRIPTS_DIR = dirname( + fileURLToPath(new URL("../cli.ts", import.meta.url)) +); + +function runningTask(): TaskState { + return { + name: "long-runner", + type: "worker", + branch: "orch/checkpoint/long-runner", + startingRef: "main", + dependsOn: [], + agentId: "agent-1", + runId: "run-1", + parentAgentId: null, + status: "running", + resultStatus: null, + handoffPath: null, + startedAt: new Date(0).toISOString(), + finishedAt: null, + lastUpdate: new Date(0).toISOString(), + note: null, + slackTs: null, + prNumber: null, + failureMode: null, + verification: null, + }; +} + +describe("planned checkpoint restart", () => { + test("Exits 100 after a clean sweep and syncs state first", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-checkpoint-")); + const task = runningTask(); + const syncedReasons: string[] = []; + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const nowValues = [0, 2_500, 2_500]; + + const mgr = { + workspace, + handoffsDir: join(workspace, "handoffs"), + attentionLog: join(workspace, "attention.log"), + plan: { + goal: "checkpoint long work", + rootSlug: "checkpoint", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [], + }, + state: { rootSlug: "checkpoint", tasks: [task], attention: [] }, + tasks: [task], + commentDestinations: () => ({}), + andon: { + drainEvents: async () => {}, + isActive: () => false, + noteSpawnPaused: () => {}, + }, + getTask: (name: string) => (name === task.name ? task : undefined), + recoverRunning: async () => null, + waitAndHandoff: async () => {}, + spawnTask: async () => null, + depsSatisfied: () => false, + savePlan: () => {}, + saveState: () => {}, + logAttention: () => {}, + syncStateToGit: (reason: string) => { + syncedReasons.push(reason); + }, + } as unknown as AgentManager; + + try { + const code = await runOrchestrateLoop(mgr, { + maxRuntimeSec: 2, + now: () => nowValues.shift() ?? 2_500, + sleep: async () => {}, + }); + + expect(code).toBe(PLANNED_CHECKPOINT_EXIT_CODE); + expect(syncedReasons).toEqual(["planned checkpoint restart"]); + expect(stderr.mock.calls[0]?.[0]).toContain( + "planned checkpoint restart at 2s" + ); + expect(stderr.mock.calls[0]?.[0]).toContain("pending=0, running=1"); + expect(stderr.mock.calls[0]?.[0]).toContain( + `re-invoke 'bun cli.ts run ${workspace}' to resume` + ); + } finally { + stderr.mockRestore(); + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("CLI run exits 100 and leaves checkpoint state pushed", () => { + const tmp = mkdtempSync(join(tmpdir(), "orch-checkpoint-cli-")); + const remote = join(tmp, "remote.git"); + const repo = join(tmp, "repo"); + const workspace = join(repo, ".orchestrate", "checkpoint"); + const git = (args: string[], cwd = repo): string => + execFileSync("git", args, { cwd, stdio: "pipe" }).toString(); + + try { + execFileSync("git", ["init", "--bare", remote], { stdio: "pipe" }); + mkdirSync(workspace, { recursive: true }); + git(["init"]); + git(["config", "user.email", "orchestrate-test@example.com"]); + git(["config", "user.name", "Orchestrate Test"]); + git(["remote", "add", "origin", remote]); + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "manual checkpoint", + rootSlug: "checkpoint", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "long-runner", + type: "worker", + scopedGoal: "Keep running until checkpoint.", + }, + ], + }) + ); + writeFileSync( + join(workspace, "state.json"), + JSON.stringify({ + rootSlug: "checkpoint", + tasks: [runningTask()], + attention: [], + }) + ); + git(["add", "."]); + git(["commit", "-m", "seed fixture"]); + git(["push", "-u", "origin", "HEAD"]); + + const childScript = ` + import { mock } from "bun:test"; + const fakeRun = { + id: "run-1", + agentId: "agent-1", + status: "running", + stream: async function* () { await new Promise(() => {}); }, + wait: () => new Promise(() => {}) + }; + mock.module("@cursor/sdk", () => ({ + Agent: { getRun: async () => fakeRun }, + CursorAgentError: class CursorAgentError extends Error {} + })); + const { main } = await import("./cli/index.ts"); + await main([ + "bun", + "cli.ts", + "run", + process.env.CHECK_WORKSPACE ?? "", + "--max-runtime-sec", + "1" + ]); + `; + const result = spawnSync(process.execPath, ["-e", childScript], { + cwd: SCRIPTS_DIR, + env: { + ...process.env, + CHECK_WORKSPACE: workspace, + CURSOR_API_KEY: "test-key", + }, + encoding: "utf8", + }); + + expect(result.status).toBe(PLANNED_CHECKPOINT_EXIT_CODE); + expect(result.stderr).toContain("planned checkpoint restart at 1s"); + expect(result.stderr).toContain("pending=0, running=1"); + expect(result.stderr).toContain( + `re-invoke 'bun cli.ts run ${workspace}' to resume` + ); + expect(git(["status", "--short"]).trim()).toBe(""); + expect(git(["log", "--oneline", "-1"])).toContain( + "orch: checkpoint planned checkpoint restart" + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts new file mode 100644 index 0000000..d77ef3e --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadKickoffThreadTsOrBail } from "../cli/comments.ts"; +const TEST_SLACK_CHANNEL = "C123TEST"; + +const CLI_PATH = new URL("../cli.ts", import.meta.url).pathname; +const SCRIPTS_DIR = new URL("..", import.meta.url).pathname; + +describe("comment CLI", () => { + test("Refuses to post without --task or --thread-ts", () => { + const result = spawnSync(process.execPath, [CLI_PATH, "comment", "hello"], { + cwd: SCRIPTS_DIR, + encoding: "utf8", + env: { + ...process.env, + SLACK_BOT_TOKEN: "xoxb-test", + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "comment requires --task or --thread-ts " + ); + }); + + test("Rejects explicit thread-ts outside the workspace run thread", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-cli-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "guard comment", + rootSlug: "guard-comment", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: TEST_SLACK_CHANNEL, + slackKickoffRef: { + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + + const result = spawnSync( + process.execPath, + [ + CLI_PATH, + "comment", + "hello", + "--thread-ts", + "999.000", + "--workspace", + workspace, + ], + { + cwd: SCRIPTS_DIR, + encoding: "utf8", + env: { + ...process.env, + SLACK_BOT_TOKEN: "xoxb-test", + }, + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("outside this workspace's run thread"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Rejects unsafe body before posting", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-cli-body-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "guard comment", + rootSlug: "guard-comment", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: TEST_SLACK_CHANNEL, + slackKickoffRef: { + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + + const result = spawnSync( + process.execPath, + [ + CLI_PATH, + "comment", + "/workspace/app/src/foo.ts", + "--thread-ts", + "111.222", + "--workspace", + workspace, + ], + { + cwd: SCRIPTS_DIR, + encoding: "utf8", + env: { + ...process.env, + SLACK_BOT_TOKEN: "xoxb-test", + }, + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("comment body refused"); + expect(result.stderr).toContain("contains /workspace path"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Requires plan.slackChannel for explicit thread-ts posts", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-cli-channel-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify( + { + goal: "guard comment", + rootSlug: "guard-comment", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }, + null, + 2 + ) + ); + + const result = spawnSync( + process.execPath, + [ + CLI_PATH, + "comment", + "hello", + "--thread-ts", + "111.222", + "--workspace", + workspace, + ], + { + cwd: SCRIPTS_DIR, + encoding: "utf8", + env: { + ...process.env, + SLACK_BOT_TOKEN: "xoxb-test", + }, + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "comments require a workspace with a plan that has plan.slackChannel set" + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); + +describe("loadKickoffThreadTsOrBail", () => { + // Regression for Bugbot finding: operator-mode `--task` previously returned + // task.slackTs (a reply's ts); the new helper reads plan.slackKickoffRef.ts + // (the kickoff thread root) regardless of operator mode. + test("returns plan.slackKickoffRef.ts", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-kickoff-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "kickoff thread", + rootSlug: "kickoff-thread", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: TEST_SLACK_CHANNEL, + slackKickoffRef: { + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }, + tasks: [ + { name: "worker-one", type: "worker", scopedGoal: "Do work." }, + ], + }) + ); + expect( + loadKickoffThreadTsOrBail({ workspace, taskName: "worker-one" }) + ).toBe("111.222"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("throws when plan.json is missing", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-kickoff-missing-")); + try { + expect(() => + loadKickoffThreadTsOrBail({ workspace, taskName: "worker-one" }) + ).toThrow(/plan\.json with slackKickoffRef/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("throws when slackKickoffRef is absent", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-kickoff-no-ref-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "no kickoff", + rootSlug: "no-kickoff", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: TEST_SLACK_CHANNEL, + tasks: [ + { name: "worker-one", type: "worker", scopedGoal: "Do work." }, + ], + }) + ); + expect(() => + loadKickoffThreadTsOrBail({ workspace, taskName: "worker-one" }) + ).toThrow(/no slackKickoffRef/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/comment-retry-queue.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/comment-retry-queue.test.ts new file mode 100644 index 0000000..c95fd45 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/comment-retry-queue.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { SlackAdapter, SlackMessageRef } from "../adapters/types.ts"; +const TEST_SLACK_CHANNEL = "C123TEST"; +import { + commentRetryQueuePath, + drainCommentRetryQueue, + postOrQueueComment, +} from "../core/comment-retry-queue.ts"; + +interface SlackPost { + threadTs: string; + text: string; + username?: string; + clientMsgId?: string; +} + +function slackAdapter(args: { + failures?: number; + posts: SlackPost[]; +}): SlackAdapter { + let calls = 0; + return { + async postRunKickoff(): Promise { + throw new Error("not used"); + }, + async lookupFirstNameByEmail(): Promise { + return undefined; + }, + async postInThread(): Promise { + throw new Error("not used"); + }, + async editThreadMessage(): Promise { + throw new Error("not used"); + }, + async uploadFileToThread(): Promise<{ fileId: string; permalink: string }> { + throw new Error("not used"); + }, + async getReactions(): Promise<{ + reactions: { name: string; users: string[] }[]; + }> { + throw new Error("not used"); + }, + async getThreadReplies(): Promise<{ + messages: { ts: string; text: string }[]; + }> { + throw new Error("not used"); + }, + async postCommentInThread(commentArgs): Promise { + calls++; + if (calls <= (args.failures ?? 0)) throw new Error("slack_unavailable"); + args.posts.push({ + threadTs: commentArgs.threadTs, + text: commentArgs.text, + username: commentArgs.username, + clientMsgId: commentArgs.clientMsgId, + }); + return { channel: TEST_SLACK_CHANNEL, ts: String(calls) }; + }, + async addReaction(): Promise { + throw new Error("not used"); + }, + async removeReaction(): Promise { + throw new Error("not used"); + }, + }; +} + +function alwaysFailingSlack(): SlackAdapter { + return slackAdapter({ failures: Number.POSITIVE_INFINITY, posts: [] }); +} + +describe("required comment retry queue", () => { + test("Persists failed required Slack comment and retries it later", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-queue-")); + const posts: SlackPost[] = []; + try { + const slack = slackAdapter({ failures: 1, posts }); + const first = await postOrQueueComment({ + destinations: { slack }, + workspace, + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "please review", + sender: "agent", + criticality: "required", + }); + + expect(first).toBe("queued"); + expect( + JSON.parse(readFileSync(commentRetryQueuePath(workspace), "utf8")) + ).toHaveLength(1); + + const queue = JSON.parse( + readFileSync(commentRetryQueuePath(workspace), "utf8") + ); + const firstDelayMs = + Date.parse(queue[0].nextAttemptAt) - Date.parse(queue[0].createdAt); + expect(firstDelayMs).toBeLessThan(5_000); + expect(firstDelayMs).toBeGreaterThanOrEqual(0); + queue[0].nextAttemptAt = "1970-01-01T00:00:00.000Z"; + writeFileSync( + commentRetryQueuePath(workspace), + JSON.stringify(queue, null, 2) + ); + + const retry = await drainCommentRetryQueue({ + workspace, + destinations: { slack }, + }); + expect(retry.posted).toBe(1); + expect(posts.map(post => post.text)).toEqual(["please review"]); + expect( + JSON.parse(readFileSync(commentRetryQueuePath(workspace), "utf8")) + ).toHaveLength(0); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Reports queued when an older queued entry consumes the single drain slot", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-queue-fifo-")); + const posts: SlackPost[] = []; + try { + writeFileSync( + commentRetryQueuePath(workspace), + JSON.stringify( + [ + { + id: "older-entry", + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "older comment", + sender: "agent", + attempts: 1, + createdAt: "1970-01-01T00:00:00.000Z", + nextAttemptAt: "1970-01-01T00:00:00.000Z", + }, + ], + null, + 2 + ) + ); + const result = await postOrQueueComment({ + destinations: { slack: slackAdapter({ posts }) }, + workspace, + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "new comment", + sender: "agent", + criticality: "required", + }); + expect(result).toBe("queued"); + expect(posts.map(post => post.text)).toEqual(["older comment"]); + const remaining = JSON.parse( + readFileSync(commentRetryQueuePath(workspace), "utf8") + ); + expect(remaining).toHaveLength(1); + expect(remaining[0].body).toBe("new comment"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Reports queued when dedup hits a near-cap entry that exhausts on this drain", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-queue-cap-")); + try { + writeFileSync( + commentRetryQueuePath(workspace), + JSON.stringify( + [ + { + id: "near-cap-entry", + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "@reviewer please look", + sender: "agent", + attempts: 4, + createdAt: "1970-01-01T00:00:00.000Z", + nextAttemptAt: "1970-01-01T00:00:00.000Z", + lastError: "previous failure", + }, + ], + null, + 2 + ) + ); + const result = await postOrQueueComment({ + destinations: { slack: alwaysFailingSlack() }, + workspace, + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "@reviewer please look", + sender: "agent", + criticality: "required", + }); + expect(result).toBe("queued"); + const remaining = JSON.parse( + readFileSync(commentRetryQueuePath(workspace), "utf8") + ); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe("near-cap-entry"); + expect(typeof remaining[0].exhaustedAt).toBe("string"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Routes Slack thread destinations through the adapter", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-queue-slack-")); + const posts: SlackPost[] = []; + const slack = slackAdapter({ failures: 1, posts }); + try { + const result = await postOrQueueComment({ + destinations: { slack }, + workspace, + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "please review", + sender: "worker-one", + criticality: "required", + }); + + expect(result).toBe("queued"); + const queue = JSON.parse( + readFileSync(commentRetryQueuePath(workspace), "utf8") + ); + queue[0].nextAttemptAt = "1970-01-01T00:00:00.000Z"; + writeFileSync( + commentRetryQueuePath(workspace), + JSON.stringify(queue, null, 2) + ); + + const retry = await drainCommentRetryQueue({ + workspace, + destinations: { slack }, + }); + expect(retry.posted).toBe(1); + expect(posts).toEqual([ + { + threadTs: "111.222", + text: "please review", + username: "worker-one", + clientMsgId: queue[0].id, + }, + ]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Rejects slack: destinations outside allowedSlackThread", async () => { + const posts: SlackPost[] = []; + const slack = slackAdapter({ posts }); + + await expect( + postOrQueueComment({ + destinations: { slack }, + destination: "slack:DM:U_OPERATOR", + body: "exfil attempt", + sender: "worker", + criticality: "best_effort", + allowedSlackThread: { + channel: TEST_SLACK_CHANNEL, + threadTs: "111.222", + }, + }) + ).rejects.toThrow(/not DMs/); + expect(posts).toEqual([]); + }); + + test("Rejects slack: destinations with a sibling-run threadTs", async () => { + const posts: SlackPost[] = []; + const slack = slackAdapter({ posts }); + + await expect( + postOrQueueComment({ + destinations: { slack }, + destination: `slack:${TEST_SLACK_CHANNEL}:999.000`, + body: "redirect attempt", + sender: "worker", + criticality: "best_effort", + allowedSlackThread: { + channel: TEST_SLACK_CHANNEL, + threadTs: "111.222", + }, + }) + ).rejects.toThrow(/outside this workspace's run thread/); + expect(posts).toEqual([]); + }); + + test("Rejects slack: destinations at the channel root", async () => { + const posts: SlackPost[] = []; + const slack = slackAdapter({ posts }); + + await expect( + postOrQueueComment({ + destinations: { slack }, + destination: `slack:${TEST_SLACK_CHANNEL}`, + body: "channel-root attempt", + sender: "worker", + criticality: "best_effort", + allowedSlackThread: { + channel: TEST_SLACK_CHANNEL, + threadTs: "111.222", + }, + }) + ).rejects.toThrow(/must include a thread_ts/); + expect(posts).toEqual([]); + }); + + test("Drain re-validates queued entries against allowedSlackThread", async () => { + const workspace = mkdtempSync( + join(tmpdir(), "orch-comment-queue-drain-bypass-") + ); + const posts: SlackPost[] = []; + try { + // Bypass enqueue-time validation and check the drain guard. + writeFileSync( + commentRetryQueuePath(workspace), + JSON.stringify( + [ + { + id: "exfil-attempt", + destination: "slack:DM:U_OPERATOR", + body: "redirect to operator DM", + sender: "worker", + attempts: 0, + createdAt: "1970-01-01T00:00:00.000Z", + nextAttemptAt: "1970-01-01T00:00:00.000Z", + }, + ], + null, + 2 + ) + ); + + const result = await drainCommentRetryQueue({ + workspace, + destinations: { slack: slackAdapter({ posts }) }, + allowedSlackThread: { + channel: TEST_SLACK_CHANNEL, + threadTs: "111.222", + }, + }); + + expect(result.posted).toBe(0); + expect(posts).toEqual([]); + const remaining = JSON.parse( + readFileSync(commentRetryQueuePath(workspace), "utf8") + ); + expect(remaining).toHaveLength(1); + expect(remaining[0].lastError).toMatch(/not DMs/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Allows slack: destinations matching allowedSlackThread", async () => { + const posts: SlackPost[] = []; + const slack = slackAdapter({ posts }); + + await postOrQueueComment({ + destinations: { slack }, + destination: `slack:${TEST_SLACK_CHANNEL}:111.222`, + body: "ok", + sender: "worker", + criticality: "best_effort", + allowedSlackThread: { + channel: TEST_SLACK_CHANNEL, + threadTs: "111.222", + }, + }); + + expect(posts).toMatchObject([{ threadTs: "111.222", text: "ok" }]); + }); + + test("Rejects non-Slack destinations with a clear error", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-comment-queue-bad-")); + try { + await expect( + postOrQueueComment({ + destinations: { slack: slackAdapter({ posts: [] }) }, + workspace, + destination: "linear:PROJ-1", + body: "should not post", + sender: "agent", + criticality: "best_effort", + }) + ).rejects.toThrow(/only slack:/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/exit-on-error.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/exit-on-error.test.ts new file mode 100644 index 0000000..298181c --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/exit-on-error.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { AgentManager } from "../core/agent-manager.ts"; +import { EXIT_ON_ERROR_EXIT_CODE, runOrchestrateLoop } from "../core/loop.ts"; +import type { TaskState } from "../schemas.ts"; + +function runningTask(name: string): TaskState { + return { + name, + type: "worker", + branch: `orch/exit-on-error/${name}`, + startingRef: "main", + dependsOn: [], + agentId: `agent-${name}`, + runId: `run-${name}`, + parentAgentId: null, + status: "running", + resultStatus: null, + handoffPath: null, + startedAt: new Date(0).toISOString(), + finishedAt: null, + lastUpdate: new Date(0).toISOString(), + note: null, + slackTs: null, + prNumber: null, + failureMode: null, + verification: null, + }; +} + +function buildManagerStub(args: { + workspace: string; + tasks: TaskState[]; + syncedReasons: string[]; + onRecover?: (task: TaskState) => TaskState; +}): AgentManager { + return { + workspace: args.workspace, + handoffsDir: join(args.workspace, "handoffs"), + attentionLog: join(args.workspace, "attention.log"), + plan: { + goal: "exit on error", + rootSlug: "exit-on-error", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [], + }, + state: { rootSlug: "exit-on-error", tasks: args.tasks, attention: [] }, + tasks: args.tasks, + commentDestinations: () => ({}), + andon: { + drainEvents: async () => {}, + isActive: () => false, + noteSpawnPaused: () => {}, + }, + getTask: (name: string) => args.tasks.find(t => t.name === name), + recoverRunning: async (task: TaskState) => { + if (args.onRecover) args.onRecover(task); + return null; + }, + waitAndHandoff: async () => {}, + spawnTask: async () => null, + depsSatisfied: () => false, + savePlan: () => {}, + saveState: () => {}, + logAttention: () => {}, + syncStateToGit: (reason: string) => { + args.syncedReasons.push(reason); + }, + } as unknown as AgentManager; +} + +describe("exit-on-error", () => { + test("Returns early when a task transitions to error this run", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-exit-on-error-")); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const tasks = [runningTask("crashy")]; + const syncedReasons: string[] = []; + + try { + const mgr = buildManagerStub({ + workspace, + tasks, + syncedReasons, + onRecover: task => { + task.status = "error"; + task.note = "simulated crash"; + return task; + }, + }); + + const code = await runOrchestrateLoop(mgr, { + maxRuntimeSec: 60, + sleep: async () => {}, + }); + + expect(code).toBe(EXIT_ON_ERROR_EXIT_CODE); + expect(syncedReasons).toEqual(["exit-on-error: crashy"]); + const messages = stderr.mock.calls.map(call => String(call[0])); + expect(messages.some(m => m.includes("exit-on-error"))).toBe(true); + expect(messages.some(m => m.includes("(crashy)"))).toBe(true); + } finally { + stderr.mockRestore(); + rmSync(workspace, { recursive: true, force: true }); + } + }); + + // syncStateToGit("exit-on-error: ...") is the unambiguous marker for + // the short-circuit path; computeLoopExitCode returns 1 for any + // terminal error, so the exit code alone can't distinguish them. + test("Pre-existing error does not trigger exit-on-error", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-exit-preexisting-")); + const tasks: TaskState[] = [ + { + ...runningTask("ghost"), + agentId: null, + runId: null, + status: "error", + note: "pre-existing error from a prior run", + }, + ]; + const syncedReasons: string[] = []; + + try { + const mgr = buildManagerStub({ workspace, tasks, syncedReasons }); + await runOrchestrateLoop(mgr, { + maxRuntimeSec: 60, + sleep: async () => {}, + }); + + expect(syncedReasons.some(r => r.startsWith("exit-on-error:"))).toBe( + false + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("exitOnError:false keeps draining past errors", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-exit-all-done-")); + const tasks = [runningTask("crashy")]; + const syncedReasons: string[] = []; + + try { + const mgr = buildManagerStub({ + workspace, + tasks, + syncedReasons, + onRecover: task => { + task.status = "error"; + return task; + }, + }); + + await runOrchestrateLoop(mgr, { + maxRuntimeSec: 60, + sleep: async () => {}, + exitOnError: false, + }); + + expect(syncedReasons.some(r => r.startsWith("exit-on-error:"))).toBe( + false + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/failure-handoff.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/failure-handoff.test.ts new file mode 100644 index 0000000..3416f94 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/failure-handoff.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + classifyFailureMode, + hasStructuredHandoff, + writeFailureHandoff, + writeFinishedNoHandoff, +} from "../core/failure-handoff.ts"; +import type { TaskState } from "../schemas.ts"; + +function fakeTask(overrides: Partial = {}): TaskState { + return { + name: "dead-worker", + type: "worker", + branch: "agent/dead-worker-abc1", + startingRef: "main", + dependsOn: [], + agentId: "bc-fake-123", + runId: "run-fake-123", + parentAgentId: null, + status: "running", + resultStatus: null, + handoffPath: null, + startedAt: "2026-04-30T00:00:00.000Z", + finishedAt: null, + lastUpdate: "2026-04-30T01:10:00.000Z", + note: "last heartbeat before crash", + slackTs: null, + prNumber: null, + failureMode: null, + verification: null, + ...overrides, + }; +} + +describe("classifyFailureMode", () => { + // OOM in the stream tail wins over a network-looking SDK string: the + // OOMKilled signal is a harder diagnostic than the generic drop. + test("OOM markers in lastOutput beat SDK error text", () => { + expect( + classifyFailureMode({ + sdkError: "fetch failed: ECONNRESET", + lastOutput: "process was OOMKilled (exit code 137)", + durationMs: 5_000, + }) + ).toBe("oom"); + }); + + test("OOM markers in SDK error classify as oom", () => { + expect( + classifyFailureMode({ + sdkError: "container terminated: out of memory", + lastOutput: null, + durationMs: null, + }) + ).toBe("oom"); + }); + + test("Duration in the 70-80 min window classifies as cap-hit", () => { + expect( + classifyFailureMode({ + sdkError: "run terminated", + lastOutput: null, + durationMs: 72 * 60 * 1000, + }) + ).toBe("cap-hit"); + }); + + test("Duration outside cap-hit window falls through", () => { + expect( + classifyFailureMode({ + sdkError: "run terminated", + lastOutput: null, + durationMs: 65 * 60 * 1000, + }) + ).toBe("unknown"); + }); + + test("Tool-use error classifies as tool-error", () => { + expect( + classifyFailureMode({ + sdkError: "tool_use_failed: invalid arguments to ReadFile", + lastOutput: null, + durationMs: 30_000, + }) + ).toBe("tool-error"); + }); + + test("Network-ish SDK error classifies as network-drop", () => { + expect( + classifyFailureMode({ + sdkError: "fetch failed: ETIMEDOUT connecting to api", + lastOutput: null, + durationMs: 30_000, + }) + ).toBe("network-drop"); + }); + + test("Empty signals default to unknown", () => { + expect( + classifyFailureMode({ + sdkError: null, + lastOutput: null, + durationMs: null, + }) + ).toBe("unknown"); + }); +}); + +describe("writeFailureHandoff", () => { + test("Writes -failure.md with the expected structure", () => { + const dir = mkdtempSync(join(tmpdir(), "orch-failure-")); + try { + const path = writeFailureHandoff({ + handoffsDir: dir, + task: fakeTask(), + failureMode: "cap-hit", + sdkError: "run terminated before completion", + lastToolCall: "EditFile", + terminatedAt: "2026-04-30T01:15:00.000Z", + }); + expect(path).toBe(join(dir, "dead-worker-failure.md")); + const body = readFileSync(path, "utf8"); + expect(body).toContain("failureMode: cap-hit"); + expect(body).toContain("# dead-worker failure handoff"); + expect(body).toContain("Failure mode: cap-hit"); + expect(body).toContain("Cloud agent: bc-fake-123"); + expect(body).toContain("Branch: agent/dead-worker-abc1"); + expect(body).toContain("Last tool call: EditFile"); + expect(body).toContain( + "Last activity: 2026-04-30T01:10:00.000Z - last heartbeat before crash" + ); + expect(body).toContain("## Suggested next steps"); + expect(body).toContain("Retry with smaller scope"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("Suggestions bend to the failure mode", () => { + const dir = mkdtempSync(join(tmpdir(), "orch-failure-")); + try { + const path = writeFailureHandoff({ + handoffsDir: dir, + task: fakeTask({ name: "flaky-worker" }), + failureMode: "network-drop", + sdkError: "fetch failed: ETIMEDOUT", + terminatedAt: "2026-04-30T00:05:00.000Z", + }); + const body = readFileSync(path, "utf8"); + expect(body).toMatch(/- Retry as-is \(treat as transient\)/); + expect(body).not.toMatch(/Retry with smaller scope/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("writeFinishedNoHandoff", () => { + test("Writes -finished-no-handoff.md with raw snippet", () => { + const dir = mkdtempSync(join(tmpdir(), "orch-finished-nh-")); + try { + const path = writeFinishedNoHandoff({ + handoffsDir: dir, + task: fakeTask({ name: "silent-worker" }), + resultStatus: "finished", + terminatedAt: "2026-04-30T01:15:00.000Z", + rawBodySnippet: "just some prose, no headings", + }); + expect(path).toBe(join(dir, "silent-worker-finished-no-handoff.md")); + const body = readFileSync(path, "utf8"); + expect(body).toContain("# silent-worker finished without handoff"); + expect(body).toContain("Status: finished (cloud agent ended cleanly"); + expect(body).toContain("## Suggested next steps"); + expect(body).toContain("just some prose, no headings"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("hasStructuredHandoff", () => { + test("Detects the worker template's ## Status heading", () => { + const body = ["## Status", "success", "## Branch", "`x/y`"].join("\n"); + expect(hasStructuredHandoff(body)).toBe(true); + }); + + // Verifier handoffs use `## Verification` instead of `## Status`. A + // verifier-only match keeps the finished-no-handoff sidecar from + // firing on every successful verifier run. + test("Detects the verifier template's ## Verification heading", () => { + const body = ["## Verification", "live-ui-verified", "## Target", "`t`"].join("\n"); + expect(hasStructuredHandoff(body)).toBe(true); + }); + + test("Prose without a structured heading returns false", () => { + expect(hasStructuredHandoff("I did some stuff and left.")).toBe(false); + }); + + test("Null or empty returns false", () => { + expect(hasStructuredHandoff(null)).toBe(false); + expect(hasStructuredHandoff("")).toBe(false); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/handoff-branch-parser.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-branch-parser.test.ts new file mode 100644 index 0000000..fe94135 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-branch-parser.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; + +import { parseHandoffBranch, resolveRunBranch } from "../core/handoff.ts"; + +describe("parseHandoffBranch / resolveRunBranch", () => { + test("Worker template wraps branch value in single backticks", () => { + const body = [ + "## Status", + "success", + "## Branch", + "`agent/foo-abc1`", + "## What I did", + "- stuff", + ].join("\n"); + expect(parseHandoffBranch(body)).toBe("agent/foo-abc1"); + }); + + // Bare branch name without backticks is tolerated. + test("Bare branch name is tolerated", () => { + const body = ["## Branch", "agent/no-backticks", "## Notes"].join("\n"); + expect(parseHandoffBranch(body)).toBe("agent/no-backticks"); + }); + + // `(no branch)` sentinel from a code-less handoff → null so callers can + // preserve the placeholder rather than overwrite it with a literal sentinel. + test("(no branch) sentinel returns null", () => { + const body = ["## Branch", "(no branch)", ""].join("\n"); + expect(parseHandoffBranch(body)).toBe(null); + }); + + // Whitespace around the value is stripped. + test("Whitespace around the value is stripped", () => { + const body = ["## Branch", " `agent/spaces-around` ", ""].join("\n"); + expect(parseHandoffBranch(body)).toBe("agent/spaces-around"); + }); + + // Section absent → null. + test("Section absent returns null", () => { + const body = ["## Status", "success", "## What I did", "- stuff"].join( + "\n" + ); + expect(parseHandoffBranch(body)).toBe(null); + }); + + // CRLF line endings (some clients normalize to CRLF on copy/paste). + test("CRLF line endings parse", () => { + const body = ["## Branch", "`agent/crlf-branch`", ""].join("\r\n"); + expect(parseHandoffBranch(body)).toBe("agent/crlf-branch"); + }); + + // Empty `## Branch` body line → null (degenerate; callers fall back). + test("Empty Branch body line returns null", () => { + const body = "## Branch\n\n## Notes\n"; + expect(parseHandoffBranch(body)).toBe(null); + }); + + // Section appears mid-document; only the first matching block is honored. + // (Worker handoffs always have one Branch section; resilience-only test.) + test("Section appears mid-document", () => { + const body = [ + "## What I did", + "- ok", + "## Branch", + "`agent/midbody-branch`", + "## Notes", + "- ok", + ].join("\n"); + expect(parseHandoffBranch(body)).toBe("agent/midbody-branch"); + }); + + // §7.1 regression: drives the precedence rule that `waitAndHandoff` uses + // to feed `reconcileVerifierStartingRefs`. The pre-fix flow had no body + // parse and would have returned the fallback (the still-placeholder + // `s.branch`); reverting `resolveRunBranch` to skip the body parse breaks + // this assertion. + test("Handoff body branch takes precedence over fallback", () => { + const result = resolveRunBranch({ + handoffBody: "## Branch\n`agent/foo-abc1`\n", + runBranches: [{ branch: undefined }, { branch: null }], + fallback: "orch/x/foo", + }); + expect(result).toBe("agent/foo-abc1"); + }); + + // runBranches takes second priority when the handoff body lacks a Branch + // section (e.g. legacy handoffs). + test("runBranches takes second priority without Branch section", () => { + const result = resolveRunBranch({ + handoffBody: "## Status\nsuccess\n", + runBranches: [{ branch: "agent/from-sdk" }], + fallback: "orch/x/foo", + }); + expect(result).toBe("agent/from-sdk"); + }); + + // Both empty → the recorded fallback wins (the planner-side placeholder + // until something better is observed). + test("Fallback wins when body and runBranches are empty", () => { + const result = resolveRunBranch({ + handoffBody: "## Status\nsuccess\n", + runBranches: [{ branch: undefined }], + fallback: "orch/x/foo", + }); + expect(result).toBe("orch/x/foo"); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-parser.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-parser.test.ts new file mode 100644 index 0000000..c17c656 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-parser.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; + +import { + parseHandoffFailureMode, + parseHandoffPrNumber, + parseHandoffVerification, +} from "../core/handoff.ts"; + +describe("parseHandoffVerification", () => { + test("Reads canonical ## Verification value", () => { + const body = [ + "## Verification", + "live-ui-verified", + "## Target", + "`x`", + ].join("\n"); + expect(parseHandoffVerification(body)).toBe("live-ui-verified"); + }); + + test("Backticks around the value are stripped", () => { + const body = ["## Verification", "`unit-test-verified`", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe("unit-test-verified"); + }); + + test("Whitespace and CRLF are tolerated", () => { + const body = ["## Verification", " type-check-only ", ""].join("\r\n"); + expect(parseHandoffVerification(body)).toBe("type-check-only"); + }); + + test("Underscores or spaces normalize to dashes", () => { + const body = ["## Verification", "verifier_blocked", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe("verifier-blocked"); + }); + + test("Mixed-case enum value is accepted", () => { + const body = ["## Verification", "Verifier-Failed", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe("verifier-failed"); + }); + + test("Unknown value returns null without falling through to legacy", () => { + const body = ["## Verification", "totally-made-up", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe(null); + }); + + test("Empty value returns null", () => { + const body = ["## Verification", "", "## Notes"].join("\n"); + expect(parseHandoffVerification(body)).toBe(null); + }); + + test("Section absent returns null", () => { + const body = ["## Status", "success", "## Branch", "`x`"].join("\n"); + expect(parseHandoffVerification(body)).toBe(null); + }); + + test("Legacy ## Verdict pass migrates to type-check-only", () => { + const body = ["## Verdict", "pass", "## Target", "`x`"].join("\n"); + expect(parseHandoffVerification(body)).toBe("type-check-only"); + }); + + test("Legacy ## Verdict fail migrates to verifier-failed", () => { + const body = ["## Verdict", "fail", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe("verifier-failed"); + }); + + test("Legacy ## Verdict inconclusive migrates to verifier-blocked", () => { + const body = ["## Verdict", "inconclusive", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe("verifier-blocked"); + }); + + test("Legacy ## Verdict unknown returns null", () => { + const body = ["## Verdict", "maybe?", ""].join("\n"); + expect(parseHandoffVerification(body)).toBe(null); + }); + + test("Canonical ## Verification wins when both sections appear", () => { + const body = [ + "## Verification", + "live-ui-verified", + "## Verdict", + "fail", + ].join("\n"); + expect(parseHandoffVerification(body)).toBe("live-ui-verified"); + }); +}); + +describe("parseHandoffFailureMode", () => { + test("Reads canonical failure mode section", () => { + const body = ["## Failure Mode", "oom", ""].join("\n"); + expect(parseHandoffFailureMode(body)).toBe("oom"); + }); + + test("Reads failureMode key value", () => { + const body = ["Cloud run failed.", "failureMode: network_drop"].join("\n"); + expect(parseHandoffFailureMode(body)).toBe("network-drop"); + }); + + test("Unknown failure mode returns null", () => { + const body = ["## Failure Mode", "something-else", ""].join("\n"); + expect(parseHandoffFailureMode(body)).toBe(null); + }); +}); + +describe("parseHandoffPrNumber", () => { + test("Reads GitHub pull request URLs", () => { + const body = + "Opened https://github.com/example-org/example-repo/pull/109301"; + expect(parseHandoffPrNumber(body)).toBe(109301); + }); + + test("Reads review.cursor.com pull request URLs", () => { + const body = + "Opened "; + expect(parseHandoffPrNumber(body)).toBe(109301); + }); + + test("Reads PR section shorthand", () => { + const body = ["## PR", "#109301"].join("\n"); + expect(parseHandoffPrNumber(body)).toBe(109301); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-record.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-record.test.ts new file mode 100644 index 0000000..fdf6179 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/handoff-verification-record.test.ts @@ -0,0 +1,171 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AgentManager } from "../core/agent-manager.ts"; +import type { State } from "../schemas.ts"; + +const REPO_URL = "https://github.com/example-org/example-repo"; + +function makeWorkspace(args: { plan: unknown }): string { + const workspace = mkdtempSync(join(tmpdir(), "orch-verification-")); + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify(args.plan, null, 2) + ); + return workspace; +} + +function readState(workspace: string): State { + return JSON.parse(readFileSync(join(workspace, "state.json"), "utf8")); +} + +const baselinePlan = { + goal: "ship a refactor", + rootSlug: "ship-refactor", + baseBranch: "main", + repoUrl: REPO_URL, + tasks: [ + { + name: "frontend-toggle", + type: "worker", + scopedGoal: "Add the toggle.", + }, + { + name: "verify-frontend-toggle", + type: "verifier", + verifies: "frontend-toggle", + scopedGoal: "Verify the toggle.", + }, + ], +} as const; + +const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY; +process.env.CURSOR_API_KEY = "test-key"; + +afterAll(() => { + if (ORIGINAL_API_KEY === undefined) { + delete process.env.CURSOR_API_KEY; + } else { + process.env.CURSOR_API_KEY = ORIGINAL_API_KEY; + } +}); + +describe("AgentManager.recordHandoffVerification", () => { + test("Verifier handoff writes verification to the target task", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const verifier = mgr.getTask("verify-frontend-toggle"); + if (!verifier) throw new Error("verifier task missing"); + + mgr.recordHandoffVerification( + verifier, + ["## Verification", "live-ui-verified", "## Target", "`x`"].join("\n") + ); + + const target = readState(workspace).tasks.find( + t => t.name === "frontend-toggle" + ); + const verifierRow = readState(workspace).tasks.find( + t => t.name === "verify-frontend-toggle" + ); + expect(target?.verification).toBe("live-ui-verified"); + // Verifiers don't carry a verification claim of their own; only the + // target row gets the value so post-run classifiers join one place. + expect(verifierRow?.verification).toBe(null); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Worker self-report writes verification to its own row", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const worker = mgr.getTask("frontend-toggle"); + if (!worker) throw new Error("worker task missing"); + + mgr.recordHandoffVerification( + worker, + ["## Verification", "unit-test-verified", "## Branch", "`x`"].join("\n") + ); + + const persisted = readState(workspace).tasks.find( + t => t.name === "frontend-toggle" + ); + expect(persisted?.verification).toBe("unit-test-verified"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Verifier overrides a prior worker self-report on the target", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const worker = mgr.getTask("frontend-toggle"); + const verifier = mgr.getTask("verify-frontend-toggle"); + if (!worker || !verifier) throw new Error("tasks missing"); + + mgr.recordHandoffVerification( + worker, + ["## Verification", "type-check-only", ""].join("\n") + ); + mgr.recordHandoffVerification( + verifier, + ["## Verification", "verifier-blocked", ""].join("\n") + ); + + const persisted = readState(workspace).tasks.find( + t => t.name === "frontend-toggle" + ); + expect(persisted?.verification).toBe("verifier-blocked"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Legacy ## Verdict pass migrates to type-check-only on read", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const verifier = mgr.getTask("verify-frontend-toggle"); + if (!verifier) throw new Error("verifier task missing"); + + mgr.recordHandoffVerification( + verifier, + ["## Verdict", "pass", "## Target", "`x`"].join("\n") + ); + + const target = readState(workspace).tasks.find( + t => t.name === "frontend-toggle" + ); + expect(target?.verification).toBe("type-check-only"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Handoff without any verdict section leaves verification null", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const verifier = mgr.getTask("verify-frontend-toggle"); + if (!verifier) throw new Error("verifier task missing"); + + mgr.recordHandoffVerification( + verifier, + ["## Status", "success", "## Branch", "`x`"].join("\n") + ); + + const target = readState(workspace).tasks.find( + t => t.name === "frontend-toggle" + ); + expect(target?.verification).toBe(null); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/kickoff-dedupe.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/kickoff-dedupe.test.ts new file mode 100644 index 0000000..bc7cc2f --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/kickoff-dedupe.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +const SCRIPTS_DIR = new URL("..", import.meta.url).pathname; + +import { + MAX_BOOT_MS, + findActiveRootPlanner, + inferKickoffRootSlug, +} from "../cli/task.ts"; + +describe("kickoff dedupe", () => { + test("adopts a recent active root for the same slug", async () => { + const now = Date.parse("2026-05-01T16:00:00.000Z"); + let createCalls = 0; + const active = await findActiveRootPlanner( + { + async list() { + return { + items: [ + { + agentId: "bc-old", + name: "refactor-ui-root", + createdAt: now - MAX_BOOT_MS - 1, + latestRun: { id: "run-old", status: "running" }, + }, + { + agentId: "bc-active", + name: "refactor-ui-root", + createdAt: now - 1_000, + latestRun: { id: "run-active", status: "pending" }, + }, + ], + }; + }, + async listRuns() { + createCalls++; + return { items: [] }; + }, + }, + "refactor-ui", + now + ); + + expect(active).toEqual({ + agentId: "bc-active", + runId: "run-active", + status: "pending", + name: "refactor-ui-root", + }); + expect(createCalls).toBe(0); + }); + + test("allows different slugs to run side by side", async () => { + const now = Date.parse("2026-05-01T16:00:00.000Z"); + const active = await findActiveRootPlanner( + { + async list() { + return { + items: [ + { + agentId: "bc-other", + name: "docs-loc-root", + createdAt: now - 1_000, + latestRun: { id: "run-other", status: "running" }, + }, + ], + }; + }, + }, + "refactor-ui", + now + ); + + expect(active).toBeNull(); + }); + + test("falls back to listRuns when list omits latest run", async () => { + const now = Date.parse("2026-05-01T16:00:00.000Z"); + const active = await findActiveRootPlanner( + { + async list() { + return { + items: [ + { + agentId: "bc-active", + name: "refactor-ui-root", + createdAt: new Date(now - 1_000).toISOString(), + }, + ], + }; + }, + async listRuns(agentId) { + expect(agentId).toBe("bc-active"); + return { items: [{ id: "run-active", _status: "running" }] }; + }, + }, + "refactor-ui", + now + ); + + expect(active?.agentId).toBe("bc-active"); + expect(active?.runId).toBe("run-active"); + }); + + test("infers root slug from an explicit kickoff prefix", () => { + expect(inferKickoffRootSlug("refactor-ui: shrink Settings")).toBe("refactor-ui"); + expect(inferKickoffRootSlug("`refactor-ui`: shrink Settings")).toBe("refactor-ui"); + }); + + test("kickoff command adopts unless --force is passed", () => { + const adopt = spawnKickoff(false); + expect(adopt.status).toBe(0); + expect(adopt.stdout).toContain("adopting bc-existing"); + expect(adopt.stdout).toContain('"adopted":true'); + + const forced = spawnKickoff(true); + expect(forced.status).toBe(0); + expect(forced.stdout).toContain('"agentId":"bc-new"'); + expect(forced.stdout).not.toContain("adopting"); + }); +}); + +function spawnKickoff(force: boolean) { + const script = ` + import { mock } from "bun:test"; + mock.module("@cursor/sdk", () => ({ + CursorAgentError: class CursorAgentError extends Error {}, + Agent: { + list: async () => { + if (${force ? "true" : "false"}) throw new Error("list should not run with --force"); + return { + items: [{ + agentId: "bc-existing", + name: "refactor-ui-root", + createdAt: Date.now(), + latestRun: { id: "run-existing", status: "running" } + }] + }; + }, + create: async () => ({ + agentId: "bc-new", + send: async () => ({ id: "run-new", status: "running" }) + }) + } + })); + const { main } = await import("./cli/index.ts"); + await main([ + "bun", + "cli.ts", + "kickoff", + "refactor-ui: shrink Settings", + "--repo", + "https://github.com/example-org/example-repo", + "--dispatcher-name", + "Alex", + ${force ? '"--force",' : ""} + ]); + `; + return spawnSync(process.execPath, ["-e", script], { + cwd: SCRIPTS_DIR, + encoding: "utf8", + env: { + ...process.env, + CURSOR_API_KEY: "test-key", + }, + }); +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/measurements-compare.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-compare.test.ts new file mode 100644 index 0000000..64f4374 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-compare.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; + +import { + applyMeasurementParser, + buildMeasurementEnv, + compareMeasurement, + type MeasurementClaim, +} from "../measurements.ts"; +import type { MeasurementSpec } from "../schemas.ts"; + +const baseSpec: MeasurementSpec = { + name: "LOC(file.ts)", + command: "wc -l file.ts", +}; + +function claim( + after: string, + before = "0", + op: MeasurementClaim["op"] = "→" +): MeasurementClaim { + return { name: baseSpec.name, before, op, after }; +} + +describe("compareMeasurement / applyMeasurementParser / buildMeasurementEnv", () => { + test("Numeric within default tolerance counts as match", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "108", + claim: claim("100"), + }); + expect(result.outcome).toBe("match"); + expect((result.driftFraction ?? 0) > 0).toBeTruthy(); + expect((result.driftFraction ?? 0) <= 0.1).toBeTruthy(); + }); + + test("Numeric outside tolerance flags value-mismatch", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "122", + claim: claim("17"), + }); + expect(result.outcome).toBe("value-mismatch"); + expect(result.detail).toMatch(/numeric drift/); + expect((result.driftFraction ?? 0) > 1).toBeTruthy(); + }); + + test("Custom tolerance widens matching window", () => { + const result = compareMeasurement({ + spec: { ...baseSpec, toleranceFraction: 0.5 }, + measured: "140", + claim: claim("100"), + }); + expect(result.outcome).toBe("match"); + }); + + test("Matching unit suffixes compare numerically", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "2.45 MB", + claim: claim("2.39 MB"), + }); + expect(result.outcome).toBe("match"); + }); + + test("Mismatched units do not pass", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "2.41 KB", + claim: claim("2.39 MB"), + }); + expect(result.outcome).toBe("value-mismatch"); + expect(result.detail).toMatch(/unit mismatch/); + expect(result.detail).toMatch(/MB/); + expect(result.detail).toMatch(/KB/); + }); + + test("Measured bare numeric with claimed unit flags inconsistency", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "2.41", + claim: claim("2.39 MB"), + }); + expect(result.outcome).toBe("value-mismatch"); + expect(result.detail).toMatch(/unit inconsistency/); + }); + + test("Measured unit with claimed bare numeric flags inconsistency", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "2.41 MB", + claim: claim("2.39"), + }); + expect(result.outcome).toBe("value-mismatch"); + expect(result.detail).toMatch(/unit inconsistency/); + }); + + test("Strings collapse whitespace for equality", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "84 passing", + claim: claim("84 passing"), + }); + expect(result.outcome).toBe("match"); + }); + + test("String mismatch when neither side parses as number", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "fail", + claim: claim("pass"), + }); + expect(result.outcome).toBe("value-mismatch"); + expect(result.detail).toMatch(/string mismatch/); + }); + + test("No claim line returns claim-missing", () => { + const result = compareMeasurement({ + spec: baseSpec, + measured: "42", + claim: null, + }); + expect(result.outcome).toBe("claim-missing"); + }); + + test("wc-l parser counts non-empty lines", () => { + const result = applyMeasurementParser({ kind: "wc-l" }, "a\nb\nc\n"); + expect(result).toEqual({ ok: true, value: "3" }); + + const trailingNewline = applyMeasurementParser({ kind: "wc-l" }, "a\nb"); + expect(trailingNewline).toEqual({ ok: true, value: "2" }); + + const empty = applyMeasurementParser({ kind: "wc-l" }, "\n\n"); + expect(empty).toEqual({ ok: true, value: "0" }); + }); + + test("regex parser extracts capture group 1", () => { + const result = applyMeasurementParser( + { kind: "regex", pattern: "^(\\d+)\\s+passing", flags: "m" }, + "ok\n84 passing (3 skipped)\n" + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe("84"); + }); + + test("regex parser with no group captures whole match", () => { + const result = applyMeasurementParser( + { kind: "regex", pattern: "[a-z]+" }, + "BUNDLE 412 bytes" + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe("bytes"); + }); + + test("regex parser without match returns ok false", () => { + const result = applyMeasurementParser( + { kind: "regex", pattern: "totally-not-here" }, + "stdout" + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/did not match/); + }); + + test("Parser default is wc-l", () => { + const result = applyMeasurementParser(undefined, "one\ntwo\n"); + expect(result).toEqual({ ok: true, value: "2" }); + }); + + test("Allowlist plus scratch HOME override drops unsafe env", () => { + const env = buildMeasurementEnv({ + source: { + PATH: "/usr/bin", + HOME: "/Users/example", + LANG: "en_US.UTF-8", + USER: "operator", + SHELL: "/bin/zsh", + CURSOR_API_KEY: "sk-cursor-redacted", + GITHUB_TOKEN: "ghp_redacted", + DB_PASSWORD: "redacted", + AWS_SECRET_ACCESS_KEY: "redacted", + AWS_PROFILE: "default", + NPM_CONFIG_USERCONFIG: "/Users/example/.npmrc", + GH_HOST: "github.com", + EMPTY: undefined, + }, + homeDir: "/tmp/orch-measure-home-abc", + }); + expect(env.HOME).toBe("/tmp/orch-measure-home-abc"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.LANG).toBe("en_US.UTF-8"); + expect(env.USER).toBe("operator"); + expect(env.SHELL).toBe("/bin/zsh"); + expect(env.CURSOR_API_KEY).toBe(undefined); + expect(env.GITHUB_TOKEN).toBe(undefined); + expect(env.DB_PASSWORD).toBe(undefined); + expect(env.AWS_SECRET_ACCESS_KEY).toBe(undefined); + expect(env.AWS_PROFILE).toBe(undefined); + expect(env.NPM_CONFIG_USERCONFIG).toBe(undefined); + expect(env.GH_HOST).toBe(undefined); + expect(env.EMPTY).toBe(undefined); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/measurements-mismatch.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-mismatch.test.ts new file mode 100644 index 0000000..04b08e8 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-mismatch.test.ts @@ -0,0 +1,332 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AgentManager } from "../core/agent-manager.ts"; +import type { TaskState } from "../schemas.ts"; + +const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY; +process.env.CURSOR_API_KEY = "test-key"; + +afterAll(() => { + if (ORIGINAL_API_KEY === undefined) { + delete process.env.CURSOR_API_KEY; + } else { + process.env.CURSOR_API_KEY = ORIGINAL_API_KEY; + } +}); + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Tester", + GIT_AUTHOR_EMAIL: "tester@example.test", + GIT_COMMITTER_NAME: "Tester", + GIT_COMMITTER_EMAIL: "tester@example.test", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + }, + }); +} + +function requireTask(task: TaskState | undefined, name: string): TaskState { + if (!task) throw new Error(`missing task: ${name}`); + return task; +} + +interface FixtureRepo { + bare: string; + bareUrl: string; + branch: string; + cleanup: () => void; +} + +/** Build a local bare repo with `branch` containing `loc` lines in Settings.tsx. */ +function makeFixtureRepo(args: { branch: string; loc: number }): FixtureRepo { + const root = mkdtempSync(join(tmpdir(), "orch-measure-fixture-")); + const bare = join(root, "origin.git"); + const work = join(root, "work"); + mkdirSync(bare, { recursive: true }); + mkdirSync(work, { recursive: true }); + git(bare, ["init", "--bare", "--initial-branch=main"]); + git(work, ["init", "--initial-branch=main"]); + git(work, ["remote", "add", "origin", bare]); + writeFileSync(join(work, "README.md"), "# fixture\n"); + git(work, ["add", "."]); + git(work, ["commit", "-m", "seed"]); + git(work, ["push", "origin", "main"]); + git(work, ["checkout", "-b", args.branch]); + const lines = Array.from({ length: args.loc }, (_, i) => `line ${i + 1}`); + writeFileSync(join(work, "Settings.tsx"), `${lines.join("\n")}\n`); + git(work, ["add", "."]); + git(work, ["commit", "-m", `worker work on ${args.branch}`]); + git(work, ["push", "origin", args.branch]); + return { + bare, + bareUrl: `file://${bare}`, + branch: args.branch, + cleanup: () => { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // best-effort + } + }, + }; +} + +function makeWorkspace(args: { plan: unknown; state: unknown }): string { + const workspace = mkdtempSync(join(tmpdir(), "orch-measure-ws-")); + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify(args.plan, null, 2) + ); + writeFileSync( + join(workspace, "state.json"), + JSON.stringify(args.state, null, 2) + ); + return workspace; +} + +function readAttention(workspace: string): string { + try { + return readFileSync(join(workspace, "attention.log"), "utf8"); + } catch { + return ""; + } +} + +describe("AgentManager.checkWorkerMeasurements", () => { + test("Measurement mismatch is recorded in attention log", async () => { + const fixture = makeFixtureRepo({ + branch: "agent/glass-palette-a", + loc: 290, + }); + try { + const plan = { + goal: "shrink the palette", + rootSlug: "glass-palette", + baseBranch: "main", + repoUrl: fixture.bareUrl, + tasks: [ + { + name: "glass-palette-a", + type: "worker", + scopedGoal: "Shrink the palette.", + measurements: [ + { + name: "LOC(Settings.tsx)", + command: "cat Settings.tsx", + }, + ], + }, + ], + }; + const state = { + rootSlug: "glass-palette", + attention: [], + tasks: [ + { + name: "glass-palette-a", + type: "worker", + branch: fixture.branch, + startingRef: "main", + dependsOn: [], + agentId: "a-1", + runId: "r-1", + parentAgentId: null, + status: "handed-off", + resultStatus: "finished", + handoffPath: null, + startedAt: null, + finishedAt: null, + lastUpdate: null, + note: null, + slackTs: null, + }, + ], + }; + const workspace = makeWorkspace({ plan, state }); + try { + const mgr = await AgentManager.load(workspace); + const worker = requireTask( + mgr.getTask("glass-palette-a"), + "glass-palette-a" + ); + + const handoff = [ + "## Status", + "success", + "## Branch", + `\`${fixture.branch}\``, + "## What I did", + "- shrunk it", + "## Measurements", + "LOC(Settings.tsx): 412 → 395", + "## Notes", + "- ok", + ].join("\n"); + + const checks = await mgr.checkWorkerMeasurements(worker, handoff); + expect(checks).toBeTruthy(); + expect(checks?.length).toBe(1); + expect(checks?.[0].outcome).toBe("value-mismatch"); + expect(checks?.[0].measured).toBe("290"); + expect(checks?.[0].detail).toMatch(/numeric drift/); + + const attention = readAttention(workspace); + expect(attention).toMatch(/measurement_mismatch LOC\(Settings\.tsx\)/); + expect(attention).toMatch(/\[value-mismatch\]/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + } finally { + fixture.cleanup(); + } + }); + + test("Within tolerance matches without attention entry", async () => { + const fixture = makeFixtureRepo({ + branch: "agent/glass-palette-b", + loc: 290, + }); + try { + const plan = { + goal: "shrink", + rootSlug: "glass-palette", + baseBranch: "main", + repoUrl: fixture.bareUrl, + tasks: [ + { + name: "glass-palette-b", + type: "worker", + scopedGoal: "Shrink.", + measurements: [ + { name: "LOC(Settings.tsx)", command: "cat Settings.tsx" }, + ], + }, + ], + }; + const state = { + rootSlug: "glass-palette", + attention: [], + tasks: [ + { + name: "glass-palette-b", + type: "worker", + branch: fixture.branch, + startingRef: "main", + dependsOn: [], + agentId: "a-1", + runId: "r-1", + parentAgentId: null, + status: "handed-off", + resultStatus: "finished", + handoffPath: null, + startedAt: null, + finishedAt: null, + lastUpdate: null, + note: null, + slackTs: null, + }, + ], + }; + const workspace = makeWorkspace({ plan, state }); + try { + const mgr = await AgentManager.load(workspace); + const worker = requireTask( + mgr.getTask("glass-palette-b"), + "glass-palette-b" + ); + + const handoff = [ + "## Status", + "success", + "## Branch", + `\`${fixture.branch}\``, + "## What I did", + "- shrunk it", + "## Measurements", + "LOC(Settings.tsx): 412 → 295", + ].join("\n"); + + const checks = await mgr.checkWorkerMeasurements(worker, handoff); + expect(checks).toBeTruthy(); + expect(checks?.length).toBe(1); + expect(checks?.[0].outcome).toBe("match"); + expect(readAttention(workspace)).not.toMatch(/measurement_mismatch/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + } finally { + fixture.cleanup(); + } + }); + + test("No measurements declared returns null", async () => { + const plan = { + goal: "x", + rootSlug: "no-measurements", + baseBranch: "main", + repoUrl: "https://example.test/never.git", + tasks: [ + { + name: "no-measurements", + type: "worker", + scopedGoal: "x", + }, + ], + }; + const state = { + rootSlug: "no-measurements", + attention: [], + tasks: [ + { + name: "no-measurements", + type: "worker", + branch: "agent/no-measurements-1", + startingRef: "main", + dependsOn: [], + agentId: "a", + runId: "r", + parentAgentId: null, + status: "handed-off", + resultStatus: "finished", + handoffPath: null, + startedAt: null, + finishedAt: null, + lastUpdate: null, + note: null, + slackTs: null, + }, + ], + }; + const workspace = makeWorkspace({ plan, state }); + try { + const mgr = await AgentManager.load(workspace); + const worker = requireTask( + mgr.getTask("no-measurements"), + "no-measurements" + ); + const checks = await mgr.checkWorkerMeasurements( + worker, + "## Status\nsuccess\n" + ); + expect(checks).toBe(null); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/measurements-parser.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-parser.test.ts new file mode 100644 index 0000000..5856622 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/measurements-parser.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; + +import { parseHandoffMeasurements } from "../measurements.ts"; + +describe("parseHandoffMeasurements", () => { + test("Section absent returns null", () => { + const result = parseHandoffMeasurements( + [ + "## Status", + "success", + "## Branch", + "`agent/foo-1`", + "## What I did", + "- stuff", + ].join("\n") + ); + expect(result).toBe(null); + }); + + test("(none) on its own line returns empty claims", () => { + const result = parseHandoffMeasurements( + [ + "## What I did", + "- stuff", + "", + "## Measurements", + "(none)", + "", + "## Notes", + "- whatever", + ].join("\n") + ); + expect(result).toBeTruthy(); + expect(result?.none).toBe(true); + expect(result?.claims).toEqual([]); + expect(result?.unparsed).toEqual([]); + }); + + test("Mixed operators and optional bullet prefix parse", () => { + const result = parseHandoffMeasurements( + [ + "## Measurements", + "- LOC(packages/ui/src/Settings.tsx): 412 → 354", + "pnpm test --filter @example/foo: 84 passing → 84 passing", + "* bundle size: 2.41 MB → 2.39 MB", + " - cold start: 1200ms <= 900ms", + "", + "## Notes", + "- nothing else", + ].join("\n") + ); + expect(result).toBeTruthy(); + expect(result?.none).toBe(false); + expect(result?.claims.length).toBe(4); + expect(result?.claims[0]).toEqual({ + name: "LOC(packages/ui/src/Settings.tsx)", + before: "412", + op: "→", + after: "354", + }); + expect(result?.claims[1]).toEqual({ + name: "pnpm test --filter @example/foo", + before: "84 passing", + op: "→", + after: "84 passing", + }); + expect(result?.claims[2]).toEqual({ + name: "bundle size", + before: "2.41 MB", + op: "→", + after: "2.39 MB", + }); + expect(result?.claims[3]).toEqual({ + name: "cold start", + before: "1200ms", + op: "<=", + after: "900ms", + }); + }); + + test("Unparseable lines land in unparsed", () => { + const result = parseHandoffMeasurements( + [ + "## Measurements", + "- LOC: 412 → 354", + "this line is just prose, no operator", + "another: missing-op-with-newlines", + ].join("\n") + ); + expect(result).toBeTruthy(); + expect(result?.claims.length).toBe(1); + expect(result?.unparsed.length).toBe(2); + expect(result?.unparsed[0]).toMatch(/this line is just prose/); + }); + + test("Final Measurements section terminates at end-of-string", () => { + const result = parseHandoffMeasurements( + ["## Measurements", "LOC: 100 → 80"].join("\n") + ); + expect(result).toBeTruthy(); + expect(result?.claims.length).toBe(1); + expect(result?.claims[0]?.after).toBe("80"); + }); + + test("Less-than-or-equal operator does not collide with less-than", () => { + const result = parseHandoffMeasurements( + ["## Measurements", "p99 latency: 410ms <= 350ms"].join("\n") + ); + expect(result).toBeTruthy(); + expect(result?.claims[0]?.op).toBe("<="); + expect(result?.claims[0]?.after).toBe("350ms"); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts new file mode 100644 index 0000000..2720fe7 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/models-catalog.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; + +import { + defaultModelForType, + isKnownModel, + MODEL_CATALOG, + resolveModelSelection, +} from "../models.ts"; + +describe("MODEL_CATALOG", () => { + test("every catalog entry passes isKnownModel", () => { + for (const profile of MODEL_CATALOG) { + expect(isKnownModel(profile.slug)).toBe(true); + } + }); + + test("slugs are unique", () => { + const slugs = MODEL_CATALOG.map(m => m.slug); + expect(new Set(slugs).size).toBe(slugs.length); + }); + + test("defaultModelForType resolves to a catalog slug for every TaskType", () => { + for (const type of ["worker", "subplanner", "verifier"] as const) { + const slug = defaultModelForType(type); + expect(isKnownModel(slug)).toBe(true); + } + }); + + // Verifiers do focused acceptance-criteria checks; xhigh is overkill there. + test("defaultModelForType('verifier') returns opus high (not xhigh)", () => { + expect(defaultModelForType("verifier")).toBe("claude-opus-4-7"); + }); + + // Subplanners decompose, route, and synthesize; reserve xhigh for them. + // (Root planners pick their own model in their prompt, not via this helper; + // there is no "planner" TaskType.) + test("defaultModelForType('subplanner') returns opus xhigh", () => { + expect(defaultModelForType("subplanner")).toBe( + "claude-opus-4-7-thinking-xhigh" + ); + }); + + test("gpt-5.5-high binds reasoning=high and fast=false", () => { + expect(isKnownModel("gpt-5.5-high")).toBe(true); + const sel = resolveModelSelection("gpt-5.5-high"); + expect(sel.id).toBe("gpt-5.5"); + const params = new Map((sel.params ?? []).map(p => [p.id, p.value])); + expect(params.get("reasoning")).toBe("high"); + expect(params.get("fast")).toBe("false"); + }); + + test("resolveModelSelection round-trips every catalog slug", () => { + for (const profile of MODEL_CATALOG) { + expect(resolveModelSelection(profile.slug)).toEqual(profile.selection); + } + }); + + test("unknown slug falls through to a bare { id } selection", () => { + expect(resolveModelSelection("not-a-real-model")).toEqual({ + id: "not-a-real-model", + }); + }); + + // /v1/models lists `gpt-5.5` with `reasoning` and `fast` parameters; this + // guards against re-introducing the stale "gpt-5.5 absent from /v1/models" + // workaround that left the slug bound to a bare `{ id: "gpt-5.5" }`. + test("gpt-5.5-high-fast binds reasoning=high and fast=true", () => { + const sel = resolveModelSelection("gpt-5.5-high-fast"); + expect(sel.id).toBe("gpt-5.5"); + const params = new Map((sel.params ?? []).map(p => [p.id, p.value])); + expect(params.get("reasoning")).toBe("high"); + expect(params.get("fast")).toBe("true"); + }); + + // Planners pass this slug straight through + // `Task({ model })`. Without an entry, `resolveModelSelection` falls back to + // `{ id: "claude-opus-4-7-thinking-xhigh" }`, which the backend rejects as + // `invalid_model`. + test("claude-opus-4-7-thinking-xhigh binds to opus with thinking + xhigh effort", () => { + const sel = resolveModelSelection("claude-opus-4-7-thinking-xhigh"); + expect(sel.id).toBe("claude-opus-4-7"); + const params = new Map((sel.params ?? []).map(p => [p.id, p.value])); + expect(params.get("thinking")).toBe("true"); + expect(params.get("effort")).toBe("xhigh"); + }); + + test("gpt-5.3-codex-high-fast binds to codex with reasoning=high + fast=true", () => { + const sel = resolveModelSelection("gpt-5.3-codex-high-fast"); + expect(sel.id).toBe("gpt-5.3-codex"); + const params = new Map((sel.params ?? []).map(p => [p.id, p.value])); + expect(params.get("reasoning")).toBe("high"); + expect(params.get("fast")).toBe("true"); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/operator-boundary.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/operator-boundary.test.ts new file mode 100644 index 0000000..5ba0a02 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/operator-boundary.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + assertOperatorModeOrBail, + isOperatorModeEnabled, + loadAllowedSlackThreadOrBail, + operatorModeFlagPath, +} from "../cli/util.ts"; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_ORCHESTRATE_OPERATOR = process.env.ORCHESTRATE_OPERATOR; +const tempDirs: string[] = []; + +afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env.HOME; + else process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_ORCHESTRATE_OPERATOR === undefined) { + delete process.env.ORCHESTRATE_OPERATOR; + } else { + process.env.ORCHESTRATE_OPERATOR = ORIGINAL_ORCHESTRATE_OPERATOR; + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("operator boundary", () => { + test("ignores ORCHESTRATE_OPERATOR without the home flag", () => { + const flagPath = operatorModeFlagPath(useTempHome()); + process.env.ORCHESTRATE_OPERATOR = "1"; + + expect(isOperatorModeEnabled(flagPath)).toBe(false); + expect(() => assertOperatorModeOrBail("test action", flagPath)).toThrow( + /operator-mode/ + ); + }); + + test("requires a current-user 0600 operator flag", () => { + const home = useTempHome(); + mkdirSync(join(home, ".orchestrate"), { recursive: true }); + const flagPath = operatorModeFlagPath(home); + writeFileSync(flagPath, ""); + chmodSync(flagPath, 0o644); + expect(isOperatorModeEnabled(flagPath)).toBe(false); + + chmodSync(flagPath, 0o600); + expect(isOperatorModeEnabled(flagPath)).toBe(true); + expect(() => + assertOperatorModeOrBail("test action", flagPath) + ).not.toThrow(); + }); + + test("does not trust HOME overrides or symlinked flags", () => { + const home = useTempHome(); + mkdirSync(join(home, ".orchestrate"), { recursive: true }); + const realFlag = join(home, "real-operator-mode"); + writeFileSync(realFlag, ""); + chmodSync(realFlag, 0o600); + const symlinkFlag = operatorModeFlagPath(home); + symlinkSync(realFlag, symlinkFlag); + + process.env.HOME = home; + + expect(operatorModeFlagPath()).not.toBe(symlinkFlag); + expect(isOperatorModeEnabled(symlinkFlag)).toBe(false); + }); + + test("loads the workspace Slack thread outside operator mode", () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-operator-workspace-")); + tempDirs.push(workspace); + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "thread guard", + rootSlug: "thread-guard", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + }, + ], + }) + ); + + expect(loadAllowedSlackThreadOrBail(workspace)).toEqual({ + channel: "C123", + threadTs: "111.222", + }); + }); +}); + +function useTempHome(): string { + const home = mkdtempSync(join(tmpdir(), "orch-operator-home-")); + tempDirs.push(home); + delete process.env.ORCHESTRATE_OPERATOR; + return home; +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/probe-models.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/probe-models.test.ts new file mode 100644 index 0000000..347b93c --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/probe-models.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; + +import type { ModelProfile } from "../models.ts"; +import { printProbeResults, probeModelCatalog } from "../tools/probe-models.ts"; + +describe("models --check", () => { + test("reports invalid_model failures from the SDK", async () => { + const catalog = [ + profile("good-model", { id: "good" }), + profile("bad-model", { id: "bad" }), + ]; + const results = await probeModelCatalog("test-key", { + catalog, + agentApi: { + create: async opts => { + const model = (opts as { model: { id: string } }).model; + if (model.id === "bad") { + throw new Error("[invalid_model] bad"); + } + return { + agentId: "bc-good", + send: async () => ({ id: "run-good" }), + }; + }, + getRun: async () => ({ + cancel: async () => {}, + }), + }, + }); + + expect(results).toEqual([ + { profile: catalog[0], ok: true }, + { profile: catalog[1], ok: false, error: "[invalid_model] bad" }, + ]); + }); + + test("printProbeResults returns nonzero failure count", () => { + const catalog = [profile("bad-model", { id: "bad" })]; + const priorLog = console.log; + const priorError = console.error; + const lines: string[] = []; + console.log = (line?: unknown) => { + lines.push(String(line ?? "")); + }; + console.error = (line?: unknown) => { + lines.push(String(line ?? "")); + }; + try { + expect( + printProbeResults([ + { profile: catalog[0], ok: false, error: "[invalid_model] bad" }, + ]) + ).toBe(1); + expect(lines.join("\n")).toContain("[invalid_model] bad"); + } finally { + console.log = priorLog; + console.error = priorError; + } + }); +}); + +function profile(slug: string, selection: ModelProfile["selection"]): ModelProfile { + return { + slug, + selection, + summary: slug, + strengths: [], + speed: "fast", + use: slug, + }; +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/prompt-plan-validation.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/prompt-plan-validation.test.ts new file mode 100644 index 0000000..033abcb --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/prompt-plan-validation.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function runPromptWithPlan(plan: unknown): ReturnType { + const workspace = mkdtempSync(join(tmpdir(), "orchestrate-plan-migration-")); + writeFileSync(join(workspace, "plan.json"), JSON.stringify(plan, null, 2)); + + const cliPath = new URL("../cli.ts", import.meta.url).pathname; + try { + return spawnSync( + process.execPath, + [cliPath, "prompt", workspace, "first-task"], + { + encoding: "utf8", + env: { + ...process.env, + CURSOR_API_KEY: "", + }, + } + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +describe("prompt plan validation", () => { + test("Removed tracker fields print migration guidance", () => { + const result = runPromptWithPlan({ + goal: "preview a prompt", + rootSlug: "preview", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tracker: "linear", + linearTeam: "ENG", + tasks: [ + { + name: "first-task", + type: "worker", + scopedGoal: "Check the prompt renderer.", + }, + ], + }); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/removed plan field/); + expect(result.stderr).not.toMatch(/CURSOR_API_KEY/); + }); + + test("Verifier task without verifies reports schema error", () => { + const result = runPromptWithPlan({ + goal: "preview a prompt", + rootSlug: "preview", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "first-task", + type: "verifier", + scopedGoal: "Check the prompt renderer.", + }, + ], + }); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/tasks\[0\]\.verifies: is required/); + }); + + test("Worker task with verifies reports schema error", () => { + const result = runPromptWithPlan({ + goal: "preview a prompt", + rootSlug: "preview", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "first-task", + type: "worker", + scopedGoal: "Check the prompt renderer.", + verifies: "other-task", + }, + ], + }); + expect(result.status).toBe(2); + expect(result.stderr).toMatch( + /tasks\[0\]\.verifies: is only valid when type is verifier/ + ); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts new file mode 100644 index 0000000..a7fbbd4 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { redactBody } from "../core/redact-body.ts"; + +describe("redactBody", () => { + test("refuses filepath-shaped bodies", () => { + const result = redactBody( + "/workspace/app/src/foo.ts\n/Users/example/repo/package.json\nerror at node_modules/.pnpm/pkg/index.ts" + ); + + expect(result.reasons).toContain("contains /workspace path"); + expect(result.reasons).toContain("contains /Users path"); + expect(result.reasons).toContain("contains .pnpm path"); + expect(result.text).toContain("[redacted-path]"); + }); + + test("refuses log dumps", () => { + const result = redactBody( + [ + "@example/proto:generate: a", + "@example/proto:generate: b", + "@example/proto:generate: c", + "@example/proto:generate: d", + "@example/proto:generate: e", + ].join("\n") + ); + + expect(result.reasons).toContain("looks like a log dump"); + }); + + test("refuses oversized bodies", () => { + const result = redactBody("x".repeat(2_049)); + + expect(result.reasons).toContain("exceeds 2048 character limit"); + }); + + test("refuses bare SHAs but allows backticked SHAs", () => { + const sha = "0123456789abcdef0123456789abcdef01234567"; + + expect(redactBody(sha).reasons).toContain("contains bare 40-char SHA"); + expect(redactBody(`\`${sha}\``).reasons).toEqual([]); + }); + + test("allows concise operational context", () => { + const result = redactBody("blocked: docker rate-limit on redis:7"); + + expect(result).toEqual({ + text: "blocked: docker rate-limit on redis:7", + reasons: [], + }); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/schemas.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/schemas.test.ts new file mode 100644 index 0000000..eca4690 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/schemas.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatZodIssues, + PlanSchema, + parsePlanJson, + parseStateJson, + parseTreeStateJson, + StateSchema, + StopResultSchema, +} from "../schemas.ts"; + +describe("schemas", () => { + test("PlanSchema accepts verifier that targets known task", () => { + const plan = PlanSchema.safeParse({ + goal: "ship a refactor", + rootSlug: "ship-refactor", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + }, + { + name: "verify-worker-task", + type: "verifier", + scopedGoal: "Check the change.", + verifies: "worker-task", + }, + ], + }); + expect(plan.success).toBe(true); + if (plan.success) { + expect(plan.data.tasks?.[1]?.type).toBe("verifier"); + expect(plan.data.tasks?.[1]?.verifies).toBe("worker-task"); + } + }); + + test("PlanSchema rejects verifier that targets missing task", () => { + const plan = PlanSchema.safeParse({ + goal: "bad verifier", + rootSlug: "bad-verifier", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "verify-missing", + type: "verifier", + scopedGoal: "Check the missing task.", + verifies: "missing-task", + }, + ], + }); + expect(plan.success).toBe(false); + if (!plan.success) { + expect(formatZodIssues(plan.error.issues)).toMatch( + /verifies unknown task/ + ); + } + }); + + test("PlanSchema rejects removed tracker fields", () => { + const plan = PlanSchema.safeParse({ + goal: "linear tracking", + rootSlug: "linear-tracking", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tracker: "linear", + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + }, + ], + }); + expect(plan.success).toBe(false); + if (!plan.success) { + expect(formatZodIssues(plan.error.issues)).toMatch(/tracker/); + } + }); + + test("PlanSchema accepts a script-written slackKickoffRef", () => { + const plan = PlanSchema.safeParse({ + goal: "slack visibility", + rootSlug: "slack-visibility", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackKickoffRef: { channel: "C123TEST", ts: "111.222" }, + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + }, + ], + }); + expect(plan.success).toBe(true); + if (plan.success) { + expect(plan.data.slackKickoffRef).toEqual({ + channel: "C123TEST", + ts: "111.222", + }); + } + }); + + test("parsePlanJson preserves script-written task slackTs", () => { + const plan = parsePlanJson( + JSON.stringify({ + goal: "slack mirror", + rootSlug: "slack-mirror", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + slackTs: "1714500000.000100", + }, + ], + }), + "plan.json" + ); + + expect(plan.tasks?.[0]?.slackTs).toBe("1714500000.000100"); + }); + + test("PlanSchema rejects legacy plan.slack with a migration error", () => { + expect(() => + parsePlanJson( + JSON.stringify({ + goal: "old slack config", + rootSlug: "old-slack-config", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slack: { channel: "#orch-runs" }, + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + }, + ], + }), + "plan.json" + ) + ).toThrow(/plan\.slackKickoffRef/); + }); + + test("parsePlanJson gives a clear migration error for tracker-backed plans", () => { + expect(() => + parsePlanJson( + JSON.stringify({ + goal: "old tracker plan", + rootSlug: "old-tracker-plan", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tracker: "linear", + linearTeam: "ENG", + trackerRef: "ROOT-1", + tasks: [ + { + name: "worker-task", + type: "worker", + scopedGoal: "Make the change.", + trackerRef: "PROJ-1", + }, + ], + }), + "plan.json" + ) + ).toThrow(/uses removed plan field\(s\)/); + }); + + test("StateSchema applies nullable defaults", () => { + const state = StateSchema.safeParse({ + rootSlug: "ship-refactor", + tasks: [ + { + name: "worker-task", + type: "worker", + branch: "orch/ship-refactor/worker-task", + startingRef: "main", + dependsOn: [], + status: "pending", + }, + ], + attention: [], + }); + expect(state.success).toBe(true); + if (state.success) { + expect(state.data.tasks[0]?.agentId).toBe(null); + expect(state.data.tasks[0]?.runId).toBe(null); + expect(state.data.tasks[0]?.slackTs).toBe(null); + expect(state.data.tasks[0]?.verification).toBe(null); + } + }); + + test("StateSchema accepts every new verification enum value", () => { + for (const value of [ + "live-ui-verified", + "unit-test-verified", + "type-check-only", + "verifier-blocked", + "verifier-failed", + "not-verified", + ] as const) { + const state = StateSchema.safeParse({ + rootSlug: "ship-refactor", + tasks: [ + { + name: "worker-task", + type: "worker", + branch: "orch/ship-refactor/worker-task", + startingRef: "main", + dependsOn: [], + status: "handed-off", + verification: value, + }, + ], + attention: [], + }); + expect(state.success).toBe(true); + if (state.success) { + expect(state.data.tasks[0]?.verification).toBe(value); + } + } + }); + + test("StateSchema rejects an unknown verification value", () => { + const state = StateSchema.safeParse({ + rootSlug: "ship-refactor", + tasks: [ + { + name: "worker-task", + type: "worker", + branch: "orch/ship-refactor/worker-task", + startingRef: "main", + dependsOn: [], + status: "handed-off", + verification: "pass", + }, + ], + attention: [], + }); + expect(state.success).toBe(false); + if (!state.success) { + expect(formatZodIssues(state.error.issues)).toMatch(/verification/); + } + }); + + test("parseStateJson drops legacy task trackerRef fields", () => { + const state = parseStateJson( + JSON.stringify({ + rootSlug: "ship-refactor", + tasks: [ + { + name: "worker-task", + type: "worker", + branch: "orch/ship-refactor/worker-task", + startingRef: "main", + dependsOn: [], + status: "pending", + trackerRef: "PROJ-1", + }, + ], + attention: [], + }), + "state.json" + ); + + expect(state.tasks[0]?.slackTs).toBe(null); + }); + + test("StopResultSchema requires previousStatus for noop", () => { + const noop = StopResultSchema.safeParse({ + name: "already-done", + action: "noop", + }); + expect(noop.success).toBe(false); + if (!noop.success) { + expect(formatZodIssues(noop.error.issues)).toMatch( + /previousStatus: Required/ + ); + } + }); + + test("parseTreeStateJson drops invalid root and bad tasks", () => { + const state = parseTreeStateJson( + JSON.stringify({ + rootSlug: "not/root", + tasks: [ + { + name: "child-planner", + type: "subplanner", + status: "running", + agentId: "agent-1", + runId: "run-1", + parentAgentId: null, + branch: 42, + }, + { + name: "bad-agent-id", + type: "worker", + status: "running", + agentId: 123, + runId: "run-2", + parentAgentId: null, + }, + ], + attention: [{ at: "not-a-date", message: 123 }], + }), + "state.json" + ); + expect(state.rootSlug).toBe(null); + expect(state.tasks.map(task => task.name)).toEqual(["child-planner"]); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts new file mode 100644 index 0000000..bb2a5e2 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts @@ -0,0 +1,353 @@ +import { afterAll, describe, expect, test } from "bun:test"; + +import { + installSlackWebApiMock, + resetSlackWebApiMock, + slackPlatformError, + slackWebApiCalls, +} from "./support/slack-web-api-mock.ts"; + +installSlackWebApiMock(); + +const { createSlackAdapter } = await import("../adapters/index.ts"); +const TEST_SLACK_CHANNEL = "C123TEST"; + +const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN; +process.env.SLACK_BOT_TOKEN = "xoxb-test"; + +afterAll(() => { + if (ORIGINAL_SLACK_TOKEN === undefined) { + delete process.env.SLACK_BOT_TOKEN; + } else { + process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN; + } +}); + +function adapter( + handler?: ( + method: string, + args: Record + ) => Promise | unknown +): NonNullable> { + resetSlackWebApiMock(handler); + const slack = createSlackAdapter(TEST_SLACK_CHANNEL); + if (!slack) { + throw new Error("expected createSlackAdapter to return a client"); + } + return slack; +} + +describe("SlackApiAdapter", () => { + test("Posts kickoff and task messages with icon_url forwarded to Slack", async () => { + const slack = adapter(); + + const kickoff = await slack.postRunKickoff({ + text: "`root`: ship it", + username: "operator's bot", + iconUrl: "https://example.test/kickoff.png", + }); + const task = await slack.postInThread({ + threadTs: kickoff.ts, + username: "worker-one", + iconUrl: "https://example.test/worker.png", + text: "running", + }); + + const calls = slackWebApiCalls(); + expect(kickoff).toEqual({ + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }); + expect(task).toEqual({ + channel: TEST_SLACK_CHANNEL, + ts: "111.222", + }); + expect(calls.map(call => call.method)).toEqual([ + "chat.postMessage", + "chat.postMessage", + ]); + expect(typeof calls[0].args.client_msg_id).toBe("string"); + expect(calls[0].args).toMatchObject({ + channel: TEST_SLACK_CHANNEL, + text: "`root`: ship it", + username: "operator's bot", + icon_url: "https://example.test/kickoff.png", + }); + expect(calls[0].args.icon_emoji).toBeUndefined(); + expect(typeof calls[1].args.client_msg_id).toBe("string"); + expect(calls[1].args).toMatchObject({ + channel: TEST_SLACK_CHANNEL, + thread_ts: "111.222", + username: "worker-one", + icon_url: "https://example.test/worker.png", + text: "running", + }); + expect(calls[1].args.icon_emoji).toBeUndefined(); + }); + + test("icon_url wins when both iconUrl and iconEmoji are set", async () => { + const slack = adapter(); + await slack.postInThread({ + threadTs: "111.222", + username: "worker-one", + iconUrl: "https://example.test/worker.png", + iconEmoji: ":robot_face:", + text: "running", + }); + const [call] = slackWebApiCalls(); + expect(call.args.icon_url).toBe("https://example.test/worker.png"); + expect(call.args.icon_emoji).toBeUndefined(); + }); + + test("Falls back to icon_emoji when iconUrl is unset", async () => { + const slack = adapter(); + await slack.postInThread({ + threadTs: "111.222", + username: "worker-one", + iconEmoji: ":robot_face:", + text: "running", + }); + const [call] = slackWebApiCalls(); + expect(call.args.icon_emoji).toBe(":robot_face:"); + expect(call.args.icon_url).toBeUndefined(); + }); + + test("Looks up first name by email, falling back gracefully", async () => { + let returnPayload: unknown = { + ok: true, + user: { profile: { first_name: "Alex", real_name: "Alex Doe" } }, + }; + const slack = adapter((method, _args) => { + expect(method).toBe("users.lookupByEmail"); + const value = returnPayload; + if (value instanceof Error) throw value; + return value; + }); + + await expect( + slack.lookupFirstNameByEmail("user@example.com") + ).resolves.toBe("Alex"); + + returnPayload = { + ok: true, + user: { profile: { real_name: " Alex Doe " } }, + }; + await expect( + slack.lookupFirstNameByEmail("user@example.com") + ).resolves.toBe("Alex"); + + returnPayload = { ok: true, user: { name: "alex" } }; + await expect( + slack.lookupFirstNameByEmail("user@example.com") + ).resolves.toBe("alex"); + + returnPayload = new Error("users_not_found"); + await expect( + slack.lookupFirstNameByEmail("user@example.com") + ).resolves.toBeUndefined(); + + returnPayload = { ok: true, user: undefined }; + await expect( + slack.lookupFirstNameByEmail("user@example.com") + ).resolves.toBeUndefined(); + }); + + test("Edits existing task messages", async () => { + await expect( + adapter().editThreadMessage({ + threadTs: "111.222", + ts: "333.444", + text: "done", + }) + ).resolves.toEqual({ + channel: TEST_SLACK_CHANNEL, + ts: "333.444", + }); + expect(slackWebApiCalls()).toEqual([ + { + method: "chat.update", + args: { + channel: TEST_SLACK_CHANNEL, + ts: "333.444", + text: "done", + }, + }, + ]); + }); + + test("Uploads files", async () => { + const slack = adapter((method, args) => { + expect(method).toBe("files.uploadV2"); + expect(args).toMatchObject({ + channel_id: TEST_SLACK_CHANNEL, + thread_ts: "111.222", + filename: "result.txt", + title: "result.txt", + initial_comment: "artifact", + }); + expect(args.file).toBeInstanceOf(Buffer); + return { + ok: true, + files: [ + { + ok: true, + files: [{ id: "F123", permalink: "https://slack.test/file" }], + }, + ], + }; + }); + + const uploaded = await slack.uploadFileToThread({ + threadTs: "111.222", + filename: "result.txt", + content: new TextEncoder().encode("hello"), + initialComment: "artifact", + }); + + expect(uploaded).toEqual({ + fileId: "F123", + permalink: "https://slack.test/file", + }); + expect(slackWebApiCalls().map(call => call.method)).toEqual([ + "files.uploadV2", + ]); + }); + + test("Reads reactions and posts comments", async () => { + const slack = adapter((method, args) => { + if (method === "reactions.get") { + expect(args).toMatchObject({ + channel: "C123", + timestamp: "111.222", + full: true, + }); + return { + ok: true, + message: { + reactions: [{ name: "rotating_light", users: ["U123"] }], + }, + }; + } + expect(method).toBe("chat.postMessage"); + expect(args).toMatchObject({ + channel: TEST_SLACK_CHANNEL, + thread_ts: "111.222", + text: "note", + username: "worker-one", + client_msg_id: "comment-1", + }); + return { ok: true, channel: TEST_SLACK_CHANNEL, ts: "555.666" }; + }); + + await expect( + slack.getReactions({ channel: "C123", ts: "111.222" }) + ).resolves.toEqual({ + reactions: [{ name: "rotating_light", users: ["U123"] }], + }); + await expect( + slack.postCommentInThread({ + threadTs: "111.222", + text: "note", + username: "worker-one", + clientMsgId: "comment-1", + }) + ).resolves.toEqual({ + channel: TEST_SLACK_CHANNEL, + ts: "555.666", + }); + expect(slackWebApiCalls()[0].method).toBe("reactions.get"); + }); + + test("Thread-only methods require threadTs at the type boundary", () => { + type Slack = NonNullable>; + const typeCheckOnly = (slack: Slack) => { + // @ts-expect-error post-kickoff writes must include threadTs. + void slack.postInThread({ + username: "worker-one", + text: "running", + }); + // @ts-expect-error comments cannot fall back to the channel root. + void slack.postCommentInThread({ + text: "note", + }); + // @ts-expect-error uploads cannot fall back to the channel root. + void slack.uploadFileToThread({ + filename: "result.txt", + content: new Uint8Array(), + }); + }; + expect(typeCheckOnly).toBeTypeOf("function"); + }); + + test("Reads thread replies", async () => { + const slack = adapter((method, args) => { + expect(method).toBe("conversations.replies"); + expect(args).toMatchObject({ + channel: "C123", + ts: "111.222", + limit: 20, + cursor: "page-2", + inclusive: true, + }); + return { + ok: true, + messages: [ + { ts: "111.222", text: "root" }, + { ts: "111.333", text: "reply" }, + ], + response_metadata: { next_cursor: "page-3" }, + }; + }); + + await expect( + slack.getThreadReplies({ + channel: "C123", + ts: "111.222", + limit: 20, + cursor: "page-2", + }) + ).resolves.toEqual({ + messages: [ + { ts: "111.222", text: "root" }, + { ts: "111.333", text: "reply" }, + ], + nextCursor: "page-3", + }); + }); + + test("Andon reaction add/remove are idempotent", async () => { + const errors = ["already_reacted", "no_reaction"]; + let call = 0; + const slack = adapter(() => { + throw slackPlatformError(errors[call++] ?? "unexpected"); + }); + + await expect( + slack.addReaction({ + channel: "C123", + ts: "111.222", + name: "rotating_light", + }) + ).resolves.toBeUndefined(); + await expect( + slack.removeReaction({ + channel: "C123", + ts: "111.222", + name: "rotating_light", + }) + ).resolves.toBeUndefined(); + }); + + test("Andon reaction add still surfaces non-benign errors", async () => { + const slack = adapter(() => { + throw slackPlatformError("channel_not_found"); + }); + await expect( + slack.addReaction({ + channel: "C123", + ts: "111.222", + name: "rotating_light", + }) + ).rejects.toThrow(/channel_not_found/); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/slack-channel-boundary.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/slack-channel-boundary.test.ts new file mode 100644 index 0000000..8c3303c --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-channel-boundary.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + resolveKickoffSlackChannelOrBail, + resolveWorkspaceSlackChannelOrBail, +} from "../cli/util.ts"; + +const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN; +const ORIGINAL_SLACK_CHANNEL = process.env.SLACK_CHANNEL_ID; + +afterEach(() => { + if (ORIGINAL_SLACK_TOKEN === undefined) delete process.env.SLACK_BOT_TOKEN; + else process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN; + if (ORIGINAL_SLACK_CHANNEL === undefined) delete process.env.SLACK_CHANNEL_ID; + else process.env.SLACK_CHANNEL_ID = ORIGINAL_SLACK_CHANNEL; +}); + +describe("Slack channel boundary", () => { + test("Token set without a channel fails before Slack initialization", () => { + process.env.SLACK_BOT_TOKEN = "xoxb-test"; + delete process.env.SLACK_CHANNEL_ID; + + expect(() => resolveKickoffSlackChannelOrBail(undefined)).toThrow( + "set --slack-channel or SLACK_CHANNEL_ID, or unset SLACK_BOT_TOKEN to disable Slack" + ); + }); + + test("No token and no channel leaves Slack disabled", () => { + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_CHANNEL_ID; + + expect(resolveKickoffSlackChannelOrBail(undefined)).toBeUndefined(); + }); + + test("No token with a channel accepts the channel for later plan persistence", () => { + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_CHANNEL_ID; + + expect(resolveKickoffSlackChannelOrBail("C123TEST")).toBe("C123TEST"); + }); + + test("Workspace run inherits plan.slackChannel", () => { + process.env.SLACK_BOT_TOKEN = "xoxb-test"; + delete process.env.SLACK_CHANNEL_ID; + const workspace = mkdtempSync(join(tmpdir(), "orch-slack-channel-")); + try { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "channel inheritance", + rootSlug: "channel-inheritance", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + slackChannel: "C123TEST", + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do the work.", + }, + ], + }) + ); + + expect( + resolveWorkspaceSlackChannelOrBail({ workspace, explicit: undefined }) + ).toBe("C123TEST"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/slack-message-format.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/slack-message-format.test.ts new file mode 100644 index 0000000..664c9cc --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-message-format.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatAgentFooter, + formatKickoffText, + kickoffUsername, +} from "../core/agent-manager.ts"; +import type { Plan } from "../schemas.ts"; + +function plan(partial: Partial): Plan { + return { + goal: "default goal", + rootSlug: "default-slug", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + syncStateToGit: true, + ...partial, + } as Plan; +} + +describe("formatAgentFooter", () => { + test("Renders Slack mrkdwn link when agentId is present", () => { + expect(formatAgentFooter("bc-abc123")).toBe( + "" + ); + }); + + test("Returns empty for missing agentId so callers can append unconditionally", () => { + expect(formatAgentFooter(null)).toBe(""); + expect(formatAgentFooter(undefined)).toBe(""); + expect(formatAgentFooter("")).toBe(""); + expect(formatAgentFooter(" ")).toBe(""); + }); +}); + +describe("formatKickoffText", () => { + test("Uses summary when set; one-line shape with footer", () => { + const text = formatKickoffText( + plan({ + goal: "verbose ten-paragraph user goal that should not appear", + summary: "smoke test of the new orchestrate substrate", + rootSlug: "canvas-toy", + selfAgentId: "bc-root", + }) + ); + + expect(text).toBe( + "`canvas-toy`: smoke test of the new orchestrate substrate " + ); + }); + + test("Falls back to truncated goal first line when summary unset", () => { + const longGoal = `${"a".repeat(250)}\n\nrest of the goal`; + const text = formatKickoffText( + plan({ goal: longGoal, rootSlug: "long-goal" }) + ); + + expect(text.startsWith("`long-goal`: ")).toBe(true); + expect(text.endsWith("…")).toBe(true); + // No agent footer when selfAgentId is missing. + expect(text.includes("cursor.com/agents")).toBe(false); + }); + + test("Footer is omitted without selfAgentId", () => { + const text = formatKickoffText( + plan({ summary: "do the thing", rootSlug: "no-agent" }) + ); + expect(text).toBe("`no-agent`: do the thing"); + }); +}); + +describe("kickoffUsername", () => { + test("Uses dispatcher.firstName when set", () => { + expect(kickoffUsername(plan({ dispatcher: { firstName: "Alex" } }))).toBe( + "Alex's bot" + ); + }); + + test("Falls back to 'orchestrate' without dispatcher", () => { + expect(kickoffUsername(plan({}))).toBe("orchestrate"); + }); + + test("Trims whitespace-only first name back to 'orchestrate'", () => { + expect( + kickoffUsername(plan({ dispatcher: { firstName: " " } as never })) + ).toBe("orchestrate"); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/slack-prompt-shape.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/slack-prompt-shape.test.ts new file mode 100644 index 0000000..8eadd63 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-prompt-shape.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildKickoffPrompt } from "../cli/util.ts"; +import { + buildSubplannerPrompt, + buildVerifierPrompt, + buildWorkerPrompt, +} from "../core/prompts.ts"; +import type { Plan } from "../schemas.ts"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PROMPTS_DIR = resolve(SCRIPT_DIR, "../../prompts"); + +const SLOP_PHRASES = [ + // Bot-speak + "I have completed", + "Successfully executed", + "Please find attached", + "Let me know if you need anything else", + "I hope this helps", + // Em dash AI tell + "—", + // Decorative + "✨", + "🎉", + "📊", +]; + +const ALLOWED_FILES_FOR_PHRASE: Record> = { + // slack-block.md documents these phrases explicitly as examples of + // bot-speak to avoid; the meta-rule that names them is allowed to use them. + "I have completed": new Set(["slack-block.md"]), + "Successfully executed": new Set(["slack-block.md"]), + "Please find attached": new Set(["slack-block.md"]), +}; + +describe("Slack prompt shape", () => { + test("Prompt files contain no slop phrases or em dashes", () => { + const files = readdirSync(PROMPTS_DIR).filter(name => name.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + + const offenders: string[] = []; + for (const file of files) { + const body = readFileSync(join(PROMPTS_DIR, file), "utf8"); + for (const phrase of SLOP_PHRASES) { + if (!body.includes(phrase)) continue; + if (ALLOWED_FILES_FOR_PHRASE[phrase]?.has(file)) continue; + offenders.push(`${file}: contains "${phrase}"`); + } + } + + expect(offenders).toEqual([]); + }); + + test("Agent prompts do not expose Slack transport details", () => { + const files = ["worker.md", "subplanner.md", "verifier.md"]; + const offenders: string[] = []; + const forbidden = [ + /Slack/i, + /thread_ts/, + /run thread/i, + /channel/i, + /comment \.\.\. --file/, + /posting status/i, + ]; + + for (const file of files) { + const body = readFileSync(join(PROMPTS_DIR, file), "utf8"); + for (const pattern of forbidden) { + if (pattern.test(body)) offenders.push(`${file}: ${pattern.source}`); + } + } + + expect(offenders).toEqual([]); + }); +}); + +describe("buildKickoffPrompt", () => { + test("Adds dispatcher instruction when firstName is provided", () => { + const prompt = buildKickoffPrompt({ + goal: "ship it", + agentId: "bc-root", + dispatcherFirstName: "Alex", + }); + + expect(prompt).toContain("Operator: Alex."); + expect(prompt).toContain('plan.dispatcher = { firstName: "Alex" }'); + expect(prompt).toContain('"Alex\'s bot"'); + }); + + test("Omits dispatcher instruction when firstName is unset", () => { + const prompt = buildKickoffPrompt({ + goal: "ship it", + agentId: "bc-root", + }); + + expect(prompt).not.toContain("Operator:"); + expect(prompt).not.toContain("dispatcher = {"); + }); + + test("Adds Slack channel instruction when provided", () => { + const prompt = buildKickoffPrompt({ + goal: "ship it", + agentId: "bc-root", + slackChannel: "C123TEST", + }); + + expect(prompt).toContain('plan.slackChannel = "C123TEST"'); + }); + + test("Omits dispatcher instruction for whitespace-only firstName", () => { + const prompt = buildKickoffPrompt({ + goal: "ship it", + agentId: "bc-root", + dispatcherFirstName: " ", + }); + + expect(prompt).not.toContain("Operator:"); + }); + + test("Escapes quotes, backticks, and backslashes in firstName", () => { + const prompt = buildKickoffPrompt({ + goal: "ship it", + agentId: "bc-root", + dispatcherFirstName: 'Alex"; throw new Error("xss', + }); + + // JSON-stringified literal protects the embedded plan.json snippet. + expect(prompt).toContain('firstName: "Alex\\"; throw new Error(\\"xss"'); + // The username string is also escaped via JSON.stringify. + expect(prompt).toContain('"Alex\\"; throw new Error(\\"xss\'s bot"'); + // Prose strips control chars/backticks but doesn't need JSON escaping. + expect(prompt).toContain('Operator: Alex"; throw new Error("xss.'); + }); +}); + +describe("agent prompt transport boundary", () => { + test("Spawned worker prompt omits Slack CLI details", () => { + const prompt = buildWorkerPrompt( + { + name: "frontend-toggle", + type: "worker", + scopedGoal: "Add the toggle.", + }, + "bc-worker-123", + promptCtx({ + slackKickoffRef: { channel: "C123", ts: "111.222" }, + }) + ); + + expect(prompt).not.toContain("--agent-id"); + expect(prompt).not.toContain("C123"); + expect(prompt).not.toContain("111.222"); + }); + + test("Spawned subplanner prompt omits Slack refs", () => { + const prompt = buildSubplannerPrompt( + { + name: "frontend-slice", + type: "subplanner", + scopedGoal: "Own frontend work.", + }, + "bc-subplanner-123", + promptCtx({ + slackKickoffRef: { channel: "C123", ts: "111.222" }, + }) + ); + + expect(prompt).not.toContain("slackKickoffRef"); + expect(prompt).not.toContain("C123"); + expect(prompt).not.toContain("111.222"); + }); + + test("Spawned verifier prompt omits Slack CLI details", () => { + const prompt = buildVerifierPrompt( + { + name: "verify-frontend-toggle", + type: "verifier", + scopedGoal: "Verify the toggle.", + verifies: "frontend-toggle", + }, + "bc-verifier-123", + promptCtx({ + slackKickoffRef: { channel: "C123", ts: "111.222" }, + tasks: [ + { + name: "frontend-toggle", + type: "worker", + scopedGoal: "Add the toggle.", + }, + ], + } as Partial) + ); + + expect(prompt).not.toContain("--agent-id"); + expect(prompt).not.toContain("C123"); + expect(prompt).not.toContain("111.222"); + }); + + test("Preview render omits Slack CLI details", () => { + const prompt = buildWorkerPrompt( + { + name: "frontend-toggle", + type: "worker", + scopedGoal: "Add the toggle.", + }, + undefined, + promptCtx({ + slackKickoffRef: { channel: "C123", ts: "111.222" }, + }) + ); + + expect(prompt).not.toContain("--agent-id"); + expect(prompt).not.toContain("--sender frontend-toggle --workspace"); + expect(prompt).not.toContain('--reason "" --workspace'); + }); +}); + +function promptCtx(planOverrides: Partial) { + const plan: Plan = { + goal: "default goal", + rootSlug: "default-slug", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + syncStateToGit: true, + ...planOverrides, + } as Plan; + return { + plan, + branchForTask: () => "orch/default-slug/frontend-toggle", + getTask: () => undefined, + readHandoff: () => null, + }; +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/support/slack-web-api-mock.ts b/orchestrate/skills/orchestrate/scripts/__tests__/support/slack-web-api-mock.ts new file mode 100644 index 0000000..1c02adf --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/support/slack-web-api-mock.ts @@ -0,0 +1,99 @@ +import { mock } from "bun:test"; + +const ErrorCode = { + PlatformError: "slack_webapi_platform_error", +} as const; + +type Call = { + method: string; + args: Record; +}; + +type Handler = ( + method: string, + args: Record +) => Promise | unknown; +type PlatformError = Error & { + code: string; + data: { ok: false; error: string }; +}; + +const calls: Call[] = []; +let handler: Handler = defaultHandler; + +export function installSlackWebApiMock(): void { + mock.module("@slack/web-api", () => ({ ErrorCode, WebClient })); +} + +export function resetSlackWebApiMock( + nextHandler: Handler = defaultHandler +): void { + calls.length = 0; + handler = nextHandler; +} + +export function slackWebApiCalls(): Call[] { + return calls; +} + +export function slackPlatformError(error: string): PlatformError { + const err = new Error(`An API error occurred: ${error}`) as PlatformError; + err.code = ErrorCode.PlatformError; + err.data = { ok: false, error }; + return err; +} + +class WebClient { + readonly chat = { + postMessage: call.bind(undefined, "chat.postMessage"), + update: call.bind(undefined, "chat.update"), + }; + + readonly conversations = { + replies: call.bind(undefined, "conversations.replies"), + }; + + readonly files = { + uploadV2: call.bind(undefined, "files.uploadV2"), + }; + + readonly reactions = { + add: call.bind(undefined, "reactions.add"), + get: call.bind(undefined, "reactions.get"), + remove: call.bind(undefined, "reactions.remove"), + }; + + readonly users = { + lookupByEmail: call.bind(undefined, "users.lookupByEmail"), + }; +} + +async function call( + method: string, + args: Record +): Promise { + calls.push({ method, args }); + return handler(method, args); +} + +function defaultHandler( + method: string, + args: Record +): Record { + if (method === "files.uploadV2") { + return { + ok: true, + files: [ + { + ok: true, + files: [{ id: "F123", permalink: "https://slack.test/file" }], + }, + ], + }; + } + return { + ok: true, + channel: args.channel, + ts: method === "chat.update" ? args.ts : "111.222", + }; +} diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/verifier-startingref-reconcile.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/verifier-startingref-reconcile.test.ts new file mode 100644 index 0000000..7a065c1 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/verifier-startingref-reconcile.test.ts @@ -0,0 +1,282 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AgentManager } from "../core/agent-manager.ts"; +import { parseHandoffBranch } from "../core/handoff.ts"; +import type { State } from "../schemas.ts"; + +const REPO_URL = "https://github.com/example-org/example-repo"; + +function makeWorkspace(args: { plan: unknown; state?: unknown }): string { + const workspace = mkdtempSync(join(tmpdir(), "orch-reconcile-")); + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify(args.plan, null, 2) + ); + if (args.state !== undefined) { + writeFileSync( + join(workspace, "state.json"), + JSON.stringify(args.state, null, 2) + ); + } + return workspace; +} + +function readState(workspace: string): State { + return JSON.parse(readFileSync(join(workspace, "state.json"), "utf8")); +} + +function readAttentionLog(workspace: string): string { + try { + return readFileSync(join(workspace, "attention.log"), "utf8"); + } catch { + return ""; + } +} + +function requireTask( + task: State["tasks"][number] | undefined, + name: string +): State["tasks"][number] { + if (!task) throw new Error(`missing task: ${name}`); + return task; +} + +const baselinePlan = { + goal: "ship a refactor", + rootSlug: "ship-refactor", + baseBranch: "main", + repoUrl: REPO_URL, + tasks: [ + { + name: "frontend-toggle", + type: "worker", + scopedGoal: "Add the toggle.", + }, + { + name: "verify-frontend-toggle", + type: "verifier", + verifies: "frontend-toggle", + scopedGoal: "Verify the toggle.", + }, + ], +} as const; + +const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY; +process.env.CURSOR_API_KEY = "test-key"; + +afterAll(() => { + if (ORIGINAL_API_KEY === undefined) { + delete process.env.CURSOR_API_KEY; + } else { + process.env.CURSOR_API_KEY = ORIGINAL_API_KEY; + } +}); + +describe("AgentManager.reconcileVerifierStartingRefs", () => { + test("Reconciles verifier startingRef after worker handoff", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const worker = requireTask( + mgr.getTask("frontend-toggle"), + "frontend-toggle" + ); + const verifier = requireTask( + mgr.getTask("verify-frontend-toggle"), + "verify-frontend-toggle" + ); + expect(verifier.startingRef).toBe("orch/ship-refactor/frontend-toggle"); + + const actualBranch = "agent/frontend-toggle-abcdef"; + mgr.touch(worker, { branch: actualBranch, status: "handed-off" }); + mgr.reconcileVerifierStartingRefs({ + updatedName: "frontend-toggle", + newBranch: actualBranch, + }); + + expect(mgr.getTask("verify-frontend-toggle")?.startingRef).toBe( + actualBranch + ); + const persisted = readState(workspace); + expect( + persisted.tasks.find(t => t.name === "verify-frontend-toggle") + ?.startingRef + ).toBe(actualBranch); + const attention = readAttentionLog(workspace); + expect(attention).toMatch( + /verify-frontend-toggle: startingRef reconciled/ + ); + expect(attention).toMatch(/agent\/frontend-toggle-abcdef/); + + // Idempotent: re-running should not flip the value or emit a duplicate + // attention entry. + const beforeCount = (attention.match(/startingRef reconciled/g) ?? []) + .length; + mgr.reconcileVerifierStartingRefs({ + updatedName: "frontend-toggle", + newBranch: actualBranch, + }); + const afterCount = ( + readAttentionLog(workspace).match(/startingRef reconciled/g) ?? [] + ).length; + expect(afterCount).toBe(beforeCount); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Planner-authored startingRef wins", async () => { + const planWithOverride = { + ...baselinePlan, + tasks: [ + baselinePlan.tasks[0], + { + ...baselinePlan.tasks[1], + startingRef: "orch/ship-refactor/release-train", + }, + ], + }; + const workspace = makeWorkspace({ plan: planWithOverride }); + try { + const mgr = await AgentManager.load(workspace); + const verifier = requireTask( + mgr.getTask("verify-frontend-toggle"), + "verify-frontend-toggle" + ); + expect(verifier.startingRef).toBe("orch/ship-refactor/release-train"); + + mgr.reconcileVerifierStartingRefs({ + updatedName: "frontend-toggle", + newBranch: "agent/frontend-toggle-abcdef", + }); + + expect(mgr.getTask("verify-frontend-toggle")?.startingRef).toBe( + "orch/ship-refactor/release-train" + ); + expect(readAttentionLog(workspace)).not.toMatch( + /verify-frontend-toggle: startingRef reconciled/ + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Handoff body branch reconciles verifier startingRef", async () => { + const workspace = makeWorkspace({ plan: baselinePlan }); + try { + const mgr = await AgentManager.load(workspace); + const worker = requireTask( + mgr.getTask("frontend-toggle"), + "frontend-toggle" + ); + const placeholder = "orch/ship-refactor/frontend-toggle"; + expect(worker.branch).toBe(placeholder); + + const handoffBody = [ + "## Status", + "success", + "## Branch", + "`agent/foo-abc1`", + "## What I did", + "- shipped", + "## Measurements", + "(none)", + ].join("\n"); + + // Pre-fix replay: with `firstRunBranch(rr)` empty (the SDK behavior + // for worker runs), the OLD precedence rule fell back to s.branch + // (still the placeholder) and the reconcile guard short-circuited. + // Document the broken behavior so a future revert reads as red. + mgr.reconcileVerifierStartingRefs({ + updatedName: "frontend-toggle", + newBranch: placeholder, + }); + expect(mgr.getTask("verify-frontend-toggle")?.startingRef).toBe( + placeholder + ); + + // Post-fix flow: parseHandoffBranch(body) wins over an empty + // firstRunBranch(rr), so reconcile sees the actual pushed branch. + const parsed = parseHandoffBranch(handoffBody); + expect(parsed).toBe("agent/foo-abc1"); + const runBranch = parsed ?? worker.branch; + mgr.touch(worker, { branch: runBranch, status: "handed-off" }); + mgr.reconcileVerifierStartingRefs({ + updatedName: "frontend-toggle", + newBranch: runBranch, + }); + + expect(mgr.getTask("verify-frontend-toggle")?.startingRef).toBe( + "agent/foo-abc1" + ); + expect(readAttentionLog(workspace)).toMatch( + /verify-frontend-toggle: startingRef reconciled/ + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Load-time sweep reconciles persisted verifier rows", async () => { + const seededState = { + rootSlug: "ship-refactor", + attention: [], + tasks: [ + { + name: "frontend-toggle", + type: "worker", + branch: "agent/frontend-toggle-abcdef", + startingRef: "main", + dependsOn: [], + agentId: "agent-1", + runId: "run-1", + parentAgentId: null, + status: "handed-off", + resultStatus: "finished", + handoffPath: "handoffs/frontend-toggle.md", + startedAt: "2026-04-22T00:00:00.000Z", + finishedAt: "2026-04-22T00:05:00.000Z", + lastUpdate: "2026-04-22T00:05:00.000Z", + note: null, + slackTs: null, + }, + { + name: "verify-frontend-toggle", + type: "verifier", + branch: "orch/ship-refactor/verify-frontend-toggle", + startingRef: "orch/ship-refactor/frontend-toggle", + dependsOn: ["frontend-toggle"], + agentId: null, + runId: null, + parentAgentId: null, + status: "pending", + resultStatus: null, + handoffPath: null, + startedAt: null, + finishedAt: null, + lastUpdate: null, + note: null, + slackTs: null, + }, + ], + }; + + const workspace = makeWorkspace({ plan: baselinePlan, state: seededState }); + try { + await AgentManager.load(workspace); + const persisted = readState(workspace); + expect( + persisted.tasks.find(t => t.name === "verify-frontend-toggle") + ?.startingRef + ).toBe("agent/frontend-toggle-abcdef"); + expect(readAttentionLog(workspace)).toMatch( + /verify-frontend-toggle: startingRef reconciled/ + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/wait-handoff-failure.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/wait-handoff-failure.test.ts new file mode 100644 index 0000000..95acaa6 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/wait-handoff-failure.test.ts @@ -0,0 +1,319 @@ +import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Single `mock.module` install that delegates to a module-local +// `currentRun` the tests swap, since `agent-manager.ts` caches the SDK +// reference after the first `loadSDK()`. + +const SDK_MOCK = "@cursor/sdk"; + +interface FakeRunResult { + id: string; + status: "finished" | "error"; + result?: string; + durationMs?: number; + error?: string; + git?: { branches: Array<{ branch?: string | null }> }; +} + +interface FakeRunOpts { + id: string; + agentId: string; + runResult: FakeRunResult; + streamChunks?: Array<{ type: string; [key: string]: unknown }>; +} + +let currentRun: FakeRunOpts | null = null; + +function setFakeRun(opts: FakeRunOpts): void { + currentRun = opts; +} + +function makeFakeRun(opts: FakeRunOpts) { + return { + id: opts.id, + agentId: opts.agentId, + status: opts.runResult.status, + stream: async function* () { + for (const chunk of opts.streamChunks ?? []) yield chunk; + }, + wait: async () => opts.runResult, + }; +} + +mock.module(SDK_MOCK, () => ({ + Agent: { + create: async () => { + if (!currentRun) throw new Error("SDK mock: setFakeRun() first"); + return { + agentId: currentRun.agentId, + send: async () => + makeFakeRun(currentRun ?? (null as unknown as FakeRunOpts)), + [Symbol.asyncDispose]: () => Promise.resolve(), + }; + }, + getRun: async () => { + if (!currentRun) throw new Error("SDK mock: setFakeRun() first"); + return makeFakeRun(currentRun); + }, + }, + CursorAgentError: class CursorAgentError extends Error {}, +})); + +// Earlier test files may have pinned `Agent` to the real SDK. Reset so +// our mock above reaches the SUT. +const { AgentManager, __resetSDKForTests } = await import( + "../core/agent-manager.ts" +); +__resetSDKForTests(); + +const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY; + +beforeAll(() => { + process.env.CURSOR_API_KEY = "test-key"; +}); + +afterAll(() => { + if (ORIGINAL_API_KEY === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = ORIGINAL_API_KEY; +}); + +afterEach(() => { + currentRun = null; +}); + +function writePlan(workspace: string) { + writeFileSync( + join(workspace, "plan.json"), + JSON.stringify({ + goal: "prove failure handoffs", + rootSlug: "fail-test", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + tasks: [ + { + name: "crashy-worker", + type: "worker", + scopedGoal: "Crash please.", + }, + ], + }) + ); +} + +describe("waitAndHandoff post-mortem paths", () => { + test("Error run writes -failure.md and marks task error", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-fail-error-")); + writePlan(workspace); + setFakeRun({ + id: "run-fail", + agentId: "bc-fail", + runResult: { + id: "run-fail", + status: "error", + error: "fetch failed: ECONNRESET", + durationMs: 20_000, + result: "", + git: { branches: [{ branch: "agent/crashy-worker-fail" }] }, + }, + }); + try { + const mgr = await AgentManager.load(workspace); + const def = mgr.plan.tasks?.find(t => t.name === "crashy-worker"); + if (!def) throw new Error("missing task def"); + const spawned = await mgr.spawnTask(def); + if (!spawned) throw new Error("spawn failed"); + await mgr.waitAndHandoff(spawned); + + const task = mgr.getTask("crashy-worker"); + expect(task?.status).toBe("error"); + + const failurePath = join( + workspace, + "handoffs", + "crashy-worker-failure.md" + ); + expect(existsSync(failurePath)).toBe(true); + const body = readFileSync(failurePath, "utf8"); + expect(body).toContain("# crashy-worker failure handoff"); + expect(body).toContain("Failure mode: network-drop"); + expect(body).toContain("SDK error: fetch failed: ECONNRESET"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + // Sidecar fires when the run finishes without a ## Status section. + test("Finished-without-handoff writes -finished-no-handoff.md", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-fail-finished-")); + writePlan(workspace); + setFakeRun({ + id: "run-silent", + agentId: "bc-silent", + runResult: { + id: "run-silent", + status: "finished", + result: "I did some stuff, no headings though.", + durationMs: 5_000, + git: { branches: [{ branch: "agent/crashy-worker-silent" }] }, + }, + }); + try { + const mgr = await AgentManager.load(workspace); + const def = mgr.plan.tasks?.find(t => t.name === "crashy-worker"); + if (!def) throw new Error("missing task def"); + const spawned = await mgr.spawnTask(def); + if (!spawned) throw new Error("spawn failed"); + await mgr.waitAndHandoff(spawned); + + const sidecar = join( + workspace, + "handoffs", + "crashy-worker-finished-no-handoff.md" + ); + expect(existsSync(sidecar)).toBe(true); + const body = readFileSync(sidecar, "utf8"); + expect(body).toContain("finished without handoff"); + expect(body).toContain("no headings though"); + + // Sidecar is additive; the regular .md still carries the raw body. + const regularPath = join(workspace, "handoffs", "crashy-worker.md"); + expect(existsSync(regularPath)).toBe(true); + expect(readFileSync(regularPath, "utf8")).toContain( + "I did some stuff, no headings though." + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("Recover on restart against already-terminated agent writes sidecar", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-fail-recover-")); + writePlan(workspace); + writeFileSync( + join(workspace, "state.json"), + JSON.stringify({ + rootSlug: "fail-test", + attention: [], + tasks: [ + { + name: "crashy-worker", + type: "worker", + branch: "orch/fail-test/crashy-worker", + startingRef: "main", + dependsOn: [], + agentId: "bc-dead", + runId: "run-dead", + parentAgentId: null, + status: "running", + resultStatus: null, + handoffPath: null, + startedAt: "2026-04-30T00:00:00.000Z", + finishedAt: null, + lastUpdate: "2026-04-30T00:01:00.000Z", + note: null, + slackTs: null, + }, + ], + }) + ); + setFakeRun({ + id: "run-dead", + agentId: "bc-dead", + runResult: { + id: "run-dead", + status: "error", + error: "container terminated: out of memory", + durationMs: 42_000, + result: "", + git: { branches: [] }, + }, + }); + try { + const mgr = await AgentManager.load(workspace); + const task = mgr.getTask("crashy-worker"); + if (!task) throw new Error("missing task"); + const rec = await mgr.recoverRunning(task); + if (!rec) throw new Error("recover returned null"); + await mgr.waitAndHandoff(rec); + + expect(mgr.getTask("crashy-worker")?.status).toBe("error"); + const failurePath = join( + workspace, + "handoffs", + "crashy-worker-failure.md" + ); + expect(existsSync(failurePath)).toBe(true); + const body = readFileSync(failurePath, "utf8"); + expect(body).toContain("Failure mode: oom"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + // recoverRunning's orphan path returns null without going through + // waitAndHandoff. exit-on-error still fires on the resulting error + // transition, so the sidecar must exist for the planner. + test("Orphan recoverRunning still writes -failure.md", async () => { + const workspace = mkdtempSync(join(tmpdir(), "orch-fail-orphan-")); + writePlan(workspace); + writeFileSync( + join(workspace, "state.json"), + JSON.stringify({ + rootSlug: "fail-test", + attention: [], + tasks: [ + { + name: "crashy-worker", + type: "worker", + branch: "orch/fail-test/crashy-worker", + startingRef: "main", + dependsOn: [], + agentId: null, + runId: null, + parentAgentId: null, + status: "running", + resultStatus: null, + handoffPath: null, + startedAt: "2026-04-30T00:00:00.000Z", + finishedAt: null, + lastUpdate: "2026-04-30T00:01:00.000Z", + note: "last heartbeat before crash", + slackTs: null, + }, + ], + }) + ); + try { + const mgr = await AgentManager.load(workspace); + const task = mgr.getTask("crashy-worker"); + if (!task) throw new Error("missing task"); + const rec = await mgr.recoverRunning(task); + expect(rec).toBeNull(); + + expect(mgr.getTask("crashy-worker")?.status).toBe("error"); + const failurePath = join( + workspace, + "handoffs", + "crashy-worker-failure.md" + ); + expect(existsSync(failurePath)).toBe(true); + const body = readFileSync(failurePath, "utf8"); + expect(body).toContain("# crashy-worker failure handoff"); + expect(body).toContain("orphaned"); + expect(body).toContain( + "Last activity: 2026-04-30T00:01:00.000Z - last heartbeat before crash" + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/watchdog-inspect.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/watchdog-inspect.test.ts new file mode 100644 index 0000000..b80f256 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/watchdog-inspect.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatToolCallIdleWarning, + inspectRunStream, +} from "../core/agent-manager.ts"; + +describe("watchdog tool-call idle warning", () => { + test("Formats last-tool-call timestamp", () => { + const warning = formatToolCallIdleWarning({ + taskLabel: "subplan-x", + idleMs: 600_000, + lastToolCallAt: Date.parse("2026-04-30T01:00:00.000Z"), + waitStartedAt: Date.parse("2026-04-30T00:55:00.000Z"), + }); + expect(warning).toBe( + "subplan-x: tool_call idle 600000ms; last=2026-04-30T01:00:00.000Z" + ); + }); +}); + +describe("inspectRunStream", () => { + test("Aggregates assistant text and tool-call liveness", async () => { + async function* stream() { + yield { + type: "status", + status: "running", + }; + yield { + type: "assistant", + message: { content: [{ type: "text", text: "hel" }] }, + }; + yield { + type: "assistant", + message: { content: [{ type: "text", text: "lo" }] }, + }; + yield { + type: "tool_call", + name: "Shell", + status: "started", + call_id: "call-1", + args: { command: "echo ok", apiKey: "secret-value" }, + }; + } + + const inspection = await inspectRunStream({ + run: { stream } as never, + task: "worker-a", + agentId: "agent-1", + runId: "run-1", + timeoutMs: 100, + }); + + expect(inspection.streamed_messages).toEqual(["hello"]); + expect(inspection.tool_calls_total).toBe(1); + expect(inspection.tool_calls_last_5min).toBe(1); + expect(inspection.last_assistant_text_snippet).toBe("hello"); + expect(inspection.last_tool_call).toMatchObject({ + type: "tool_call", + name: "Shell", + call_id: "call-1", + }); + expect(inspection.last_tool_call?.payload_snippet).not.toContain( + "secret-value" + ); + }); + + test("Reports truncated=true when full payload sits one char past the cap", async () => { + // Build a payload whose redacted JSON is exactly 1_001 chars long. The + // older `snippet.length { + test("workers must use the per-task branch", async () => { + const workspace = writeWorkspace(planFixture()); + const priorApiKey = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "test-key"; + try { + const mgr = await AgentManager.load(workspace); + const task = mgr.getTask("worker-one"); + if (!task) throw new Error("worker-one missing"); + + expect(mgr.branchForTask(task)).toBe("orch/refactor-ui/worker-one"); + + mgr.touch(task, { branch: "orch/refactor-ui/slice-a" }); + expect(() => mgr.branchForTask(task)).toThrow( + "worker-one: branch must be orch/refactor-ui/worker-one, got orch/refactor-ui/slice-a" + ); + } finally { + if (priorApiKey === undefined) { + delete process.env.CURSOR_API_KEY; + } else { + process.env.CURSOR_API_KEY = priorApiKey; + } + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("merge workers serialize dependency branches into one slice", () => { + const plan = planFixture(); + const mergeTask = requireTask(plan, "merge-slice-a"); + + expect(mergeWorkerTargetBranch(plan, mergeTask)).toBe( + "orch/refactor-ui/slice-a" + ); + expect(mergeWorkerSourceBranches(plan, mergeTask)).toEqual([ + "orch/refactor-ui/worker-one", + "orch/refactor-ui/worker-two", + ]); + }); + + test("worker prompt names the only branch a normal worker may push", () => { + const plan = planFixture(); + const task = requireTask(plan, "worker-one"); + const prompt = buildWorkerPrompt(task, "bc-worker", promptContext(plan)); + + expect(prompt).toContain("Push exactly `orch/refactor-ui/worker-one`"); + }); + + test("merge worker prompt lists source branches in dependency order", () => { + const plan = planFixture(); + const task = requireTask(plan, "merge-slice-a"); + const prompt = buildWorkerPrompt(task, "bc-merge", promptContext(plan)); + + expect(prompt).toContain("This is a merge worker for slice `slice-a`"); + expect(prompt).toContain( + "Merge dependency branches into `orch/refactor-ui/slice-a` one at a time" + ); + expect(prompt.indexOf("orch/refactor-ui/worker-one")).toBeLessThan( + prompt.indexOf("orch/refactor-ui/worker-two") + ); + }); +}); + +function planFixture(): Plan { + return { + goal: "merge worker test", + rootSlug: "refactor-ui", + baseBranch: "main", + repoUrl: "https://github.com/example-org/example-repo", + syncStateToGit: false, + tasks: [ + { + name: "worker-one", + type: "worker", + scopedGoal: "Do one.", + }, + { + name: "worker-two", + type: "worker", + scopedGoal: "Do two.", + }, + { + name: "merge-slice-a", + type: "worker", + scopedGoal: "Merge accepted worker branches into slice-a.", + dependsOn: ["worker-one", "worker-two"], + }, + ], + }; +} + +function writeWorkspace(plan: Plan): string { + const workspace = mkdtempSync(join(tmpdir(), "orch-worker-branches-")); + writeFileSync(join(workspace, "plan.json"), JSON.stringify(plan, null, 2)); + return workspace; +} + +function requireTask(plan: Plan, name: string): PlanTask { + const task = (plan.tasks ?? []).find(task => task.name === name); + if (!task) throw new Error(`${name} missing`); + return task; +} + +function promptContext(plan: Plan) { + return { + plan, + branchForTask: (task: PlanTask | TaskState) => + plannedBranchForTask(plan, task), + getTask: () => undefined, + readHandoff: () => null, + }; +} diff --git a/orchestrate/skills/orchestrate/scripts/adapters/README.md b/orchestrate/skills/orchestrate/scripts/adapters/README.md new file mode 100644 index 0000000..dd01b10 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/adapters/README.md @@ -0,0 +1,23 @@ +# Adapters + +Adapters are external IO shells. They do not own orchestration state. + +## SlackAdapter + +`SlackAdapter` is the only adapter orchestrate ships. Slack is the human-visibility layer: + +- post the run kickoff message +- post or edit one task message per task in the kickoff thread +- upload files when an operator explicitly asks +- read reactions for Andon +- post free-form comments in the run thread + +`postRunKickoff` is the only adapter method that writes to the channel root. The CLI resolves the channel once, then constructs the adapter with that channel. Every later Slack write takes a `threadTs` and stays in the same run thread. + +Orchestrate owns Slack status mirrors, Andon, and the comment retry queue. Agents can still call MCPs directly for Linear, GitHub, Slack, Notion, and other ad-hoc external work. Those systems are not adapter destinations. + +## Comment retry queue + +`comment-retry-queue.json` stores required comments by destination string. The valid Slack shape is `slack::`. Workspace calls validate the destination against `plan.slackKickoffRef` before posting or draining. + +Routine lifecycle mirrors are best effort. Required comments retry with the queue's backoff schedule. diff --git a/orchestrate/skills/orchestrate/scripts/adapters/index.ts b/orchestrate/skills/orchestrate/scripts/adapters/index.ts new file mode 100644 index 0000000..4d406d2 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/adapters/index.ts @@ -0,0 +1,9 @@ +import { createSlackWebClient } from "./slack/client.ts"; +import { SlackApiAdapter } from "./slack/index.ts"; +import type { SlackAdapter } from "./types.ts"; + +export function createSlackAdapter(channelId: string): SlackAdapter | undefined { + const client = createSlackWebClient(); + if (!client) return undefined; + return new SlackApiAdapter(client, channelId); +} diff --git a/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts b/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts new file mode 100644 index 0000000..918ac53 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts @@ -0,0 +1,19 @@ +import { WebClient } from "@slack/web-api"; + +export function createSlackWebClient(): WebClient | undefined { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + console.error( + "[orchestrate] SLACK_BOT_TOKEN not set; Slack visibility disabled" + ); + return undefined; + } + return new WebClient(token, { + retryConfig: { + retries: 5, + factor: 2, + minTimeout: 1000, + maxTimeout: 60_000, + }, + }); +} diff --git a/orchestrate/skills/orchestrate/scripts/adapters/slack/index.ts b/orchestrate/skills/orchestrate/scripts/adapters/slack/index.ts new file mode 100644 index 0000000..e694e58 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/adapters/slack/index.ts @@ -0,0 +1,296 @@ +import { randomUUID } from "node:crypto"; +import { + type ChatPostMessageResponse, + type ChatUpdateResponse, + type ConversationsRepliesResponse, + ErrorCode, + type FilesCompleteUploadExternalResponse, + type ReactionsGetResponse, + type UsersLookupByEmailResponse, + type WebAPICallResult, + type WebClient, +} from "@slack/web-api"; + +import type { SlackAdapter, SlackMessageRef } from "../types.ts"; + +export type SlackWebClient = Pick< + WebClient, + "chat" | "conversations" | "files" | "reactions" | "users" +>; + +type FilesUploadV2Response = WebAPICallResult & { + files?: FilesCompleteUploadExternalResponse[]; +}; + +export class SlackApiAdapter implements SlackAdapter { + constructor( + private readonly client: SlackWebClient, + private readonly channelId: string + ) {} + + async postRunKickoff(args: { + text: string; + username: string; + iconUrl?: string; + iconEmoji?: string; + }): Promise { + return this.postChannelRootMessage({ + text: args.text, + username: args.username, + iconUrl: args.iconUrl, + iconEmoji: args.iconEmoji, + clientMsgId: randomUUID(), + }); + } + + async lookupFirstNameByEmail(email: string): Promise { + try { + const payload = (await this.client.users.lookupByEmail({ + email, + })) as UsersLookupByEmailResponse; + const profile = payload.user?.profile; + const candidate = + profile?.first_name || + profile?.real_name || + profile?.display_name || + payload.user?.real_name || + payload.user?.name; + const first = candidate?.trim().split(/\s+/)[0]; + return first && first.length > 0 ? first : undefined; + } catch { + return undefined; + } + } + + async postInThread(args: { + threadTs: string; + username: string; + iconUrl?: string; + iconEmoji?: string; + text: string; + }): Promise { + return this.postMessageInThread({ + threadTs: args.threadTs, + text: args.text, + username: args.username, + iconUrl: args.iconUrl, + iconEmoji: args.iconEmoji, + clientMsgId: randomUUID(), + }); + } + + async editThreadMessage(args: { + threadTs: string; + ts: string; + text: string; + }): Promise { + // Keep edits on the same thread-only adapter shape as posts and uploads. + void args.threadTs; + const updated = await this.client.chat.update({ + channel: this.channelId, + ts: args.ts, + text: args.text, + }); + return messageRefFromResponse(updated, { + method: "chat.update", + fallback: { channel: this.channelId, ts: args.ts }, + }); + } + + async uploadFileToThread(args: { + threadTs: string; + filename: string; + content: Buffer | Uint8Array; + initialComment?: string; + }): Promise<{ fileId: string; permalink: string }> { + const uploaded = (await this.client.files.uploadV2({ + channel_id: this.channelId, + thread_ts: args.threadTs, + filename: args.filename, + title: args.filename, + file: Buffer.from(args.content), + ...(args.initialComment ? { initial_comment: args.initialComment } : {}), + })) as FilesUploadV2Response; + const file = uploaded.files + ?.flatMap(completion => completion.files ?? []) + .find(candidate => candidate.id && candidate.permalink); + if (!file?.id || !file.permalink) { + throw new Error("Slack files.uploadV2 did not return a permalink"); + } + return { + fileId: file.id, + permalink: file.permalink, + }; + } + + async getReactions(args: SlackMessageRef): Promise<{ + reactions: { name: string; users: string[] }[]; + }> { + const payload = (await this.client.reactions.get({ + channel: args.channel, + timestamp: args.ts, + full: true, + })) as ReactionsGetResponse; + return { + reactions: (payload.message?.reactions ?? []).flatMap(reaction => + reaction.name + ? [{ name: reaction.name, users: reaction.users ?? [] }] + : [] + ), + }; + } + + async getThreadReplies( + args: SlackMessageRef & { + limit: number; + cursor?: string; + latest?: string; + } + ): Promise<{ + messages: { ts: string; text: string }[]; + nextCursor?: string; + }> { + const payload = (await this.client.conversations.replies({ + channel: args.channel, + ts: args.ts, + limit: args.limit, + cursor: args.cursor, + inclusive: true, + latest: args.latest, + })) as ConversationsRepliesResponse; + return { + messages: (payload.messages ?? []).flatMap(message => + message.ts && typeof message.text === "string" + ? [{ ts: message.ts, text: message.text }] + : [] + ), + nextCursor: payload.response_metadata?.next_cursor || undefined, + }; + } + + async postCommentInThread(args: { + threadTs: string; + text: string; + username?: string; + clientMsgId?: string; + }): Promise { + return this.postMessageInThread(args); + } + + async addReaction(args: SlackMessageRef & { name: string }): Promise { + await this.callIgnoringSlackError( + () => + this.client.reactions.add({ + channel: args.channel, + timestamp: args.ts, + name: args.name, + }), + "already_reacted" + ); + } + + async removeReaction( + args: SlackMessageRef & { name: string } + ): Promise { + await this.callIgnoringSlackError( + () => + this.client.reactions.remove({ + channel: args.channel, + timestamp: args.ts, + name: args.name, + }), + "no_reaction" + ); + } + + private async callIgnoringSlackError( + call: () => Promise, + benignError: string + ): Promise { + try { + await call(); + } catch (err) { + if (slackErrorCode(err) === benignError) return; + if (err instanceof Error && err.message.includes(`: ${benignError}`)) { + return; + } + throw err; + } + } + + private async postChannelRootMessage(args: { + text: string; + username?: string; + iconUrl?: string; + iconEmoji?: string; + clientMsgId?: string; + }): Promise { + const payload = await this.client.chat.postMessage({ + channel: this.channelId, + text: args.text, + ...(args.username ? { username: args.username } : {}), + ...iconOverride(args), + ...(args.clientMsgId ? { client_msg_id: args.clientMsgId } : {}), + }); + return messageRefFromResponse(payload, { method: "chat.postMessage" }); + } + + private async postMessageInThread(args: { + threadTs: string; + text: string; + username?: string; + iconUrl?: string; + iconEmoji?: string; + clientMsgId?: string; + }): Promise { + const payload = await this.client.chat.postMessage({ + channel: this.channelId, + text: args.text, + thread_ts: args.threadTs, + ...(args.username ? { username: args.username } : {}), + ...iconOverride(args), + ...(args.clientMsgId ? { client_msg_id: args.clientMsgId } : {}), + }); + return messageRefFromResponse(payload, { method: "chat.postMessage" }); + } +} + +// Slack's `Icon` is `IconURL | IconEmoji` (each variant `never`s the other), +// so the spread must emit at most one key. `icon_url` wins when both are set, +// matching Slack's server-side precedence. +function iconOverride(args: { + iconUrl?: string; + iconEmoji?: string; +}): { icon_url: string } | { icon_emoji: string } | Record { + if (args.iconUrl) return { icon_url: args.iconUrl }; + if (args.iconEmoji) return { icon_emoji: args.iconEmoji }; + return {}; +} + +function messageRefFromResponse( + payload: ChatPostMessageResponse | ChatUpdateResponse, + args: { method: string; fallback?: SlackMessageRef } +): SlackMessageRef { + const channel = payload.channel ?? args.fallback?.channel; + const ts = payload.ts ?? args.fallback?.ts; + if (!channel || !ts) { + throw new Error(`Slack ${args.method} did not return a message reference`); + } + return { channel, ts }; +} + +function slackErrorCode(err: unknown): string | undefined { + if ( + !(err instanceof Error) || + !("code" in err) || + err.code !== ErrorCode.PlatformError || + !("data" in err) + ) { + return undefined; + } + const { data } = err; + if (typeof data !== "object" || data === null || !("error" in data)) { + return undefined; + } + return typeof data.error === "string" ? data.error : undefined; +} diff --git a/orchestrate/skills/orchestrate/scripts/adapters/types.ts b/orchestrate/skills/orchestrate/scripts/adapters/types.ts new file mode 100644 index 0000000..179d7ad --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/adapters/types.ts @@ -0,0 +1,62 @@ +import type { TaskStatus, TaskType } from "../schemas.ts"; + +export type { TaskStatus, TaskType }; + +export type CommentCriticality = "best_effort" | "required"; + +export type SlackMessageRef = { channel: string; ts: string }; + +export interface SlackAdapter { + postRunKickoff(args: { + text: string; + username: string; + iconUrl?: string; + iconEmoji?: string; + }): Promise; + /** + * Best-effort first-name lookup for `'s bot` kickoff bot + * username. Returns undefined when the lookup fails (missing scope, no + * match, network error). The dispatcher CLI uses this to default + * `plan.dispatcher.firstName` from the operator's git email. + */ + lookupFirstNameByEmail(email: string): Promise; + postInThread(args: { + threadTs: string; + username: string; + iconUrl?: string; + iconEmoji?: string; + text: string; + }): Promise; + editThreadMessage(args: { + threadTs: string; + ts: string; + text: string; + }): Promise; + uploadFileToThread(args: { + threadTs: string; + filename: string; + content: Buffer | Uint8Array; + initialComment?: string; + }): Promise<{ fileId: string; permalink: string }>; + getReactions(args: SlackMessageRef): Promise<{ + reactions: { name: string; users: string[] }[]; + }>; + getThreadReplies( + args: SlackMessageRef & { + limit: number; + cursor?: string; + latest?: string; + } + ): Promise<{ + messages: { ts: string; text: string }[]; + nextCursor?: string; + }>; + postCommentInThread(args: { + threadTs: string; + text: string; + username?: string; + clientMsgId?: string; + }): Promise; + addReaction(args: SlackMessageRef & { name: string }): Promise; + removeReaction(args: SlackMessageRef & { name: string }): Promise; +} diff --git a/orchestrate/skills/orchestrate/scripts/biome.json b/orchestrate/skills/orchestrate/scripts/biome.json new file mode 100644 index 0000000..1654d3a --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/biome.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "files": { + "includes": ["**/*.ts", "!**/node_modules"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "javascript": { + "formatter": { + "trailingCommas": "es5", + "arrowParentheses": "asNeeded" + } + }, + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "assist": { + "actions": { + "source": { "organizeImports": "on" } + } + } +} diff --git a/orchestrate/skills/orchestrate/scripts/bun.lock b/orchestrate/skills/orchestrate/scripts/bun.lock new file mode 100644 index 0000000..9205754 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/bun.lock @@ -0,0 +1,414 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@cursor-skill/orchestrate", + "dependencies": { + "@cursor/sdk": "^1.0.8", + "@slack/web-api": "^7.15.1", + "commander": "^12.0.0", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "bun-types": "1.3.13", + "typescript": "6.0.3", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.13", "@biomejs/cli-darwin-x64": "2.4.13", "@biomejs/cli-linux-arm64": "2.4.13", "@biomejs/cli-linux-arm64-musl": "2.4.13", "@biomejs/cli-linux-x64": "2.4.13", "@biomejs/cli-linux-x64-musl": "2.4.13", "@biomejs/cli-win32-arm64": "2.4.13", "@biomejs/cli-win32-x64": "2.4.13" }, "bin": { "biome": "bin/biome" } }, "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.13", "", { "os": "linux", "cpu": "x64" }, "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.13", "", { "os": "linux", "cpu": "x64" }, "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.13", "", { "os": "win32", "cpu": "x64" }, "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ=="], + + "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.0", "", {}, "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag=="], + + "@connectrpc/connect": ["@connectrpc/connect@1.7.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w=="], + + "@connectrpc/connect-node": ["@connectrpc/connect-node@1.7.0", "", { "dependencies": { "undici": "^5.28.4" }, "peerDependencies": { "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "1.7.0" } }, "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A=="], + + "@cursor/sdk": ["@cursor/sdk@1.0.8", "", { "dependencies": { "@bufbuild/protobuf": "1.10.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", "@statsig/js-client": "3.31.0", "sqlite3": "^5.1.7", "zod": "^3.25.0" }, "optionalDependencies": { "@cursor/sdk-darwin-arm64": "1.0.8", "@cursor/sdk-darwin-x64": "1.0.8", "@cursor/sdk-linux-arm64": "1.0.8", "@cursor/sdk-linux-x64": "1.0.8", "@cursor/sdk-win32-x64": "1.0.8" } }, "sha512-8jpACxDwr2Cv73mZugWR99uN/nCfT/smJkPgUXCjUpJMZdIKeYXhCPUzrBgx+DZNf1QbMRcm8KthPRcQ8X8ShA=="], + + "@cursor/sdk-darwin-arm64": ["@cursor/sdk-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lO6GONkBFrVqofez8W2ACfWiDv1Hkn0mTp5gwUzGX7s3cr1UQN3MhmatB6umCW/0Her7rfCDdvEyrszXGNvfDA=="], + + "@cursor/sdk-darwin-x64": ["@cursor/sdk-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-OYwg45xB+JorM6pzBUtH3VXJ9NtUJvmXDlCPR8zforNMi/8sk2KFhgqt7nbCYG/V+uWd9YS5JTtjpzc2KEnwBA=="], + + "@cursor/sdk-linux-arm64": ["@cursor/sdk-linux-arm64@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-SN84cqr3yWoYWE5d++KDU5NirZdQSzf8u3GVcFzmF4I+mUqumtUqbGdunembEQdrsYibcGmyD1Un4ZzK4gVYXw=="], + + "@cursor/sdk-linux-x64": ["@cursor/sdk-linux-x64@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-wdOZSVzsRbMiwnDsyaGodlFqeD0YnBKdMl+9axI/V74VAWp5U7JYThjFgehJRM+2yKVHlqHGxxGlHgHbcCOKGQ=="], + + "@cursor/sdk-win32-x64": ["@cursor/sdk-win32-x64@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-yopN4NM2M52qIXVl95O+cadmmRSRskWc5IcVCjYT1KunlNlSyxxOcPOmrmhh60dASYWB34B/ysvRIO7NNqj/4g=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], + + "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], + + "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], + + "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], + + "@slack/types": ["@slack/types@2.20.1", "", {}, "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A=="], + + "@slack/web-api": ["@slack/web-api@7.15.1", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.20.1", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.15.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg=="], + + "@statsig/client-core": ["@statsig/client-core@3.31.0", "", {}, "sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ=="], + + "@statsig/js-client": ["@statsig/js-client@3.31.0", "", { "dependencies": { "@statsig/client-core": "3.31.0" } }, "sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g=="], + + "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@4.0.1", "", { "dependencies": { "@tootallnate/once": "1", "agent-base": "6", "debug": "4" } }, "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + + "minipass-fetch": ["minipass-fetch@1.4.1", "", { "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" }, "optionalDependencies": { "encoding": "^0.1.12" } }, "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-gyp": ["node-gyp@8.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^9.1.0", "nopt": "^5.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@6.2.1", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ=="], + + "sqlite3": ["sqlite3@5.1.7", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { "node-gyp": "8.x" } }, "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog=="], + + "ssri": ["ssri@8.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unique-filename": ["unique-filename@1.1.1", "", { "dependencies": { "unique-slug": "^2.0.0" } }, "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ=="], + + "unique-slug": ["unique-slug@2.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@cursor/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + } +} diff --git a/orchestrate/skills/orchestrate/scripts/cli.ts b/orchestrate/skills/orchestrate/scripts/cli.ts new file mode 100644 index 0000000..05a7d00 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { main } from "./cli/index.ts"; + +await main(); diff --git a/orchestrate/skills/orchestrate/scripts/cli/andon.ts b/orchestrate/skills/orchestrate/scripts/cli/andon.ts new file mode 100644 index 0000000..307d569 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/andon.ts @@ -0,0 +1,123 @@ +import type { Command } from "commander"; +import { appendAgentFooter } from "../core/agent-manager.ts"; +import { + assertOperatorModeOrBail, + errorMessage, + loadAndonTargetOrBail, +} from "./util.ts"; + +const ANDON_RAISED_PREFIX = "🚨 ANDON RAISED"; +const ANDON_CLEARED_PREFIX = "✅ ANDON CLEARED"; + +/** + * Children gate on `:rotating_light:` via `reactions.get`, which is cheap + * and text-free. The reason/note posts as a separate thread message so + * humans can see *why* orchestration paused without children reading + * message bodies. The prefixes above let `attention.log` sweeps grep + * reasons back out of history. + */ +export function registerAndonCommands(program: Command): void { + const andonProgram = program + .command("andon") + .description("Raise or clear the tree-wide Andon spawn pause."); + + andonProgram + .command("raise") + .requiredOption( + "--reason ", + "Posted to the run thread so the tree sees why orchestration paused." + ) + .option( + "--workspace ", + "Workspace containing plan.json with slackKickoffRef" + ) + .option( + "--sender ", + "Label for the reason message (defaults to $USER, else 'operator')", + defaultSender() + ) + .option( + "--agent-id ", + "Cloud agent id for the footer link back to cursor.com (omit for operator-issued raises)." + ) + .description( + "Post the reason in the run thread and add :rotating_light: to the kickoff message." + ) + .action( + async (opts: { + reason: string; + workspace?: string; + sender: string; + agentId?: string; + }) => { + try { + const { slack, ref } = loadAndonTargetOrBail(opts); + const head = `${ANDON_RAISED_PREFIX} by ${opts.sender}: ${opts.reason}`; + await slack.postCommentInThread({ + threadTs: ref.ts, + text: appendAgentFooter(head, opts.agentId), + username: "orchestrate", + }); + await slack.addReaction({ ...ref, name: "rotating_light" }); + } catch (err) { + console.error(`andon raise failed: ${errorMessage(err)}`); + process.exit(1); + } + } + ); + + andonProgram + .command("clear") + .option( + "--workspace ", + "Workspace containing plan.json with slackKickoffRef" + ) + .option( + "--note ", + "Optional note posted to the run thread alongside the clear." + ) + .option( + "--sender ", + "Label for the clear message (defaults to $USER, else 'operator')", + defaultSender() + ) + .option( + "--agent-id ", + "Cloud agent id for the footer link back to cursor.com (omit for operator-issued clears)." + ) + .description( + "Remove :rotating_light: from the kickoff message and note the clear in the run thread." + ) + .action( + async (opts: { + workspace?: string; + note?: string; + sender: string; + agentId?: string; + }) => { + try { + assertOperatorModeOrBail( + "andon clear (workers must not clear an active Andon)" + ); + const { slack, ref } = loadAndonTargetOrBail(opts); + const note = opts.note?.trim(); + const head = note + ? `${ANDON_CLEARED_PREFIX} by ${opts.sender}: ${note}` + : `${ANDON_CLEARED_PREFIX} by ${opts.sender}`; + await slack.postCommentInThread({ + threadTs: ref.ts, + text: appendAgentFooter(head, opts.agentId), + username: "orchestrate", + }); + await slack.removeReaction({ ...ref, name: "rotating_light" }); + } catch (err) { + console.error(`andon clear failed: ${errorMessage(err)}`); + process.exit(1); + } + } + ); +} + +function defaultSender(): string { + return process.env.USER?.trim() || "operator"; +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/comments.ts b/orchestrate/skills/orchestrate/scripts/cli/comments.ts new file mode 100644 index 0000000..e6d1f82 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/comments.ts @@ -0,0 +1,290 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import type { Command } from "commander"; + +import { createSlackAdapter } from "../adapters/index.ts"; +import { appendAgentFooter } from "../core/agent-manager.ts"; +import { postOrQueueComment } from "../core/comment-retry-queue.ts"; +import { redactBody } from "../core/redact-body.ts"; +import { type Plan, parsePlanJson, parseStateJson } from "../schemas.ts"; +import { + type CommentOptions, + errorMessage, + loadAllowedSlackThreadOrBail, + loadCommentDestinations, + parseCommentCriticalityOrBail, + resolveTaskBody, +} from "./util.ts"; + +type CommentCommandOptions = CommentOptions & { + sender: string; + criticality: string; + file?: string; + comment?: string; + task?: string; + threadTs?: string; + agentId?: string; +}; + +const COMMENTS_PLAN_SLACK_CHANNEL_MESSAGE = + "comments require a workspace with a plan that has plan.slackChannel set; run kickoff or run --root with --slack-channel first"; + +export function registerCommentCommands(program: Command): void { + program + .command("comment") + .argument( + "[body...]", + "Comment body (or '-' to read stdin). Required unless --file is set." + ) + .option( + "--workspace ", + "Workspace whose plan.slackKickoffRef scopes the bot to the run thread (mandatory outside operator mode)." + ) + .option("--sender ", "Sender name stored with the comment", "agent") + .option( + "--task ", + "Task name used to validate context; posts still go to the run thread." + ) + .option("--thread-ts ", "Slack thread_ts for the existing run thread.") + .option( + "--criticality ", + "best_effort | required (required uses comment-retry-queue.json under --workspace)", + "best_effort" + ) + .option( + "--file ", + "Upload a local file to the target thread instead of posting text. Path must resolve under --workspace outside operator mode." + ) + .option( + "--comment ", + "Initial comment shown alongside the uploaded file (only with --file)." + ) + .option( + "--agent-id ", + "Cloud agent id for the footer link back to cursor.com (omit for operator-issued posts)." + ) + .description( + "Post a Slack comment, or upload a file with --file, inside the run thread. Use MCPs directly for Linear, GitHub, and other external systems." + ) + .action(async (bodyParts: string[], opts: CommentCommandOptions) => { + try { + if (!opts.task?.trim() && !opts.threadTs?.trim()) { + throw new Error( + "comment requires --task or --thread-ts ; kickoff messages are created by run" + ); + } + const allowedSlackThread = loadAllowedSlackThreadOrBail(opts.workspace); + const target = resolveCommentTarget({ + workspace: opts.workspace, + task: opts.task, + threadTs: opts.threadTs, + allowedSlackThread, + }); + const destination = slackDestinationForThread(target); + if (opts.file) { + await uploadFileToThread({ + channel: target.channel, + threadTs: target.threadTs, + filePath: opts.file, + initialComment: opts.comment, + sender: opts.sender, + agentId: opts.agentId, + workspace: opts.workspace, + }); + console.log(`uploaded ${opts.file} to ${destination}`); + return; + } + if (bodyParts.length === 0) { + throw new Error("comment body is required when --file is not set"); + } + const body = resolveTaskBody(bodyParts); + const safeBody = requireSafeCommentBody(body); + const result = await postOrQueueComment({ + destinations: loadCommentDestinations(target.channel), + workspace: opts.workspace, + destination, + body: appendAgentFooter(safeBody, opts.agentId), + sender: opts.sender, + criticality: parseCommentCriticalityOrBail(opts.criticality), + allowedSlackThread, + }); + console.log( + result === "posted" + ? `posted comment on ${destination}` + : `queued required comment on ${destination}` + ); + } catch (err) { + console.error(errorMessage(err)); + process.exit(1); + } + }); +} + +async function uploadFileToThread(args: { + channel: string; + threadTs: string; + filePath: string; + initialComment: string | undefined; + sender: string; + agentId: string | undefined; + workspace: string | undefined; +}): Promise { + const allowedThread = loadAllowedSlackThreadOrBail(args.workspace); + if (!existsSync(args.filePath) || !statSync(args.filePath).isFile()) { + throw new Error( + `--file path is missing or not a regular file: ${args.filePath}` + ); + } + // Non-operators can only upload files from the run workspace. + if (allowedThread !== undefined) { + assertThreadAllowed(args.threadTs, allowedThread); + if (!args.workspace) { + throw new Error( + "--file requires --workspace so uploads stay confined to the run's workspace" + ); + } + assertFileInsideWorkspace(args.filePath, args.workspace); + } + const slack = createSlackAdapter(args.channel); + if (!slack) { + throw new Error( + "SLACK_BOT_TOKEN not set; cannot upload Slack file" + ); + } + const content = readFileSync(args.filePath); + const initial = + args.initialComment ?? `${args.sender} attached ${basename(args.filePath)}`; + const safeInitial = requireSafeCommentBody(initial); + await slack.uploadFileToThread({ + threadTs: args.threadTs, + filename: basename(args.filePath), + content, + initialComment: appendAgentFooter(safeInitial, args.agentId), + }); +} + +function requireSafeCommentBody(body: string): string { + const result = redactBody(body); + if (result.reasons.length === 0) return result.text; + throw new Error(`comment body refused: ${result.reasons.join("; ")}`); +} + +// Canonicalizes both paths via realpath so symlinks can't escape the +// workspace subtree. Rejects paths outside the workspace, absolute +// `relative()` output (when the file lives on a different volume), or +// the workspace root itself. +function assertFileInsideWorkspace(filePath: string, workspace: string): void { + const fileAbs = realpathSync(resolve(filePath)); + const workspaceAbs = realpathSync(resolve(workspace)); + const rel = relative(workspaceAbs, fileAbs); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error( + `--file ${filePath} resolves to ${fileAbs}, which is outside ` + + `--workspace ${workspaceAbs}; uploads are confined to the workspace ` + + `subtree unless operator mode is enabled.` + ); + } +} + +function resolveCommentTarget(args: { + workspace: string | undefined; + task: string | undefined; + threadTs: string | undefined; + allowedSlackThread: { channel: string; threadTs: string } | undefined; +}): { channel: string; threadTs: string } { + const explicit = args.threadTs?.trim(); + if (explicit) { + assertThreadAllowed(explicit, args.allowedSlackThread); + return { + channel: loadPlanSlackChannelOrBail(args.workspace), + threadTs: explicit, + }; + } + const taskName = args.task?.trim(); + if (!taskName) { + throw new Error( + "comment requires --task or --thread-ts ; kickoff messages are created by run" + ); + } + if (!args.workspace) { + throw new Error( + "--task requires --workspace so the task Slack thread can be resolved" + ); + } + const statePath = join(resolve(args.workspace), "state.json"); + const state = parseStateJson(readFileSync(statePath, "utf8"), statePath); + const task = state.tasks.find(candidate => candidate.name === taskName); + if (!task) { + throw new Error(`task ${taskName} not found in ${statePath}`); + } + // Operator mode: load the kickoff thread root directly. task.slackTs is the + // reply's ts (the per-task status mirror) and would start a sub-thread off + // the task message instead of posting in the run thread. + return loadKickoffRefOrBail({ workspace: args.workspace, taskName }); +} + +export function loadKickoffThreadTsOrBail(args: { + workspace: string; + taskName: string; +}): string { + return loadKickoffRefOrBail(args).threadTs; +} + +function loadKickoffRefOrBail(args: { + workspace: string; + taskName: string; +}): { channel: string; threadTs: string } { + const planPath = join(resolve(args.workspace), "plan.json"); + if (!existsSync(planPath)) { + throw new Error( + `--task ${args.taskName} requires plan.json with slackKickoffRef in ${args.workspace}; ` + + `pass --thread-ts explicitly to override` + ); + } + const plan = parsePlanJson(readFileSync(planPath, "utf8"), planPath); + const kickoffRef = plan.slackKickoffRef; + if (!kickoffRef?.channel || !kickoffRef.ts) { + throw new Error( + `${planPath} has no slackKickoffRef; pass --thread-ts explicitly` + ); + } + return { channel: requirePlanSlackChannel(plan), threadTs: kickoffRef.ts }; +} + +function loadPlanSlackChannelOrBail(workspace: string | undefined): string { + if (!workspace) { + throw new Error(COMMENTS_PLAN_SLACK_CHANNEL_MESSAGE); + } + const planPath = join(resolve(workspace), "plan.json"); + if (!existsSync(planPath)) { + throw new Error(COMMENTS_PLAN_SLACK_CHANNEL_MESSAGE); + } + return requirePlanSlackChannel( + parsePlanJson(readFileSync(planPath, "utf8"), planPath) + ); +} + +function requirePlanSlackChannel(plan: Plan): string { + const channel = plan.slackChannel?.trim(); + if (!channel) { + throw new Error(COMMENTS_PLAN_SLACK_CHANNEL_MESSAGE); + } + return channel; +} + +function slackDestinationForThread(target: { + channel: string; + threadTs: string; +}): string { + return `slack:${target.channel}:${target.threadTs}`; +} + +function assertThreadAllowed( + threadTs: string, + allowedThread: { channel: string; threadTs: string } | undefined +): void { + if (!allowedThread || threadTs === allowedThread.threadTs) return; + throw new Error( + `thread_ts ${threadTs} is outside this workspace's run thread (${allowedThread.channel}:${allowedThread.threadTs})` + ); +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/forensics.ts b/orchestrate/skills/orchestrate/scripts/cli/forensics.ts new file mode 100644 index 0000000..c5ee556 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/forensics.ts @@ -0,0 +1,166 @@ +import { execFileSync } from "node:child_process"; +import type { Command } from "commander"; +import { cancelCloudRun } from "../core/agent-manager.ts"; +import { + collectRunningAgentsInTree, + crawlBranch, + errorMessage, + filterVictimsToSubtree, +} from "./util.ts"; + +export function registerForensicsCommands(program: Command): void { + program + .command("crawl") + .argument( + "", + "Local path to a git clone of the orchestrated repo" + ) + .argument( + "", + "Branch hosting the root planner's committed workspace" + ) + .argument( + "", + "Root planner's rootSlug (workspace lives at .orchestrate//)" + ) + .option( + "--no-fetch", + "Skip `git fetch` (useful for offline tests or repeated calls)" + ) + .description( + "Recursively walk a running orchestrate tree across branches. Reads state.json for each planner from git (root planner + each subplanner on its own branch) and renders a deep, indented tree. Relies on the script's auto-commit of state.json on status transitions — older runs that predate that behavior won't show up." + ) + .action( + async ( + repoPath: string, + rootBranch: string, + rootSlug: string, + opts: { fetch?: boolean } + ) => { + if (opts.fetch !== false) { + try { + execFileSync( + "git", + ["-C", repoPath, "fetch", "--quiet", "origin"], + { + stdio: "pipe", + } + ); + } catch (err) { + console.error(`git fetch failed: ${errorMessage(err)}`); + process.exit(2); + } + } + const out: string[] = []; + const visited = new Set(); + crawlBranch( + { repoPath, branch: rootBranch, slug: rootSlug }, + 0, + out, + visited + ); + console.log(out.join("\n")); + } + ); + + program + .command("kill-tree") + .argument( + "", + "Local path to a git clone of the orchestrated repo" + ) + .argument( + "", + "Branch hosting the root planner's committed workspace" + ) + .argument("", "Root planner's rootSlug") + .option("--no-fetch", "Skip `git fetch` before walking") + .option("-y, --yes", "Skip the confirmation prompt") + .option( + "--agent-id ", + "Only cancel agents under this id (follows parentAgentId links in state)" + ) + .description( + "Cancel every running cloud agent across an orchestrate tree. Walks state.json like `crawl`, collects `running` (agentId, runId) pairs, and cancels each via the SDK. With --agent-id, only that subtree. Needs CURSOR_API_KEY. Does not edit state.json; cancellations show up on the next reconcile." + ) + .action( + async ( + repoPath: string, + rootBranch: string, + rootSlug: string, + opts: { fetch?: boolean; yes?: boolean; agentId?: string } + ) => { + const apiKey = process.env.CURSOR_API_KEY; + if (!apiKey) { + console.error( + "CURSOR_API_KEY required; see cursor-sdk/references/auth.md" + ); + process.exit(2); + } + if (opts.fetch !== false) { + try { + execFileSync( + "git", + ["-C", repoPath, "fetch", "--quiet", "origin"], + { + stdio: "pipe", + } + ); + } catch (err) { + console.error(`git fetch failed: ${errorMessage(err)}`); + process.exit(2); + } + } + const allVictims = collectRunningAgentsInTree({ + repoPath, + branch: rootBranch, + slug: rootSlug, + }); + const victims = filterVictimsToSubtree( + allVictims, + opts.agentId ?? null + ); + if (allVictims.length === 0) { + console.log("nothing to kill — no running agents found in the tree."); + return; + } + if (opts.agentId && victims.length === 0) { + console.error( + `no running agents under ${opts.agentId} (bad id, already stopped, or missing selfAgentId/parentAgentId in state). Retry without --agent-id to list all.` + ); + process.exit(1); + } + const scope = opts.agentId ? `subtree of ${opts.agentId}` : "tree"; + console.error( + `about to cancel ${victims.length} cloud agent(s) in ${scope}:` + ); + for (const v of victims) { + const parent = v.parentAgentId ? ` <- ${v.parentAgentId}` : ""; + console.error( + ` ${v.taskName.padEnd(28)} ${v.agentId}${parent} (${v.branch})` + ); + } + if (!opts.yes) { + console.error(""); + console.error("re-run with -y to confirm."); + process.exit(1); + } + let cancelled = 0; + let failed = 0; + for (const v of victims) { + try { + await cancelCloudRun({ + apiKey, + agentId: v.agentId, + runId: v.runId, + }); + cancelled++; + } catch (err) { + console.error(`${v.taskName} (${v.agentId}): ${errorMessage(err)}`); + failed++; + } + } + console.log(`killed ${cancelled} cloud agent(s); ${failed} failed.`); + } + ); +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/index.ts b/orchestrate/skills/orchestrate/scripts/cli/index.ts new file mode 100644 index 0000000..eca2f1a --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/index.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env bun +import { Command } from "commander"; + +import { registerAndonCommands } from "./andon.ts"; +import { registerCommentCommands } from "./comments.ts"; +import { registerForensicsCommands } from "./forensics.ts"; +import { registerInspectCommands } from "./inspect.ts"; +import { registerTaskCommands } from "./task.ts"; + +export async function main(argv: string[] = process.argv): Promise { + const program = new Command(); + + program + .name("orchestrate") + .description( + "Operate a single /orchestrate workspace: run the reconcile loop, inspect the task tree, spawn ad-hoc tasks, read handoffs, cancel/kill/respawn, or tail running agents." + ) + .version("0.0.0"); + + registerTaskCommands(program); + registerInspectCommands(program); + registerCommentCommands(program); + registerAndonCommands(program); + registerForensicsCommands(program); + + await program.parseAsync(argv); +} + +if (import.meta.main) { + await main(); +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/inspect.ts b/orchestrate/skills/orchestrate/scripts/cli/inspect.ts new file mode 100644 index 0000000..0e2db70 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/inspect.ts @@ -0,0 +1,206 @@ +import type { Command } from "commander"; +import { isAndonActive } from "../core/andon.ts"; +import { renderPrompt } from "../core/prompts.ts"; +import { renderModelCatalog } from "../models.ts"; +import type { TaskState } from "../schemas.ts"; +import { firstChars, loadOrBail, parsePositiveIntegerOrBail } from "./util.ts"; + +export function registerInspectCommands(program: Command): void { + program + .command("inspect") + .argument("", "Path to the orchestrate workspace") + .argument("", "Task name to inspect") + .option("--timeout-sec ", "Stream drain timeout (seconds)", "30") + .description( + "Sample a running task's stream briefly; prints assistant deltas and tool-call counts." + ) + .action( + async (workspace: string, task: string, opts: { timeoutSec: string }) => { + try { + const mgr = await loadOrBail(workspace); + const timeoutSec = parsePositiveIntegerOrBail({ + value: opts.timeoutSec, + flag: "--timeout-sec", + }); + const stateTask = mgr.getTask(task); + if (!stateTask?.agentId || !stateTask.runId) { + console.error( + `inspect: ${task}: missing agentId/runId in state.json` + ); + process.exit(1); + } + const inspection = await mgr.inspectTask(task, timeoutSec * 1000); + console.log(JSON.stringify(inspection, null, 2)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`inspect failed: ${msg}`); + process.exit(1); + } + } + ); + + program + .command("tree") + .argument("", "Path to the orchestrate workspace") + .description( + "Print an ascii tree of every tracked task under this workspace's rootSlug, with type/status/branch/agentId/attempts. Use this to see what's been spawned." + ) + .action(async (workspace: string) => { + const mgr = await loadOrBail(workspace); + if (isAndonActive(mgr.state.andon)) { + console.log(`>> ANDON ACTIVE (raised ${mgr.state.andon.raisedAt})`); + } + console.log(mgr.renderTree()); + }); + + program + .command("list") + .argument("", "Path to the orchestrate workspace") + .option( + "--status ", + "Filter by status (pending|running|handed-off|error|pruned)" + ) + .option("--json", "Emit JSON instead of a table") + .description( + "Flat table of every tracked task: NAME / TYPE / STATUS / BRANCH / AGENT / RUN / ATTEMPTS. Optionally filter by --status." + ) + .action( + async (workspace: string, opts: { status?: string; json?: boolean }) => { + const mgr = await loadOrBail(workspace); + const rows = opts.status + ? mgr.tasks.filter(t => t.status === opts.status) + : mgr.tasks; + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + if (rows.length === 0) { + console.log("(no tasks match)"); + return; + } + const cols: { header: string; get: (t: TaskState) => string }[] = [ + { header: "NAME", get: t => t.name }, + { header: "TYPE", get: t => t.type }, + { header: "STATUS", get: t => t.status }, + { header: "BRANCH", get: t => t.branch }, + { header: "AGENT", get: t => t.agentId ?? "" }, + { header: "RUN", get: t => t.runId ?? "" }, + { header: "ATTEMPTS", get: t => String(t.attempts ?? 0) }, + ]; + const widths = cols.map(c => + Math.max(c.header.length, ...rows.map(r => c.get(r).length)) + ); + const line = (cells: string[]) => + cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" "); + console.log(line(cols.map(c => c.header))); + for (const r of rows) console.log(line(cols.map(c => c.get(r)))); + } + ); + + program + .command("status") + .argument("", "Path to the orchestrate workspace") + .description( + "One-line summary of the workspace: counts by status + attention-log entry count. Useful for watch scripts." + ) + .action(async (workspace: string) => { + const mgr = await loadOrBail(workspace); + const counts: Record = {}; + for (const t of mgr.tasks) counts[t.status] = (counts[t.status] ?? 0) + 1; + const parts = Object.entries(counts) + .sort() + .map(([k, v]) => `${v} ${k}`); + const attn = mgr.state.attention.length; + const attnSuffix = attn > 0 ? `, ${attn} attention` : ""; + console.log( + `orchestrate[${mgr.plan.rootSlug}]: ${parts.join(", ")}${attnSuffix}` + ); + if (mgr.state.andon) { + if (isAndonActive(mgr.state.andon)) { + console.log( + `>> ANDON ACTIVE: raised at ${mgr.state.andon.raisedAt} by ${mgr.state.andon.raisedBy ?? "unknown"} - ${firstChars(mgr.state.andon.reason, 100)}` + ); + } else if (mgr.state.andon.cleared) { + console.log( + `Andon last cleared at ${mgr.state.andon.clearedAt ?? "unknown"} by ${mgr.state.andon.clearedBy ?? "unknown"}` + ); + } + } + }); + + program + .command("handoff") + .argument("", "Path to the orchestrate workspace") + .argument( + "", + "Task name (kebab-case, matches plan.json / state.json)" + ) + .description( + "Print the collected handoff markdown for a completed task. Prints an error if the task hasn't handed off yet." + ) + .action(async (workspace: string, task: string) => { + const mgr = await loadOrBail(workspace); + const body = mgr.readHandoff(task); + if (body == null) { + console.error( + `no handoff yet for task "${task}" (status: ${mgr.getTask(task)?.status ?? "unknown"})` + ); + process.exit(1); + } + process.stdout.write(body); + }); + + program + .command("models") + .option( + "--check", + "Validate each catalog entry against /v1/agents. Run after SDK or backend model-schema changes, or when kickoff/spawn returns invalid_model." + ) + .description( + "Print the model catalog. Planners consult this when setting `tasks[].model`." + ) + .action(async (opts: { check?: boolean }) => { + if (!opts.check) { + console.log(renderModelCatalog()); + return; + } + const apiKey = process.env.CURSOR_API_KEY; + if (!apiKey) { + console.error("CURSOR_API_KEY not set"); + process.exit(2); + } + const { printProbeResults, probeModelCatalog } = await import( + "../tools/probe-models.ts" + ); + const results = await probeModelCatalog(apiKey); + process.exit(printProbeResults(results) > 0 ? 1 : 0); + }); + + program + .command("prompt") + .argument("", "Path to the orchestrate workspace") + .argument("", "Task name (kebab-case, matches plan.json)") + .description( + "Render the spawn prompt that a task will receive, without spawning. Includes upstream handoffs from any `dependsOn` tasks that have already handed off. Useful for previewing context or auditing prompt length." + ) + .action(async (workspace: string, task: string) => { + const mgr = await loadOrBail(workspace); + try { + process.stdout.write( + renderPrompt({ + taskName: task, + ctx: { + plan: mgr.plan, + branchForTask: t => mgr.branchForTask(t), + getTask: name => mgr.getTask(name), + readHandoff: name => mgr.readHandoff(name), + }, + }) + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + process.exit(1); + } + }); +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/task.ts b/orchestrate/skills/orchestrate/scripts/cli/task.ts new file mode 100644 index 0000000..cd3284e --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/task.ts @@ -0,0 +1,604 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import type { CursorAgentError as CursorAgentErrorValue } from "@cursor/sdk"; +import type { Command } from "commander"; +import { createSlackAdapter } from "../adapters/index.ts"; +import { DEFAULT_MAX_RUNTIME_SEC, runOrchestrateLoop } from "../core/loop.ts"; +import { resolveModelSelection } from "../models.ts"; +import { + parsePlanTaskJson, + parsePlanTaskValue, + type StopResult, + type TaskState, +} from "../schemas.ts"; +import { + buildInlineTask, + buildKickoffAgentName, + buildKickoffPrompt, + collect, + collectCascadeVictims, + errorMessage, + loadOrBail, + parsePositiveIntegerOrBail, + parseRespawnSourceOrBail, + resolveKickoffSlackChannelOrBail, + resolveKickoffRepoUrl, + resolveWorkspaceSlackChannelOrBail, + type SpawnOptions, + transitivelyDependsOn, +} from "./util.ts"; + +export const MAX_BOOT_MS = 30 * 60 * 1000; +const ACTIVE_ROOT_RUN_STATUSES = new Set(["pending", "running"]); + +type AgentListApi = { + list: (opts: { runtime: "cloud"; limit: number }) => Promise; + listRuns?: ( + agentId: string, + opts: { runtime: "cloud"; limit: number } + ) => Promise; +}; + +export type ActiveRootPlanner = { + agentId: string; + runId: string | null; + status: string; + name: string; +}; + +export function registerTaskCommands(program: Command): void { + program + .command("run") + .argument( + "", + "Path to the orchestrate workspace (contains plan.json)" + ) + .option( + "--root", + "Mark this as the root workspace for the operator-facing re-run hint." + ) + .option( + "--slack-channel ", + "Slack channel id for the root run. Falls back to SLACK_CHANNEL_ID." + ) + .option( + "--max-runtime-sec ", + "Exit with code 100 after this many seconds when non-terminal work remains.", + String(DEFAULT_MAX_RUNTIME_SEC) + ) + .option( + "--exit-on-all-done", + "Keep draining to quiescence instead of returning on the first terminal error. Default is exit-on-error so the planner reacts to failures promptly (in-flight workers reattach on the next run)." + ) + .description( + "Run the reconcile loop: spawn pending tasks (respecting dependsOn), wait for handoffs, write them to /handoffs/, repeat until terminal. Idempotent — rerun to pick up plan.json changes or retries." + ) + .action( + async ( + workspace: string, + opts: { + root?: boolean; + slackChannel?: string; + maxRuntimeSec: string; + exitOnAllDone?: boolean; + } + ) => { + const maxRuntimeSec = parsePositiveIntegerOrBail({ + value: opts.maxRuntimeSec, + flag: "--max-runtime-sec", + }); + const slackChannel = opts.root + ? resolveWorkspaceSlackChannelOrBail({ + workspace, + explicit: opts.slackChannel, + }) + : undefined; + const mgr = await loadOrBail(workspace, { slackChannel }); + const code = await runOrchestrateLoop(mgr, { + maxRuntimeSec, + rootMode: opts.root, + exitOnError: !opts.exitOnAllDone, + }); + process.exit(code); + } + ); + + program + .command("kickoff") + .argument("", "Root orchestration goal") + .option("--repo ", "Repository URL to orchestrate") + .option("--ref ", "Starting git ref for the cloud workspace", "main") + .option("--model ", "Model id for the root planner", "claude-opus-4-7") + .option("--force", "Spawn a new root planner even when a recent matching run exists.") + .option( + "--slack-channel ", + "Slack channel id for run visibility. Falls back to SLACK_CHANNEL_ID." + ) + .option( + "--dispatcher-name ", + "First name for the kickoff bot username (`'s bot`). Falls back to Slack lookup by `git config user.email`, then to `orchestrate`." + ) + .description( + "Spawn a cloud root planner for an orchestrate goal, then print the agent/run identifiers." + ) + .action( + async ( + goal: string, + opts: { + repo?: string; + ref: string; + model: string; + force?: boolean; + slackChannel?: string; + dispatcherName?: string; + } + ) => { + const apiKey = process.env.CURSOR_API_KEY; + if (!apiKey) { + console.error("CURSOR_API_KEY not set"); + process.exit(2); + } + let CursorAgentErrorCtor: typeof CursorAgentErrorValue | null = null; + try { + const slackChannel = resolveKickoffSlackChannelOrBail( + opts.slackChannel + ); + const sdk = await import("@cursor/sdk"); + CursorAgentErrorCtor = sdk.CursorAgentError; + const url = resolveKickoffRepoUrl(opts.repo); + const rootSlug = inferKickoffRootSlug(goal); + if (!opts.force) { + const active = await findActiveRootPlanner(sdk.Agent, rootSlug); + if (active) { + console.log(`adopting ${active.agentId}`); + console.log( + JSON.stringify({ + agentId: active.agentId, + runId: active.runId, + status: active.status, + url: `https://cursor.com/agents/${active.agentId}`, + adopted: true, + }) + ); + process.exit(0); + } + } + const dispatcherFirstName = await resolveDispatcherFirstName( + opts.dispatcherName, + slackChannel + ); + const agent = await sdk.Agent.create({ + apiKey, + name: `${rootSlug}-root`, + cloud: { + repos: [{ url, startingRef: opts.ref }], + autoCreatePR: false, + }, + model: resolveModelSelection(opts.model), + }); + const prompt = buildKickoffPrompt({ + goal, + agentId: agent.agentId, + dispatcherFirstName, + slackChannel, + }); + const run = await agent.send({ text: prompt }); + console.log( + JSON.stringify({ + agentId: agent.agentId, + runId: run.id, + status: run.status, + url: `https://cursor.com/agents/${agent.agentId}`, + dispatcherFirstName: dispatcherFirstName ?? null, + }) + ); + // agent.send opens an SSE stream that keeps the event loop alive; + // explicit exit so the dispatcher CLI returns instead of hanging. + process.exit(0); + } catch (err) { + if (CursorAgentErrorCtor && err instanceof CursorAgentErrorCtor) { + console.error(err.message); + process.exit(2); + } + console.error( + err instanceof Error ? (err.stack ?? err.message) : String(err) + ); + process.exit(1); + } + } + ); + + program + .command("spawn") + .argument("", "Path to the orchestrate workspace") + .description( + "Ad-hoc spawn a tracked task that wasn't in plan.json. The task is added to state.json with adHoc=true, spawned as a cloud agent, and handoff-collected either immediately (--wait) or via a later `run` invocation." + ) + .option( + "--file ", + "Path to a JSON file with the PlanTask definition (name/type/scopedGoal/...)" + ) + .option("--name ", "Task name (kebab-case)") + .option("--type ", "worker | subplanner") + .option( + "--goal ", + "scopedGoal text (prefer --file for anything non-trivial)" + ) + .option( + "--paths-allowed ", + "Allowed path glob; repeat for multiple", + collect, + [] satisfies string[] + ) + .option( + "--paths-forbidden ", + "Forbidden path glob; repeat for multiple", + collect, + [] satisfies string[] + ) + .option( + "--acceptance ", + "Acceptance criterion; repeat for multiple", + collect, + [] satisfies string[] + ) + .option("--starting-ref ", "startingRef override") + .option( + "--depends-on ", + "Dep task name; repeat for multiple", + collect, + [] satisfies string[] + ) + .option("--model ", "Model id override (default composer-2)") + .option( + "--wait", + "Block until the spawned task hands off (default: exit right after spawn)" + ) + .action(async (workspace: string, opts: SpawnOptions) => { + const mgr = await loadOrBail(workspace); + const def = opts.file + ? parsePlanTaskJson(readFileSync(opts.file, "utf8"), opts.file) + : parsePlanTaskValue(buildInlineTask(opts), "spawn options"); + if (def.type === "verifier") { + console.error( + `invalid task.type: ad-hoc spawn supports "worker" or "subplanner", got ${JSON.stringify(def.type)}` + ); + process.exit(1); + } + const existing = mgr.getTask(def.name); + if (existing && existing.status !== "pending") { + console.error( + `task "${def.name}" already exists with status=${existing.status}. Pick a different name or prune first.` + ); + process.exit(1); + } + console.error(`spawning ${def.name} (${def.type}) ...`); + const spawned = await mgr.spawnTask(def, { adHoc: true }); + if (!spawned) { + console.error(`spawn failed; see ${mgr.attentionLog}`); + process.exit(1); + } + console.error(` agent=${spawned.agent.agentId} run=${spawned.run.id}`); + if (opts.wait) { + console.error(` waiting for handoff ...`); + await mgr.waitAndHandoff(spawned); + const body = mgr.readHandoff(def.name); + if (body) process.stdout.write(body); + } else { + console.error(` tail: bun cli.ts tail ${workspace} ${def.name}`); + } + }); + + program + .command("respawn") + .argument("", "Path to the orchestrate workspace") + .argument("", "Task name to reset to pending") + .option( + "--cascade", + "Also reset any downstream tasks that were cascade-pruned when this one failed" + ) + .option( + "--source ", + "Who initiated the respawn: local-cli | self-planner | script-auto-retry", + "local-cli" + ) + .description( + "Put a terminal task (`error` or `pruned`) back to `pending` for the next `run`. Bump is recorded via `attempts` on spawn. `--source` labels who initiated the reset." + ) + .action( + async ( + workspace: string, + task: string, + opts: { cascade?: boolean; source: string } + ) => { + const mgr = await loadOrBail(workspace); + try { + const source = parseRespawnSourceOrBail(opts.source); + const s = mgr.respawnTask(task, { source }); + console.log(`respawned ${task} (prev attempts=${s.attempts ?? 0})`); + if (opts.cascade) { + const toReset = mgr.tasks.filter( + t => + t.status === "pruned" && + transitivelyDependsOn(mgr.tasks, { + task: t.name, + ancestor: task, + }) + ); + for (const t of toReset) { + mgr.respawnTask(t.name, { source }); + console.log(` + cascaded: ${t.name}`); + } + if (toReset.length === 0) + console.log(" (no cascade candidates found)"); + } + console.log(`next: bun cli.ts run ${workspace}`); + } catch (err) { + console.error(`respawn failed: ${errorMessage(err)}`); + process.exit(1); + } + } + ); + + program + .command("cancel") + .argument("", "Path to the orchestrate workspace") + .argument("", "Task name to cancel") + .description( + 'Cancel a single running task via the SDK. Marks the task status=error with note "cancelled by operator". For bulk/tree stops, use `kill` instead.' + ) + .action(async (workspace: string, task: string) => { + const mgr = await loadOrBail(workspace); + try { + await mgr.cancel(task); + console.log(`cancelled ${task}`); + } catch (err) { + console.error(`cancel failed: ${errorMessage(err)}`); + process.exit(1); + } + }); + + program + .command("kill") + .argument("", "Path to the orchestrate workspace") + .argument("[task]", "Task name to kill; omit to kill the whole workspace") + .option( + "--no-cascade", + "When killing a single task, leave its dependents `pending` instead of pruning them (default: cascade)" + ) + .option("-y, --yes", "Skip the confirmation prompt") + .description( + "Stop tasks in bulk: cancel running ones (via SDK) and prune pending ones (won't spawn). With no task argument, kills every non-terminal task in the workspace. With a task argument, cascade-prunes dependents by default. Useful for tearing down a misbehaving orchestrate tree." + ) + .action( + async ( + workspace: string, + task: string | undefined, + opts: { cascade?: boolean; yes?: boolean } + ) => { + const mgr = await loadOrBail(workspace); + + const victims: TaskState[] = task + ? opts.cascade === false + ? [mgr.getTask(task)].filter((t): t is TaskState => t != null) + : collectCascadeVictims(mgr, task) + : mgr.tasks.filter( + t => t.status === "pending" || t.status === "running" + ); + + if (victims.length === 0) { + console.log("nothing to kill"); + return; + } + + console.error(`about to stop ${victims.length} task(s):`); + for (const v of victims) + console.error(` ${v.name.padEnd(32)} ${v.status}`); + + if (!opts.yes) { + console.error(""); + console.error("re-run with -y to confirm."); + process.exit(1); + } + + let results: StopResult[]; + if (task) { + results = + opts.cascade === false + ? [await mgr.stopTask(task)] + : await mgr.stopTaskCascade(task); + } else { + results = await mgr.stopAll(); + } + + const cancelled = results.filter(r => r.action === "cancelled").length; + const pruned = results.filter(r => r.action === "pruned").length; + console.log( + `stopped ${results.length} task(s): ${cancelled} cancelled, ${pruned} pruned` + ); + } + ); + + program + .command("tail") + .argument("", "Path to the orchestrate workspace") + .argument("", "Task name to stream") + .option( + "--only-text", + "Drop tool_call/status chrome; show only assistant text + thinking" + ) + .description( + "Stream SDK events (assistant/thinking/tool_call/status) from a running task. Exits when the stream ends." + ) + .action( + async (workspace: string, task: string, opts: { onlyText?: boolean }) => { + const mgr = await loadOrBail(workspace); + try { + for await (const event of mgr.tail(task)) { + switch (event.type) { + case "assistant": + for (const block of event.message.content) { + if (block.type === "text") process.stdout.write(block.text); + else if (!opts.onlyText) + process.stderr.write(`\n[tool ${block.name}]\n`); + } + break; + case "thinking": + process.stdout.write(event.text); + break; + case "tool_call": + if (opts.onlyText) break; + process.stderr.write( + `\n[tool_call ${event.name} ${event.status} ${event.call_id}]\n` + ); + break; + case "status": + if (opts.onlyText) break; + process.stderr.write(`\n[status ${event.status}]\n`); + break; + case "task": + if (!opts.onlyText && event.text) + process.stderr.write(`\n[task ${event.text}]\n`); + break; + default: + break; + } + } + } catch (err) { + console.error(`\ntail failed: ${errorMessage(err)}`); + process.exit(1); + } + } + ); +} + +export async function findActiveRootPlanner( + agentApi: AgentListApi, + rootSlug: string, + nowMs: number = Date.now() +): Promise { + const list = await agentApi.list({ runtime: "cloud", limit: 50 }); + for (const item of listItems(list)) { + const name = stringField(item, "name"); + if (!name) continue; + if (!name.startsWith(rootSlug)) continue; + const createdAt = timeMs(field(item, "createdAt")); + if (createdAt === null || nowMs - createdAt > MAX_BOOT_MS) continue; + const agentId = + stringField(item, "agentId") ?? stringField(item, "id") ?? null; + if (!agentId) continue; + const embeddedRun = latestRunFromItem(item); + const run = embeddedRun ?? (await latestRunForAgent(agentApi, agentId)); + const status = runStatus(run); + if (!ACTIVE_ROOT_RUN_STATUSES.has(status)) continue; + return { + agentId, + runId: stringField(run, "id") ?? null, + status, + name, + }; + } + return null; +} + +export function inferKickoffRootSlug(goal: string): string { + const firstLine = goal.split("\n")[0]?.trim() ?? ""; + const explicit = firstLine.match(/^`?([a-z0-9][a-z0-9._-]*)`?\s*:/i); + const slug = explicit?.[1]; + return slug ?? buildKickoffAgentName(goal); +} + +function listItems(value: unknown): Record[] { + const items = Array.isArray(field(value, "items")) + ? field(value, "items") + : []; + return (items as unknown[]).filter(isRecord); +} + +async function latestRunForAgent( + agentApi: AgentListApi, + agentId: string +): Promise | null> { + if (!agentApi.listRuns) return null; + const runs = await agentApi.listRuns(agentId, { runtime: "cloud", limit: 1 }); + const items = field(runs, "items"); + const item = Array.isArray(items) ? items[0] : null; + return isRecord(item) ? item : null; +} + +function latestRunFromItem( + item: Record +): Record | null { + const candidates = [ + field(item, "latestRun"), + field(item, "lastRun"), + field(item, "run"), + ]; + return candidates.find(isRecord) ?? null; +} + +function runStatus(run: Record | null): string { + const raw = + stringField(run, "status") ?? + stringField(run, "_status") ?? + stringField(run, "state") ?? + ""; + return raw.toLowerCase(); +} + +function timeMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string" || value.trim().length === 0) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function stringField( + obj: Record | null, + key: string +): string | undefined { + const value = obj?.[key]; + return typeof value === "string" ? value : undefined; +} + +function field(obj: unknown, key: string): unknown { + return isRecord(obj) ? obj[key] : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Pick the dispatcher first name for the kickoff bot username. Order: + * `--dispatcher-name`, then Slack `users.lookupByEmail` against + * `git config user.email`, then undefined (kickoff falls back to + * `orchestrate`). Best-effort: missing git config or Slack token is not + * an error. + */ +export async function resolveDispatcherFirstName( + override: string | undefined, + slackChannel: string | undefined +): Promise { + const trimmed = override?.trim(); + if (trimmed) return trimmed; + const email = readGitUserEmail(); + if (!email) return undefined; + if (!slackChannel) return undefined; + const slack = createSlackAdapter(slackChannel); + if (!slack) return undefined; + return slack.lookupFirstNameByEmail(email); +} + +function readGitUserEmail(): string | undefined { + try { + const email = execFileSync("git", ["config", "--get", "user.email"], { + stdio: ["ignore", "pipe", "pipe"], + }) + .toString() + .trim(); + return email.length > 0 ? email : undefined; + } catch { + return undefined; + } +} diff --git a/orchestrate/skills/orchestrate/scripts/cli/util.ts b/orchestrate/skills/orchestrate/scripts/cli/util.ts new file mode 100644 index 0000000..d81272f --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/cli/util.ts @@ -0,0 +1,595 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { userInfo } from "node:os"; +import { join, resolve } from "node:path"; + +import { createSlackAdapter } from "../adapters/index.ts"; +import type { CommentCriticality, SlackAdapter } from "../adapters/types.ts"; +import { AgentManager, type RespawnSource } from "../core/agent-manager.ts"; +import type { CommentDestinations } from "../core/comment-retry-queue.ts"; +import { renderPromptTemplate } from "../core/prompts.ts"; +import { PlanValidationError } from "../errors.ts"; +import { + type PlanTask, + parsePlanJson, + parseTreeStateJson, + type TaskState, + type TreeTask, +} from "../schemas.ts"; + +export interface CommentOptions { + workspace?: string; +} + +const OPERATOR_MODE_FLAG = ".orchestrate/operator-mode"; + +export interface SpawnOptions { + file?: string; + name?: string; + type?: string; + goal?: string; + pathsAllowed?: string[]; + pathsForbidden?: string[]; + acceptance?: string[]; + startingRef?: string; + dependsOn?: string[]; + model?: string; + wait?: boolean; +} + +const SLACK_CHANNEL_REQUIRED_MESSAGE = + "set --slack-channel or SLACK_CHANNEL_ID, or unset SLACK_BOT_TOKEN to disable Slack"; + +export interface TreeVictim { + taskName: string; + agentId: string; + runId: string; + branch: string; + parentAgentId: string | null; +} + +export function crawlBranch( + { + repoPath, + branch, + slug, + }: { repoPath: string; branch: string; slug: string }, + depth: number, + out: string[], + visited: Set +): void { + const key = `${branch}:${slug}`; + if (visited.has(key)) { + out.push(`${indent(depth)}(cycle detected: ${key})`); + return; + } + visited.add(key); + const path = `.orchestrate/${slug}/state.json`; + let raw: string; + try { + raw = execFileSync( + "git", + ["-C", repoPath, "show", `origin/${branch}:${path}`], + { + stdio: ["ignore", "pipe", "pipe"], + } + ).toString(); + } catch { + out.push( + `${indent(depth)}${branch}:${path} (not found — planner hasn't committed state yet)` + ); + return; + } + let state: TreeTask[] | null = null; + let ownSlug = slug; + try { + const parsed = parseTreeStateJson(raw, `${branch}:${path}`); + state = parsed.tasks; + ownSlug = parsed.rootSlug ?? slug; + } catch (err) { + out.push( + `${indent(depth)}${branch}:${path} (parse failed: ${errorMessage(err)})` + ); + return; + } + out.push( + `${indent(depth)}${ownSlug}/ (${state?.length ?? 0} tasks, on ${branch})` + ); + for (const t of state ?? []) { + const lineage = t.parentAgentId ? ` parent=${t.parentAgentId}` : ""; + out.push( + `${indent(depth + 1)}${t.name.padEnd(28)} ${t.type.padEnd(11)} ${t.status.padEnd(11)} ${t.agentId ?? ""}${lineage}` + ); + if ( + t.type === "subplanner" && + (t.status === "running" || t.status === "handed-off") + ) { + // `ownSlug` (from loaded state.json) is authoritative over the param. + crawlBranch( + { repoPath, branch: `orch/${ownSlug}/${t.name}`, slug: t.name }, + depth + 1, + out, + visited + ); + } + } +} + +export function indent(depth: number): string { + return " ".repeat(depth); +} + +/** + * Walk the tree the same way `crawl` does and collect every `running` task's + * (agentId, runId). Reads only the fields needed for containment so unrelated + * child state errors do not hide deeper agents from `kill-tree`. + */ +export function collectRunningAgentsInTree( + { + repoPath, + branch, + slug, + }: { repoPath: string; branch: string; slug: string }, + collected: TreeVictim[] = [], + visited: Set = new Set() +): TreeVictim[] { + const key = `${branch}:${slug}`; + if (visited.has(key)) return collected; + visited.add(key); + let raw: string; + try { + raw = execFileSync( + "git", + [ + "-C", + repoPath, + "show", + `origin/${branch}:.orchestrate/${slug}/state.json`, + ], + { stdio: ["ignore", "pipe", "pipe"] } + ).toString(); + } catch { + return collected; + } + let parsed: ReturnType; + try { + parsed = parseTreeStateJson( + raw, + `origin/${branch}:.orchestrate/${slug}/state.json` + ); + } catch { + return collected; + } + const ownSlug = parsed.rootSlug ?? slug; + for (const t of parsed.tasks) { + if (t.status === "running" && t.agentId && t.runId) { + collected.push({ + taskName: t.name, + agentId: t.agentId, + runId: t.runId, + branch, + parentAgentId: t.parentAgentId ?? null, + }); + } + if (t.type === "subplanner" && t.status === "running") { + collectRunningAgentsInTree( + { repoPath, branch: `orch/${ownSlug}/${t.name}`, slug: t.name }, + collected, + visited + ); + } + } + return collected; +} + +export function filterVictimsToSubtree( + victims: TreeVictim[], + rootAgentId: string | null +): TreeVictim[] { + if (!rootAgentId) return victims; + const childrenByParent = new Map(); + for (const v of victims) { + if (!v.parentAgentId) continue; + const list = childrenByParent.get(v.parentAgentId) ?? []; + list.push(v); + childrenByParent.set(v.parentAgentId, list); + } + const out: TreeVictim[] = []; + const seen = new Set(); + const visit = (agentId: string): void => { + if (seen.has(agentId)) return; + seen.add(agentId); + const directHit = victims.find(v => v.agentId === agentId); + if (directHit) out.push(directHit); + for (const child of childrenByParent.get(agentId) ?? []) { + visit(child.agentId); + } + }; + visit(rootAgentId); + return out; +} + +export function buildInlineTask(opts: SpawnOptions): PlanTask { + if (!opts.name) bail("--file or --name required"); + if (!opts.type) bail("--type required (worker|subplanner)"); + if (!opts.goal) bail("--goal required (or pass --file)"); + const taskBase = { + name: opts.name, + scopedGoal: opts.goal, + pathsAllowed: opts.pathsAllowed, + pathsForbidden: opts.pathsForbidden, + acceptance: opts.acceptance, + startingRef: opts.startingRef, + dependsOn: opts.dependsOn, + model: opts.model, + }; + switch (opts.type) { + case "worker": + return { ...taskBase, type: "worker" }; + case "subplanner": + return { ...taskBase, type: "subplanner" }; + default: + bail(`--type must be worker or subplanner, got ${opts.type}`); + } +} + +export function transitivelyDependsOn( + tasks: TaskState[], + opts: { task: string; ancestor: string } +): boolean { + const seen = new Set(); + const walk = (name: string): boolean => { + if (seen.has(name)) return false; + seen.add(name); + const t = tasks.find(x => x.name === name); + if (!t) return false; + if (t.dependsOn.includes(opts.ancestor)) return true; + return t.dependsOn.some(dep => walk(dep)); + }; + return walk(opts.task); +} + +export function collectCascadeVictims( + mgr: AgentManager, + rootName: string +): TaskState[] { + const start = mgr.getTask(rootName); + if (!start) return []; + const out: TaskState[] = [start]; + const seen = new Set([rootName]); + const frontier = [rootName]; + while (frontier.length > 0) { + const cur = frontier.shift(); + if (cur === undefined) break; + for (const t of mgr.tasks) { + if (seen.has(t.name)) continue; + if (!t.dependsOn.includes(cur)) continue; + if (t.status !== "pending" && t.status !== "running") continue; + seen.add(t.name); + frontier.push(t.name); + out.push(t); + } + } + return out; +} + +export function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export function resolveTaskBody(parts: string[]): string { + if (parts.length === 1 && parts[0] === "-") { + return readFileSync(0, "utf8"); + } + return parts.join(" "); +} + +export function parseRespawnSourceOrBail(value: string): RespawnSource { + switch (value) { + case "local-cli": + case "self-planner": + case "script-auto-retry": + return value; + default: + throw new PlanValidationError( + `--source must be local-cli, self-planner, or script-auto-retry (got ${value})` + ); + } +} + +export function parseCommentCriticalityOrBail( + value: string +): CommentCriticality { + switch (value) { + case "best_effort": + case "required": + return value; + default: + throw new PlanValidationError( + `--criticality must be best_effort or required (got ${value})` + ); + } +} + +export function parsePositiveIntegerOrBail(args: { + value: string; + flag: string; +}): number { + if (!/^[1-9]\d*$/.test(args.value.trim())) { + throw new PlanValidationError(`${args.flag} must be a positive integer`); + } + const parsed = Number.parseInt(args.value, 10); + return parsed; +} + +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function errorStackOrMessage(err: unknown): string { + if (err instanceof Error) return err.stack ?? err.message; + return String(err); +} + +export function buildKickoffPrompt(args: { + goal: string; + agentId: string; + dispatcherFirstName?: string; + slackChannel?: string; +}): string { + return renderPromptTemplate("root", { + goal: args.goal, + agentId: args.agentId, + dispatcherInstruction: dispatcherInstruction(args.dispatcherFirstName), + slackChannelInstruction: slackChannelInstruction(args.slackChannel), + loopHygiene: renderPromptTemplate("loop-hygiene", { rootFlag: " --root" }), + }); +} + +function dispatcherInstruction(firstName: string | undefined): string { + const raw = firstName?.trim(); + if (!raw) return ""; + // Slack first_name is operator-controlled but not vetted. Strip every + // interpolation hazard once so a `"`, backtick, backslash, or `{{...}}` + // can't malform the prompt, crash renderPromptTemplate's leftover- + // placeholder check, or break the JSON literal the planner copies into + // plan.json. JSON.stringify handles `"` and `\`; the regex covers + // newlines, backticks, and curly braces. + const safe = raw.replace(/[\r\n`{}]/g, " "); + const safeJson = JSON.stringify(safe); + // Leading newline slots this between the summary instruction and the + // bootstrapping paragraph in the rendered kickoff prompt. + return `\n\nOperator: ${safe}. Set \`plan.dispatcher = { firstName: ${safeJson} }\` so the kickoff bot reads ${JSON.stringify(`${safe}'s bot`)}.`; +} + +function slackChannelInstruction(slackChannel: string | undefined): string { + if (!slackChannel) return ""; + return `\n\nSet \`plan.slackChannel = ${JSON.stringify(slackChannel)}\` in plan.json. Subplanners inherit this value.`; +} + +// Agent name cap on the server is 100 chars. First-line-only so +// multi-paragraph goals don't surface their boilerplate preamble. +export function buildKickoffAgentName(goal: string): string { + const firstLine = goal.split("\n")[0]?.trim() ?? ""; + const excerpt = firstLine.slice(0, 100).trim(); + return excerpt || "root planner"; +} + +export function resolveKickoffRepoUrl(repo: string | undefined): string { + if (repo) return repo; + const originUrl = execFileSync( + "git", + ["config", "--get", "remote.origin.url"], + { + stdio: ["ignore", "pipe", "pipe"], + } + ) + .toString() + .trim(); + if (!originUrl) { + throw new Error("git remote.origin.url not set"); + } + return normalizeKickoffRepoUrl(originUrl); +} + +export function normalizeKickoffRepoUrl(url: string): string { + const trimmed = url.trim(); + const scpPrefix = "git@github.com:"; + if (trimmed.startsWith(scpPrefix)) { + return stripGitSuffix( + `https://github.com/${trimmed.slice(scpPrefix.length)}` + ); + } + + try { + const parsed = new URL(trimmed); + if ( + parsed.protocol === "ssh:" && + parsed.username === "git" && + parsed.hostname === "github.com" + ) { + return stripGitSuffix(`https://github.com${parsed.pathname}`); + } + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return stripGitSuffix(parsed.toString()); + } + } catch { + return stripGitSuffix(trimmed); + } + + return stripGitSuffix(trimmed); +} + +export function stripGitSuffix(url: string): string { + return url.endsWith(".git") ? url.slice(0, -4) : url; +} + +export function loadCommentDestinations(channelId: string): CommentDestinations { + return { slack: createSlackAdapter(channelId) }; +} + +export function resolveSlackChannelOption( + explicit: string | undefined +): string | undefined { + const flag = explicit?.trim(); + if (flag) return flag; + const env = process.env.SLACK_CHANNEL_ID?.trim(); + return env || undefined; +} + +export function resolveKickoffSlackChannelOrBail( + explicit: string | undefined +): string | undefined { + const channel = resolveSlackChannelOption(explicit); + requireSlackChannelIfTokenSet(channel); + return channel; +} + +export function resolveWorkspaceSlackChannelOrBail(args: { + workspace: string; + explicit?: string; +}): string | undefined { + const fromOption = resolveSlackChannelOption(args.explicit); + if (fromOption) return fromOption; + const planPath = join(resolve(args.workspace), "plan.json"); + if (!existsSync(planPath)) { + requireSlackChannelIfTokenSet(undefined); + return undefined; + } + const plan = parsePlanJson(readFileSync(planPath, "utf8"), planPath); + const channel = plan.slackChannel ?? plan.slackKickoffRef?.channel; + requireSlackChannelIfTokenSet(channel); + return channel; +} + +function requireSlackChannelIfTokenSet(channel: string | undefined): void { + if (process.env.SLACK_BOT_TOKEN && !channel) { + throw new PlanValidationError(SLACK_CHANNEL_REQUIRED_MESSAGE); + } +} + +export function operatorModeFlagPath( + home: string = userInfo().homedir +): string { + return join(home, OPERATOR_MODE_FLAG); +} + +export function operatorModeHint(): string { + return `create ${operatorModeFlagPath()} owned by your user with chmod 600`; +} + +/** + * Workers control argv/env/cwd and can point --workspace at a forged plan.json. + * Operator mode therefore uses an OS-home flag outside the workspace/env + * surface. If workers can write that home directory, use a stronger boundary. + */ +export function isOperatorModeEnabled( + flagPath: string = operatorModeFlagPath() +): boolean { + if (typeof process.getuid !== "function") return false; + try { + const stat = lstatSync(flagPath); + return ( + stat.isFile() && + stat.uid === process.getuid() && + (stat.mode & 0o777) === 0o600 + ); + } catch { + return false; + } +} + +export function assertOperatorModeOrBail( + action: string, + flagPath?: string +): void { + if (isOperatorModeEnabled(flagPath)) return; + throw new PlanValidationError( + `${action} is operator-only; ${operatorModeHint()}. Environment variables are ignored for this boundary.` + ); +} + +/** + * Returns the run thread so comments cannot target another channel, sibling + * thread, or the channel root. Missing workspace state fails closed unless the + * operator-mode home flag is present. + */ +export function loadAllowedSlackThreadOrBail( + workspace: string | undefined +): { channel: string; threadTs: string } | undefined { + if (isOperatorModeEnabled()) return undefined; + if (!workspace) { + throw new PlanValidationError( + "comment requires --workspace so the bot stays in the run thread; " + + `${operatorModeHint()} to post from outside an orchestrate run.` + ); + } + const planPath = join(resolve(workspace), "plan.json"); + if (!existsSync(planPath)) { + throw new PlanValidationError( + `comment --workspace ${workspace} has no plan.json; ` + + `${operatorModeHint()} to post from outside an orchestrate run.` + ); + } + const plan = parsePlanJson(readFileSync(planPath, "utf8"), planPath); + const ref = plan.slackKickoffRef; + if (!ref?.channel || !ref?.ts) { + throw new PlanValidationError( + `${planPath} has no slackKickoffRef; run the workspace once so Slack visibility is initialized, ` + + `or ${operatorModeHint()} to post outside the run.` + ); + } + return { channel: ref.channel, threadTs: ref.ts }; +} + +export function loadAndonTargetOrBail(opts: { workspace?: string }): { + slack: SlackAdapter; + ref: { channel: string; ts: string }; +} { + if (!opts.workspace) { + throw new PlanValidationError("pass --workspace"); + } + const planPath = join(resolve(opts.workspace), "plan.json"); + const plan = parsePlanJson(readFileSync(planPath, "utf8"), planPath); + if (!plan.slackKickoffRef) { + throw new PlanValidationError( + `${planPath} has no slackKickoffRef; run the workspace once so Slack visibility is initialized` + ); + } + const slack = createSlackAdapter(plan.slackKickoffRef.channel); + if (!slack) { + throw new PlanValidationError( + "set SLACK_BOT_TOKEN before running andon commands" + ); + } + return { slack, ref: plan.slackKickoffRef }; +} + +export async function loadOrBail( + workspace: string, + opts: { slackChannel?: string } = {} +): Promise { + try { + return await AgentManager.load(workspace, opts); + } catch (err) { + if (err instanceof PlanValidationError) { + console.error(err.message); + process.exit(2); + } + console.error(errorStackOrMessage(err)); + process.exit(2); + } +} + +export function bail(msg: string): never { + console.error(msg); + process.exit(2); +} + +export function firstChars(s: string | undefined, n: number): string { + return (s ?? "").slice(0, n); +} diff --git a/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts b/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts new file mode 100644 index 0000000..010eb38 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts @@ -0,0 +1,2090 @@ +#!/usr/bin/env bun +import { execFileSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import type { + Run, + RunResult, + SDKAgent, + Agent as SDKAgentStatic, + SDKMessage, +} from "@cursor/sdk"; +import { createSlackAdapter } from "../adapters/index.ts"; +import { createSlackWebClient } from "../adapters/slack/client.ts"; +import type { SlackAdapter, TaskStatus } from "../adapters/types.ts"; +import { PlanValidationError } from "../errors.ts"; +import type { + Plan, + PlanTask, + RecoverResult, + SpawnResult, + State, + StopResult, + TaskState, +} from "../schemas.ts"; +import { + parsePlanJson, + parsePlanValue, + parseStateJson, + TASK_NAME_RE, +} from "../schemas.ts"; +import type { CommentDestinations } from "./comment-retry-queue.ts"; +import { plannedBranchForTask } from "./branches.ts"; + +const SPAWN_MAX_ATTEMPTS = 3; +const SPAWN_RETRY_BACKOFF_MS = 2_000; +const WAIT_WATCHDOG_POLL_INTERVAL_MS = 60_000; +const WAIT_SSE_IDLE_ATTENTION_MS = 300_000; +const WAIT_TOOL_CALL_IDLE_ATTENTION_MS = 300_000; +// Caps rapid cycling if `run.wait()` errors sub-second while `Agent.getRun` +// still reports running. Without it, state.attention and saveState frequency +// would blow up. Negligible cost when retries take ~1-2 minutes. +const WAIT_RECOVERY_RETRY_BACKOFF_MS = 5_000; + +export type RespawnSource = "local-cli" | "self-planner" | "script-auto-retry"; + +export interface RunInspection { + task: string; + agentId: string; + runId: string; + drainedMs: number; + streamed_messages: string[]; + tool_calls_total: number; + tool_calls_last_5min: number; + last_assistant_text_snippet: string; + last_tool_call: ToolCallInspection | null; +} + +export interface ToolCallInspection { + type: "tool_call"; + name?: string; + status?: string; + call_id?: string; + payload_keys: string[]; + payload_snippet: string; + truncated: boolean; +} + +type SlackDisplayKind = + | "running" + | "stuck" + | "completed" + | "errored" + | "cancelled"; + +interface SlackTaskRender { + emoji: string; + summary: string; + text: string; +} + +let Agent: typeof SDKAgentStatic; +async function loadSDK(): Promise { + if (Agent) return Agent; + const mod = await import("@cursor/sdk"); + Agent = mod.Agent; + return Agent; +} + +// Test-only: clears the cached `Agent` so a later `mock.module` reaches +// the SUT. Not for production callers. +export function __resetSDKForTests(): void { + Agent = undefined as unknown as typeof SDKAgentStatic; +} + +/** Cancel a cloud run by IDs, for tools operating outside a loaded workspace + * (e.g. `kill-tree` walking across branches). */ +export async function cancelCloudRun(opts: { + apiKey: string; + agentId: string; + runId: string; +}): Promise { + const sdk = await loadSDK(); + const run = await sdk.getRun(opts.runId, { + runtime: "cloud", + apiKey: opts.apiKey, + agentId: opts.agentId, + }); + if (typeof run.cancel !== "function" || run.supports?.("cancel") === false) { + throw new Error( + run.unsupportedReason?.("cancel") ?? "run.cancel unsupported" + ); + } + await run.cancel(); +} + +import { + applyMeasurementParser, + checkoutBranchForMeasurement, + compareMeasurement, + type MeasurementCheck, + type MeasurementClaim, + parseHandoffMeasurements, + runMeasurementCommand, +} from "../measurements.ts"; +import { + defaultModelForType, + isKnownModel, + resolveModelSelection, +} from "../models.ts"; +import { AndonPoller, SlackReactionAndonSource } from "./andon.ts"; +import { commentRetryQueuePath } from "./comment-retry-queue.ts"; +import { + classifyFailureMode, + hasStructuredHandoff, + writeFailureHandoff, + writeFinishedNoHandoff, +} from "./failure-handoff.ts"; +import { + emptyErrorHandoffBody, + parseHandoffBranch, + parseHandoffFailureMode, + parseHandoffPrNumber, + parseHandoffVerification, + resolveRunBranch, + writeHandoff, +} from "./handoff.ts"; +import { + buildSubplannerPrompt, + buildVerifierPrompt, + buildWorkerPrompt, + type PromptRenderContext, + renderPromptTemplate, +} from "./prompts.ts"; + +export class AgentManager { + readonly workspace: string; + readonly planPath: string; + readonly statePath: string; + readonly handoffsDir: string; + readonly attentionLog: string; + readonly plan: Plan; + state: State; + private readonly apiKey: string; + readonly slackAdapter?: SlackAdapter; + readonly andon: AndonPoller; + private readonly slackMirrorQueue = new Map>(); + + private constructor(args: { + workspace: string; + plan: Plan; + state: State; + apiKey: string; + slackAdapter?: SlackAdapter; + }) { + this.workspace = args.workspace; + this.plan = args.plan; + this.state = args.state; + this.apiKey = args.apiKey; + this.slackAdapter = args.slackAdapter; + this.planPath = join(args.workspace, "plan.json"); + this.statePath = join(args.workspace, "state.json"); + this.handoffsDir = join(args.workspace, "handoffs"); + this.attentionLog = join(args.workspace, "attention.log"); + this.andon = new AndonPoller({ + source: + this.isRootPlanner() && this.slackAdapter && this.plan.slackKickoffRef + ? new SlackReactionAndonSource( + this.slackAdapter, + this.plan.slackKickoffRef + ) + : undefined, + getState: () => this.state, + saveState: reason => this.saveAndonState(reason), + logAttention: line => this.logAttention(line), + pollSource: this.isRootPlanner(), + cachedState: this.andonCachedState(), + }); + } + + static async load( + workspacePath: string, + opts: { slackChannel?: string } = {} + ): Promise { + const workspace = resolve(workspacePath); + mkdirSync(workspace, { recursive: true }); + const planPath = join(workspace, "plan.json"); + if (!existsSync(planPath)) { + throw new PlanValidationError( + `missing ${planPath} — the planner writes plan.json first; see SKILL.md → Phase 1` + ); + } + const parsedPlan = parsePlanJson(readFileSync(planPath, "utf8"), planPath); + const plan: Plan = { + ...parsedPlan, + tasks: parsedPlan.tasks ?? [], + }; + const apiKey = process.env.CURSOR_API_KEY; + if (!apiKey) { + throw new PlanValidationError( + "CURSOR_API_KEY required; see cursor-sdk/references/auth.md" + ); + } + const slackInitAttention: string[] = []; + const isRootPlanner = planIsRootPlanner(plan); + if (isRootPlanner && opts.slackChannel && !plan.slackChannel) { + plan.slackChannel = opts.slackChannel; + writePlanAtomic(planPath, plan); + } + const slackAdapter = slackAdapterForPlan(plan); + if (!isRootPlanner && !plan.slackKickoffRef) { + throw new PlanValidationError( + `${planPath} is a child planner plan missing slackKickoffRef; parent kickoff state must be propagated` + ); + } + await ensureSlackKickoff({ + plan, + planPath, + isRootPlanner, + slackAdapter, + logAttention: line => slackInitAttention.push(line), + }); + await loadSDK(); + parsePlanValue(plan, planPath); + + const statePath = join(workspace, "state.json"); + const handoffsDir = join(workspace, "handoffs"); + mkdirSync(handoffsDir, { recursive: true }); + + let state: State; + if (existsSync(statePath)) { + state = parseStateJson(readFileSync(statePath, "utf8"), statePath); + reconcileStateWithPlan(state, plan); + } else { + state = { + rootSlug: plan.rootSlug, + tasks: planTasks(plan).map(t => initialTaskState(plan, t)), + attention: [], + }; + } + + const mgr = new AgentManager({ + workspace, + plan, + state, + apiKey, + slackAdapter, + }); + for (const line of slackInitAttention) { + if (!mgr.state.attention.some(a => a.message === line)) { + mgr.logAttention(line); + } + } + // Converge handed-off rows before any verifier respawns against the placeholder. + for (const handed of mgr.state.tasks) { + if (handed.status !== "handed-off") continue; + mgr.reconcileVerifierStartingRefs({ + updatedName: handed.name, + newBranch: handed.branch, + }); + } + mgr.saveState(); + return mgr; + } + + get tasks(): TaskState[] { + return this.state.tasks; + } + + getTask(name: string): TaskState | undefined { + return this.state.tasks.find(s => s.name === name); + } + + private isRootPlanner(): boolean { + return planIsRootPlanner(this.plan); + } + + private andonCachedState(): + | { workspace: string; ref: string; path: string } + | undefined { + if (this.isRootPlanner()) return undefined; + if (!this.plan.andonStateRef || !this.plan.andonStatePath) return undefined; + return { + workspace: this.workspace, + ref: this.plan.andonStateRef, + path: this.plan.andonStatePath, + }; + } + + private planWithAndonCache(): Plan { + if (!this.plan.slackKickoffRef) return this.plan; + if (this.plan.andonStateRef && this.plan.andonStatePath) return this.plan; + const ref = this.currentGitRef(); + const path = this.repoRelativeStatePath(); + if (!ref || !path) return this.plan; + return { + ...this.plan, + andonStateRef: ref, + andonStatePath: path, + }; + } + + private currentGitRef(): string | undefined { + try { + const branch = execFileSync( + "git", + ["-C", this.workspace, "rev-parse", "--abbrev-ref", "HEAD"], + { + encoding: "utf8", + stdio: "pipe", + } + ).trim(); + if (branch && branch !== "HEAD") return branch; + return execFileSync("git", ["-C", this.workspace, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: "pipe", + }).trim(); + } catch { + return undefined; + } + } + + private repoRelativeStatePath(): string | undefined { + try { + const root = execFileSync( + "git", + ["-C", this.workspace, "rev-parse", "--show-toplevel"], + { + encoding: "utf8", + stdio: "pipe", + } + ).trim(); + return relative(root, this.statePath); + } catch { + return undefined; + } + } + + depsSatisfied(task: TaskState): boolean { + for (const dep of task.dependsOn ?? []) { + const ds = this.getTask(dep); + if (!ds || ds.status !== "handed-off") return false; + } + return true; + } + + isAndonRaised(): boolean { + return this.andon.isActive(); + } + + renderTree(): string { + const lines: string[] = [ + `${this.plan.rootSlug}/ (${this.state.tasks.length} tasks)`, + ]; + const rows = this.state.tasks; + rows.forEach((t, i) => { + const last = i === rows.length - 1; + const branch = last ? "└─" : "├─"; + const deps = + t.dependsOn.length > 0 ? ` deps: ${t.dependsOn.join(", ")}` : ""; + const ids = t.agentId + ? ` ${t.agentId}${t.runId ? ` / ${t.runId}` : ""}` + : ""; + const attempts = (t.attempts ?? 0) > 1 ? ` attempts=${t.attempts}` : ""; + const adHoc = t.adHoc ? " [ad-hoc]" : ""; + lines.push( + `${branch} ${t.name.padEnd(32)} ${t.type.padEnd(11)} ${t.status.padEnd(11)} ${t.branch}${ids}${attempts}${deps}${adHoc}` + ); + }); + return lines.join("\n"); + } + + branchForTask(t: PlanTask | TaskState): string { + const planned = plannedBranchForTask(this.plan, t); + if ("branch" in t && t.branch.trim().length > 0) { + const actual = t.branch.trim(); + if ( + (t.type === "worker" || t.type === "subplanner") && + actual !== planned + ) { + throw new PlanValidationError( + `${t.name}: branch must be ${planned}, got ${actual}` + ); + } + return actual; + } + const existing = this.getTask(t.name); + if (existing?.branch && existing.branch.trim().length > 0) { + const actual = existing.branch.trim(); + if ( + (t.type === "worker" || t.type === "subplanner") && + actual !== planned + ) { + throw new PlanValidationError( + `${t.name}: branch must be ${planned}, got ${actual}` + ); + } + return actual; + } + return planned; + } + + /** + * Propagate `updatedName`'s actual pushed branch to verifiers whose + * `verifies` matches and whose `startingRef` is still the placeholder. + * Idempotent; planner-authored overrides are skipped; each propagation + * logs to `attention.log`. + */ + reconcileVerifierStartingRefs(args: { + updatedName: string; + newBranch: string; + }): void { + const { updatedName, newBranch } = args; + if (!newBranch.trim()) return; + const placeholder = `orch/${this.plan.rootSlug}/${updatedName}`; + if (newBranch === placeholder) return; + for (const dependent of this.state.tasks) { + if (dependent.type !== "verifier") continue; + if (dependent.startingRef !== placeholder) continue; + const planTask = planTasks(this.plan).find( + t => t.name === dependent.name + ); + if (!planTask || planTask.type !== "verifier") continue; + if (planTask.verifies !== updatedName) continue; + if (planTask.startingRef) continue; + this.touch(dependent, { startingRef: newBranch }); + this.logAttention( + `${dependent.name}: startingRef reconciled ${placeholder} -> ${newBranch} (target ${updatedName} handed off on actual branch)` + ); + } + } + + /** + * Re-run each declared `measurements[]` command on the worker's branch + * and diff against the `## Measurements` self-report. Mismatches log to + * `attention.log`. + */ + async checkWorkerMeasurements( + task: TaskState, + handoffBody: string + ): Promise { + const planTask = planTasks(this.plan).find(t => t.name === task.name); + const measurements = planTask?.measurements ?? []; + if (measurements.length === 0) return null; + const reportedBranch = parseHandoffBranch(handoffBody); + if (!task.branch.trim() || !reportedBranch) { + this.logAttention( + `${task.name}: skipped re-measurement; worker did not push a real branch (handoff branch=${reportedBranch ?? "(none)"})` + ); + return null; + } + const parsed = parseHandoffMeasurements(handoffBody); + if (!parsed) { + this.logAttention( + `${task.name}: handoff missing required \`## Measurements\` section; declared measurements=${measurements.map(m => m.name).join(", ")}` + ); + } else if (parsed.unparsed.length > 0) { + this.logAttention( + `${task.name}: \`## Measurements\` had ${parsed.unparsed.length} unparseable line(s); first: ${truncate(parsed.unparsed[0], 120)}` + ); + } + const claimByName = new Map(); + for (const claim of parsed?.claims ?? []) { + claimByName.set(claim.name, claim); + } + let checkout: { dir: string; cleanup: () => void } | null = null; + try { + checkout = checkoutBranchForMeasurement({ + branch: task.branch, + repoUrl: this.plan.repoUrl, + }); + } catch (err) { + this.logAttention( + `${task.name}: re-measurement clone failed (${truncate(errorMessage(err), 200)}); skipping ${measurements.length} measurement(s)` + ); + return null; + } + const checks: MeasurementCheck[] = []; + try { + for (const spec of measurements) { + const run = runMeasurementCommand({ + command: spec.command, + cwd: checkout.dir, + }); + if (!run.ok) { + checks.push({ + name: spec.name, + command: spec.command, + measured: "", + measuredNumeric: null, + claim: claimByName.get(spec.name) ?? null, + outcome: "command-failed", + driftFraction: null, + detail: run.reason, + }); + continue; + } + const parsedValue = applyMeasurementParser(spec.parser, run.stdout); + if (!parsedValue.ok) { + checks.push({ + name: spec.name, + command: spec.command, + measured: "", + measuredNumeric: null, + claim: claimByName.get(spec.name) ?? null, + outcome: "parse-failed", + driftFraction: null, + detail: parsedValue.reason, + }); + continue; + } + checks.push( + compareMeasurement({ + spec, + measured: parsedValue.value, + claim: claimByName.get(spec.name) ?? null, + }) + ); + } + } finally { + checkout.cleanup(); + } + const mismatches = checks.filter(c => c.outcome !== "match"); + if (mismatches.length > 0) { + for (const check of mismatches) { + this.logAttention( + `${task.name}: measurement_mismatch ${check.name} [${check.outcome}] ${check.detail}` + ); + } + } + return checks; + } + + private startingRefForTask(t: PlanTask, s: TaskState): string { + if (t.startingRef) return t.startingRef; + switch (t.type) { + case "worker": + case "subplanner": + return s.startingRef; + case "verifier": { + const target = planTasks(this.plan).find(x => x.name === t.verifies); + if (target) return this.branchForTask(target); + return s.startingRef; + } + default: { + const _exhaustive: never = t; + return _exhaustive; + } + } + } + + /** + * Spawn a cloud agent for `def`. One call = one logical attempt (bumps + * `attempts`), with an inner transient-retry loop for network hiccups. + * Returns null on final failure; state is left as error + attention entry. + */ + async spawnTask( + def: PlanTask, + options: { adHoc?: boolean } = {} + ): Promise { + if (!TASK_NAME_RE.test(def.name)) { + throw new PlanValidationError( + `task.name must be kebab-case ascii (no path traversal): got ${JSON.stringify(def.name)}` + ); + } + + let s = this.getTask(def.name); + if (!s) { + s = { + ...initialTaskState(this.plan, def), + adHoc: options.adHoc ?? false, + }; + this.state.tasks.push(s); + this.saveState(); + } + const attemptsBefore = s.attempts ?? 0; + if (def.maxAttempts != null && attemptsBefore >= def.maxAttempts) { + const msg = `exceeded maxAttempts=${def.maxAttempts} (attempts=${attemptsBefore}); planner must bump maxAttempts or abandon this task`; + this.touch(s, { status: "error", note: msg, failureMode: "unknown" }); + this.logAttention(`${def.name}: ${msg}`); + return null; + } + + const attemptNumber = attemptsBefore + 1; + this.touch(s, { attempts: attemptNumber }); + + if (def.model && !isKnownModel(def.model)) { + this.logAttention( + `${def.name}: model "${def.model}" not in MODEL_CATALOG (spawning anyway). Run \`bun cli.ts models\` to see known slugs.` + ); + } + + let lastErr: unknown = null; + for (let subAttempt = 1; subAttempt <= SPAWN_MAX_ATTEMPTS; subAttempt++) { + try { + const startingRef = this.startingRefForTask(def, s); + if (startingRef !== s.startingRef) { + this.touch(s, { startingRef }); + } + const agent = await Agent.create({ + apiKey: this.apiKey, + name: `${this.plan.rootSlug}/${def.name}`, + model: resolveModelSelection( + def.model ?? defaultModelForType(def.type) + ), + cloud: { + repos: [{ url: this.plan.repoUrl, startingRef }], + autoCreatePR: def.openPR ?? false, + }, + }); + // Persist IDs only after send() succeeds; a crash before run creation + // should recover as an orphaned spawn. + this.touch(s, { + agentId: null, + status: "running", + startedAt: new Date().toISOString(), + }); + const promptCtx: PromptRenderContext = { + plan: this.planWithAndonCache(), + branchForTask: task => this.branchForTask(task), + getTask: name => this.getTask(name), + readHandoff: taskName => this.readHandoff(taskName), + }; + let prompt: string; + switch (def.type) { + case "worker": + prompt = buildWorkerPrompt(def, agent.agentId, promptCtx); + break; + case "subplanner": + prompt = buildSubplannerPrompt(def, agent.agentId, promptCtx); + break; + case "verifier": + prompt = buildVerifierPrompt(def, agent.agentId, promptCtx); + break; + } + const run = await agent.send(prompt); + this.touch(s, { + agentId: agent.agentId, + runId: run.id, + parentAgentId: this.plan.selfAgentId ?? null, + }); + if (subAttempt > 1) { + this.logAttention( + `${def.name}: transient-retry succeeded on sub-attempt ${subAttempt}` + ); + } + return { kind: "spawned", agent, run, s }; + } catch (err) { + lastErr = err; + if (subAttempt < SPAWN_MAX_ATTEMPTS) { + await sleep(SPAWN_RETRY_BACKOFF_MS * subAttempt); + } + } + } + const errText = truncate(String(lastErr), 200); + const hint = errText.includes("invalid_model") + ? ` run \`bun cli.ts models --check\` to re-probe the catalog against /v1/agents.` + : ""; + const msg = `spawn failed after ${SPAWN_MAX_ATTEMPTS} transient sub-attempts: ${errText}${hint}`; + this.touch(s, { + status: "error", + note: msg, + failureMode: classifyFailureMode({ + sdkError: msg, + durationMs: null, + lastOutput: null, + }), + }); + this.logAttention(`${def.name}: ${msg}`); + return null; + } + + /** Reset a terminal task to pending; the next `run` re-spawns it. */ + respawnTask( + taskName: string, + options: { source?: RespawnSource } = {} + ): TaskState { + const s = this.getTask(taskName); + if (!s) throw new Error(`unknown task: ${taskName}`); + if (s.status === "running") { + throw new Error( + `task ${taskName} is still running; use \`kill\` first if you really want to respawn` + ); + } + if (s.status === "handed-off") { + throw new Error( + `task ${taskName} already handed off; add a new task to plan.json if you want another attempt` + ); + } + const prevStatus = s.status; + const source = options.source ?? "local-cli"; + // Subplanners resume from their own branch: the prior state.json and + // committed handoffs inherit into the new clone, so the resumed subplanner + // skips children that already handed off. Workers have no such internal + // state; keep their original startingRef. + const resume = s.type === "subplanner" && (s.attempts ?? 0) > 0; + this.touch(s, { + status: "pending", + agentId: null, + runId: null, + resultStatus: null, + handoffPath: null, + prNumber: null, + failureMode: null, + startedAt: null, + finishedAt: null, + startingRef: resume ? s.branch : s.startingRef, + note: `respawned by ${source} (was ${prevStatus}; attempts=${s.attempts ?? 0})`, + }); + this.logAttention( + `${taskName}: respawned by ${source} (was ${prevStatus})${resume ? `; resuming from ${s.branch}` : ""}` + ); + return s; + } + + /** Re-attach to a task still `running` after a script restart. */ + async recoverRunning(s: TaskState): Promise { + if (!s.agentId && !s.runId) { + this.recordRecoverFailure( + s, + "orphaned — status was `running` but no agentId or runId recorded (likely crashed mid-spawn)" + ); + return null; + } + if (!s.runId) { + this.recordRecoverFailure( + s, + "orphaned — had agentId but no runId on restart" + ); + return null; + } + if (!s.agentId) { + this.recordRecoverFailure( + s, + "orphaned — had runId but no agentId on restart" + ); + return null; + } + try { + const run = await Agent.getRun(s.runId, { + runtime: "cloud", + apiKey: this.apiKey, + agentId: s.agentId, + }); + return { kind: "recovered", run, s }; + } catch (err) { + this.recordRecoverFailure( + s, + `recover failed: ${truncate(errorMessage(err), 200)}` + ); + return null; + } + } + + // Every error-status transition needs a `-failure.md` sidecar so the + // exit-on-error log message ("See handoffs/-failure.md") points at a + // real file. Captures `lastUpdate`/`note` before `touch` clobbers them. + private recordRecoverFailure(s: TaskState, msg: string): void { + const lastActivityAt = s.lastUpdate; + const lastActivityNote = s.note; + const failureMode = classifyFailureMode({ + sdkError: msg, + durationMs: null, + lastOutput: null, + }); + this.touch(s, { status: "error", note: msg, failureMode }); + this.logAttention(`${s.name}: ${msg}`); + try { + const terminatedAt = new Date().toISOString(); + const sidecarPath = writeFailureHandoff({ + handoffsDir: this.handoffsDir, + task: s, + failureMode, + sdkError: msg, + lastActivityAt, + lastActivityNote, + lastToolCall: null, + terminatedAt, + }); + this.logAttention( + `${s.name}: synthetic failure handoff written to ${sidecarPath} (recover failed)` + ); + } catch (writeErr) { + this.logAttention( + `${s.name}: failed to write synthetic failure handoff: ${truncate(errorMessage(writeErr), 200)}` + ); + } + } + + /** + * Wait for a run to finish, write its final message as a handoff, update + * state. Prefers `rr.result` (the final assistant message); falls back to + * the stream concat if unset. Writes the handoff file before touching + * state so downstream tasks never see status=handed-off with a missing file. + */ + async waitAndHandoff(result: SpawnResult | RecoverResult): Promise { + let { run } = result; + const { s } = result; + const agent = agentForWaitResult(result); + let accumulatedText = ""; + let handoffSucceeded = false; + let handoffBody: string | null = null; + let handoffBranch: string | null = null; + const lastToolCallAt = { value: null as number | null }; + const lastToolCallName = { value: null as string | null }; + let lastWaitError: string | null = null; + // Hoisted so the failure-sidecar paths can record the actual last + // heartbeat. `this.touch` clobbers `task.lastUpdate` to "now" before + // the sidecar writes, so the fallback inside writeFailureHandoff + // would otherwise show the termination time as "last activity". + const lastSseActivityAt = { value: Date.now() }; + try { + let rr: RunResult; + for (let recoveryAttempt = 1; ; recoveryAttempt++) { + lastSseActivityAt.value = Date.now(); + const streamPromise = (async () => { + try { + for await (const event of run.stream()) { + lastSseActivityAt.value = Date.now(); + if (event.type === "tool_call") { + lastToolCallAt.value = Date.now(); + if (typeof event.name === "string") { + lastToolCallName.value = event.name; + } + } + if (event.type !== "assistant") continue; + for (const block of event.message.content) { + if (block.type === "text" && typeof block.text === "string") { + accumulatedText += block.text; + } + } + } + } catch { + // Stream errors are non-fatal; run.wait() is authoritative. + } + })(); + try { + rr = await waitRunWithWatchdog({ + run, + agentId: s.agentId ?? run.agentId, + runId: s.runId ?? run.id, + apiKey: this.apiKey, + lastSseActivityAt, + lastToolCallAt, + logAttention: line => this.logAttention(line), + taskLabel: s.name, + }); + } catch (waitErr) { + lastWaitError = errorMessage(waitErr); + throw waitErr; + } + await streamPromise; + if (rr.status !== "error") break; + lastWaitError = runResultErrorMessage(rr) ?? lastWaitError; + + const agentId = s.agentId ?? run.agentId; + const runId = s.runId ?? run.id; + let freshRun: Run; + try { + freshRun = await Agent.getRun(runId, { + runtime: "cloud", + apiKey: this.apiKey, + agentId, + }); + } catch (probeErr) { + this.logAttention( + `${s.name}: run.wait returned status=error; recovery probe failed, accepting error: ${truncate(String(probeErr), 200)}` + ); + break; + } + + if (freshRun.status !== "running") { + rr = await freshRun.wait(); + this.logAttention( + `${s.name}: run.wait returned status=error; recovery probe found terminal status=${rr.status}, accepting authoritative status` + ); + break; + } + + // Agent.getRun is authoritative. Retry without a cap: the watchdog + // inside waitRunWithWatchdog polls Agent.getRun every 60s and wins + // the race whenever REST flips to terminal, so a genuinely stuck + // server can't hang this loop any worse than the watchdog already + // allows. A prior cap of 3 truncated legitimate long recoveries + // (subplanners running 3h+ with repeated SSE drops). + this.logAttention( + `${s.name}: run.wait returned status=error but Agent.getRun still reports running; treating as dropped stream and retrying wait (attempt ${recoveryAttempt})` + ); + await sleep(WAIT_RECOVERY_RETRY_BACKOFF_MS); + run = freshRun; + } + let body = rr.result?.trim() || accumulatedText; + if (!body && rr.status === "error") { + body = emptyErrorHandoffBody({ + task: s, + result: rr, + renderTemplate: renderPromptTemplate, + }); + } + const runBranch = resolveRunBranch({ + handoffBody: body, + runBranches: rr.git?.branches ?? [], + fallback: s.branch, + }); + if (runBranch !== s.branch) { + s.branch = runBranch; + } + const prNumber = parseHandoffPrNumber(body); + const sdkError = + rr.status === "finished" ? null : runResultErrorMessage(rr) ?? lastWaitError; + const failureMode = + parseHandoffFailureMode(body) ?? + (rr.status === "finished" + ? null + : classifyFailureMode({ + sdkError, + durationMs: rr.durationMs ?? null, + lastOutput: body, + })); + const finishedAt = new Date().toISOString(); + const resultStatus = rr.status; + const nextStatus: TaskStatus = + rr.status === "finished" ? "handed-off" : "error"; + const handoffPath = writeHandoff({ + handoffsDir: this.handoffsDir, + task: s, + body, + resultStatus, + finishedAt, + }); + this.touch(s, { + branch: runBranch, + resultStatus, + status: nextStatus, + finishedAt, + handoffPath: `handoffs/${s.name}.md`, + prNumber, + failureMode, + }); + const lastActivityAt = new Date(lastSseActivityAt.value).toISOString(); + // Sidecar writes are wrapped so a disk-full or rename failure can't + // unwind the just-committed handed-off status via the outer catch. + if (rr.status !== "finished") { + this.logAttention( + `${s.name}: run ended with status=${rr.status}; see ${handoffPath}` + ); + try { + const sidecarPath = writeFailureHandoff({ + handoffsDir: this.handoffsDir, + task: s, + failureMode: failureMode ?? "unknown", + sdkError, + lastActivityAt, + lastToolCall: lastToolCallName.value, + terminatedAt: finishedAt, + }); + this.logAttention( + `${s.name}: synthetic failure handoff written to ${sidecarPath}` + ); + } catch (writeErr) { + this.logAttention( + `${s.name}: failed to write synthetic failure handoff: ${truncate(errorMessage(writeErr), 200)}` + ); + } + } else if (!hasStructuredHandoff(body)) { + try { + const sidecarPath = writeFinishedNoHandoff({ + handoffsDir: this.handoffsDir, + task: s, + resultStatus, + terminatedAt: finishedAt, + rawBodySnippet: body, + }); + this.logAttention( + `${s.name}: finished-no-handoff written to ${sidecarPath} (run status=${resultStatus} but no \`## Status\` section produced)` + ); + } catch (writeErr) { + this.logAttention( + `${s.name}: failed to write finished-no-handoff sidecar: ${truncate(errorMessage(writeErr), 200)}` + ); + } + } + handoffSucceeded = rr.status === "finished"; + handoffBody = body; + handoffBranch = runBranch; + } catch (err) { + const errMsg = errorMessage(err); + const failureMode = classifyFailureMode({ + sdkError: errMsg, + durationMs: null, + lastOutput: accumulatedText, + }); + this.touch(s, { + status: "error", + note: `wait failed: ${truncate(errMsg, 200)}`, + failureMode, + }); + this.logAttention(`${s.name}: wait threw: ${errMsg}`); + // A throw skipped the normal handoff write; leave a sidecar so the + // planner's next turn isn't staring at a silent error transition. + try { + const terminatedAt = new Date().toISOString(); + const sidecarPath = writeFailureHandoff({ + handoffsDir: this.handoffsDir, + task: s, + failureMode, + sdkError: errMsg, + lastActivityAt: new Date(lastSseActivityAt.value).toISOString(), + lastToolCall: lastToolCallName.value, + terminatedAt, + }); + this.logAttention( + `${s.name}: synthetic failure handoff written to ${sidecarPath} (wait threw before handoff)` + ); + } catch (writeErr) { + this.logAttention( + `${s.name}: failed to write synthetic failure handoff: ${truncate(errorMessage(writeErr), 200)}` + ); + } + } finally { + if (agent) { + await Promise.resolve(agent[Symbol.asyncDispose]()).catch(() => {}); + } + } + // Post-handoff reconciliation runs outside the try so a failure here + // can't clobber the handoff status that was just committed. Only fires + // on a successful run: an errored run may have pushed to a real branch, + // and propagating that ref would stick verifiers on the failed attempt + // even after a respawn lands on a different branch. Reconcile and the + // measurement re-check are isolated so a throw in one cannot silently + // skip the other. + if (handoffSucceeded && handoffBranch !== null && handoffBody !== null) { + try { + this.reconcileVerifierStartingRefs({ + updatedName: s.name, + newBranch: handoffBranch, + }); + } catch (err) { + this.logAttention( + `${s.name}: reconcileVerifierStartingRefs threw: ${errorMessage(err)}` + ); + } + try { + await this.checkWorkerMeasurements(s, handoffBody); + } catch (err) { + this.logAttention( + `${s.name}: checkWorkerMeasurements threw: ${errorMessage(err)}` + ); + } + try { + this.recordHandoffVerification(s, handoffBody); + } catch (err) { + this.logAttention( + `${s.name}: recordHandoffVerification threw: ${errorMessage(err)}` + ); + } + } + } + + /** + * Persist the `## Verification` claim from a handoff body. Verifier + * verdicts land on the *target* task's row (looked up via + * `plan.tasks[].verifies`); worker and subplanner self-reports land on + * their own row. A later verifier handoff overwrites the self-report + * because verifiers always depend on (and so hand off after) their target. + */ + recordHandoffVerification(task: TaskState, handoffBody: string): void { + const verification = parseHandoffVerification(handoffBody); + if (!verification) return; + if (task.type === "verifier") { + const planTask = planTasks(this.plan).find(t => t.name === task.name); + if (!planTask || planTask.type !== "verifier") return; + const target = this.getTask(planTask.verifies); + if (!target) return; + if (target.verification === verification) return; + this.touch(target, { verification }); + this.logAttention( + `${target.name}: verification recorded (${verification}) by verifier ${task.name}` + ); + return; + } + if (task.verification === verification) return; + this.touch(task, { verification }); + this.logAttention( + `${task.name}: verification self-reported (${verification})` + ); + } + + async cancel(taskName: string): Promise { + const s = this.getTask(taskName); + if (!s) throw new Error(`unknown task: ${taskName}`); + if (!s.runId) + throw new Error(`task ${taskName} has no runId (status=${s.status})`); + if (!s.agentId) + throw new Error(`task ${taskName} has no agentId (status=${s.status})`); + const run = await Agent.getRun(s.runId, { + runtime: "cloud", + apiKey: this.apiKey, + agentId: s.agentId, + }); + if ( + typeof run.cancel !== "function" || + run.supports?.("cancel") === false + ) { + throw new Error( + run.unsupportedReason?.("cancel") ?? "run.cancel unsupported" + ); + } + await run.cancel(); + this.touch(s, { + status: "cancelled", + note: "cancelled by operator via cli", + }); + this.logAttention(`${s.name}: cancelled by operator`); + } + + /** + * Stop a task: cancel if running, prune if pending. If the SDK cancel fails + * (backend drift, stale agentId), mark error locally so the operator isn't + * stuck. Terminal tasks are no-op. + */ + async stopTask(taskName: string): Promise { + const s = this.getTask(taskName); + if (!s) throw new Error(`unknown task: ${taskName}`); + if (s.status === "running") { + try { + await this.cancel(taskName); + return { name: taskName, action: "cancelled" }; + } catch (err) { + const msg = errorMessage(err); + const failureMode = classifyFailureMode({ + sdkError: msg, + durationMs: null, + lastOutput: null, + }); + this.touch(s, { + status: "error", + note: `cancel failed; orphaned on backend: ${truncate(msg, 200)}`, + failureMode, + }); + this.logAttention( + `${s.name}: cancel failed (${msg}); marked error. Cloud agent may still be running — check via Agent.list.` + ); + return { name: taskName, action: "cancelled" }; + } + } + if (s.status === "pending") { + this.touch(s, { status: "pruned", note: "pruned by operator via cli" }); + this.logAttention(`${s.name}: pruned before spawn (operator)`); + return { name: taskName, action: "pruned" }; + } + return { name: taskName, action: "noop", previousStatus: s.status }; + } + + /** Stop a task and transitively prune every pending descendant. */ + async stopTaskCascade(taskName: string): Promise { + const primary = await this.stopTask(taskName); + const results: StopResult[] = [primary]; + const toVisit = [taskName]; + const seen = new Set([taskName]); + while (toVisit.length > 0) { + const current = toVisit.shift(); + if (current === undefined) break; + for (const candidate of this.state.tasks) { + if (seen.has(candidate.name)) continue; + if (!candidate.dependsOn.includes(current)) continue; + if (candidate.status !== "pending" && candidate.status !== "running") + continue; + seen.add(candidate.name); + toVisit.push(candidate.name); + const r = await this.stopTask(candidate.name); + if (r.action !== "noop") results.push(r); + } + } + return results; + } + + async stopAll(): Promise { + const results: StopResult[] = []; + for (const s of this.state.tasks) { + if (s.status !== "pending" && s.status !== "running") continue; + results.push(await this.stopTask(s.name)); + } + return results; + } + + async *tail(taskName: string): AsyncGenerator { + const s = this.getTask(taskName); + if (!s) throw new Error(`unknown task: ${taskName}`); + if (!s.runId) + throw new Error(`task ${taskName} has no runId (status=${s.status})`); + if (!s.agentId) + throw new Error(`task ${taskName} has no agentId (status=${s.status})`); + const run = await Agent.getRun(s.runId, { + runtime: "cloud", + apiKey: this.apiKey, + agentId: s.agentId, + }); + yield* run.stream(); + } + + async inspectTask( + taskName: string, + timeoutMs: number + ): Promise { + const s = this.getTask(taskName); + if (!s) throw new Error(`unknown task: ${taskName}`); + if (!s.runId) + throw new Error(`task ${taskName} has no runId (status=${s.status})`); + if (!s.agentId) + throw new Error(`task ${taskName} has no agentId (status=${s.status})`); + const run = await Agent.getRun(s.runId, { + runtime: "cloud", + apiKey: this.apiKey, + agentId: s.agentId, + }); + return inspectRunStream({ + run, + task: taskName, + agentId: s.agentId, + runId: s.runId, + timeoutMs, + }); + } + + readHandoff(taskName: string): string | null { + const s = this.getTask(taskName); + if (!s?.handoffPath) return null; + const p = join(this.workspace, s.handoffPath); + return existsSync(p) ? readFileSync(p, "utf8") : null; + } + + commentDestinations(): CommentDestinations { + return { slack: this.slackAdapter }; + } + + saveState(): void { + const tmp = `${this.statePath}.tmp`; + writeFileSync(tmp, JSON.stringify(this.state, null, 2)); + renameSync(tmp, this.statePath); + } + + savePlan(): void { + const tmp = `${this.planPath}.tmp`; + writeFileSync(tmp, JSON.stringify(this.plan, null, 2)); + renameSync(tmp, this.planPath); + } + + private saveAndonState(reason?: string): void { + this.saveState(); + if (reason && this.plan.syncStateToGit) { + this.commitStateSnapshot(reason); + } + } + + touch(task: TaskState, patch: Partial): void { + const prevStatus = task.status; + const prevAgentId = task.agentId; + Object.assign(task, patch, { lastUpdate: new Date().toISOString() }); + this.saveState(); + const statusChanged = !!patch.status && patch.status !== prevStatus; + if (statusChanged) { + if (this.plan.syncStateToGit) { + this.commitStateSnapshot( + `${task.name} ${prevStatus} -> ${task.status}` + ); + } + } + // Re-mirror when agentId transitions from null to a real id so the footer + // link appears mid-run. Spawn ordering sets status="running" with + // agentId=null first (orphan-recovery soundness), then fills agentId after + // agent.send() resolves; without this re-mirror, the footer would only + // appear at terminal status. + const agentIdLanded = + "agentId" in patch && !!task.agentId && task.agentId !== prevAgentId; + if (statusChanged || agentIdLanded) { + this.enqueueSlackMirror(task); + } + } + + syncStateToGit(reason: string): void { + this.saveState(); + if (this.plan.syncStateToGit) { + this.commitStateSnapshot(reason); + } + } + + private async mirrorTaskToSlack( + task: TaskState, + rendered = this.slackRenderForTask(task) + ): Promise { + const kickoffRef = this.plan.slackKickoffRef; + if (!this.slackAdapter || !kickoffRef) return; + if (!rendered) return; + if ( + task.slackTs && + task.slackRendered?.emoji === rendered.emoji && + task.slackRendered.summary === rendered.summary + ) { + return; + } + const ref = task.slackTs + ? await this.slackAdapter.editThreadMessage({ + threadTs: kickoffRef.ts, + ts: task.slackTs, + text: rendered.text, + }) + : await this.slackAdapter.postInThread({ + threadTs: kickoffRef.ts, + username: task.name, + text: rendered.text, + }); + task.slackTs = ref.ts; + task.slackRendered = { + emoji: rendered.emoji, + summary: rendered.summary, + }; + this.syncStateToGit(`${task.name} slack mirror`); + } + + private enqueueSlackMirror(task: TaskState): void { + const rendered = this.slackRenderForTask(task); + if (!rendered) return; + const previous = this.slackMirrorQueue.get(task.name) ?? Promise.resolve(); + let next: Promise; + next = previous + .catch(() => {}) + .then(() => this.mirrorTaskToSlack(task, rendered)) + .catch(err => { + this.logAttention( + `${task.name}: slack mirror failed: ${truncate(errorMessage(err), 200)}` + ); + }) + .finally(() => { + if (this.slackMirrorQueue.get(task.name) === next) { + this.slackMirrorQueue.delete(task.name); + } + }); + this.slackMirrorQueue.set(task.name, next); + } + + private slackRenderForTask(task: TaskState): SlackTaskRender | null { + const status = this.slackDisplayStatus(task); + if (!status) return null; + const summary = this.slackSummaryForTask(task, status.kind); + return { + emoji: status.emoji, + summary, + text: summary + ? `${status.emoji} ${status.label}\n${summary}` + : `${status.emoji} ${status.label}`, + }; + } + + private slackDisplayStatus( + task: TaskState + ): { kind: SlackDisplayKind; emoji: string; label: string } | null { + if (task.status === "pending") return null; + if (task.status === "running" && this.latestIdleWarningMs(task) !== null) { + return { kind: "stuck", emoji: "⚠", label: "stuck" }; + } + switch (task.status) { + case "running": + return { kind: "running", emoji: "▶︎", label: "running" }; + case "handed-off": + return { kind: "completed", emoji: "✓", label: "completed" }; + case "error": + return { kind: "errored", emoji: "✗", label: "errored" }; + case "cancelled": + case "pruned": + return { kind: "cancelled", emoji: "⊘", label: "cancelled" }; + default: { + const _exhaustive: never = task.status; + return _exhaustive; + } + } + } + + private slackSummaryForTask( + task: TaskState, + kind: SlackDisplayKind + ): string { + const view = formatAgentFooter(task.agentId); + switch (kind) { + case "running": + return appendSlackView( + `started ${formatElapsedMinutes(task.startedAt)} ago`, + view + ); + case "stuck": { + const idleMs = this.latestIdleWarningMs(task); + return appendSlackView( + `no activity for ${formatDurationMinutes(idleMs ?? 0)}`, + view + ); + } + case "completed": + return appendSlackView(this.completedSummary(task), view); + case "errored": + return appendSlackView(failureModeText(task.failureMode), view); + case "cancelled": + return view; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } + } + + private completedSummary(task: TaskState): string { + if (task.prNumber) { + const repo = githubRepoFromRemote(this.plan.repoUrl); + if (repo) { + return `opened `; + } + return `opened #${task.prNumber}`; + } + return this.diffStatsForTask(task) ?? ""; + } + + private diffStatsForTask(task: TaskState): string | null { + const branch = task.branch.trim(); + const base = (this.plan.prBase ?? this.plan.baseBranch).trim(); + if (!branch || !base || branch === base) return null; + const range = `${base}...${branch}`; + const log = this.gitOutput(["log", "--oneline", range, "--"]); + if (!log?.trim()) return null; + const shortstat = this.gitOutput(["diff", "--shortstat", range, "--"]); + return shortstat ? formatGitShortstat(shortstat) : null; + } + + private gitOutput(args: string[]): string | null { + try { + return execFileSync("git", ["-C", this.workspace, ...args], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }); + } catch { + return null; + } + } + + private latestIdleWarningMs(task: TaskState): number | null { + for (const entry of [...this.state.attention].reverse()) { + if (!entry.message.startsWith(`${task.name}:`)) continue; + const idleMs = parseIdleWarningMs(entry.message); + if (idleMs !== null) return idleMs; + } + return null; + } + + /** + * Commit and push state.json + plan.json so remote observers can + * `bun cli.ts crawl`. Called only on status transitions, not every + * `lastUpdate`. + */ + private commitStateSnapshot(reason: string): void { + const gitExec = (args: string[]): void => { + execFileSync("git", ["-C", this.workspace, ...args], { stdio: "pipe" }); + }; + const hasStagedDiff = (paths: string[]): boolean => { + try { + gitExec(["diff", "--cached", "--quiet", "--", ...paths]); + return false; + } catch { + return true; + } + }; + try { + gitExec(["rev-parse", "--show-toplevel"]); + } catch { + return; + } + try { + const addPaths = [this.statePath, this.planPath, this.handoffsDir]; + const retryQueuePath = commentRetryQueuePath(this.workspace); + if (existsSync(retryQueuePath)) addPaths.push(retryQueuePath); + gitExec(["add", ...addPaths]); + if (!hasStagedDiff(addPaths)) { + gitExec(["push"]); + return; + } + gitExec(["commit", "-m", `orch: ${this.plan.rootSlug} ${reason}`]); + gitExec(["push"]); + } catch (err) { + this.logAttention( + `git-sync failed (${reason}): ${truncate(errorMessage(err), 200)}` + ); + } + } + + logAttention(line: string): void { + const ts = new Date().toISOString(); + appendFileSync(this.attentionLog, `[${ts}] ${line}\n`); + this.state.attention.push({ at: ts, message: line }); + this.saveState(); + const taskName = line.split(":", 1)[0]; + const task = this.getTask(taskName); + if (task?.status === "running" && parseIdleWarningMs(line) !== null) { + this.enqueueSlackMirror(task); + } + } +} + +/** + * Reconcile state against the current plan on load: append new tasks, sync + * dependsOn on pending rows, prune pending rows whose task was removed from + * plan.json. Leaves terminal and running rows untouched. + */ +function reconcileStateWithPlan(state: State, plan: Plan): void { + const tasks = planTasks(plan); + const planByName = new Map(tasks.map(t => [t.name, t])); + + for (const t of tasks) { + if (!state.tasks.find(s => s.name === t.name)) { + state.tasks.push(initialTaskState(plan, t)); + } + } + + for (const s of state.tasks) { + if (s.status !== "pending") continue; + const planTask = planByName.get(s.name); + if (planTask) { + s.dependsOn = normalizedDependsOn(planTask); + s.slackTs = s.slackTs ?? planTask.slackTs ?? null; + } + if (!planTask && !s.adHoc) { + s.status = "pruned"; + s.note = "orphaned: removed from plan.json; auto-pruned on load"; + s.lastUpdate = new Date().toISOString(); + state.attention.push({ + at: s.lastUpdate, + message: `${s.name}: auto-pruned on load because it was removed from plan.json`, + }); + } + } +} + +function initialTaskState(plan: Plan, t: PlanTask): TaskState { + return { + name: t.name, + type: t.type, + branch: plannedBranchForTask(plan, t), + startingRef: t.startingRef ?? defaultStartingRefForTask(plan, t), + dependsOn: normalizedDependsOn(t), + agentId: null, + runId: null, + parentAgentId: null, + status: "pending", + resultStatus: null, + handoffPath: null, + startedAt: null, + finishedAt: null, + lastUpdate: null, + note: null, + slackTs: t.slackTs ?? null, + prNumber: null, + failureMode: null, + verification: null, + }; +} + +function defaultStartingRefForTask(plan: Plan, t: PlanTask): string { + switch (t.type) { + case "worker": + return plan.baseBranch; + case "subplanner": + return plan.baseBranch; + case "verifier": + return `orch/${plan.rootSlug}/${t.verifies}`; + default: { + const _exhaustive: never = t; + return _exhaustive; + } + } +} + +function normalizedDependsOn(t: PlanTask): string[] { + const deps = [...(t.dependsOn ?? [])]; + switch (t.type) { + case "worker": + case "subplanner": + return deps; + case "verifier": + if (!deps.includes(t.verifies)) { + deps.unshift(t.verifies); + } + return deps; + default: { + const _exhaustive: never = t; + return _exhaustive; + } + } +} + +function planTasks(plan: Plan): PlanTask[] { + return plan.tasks ?? []; +} + +function agentForWaitResult( + result: SpawnResult | RecoverResult +): SDKAgent | undefined { + switch (result.kind) { + case "spawned": + return result.agent; + case "recovered": + return undefined; + default: { + const _exhaustive: never = result; + return _exhaustive; + } + } +} + +function planSlackChannel(plan: Plan): string | undefined { + return plan.slackChannel ?? plan.slackKickoffRef?.channel; +} + +function slackAdapterForPlan(plan: Plan): SlackAdapter | undefined { + // SLACK_BOT_TOKEN missing is signalled once via console.error inside + // createSlackWebClient. Don't double-log to attention.log: env config + // belongs to the operator surface, not workspace-visible state. + const channel = planSlackChannel(plan); + if (!channel) { + // Surface the missing-token signal even when the channel is also unset, + // so an operator who expected Slack visibility sees a single console line + // explaining why it's off. + if (!process.env.SLACK_BOT_TOKEN) createSlackWebClient(); + return undefined; + } + return createSlackAdapter(channel); +} + +async function ensureSlackKickoff(args: { + plan: Plan; + planPath: string; + isRootPlanner: boolean; + slackAdapter?: SlackAdapter; + logAttention: (line: string) => void; +}): Promise { + if (!args.isRootPlanner) return; + if (args.plan.slackKickoffRef) return; + if (!args.slackAdapter) return; + try { + args.plan.slackKickoffRef = await args.slackAdapter.postRunKickoff({ + text: formatKickoffText(args.plan), + username: kickoffUsername(args.plan), + }); + // Atomic write so a crash mid-flush can't leave a truncated plan.json + // that fails strict zod parsing on restart. + writePlanAtomic(args.planPath, args.plan); + } catch (err) { + args.logAttention( + `slack kickoff failed: ${truncate(errorMessage(err), 200)}` + ); + } +} + +function writePlanAtomic(planPath: string, plan: Plan): void { + const tmp = `${planPath}.tmp`; + writeFileSync(tmp, JSON.stringify(plan, null, 2)); + renameSync(tmp, planPath); +} + +function planIsRootPlanner(plan: Plan): boolean { + return !plan.andonStateRef && !plan.andonStatePath; +} + +const KICKOFF_GOAL_FALLBACK_CHARS = 200; + +/** + * Kickoff body: `: