
Orchestrate
- 318 installs
- 2.5k repo stars
- Updated August 5, 2026
- cursor/plugins
Coordinate multiple Cursor agents or subtasks—planning, coding, testing—in sequence or parallel to finish a feature without losing state between handoffs.
About
Cursor plugin skill for orchestrating several agents through a shared plan: decompose work, assign subagents, merge results, and recover from partial failures so complex builds complete with coherent context and minimal manual babysitting.
- Sequences multi-agent work
- Defines handoff boundaries
- Preserves task state
- Parallelizes independent steps
Orchestrate by the numbers
- 318 all-time installs (skills.sh)
- Ranked #2,237 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cursor/plugins --skill orchestrateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 318 |
|---|---|
| repo stars | ★ 2.5k |
| Last updated | August 5, 2026 |
| Repository | cursor/plugins ↗ |
What it does
Coordinate multiple Cursor agents or subtasks—planning, coding, testing—in sequence or parallel to finish a feature without losing state between handoffs.
Files
Orchestrate
An explicit /orchestrate <goal> 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_KEYmust be a personal/user key. Create it from Cursor Dashboard > Integrations, then readcursor-sdkAuth before using it.SLACK_BOT_TOKENis optional. When set, pass--slack-channel <id>tokickoffor the firstrun --root, or setSLACK_CHANNEL_ID. The script stores the channel inplan.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 <goal>. 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.
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 <path-to-orchestrate>/scripts/cli.ts andon raise --reason "<why>"{{agentIdFlag}} --workspace <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.
Cloud run errored before producing a final message.
agent: https://cursor.com/agents/{{agentId}} agentId: {{agentId}} runId: {{runId}} resultStatus: {{resultStatus}} result:
{{resultDataJson}}<!-- orchestrate failure handoff task: {{taskName}} branch: {{branch}} agentId: {{agentId}} runId: {{runId}} failureMode: {{failureMode}} terminatedAt: {{terminatedAt}} -->
{{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}}
<!-- orchestrate finished-no-handoff task: {{taskName}} branch: {{branch}} agentId: {{agentId}} runId: {{runId}} resultStatus: {{resultStatus}} terminatedAt: {{terminatedAt}} -->
{{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}}.mdto 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}}
Loop hygiene:
- Run
bun cli.ts run{{rootFlag}} <workspace>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/<task>-failure.mdfor each dead worker and anyhandoffs/<task>-finished-no-handoff.mdfor workers that ended without a structured handoff. In-flight workers keep running; the nextrunreattaches viarecoverRunning. - After
runreturns, calltree. If any task is stillpendingorrunning, 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/<task>-failure.md, read the Failure mode line and decide:
cap-hitoroom: retry with smaller scope (split into narrower tasks, tighterpathsAllowed, leanerscopedGoal).network-drop: retry as-is; treat as transient.tool-error: retry with a differentmodel.unknown: read theLast activityandSDK errorlines; if no signal, treat as transient and retry as-is; abandon if it fails again.
For <task>-finished-no-handoff.md, read the raw snippet at handoffs/<task>.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}} <workspace> to continue.
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}}
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 <path-to-orchestrate>/scripts/cli.ts comment "<note>" --thread-ts {{threadTs}} --sender {{taskName}}{{agentIdFlag}} --workspace <workspace>.--agent-idadds a footer link back to your cursor.com page.- File attachments (repro/fix videos):
bun cli.ts comment --thread-ts {{threadTs}} --file <path> --comment "<initial>" --sender {{taskName}}{{agentIdFlag}} --workspace <workspace>. Lands in the run thread alongside the status mirror. - Add
--criticality requiredfor messages that must land. Default is best-effort.
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
<actual branch name>
What my subtree did
- <aggregated summary of your children's work>
Verification
<one of: live-ui-verified | unit-test-verified | type-check-only | verifier-blocked | verifier-failed | not-verified>
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
- <anything bubbled up from your children that the parent should know, plus your own thoughts about how this sub-goal was scoped>
Suggested follow-ups
- <tasks the parent should consider publishing>
Do not open a PR. Your parent decides what to do with your branch.
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.
## Verificationis the only structured signal the planner gets about your evidence. A planner that readslive-ui-verifiedand the underlying truth wasverifier-blockedships a broken fix.- When environment failures (Docker rate limit, port conflicts, missing creds, broken harness) prevent the verification, set
verifier-blocked. Don't reporttype-check-onlyfor 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-blockedand 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
<one of: live-ui-verified | unit-test-verified | type-check-only | verifier-blocked | verifier-failed>
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
<actual branch name> (or "(no branch)" if you committed nothing)
Execution
- <command run> → <outcome>
- <test suite> → <pass/fail counts>
- <manual repro step> → <observed behavior>
(list every meaningful thing you actually ran; this section is what distinguishes a real verification from pattern-matching)
Findings
Per acceptance criterion:
- [x] <criterion text>: <evidence> (met | not met | n/a)
Other findings (severity-ordered):
- (high) <finding>: evidence
- (med) <finding>: evidence
- (low) <finding>: evidence
Notes & suggestions
- <anything the planner should know: flaky tests, adjacent issues noticed, suggestions for follow-up tasks>
Put everything important here. The planner doesn't see your intermediate output.
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
<actual branch name> (or "(no branch)" if you produced no code)
What I did
- <high-level summary, per file if useful>
Measurements
- <metric>: <before> <op> <after>
One line per quantitative acceptance criterion. <op> is one of →, <=, <, >, >=, ==. Example lines:
LOC(packages/ui/src/Settings.tsx): 412 → 354pnpm test --filter @example/foo: 84 passing → 84 passingbundle 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
<one of: live-ui-verified | unit-test-verified | type-check-only | not-verified>
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
- <anything the planner needs to know: assumptions, surprises, decisions, broken invariants, flaky tests, unclear requirements, your opinions about how this task or the overall goal was scoped>
Suggested follow-ups
- <tasks the planner should consider publishing>
Put everything important here. The planner doesn't see your intermediate output.
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.
bun cli.ts kickoff "<goal>" [--repo <url>] [--ref main] [--model claude-opus-4-8] [--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 <id> or set SLACK_CHANNEL_ID; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled.
Dispatcher identity
Kickoff bot username is <firstName>'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: "<name>" } 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 <rootSlug>: <summary> <agent-link>; 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 <repo-path> <branch> <root-slug>for a deep tree view.bun cli.ts statusfor top-level state.- The Slack kickoff thread in
plan.slackChannel, whenSLACK_BOT_TOKENis set.
syncStateToGit defaults to true. Set syncStateToGit: false on the root plan when goals or handoffs should not be committed.
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 <workspace>/handoffs/<task-name>.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/<task>-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.
<!-- orchestrate failure handoff
task: <name>
branch: <actual or placeholder branch>
agentId: bc-...
runId: run-...
failureMode: cap-hit | oom | network-drop | tool-error | unknown
terminatedAt: <iso>
-->
# <name> — 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: <iso>
Terminated: <iso>
Duration: <ms>
Last activity: <iso> — <status text from state.json lastUpdate>
Last tool call: <name from SDK stream tail, or (unknown)>
Branch: <branch>
SDK error: <truncated error text>
## Suggested next steps
- <one bullet per option, planner picks>Classifier heuristics:
cap-hitwhen duration is 70–80 minutes and the run is terminal-erroroomwhen output or SDK error containsout of memory/OOMKilled/exit code 137network-dropwhen the SDK error matchesfetch failed/ETIMEDOUT/ECONN/socket/dns/disconnecttool-errorwhen the SDK error mentionstool_use_failedortool-errorunknownotherwise
Default retry strategy by mode:
cap-hit/oom: retry with smaller scopenetwork-drop: retry as-is (treat as transient)tool-error: retry with a differentmodelunknown: 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/<task>-finished-no-handoff.md alongside the raw <task>.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 <workspace> <task> before spawning.
Consequences:
dependsOnis 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 didsections 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/<parent-rootSlug>/<your-name>). 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.
## Verification
<one of: live-ui-verified | unit-test-verified | type-check-only | verifier-blocked | verifier-failed>
## Target
`<target-name>` on branch `<target-branch>`
## Branch
`<verifier-branch>` (or "(no branch)" if you committed nothing)
## Execution
- <command run> → <outcome>
- <test suite> → <pass/fail counts>
- <manual repro step> → <observed behavior>
(list every meaningful thing you actually ran; this section is what distinguishes a real verification from pattern-matching)
## Findings
Per acceptance criterion:
- [x] <criterion text>: <evidence> (met | not met | n/a)
Other findings (severity-ordered):
- (high) <finding>: evidence
- (med) <finding>: evidence
- (low) <finding>: evidence
## Notes & suggestions
- <anything the planner should know: flaky tests, adjacent issues noticed, suggestions for follow-up tasks>## 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:
{
"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:
{
"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.
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 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 withfiles:writefor the upload v2 flow.reactions:read— watch the Andon:rotating_light:reaction on the kickoff message.channels:history— read thread replies viaconversations.replies. Addgroups:historyinstead 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-nameexplicitly.
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.jsoncarries the task graph, Slack config, repo URL, and model choices.state.jsoncarries task status, agent/run ids, branch names, and Slack message timestamps.handoffs/*.mdcarries worker and verifier output.attention.logcarries 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 <workspace>; the default workspace is .orchestrate/<rootSlug>/.
{
"$schema": "<path-to-orchestrate>/schemas/plan.schema.json",
"goal": "<verbatim user 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
scopedGoalsays 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"andverifies: "<target-task-name>". - Use
verifyfor the concrete check recipe. Workers read it as target behavior; verifiers inherit it from their target. - Set
openPR: trueonly 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
commentCLI for Slack notes routed through the retry queue. Use--criticality requiredonly 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.
bun <path-to-orchestrate>/scripts/cli.ts <subcommand> <workspace> [...]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 <name> to validate task context and resolve the run thread, or pass --thread-ts <ts> explicitly.
Examples:
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
pendingwhen an upstream fails. Fix the upstream and rerun, orkillabandoned downstream work. - Subplanner respawn clones from its own branch after the first attempt so committed child state and handoffs survive.
maxAttemptscaps 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.
bun <path-to-orchestrate>/scripts/cli.ts andon raise --reason "<why>" --workspace <workspace>
bun <path-to-orchestrate>/scripts/cli.ts andon clear --workspace <workspace> [--note "<what changed>"]--reason is required on raise. The root polls the Slack kickoff message for :rotating_light: and scans the most recent matching 🚨 ANDON RAISED ...: <reason> 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 <workspace> and bun cli.ts list <workspace> 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.
bun cli.ts crawl <local-repo-path> <root-branch> <root-slug>
bun cli.ts kill-tree <local-repo-path> <root-branch> <root-slug> [-y] [--agent-id <id>]Both commands walk .orchestrate/<rootSlug>/state.json; every subplanner row recurses into orch/<rootSlug>/<subplanner-name>.
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/<rootSlug>/<task-name>) 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) | <first line of goal, up to 100 chars> |
Worker / subplanner / verifier (spawnTask) | <rootSlug>/<taskName> — echoes the task's branch without the orch/ prefix |
Model catalog probe (probe-models) | probe: <modelSlug> |
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.
autoCreatePRon the cloud-agent create call mirrors the task'sopenPRflag (default false). The server-sidecloud_agent_pr_controlgate makes that flag a no-op today, so the real mechanism is the worker prompt: whenopenPR: true, the worker is instructed to open a draft PR againstplan.prBase ?? plan.baseBranchvia 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.baseBranchis the starting ref for workers that don't specify their own.plan.prBase(optional, defaults tobaseBranch) 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 againstmain(so each worker PR is mergeable without the planner's branch landing first). LeaveprBaseunset 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 "<note>" --thread-ts <run-thread-ts> --workspace <workspace>posts a note through the retry queue when silence would hide useful context.--task <name>with--workspaceis 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
commentCLI 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/<rootSlug>/<task> 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.
{
"$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 (`<firstName>'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
}
{
"$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
}
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 type { State } from "../schemas.ts";
import {
installSlackWebApiMock,
resetSlackWebApiMock,
slackWebApiCalls,
} from "./support/slack-web-api-mock.ts";
const TEST_SLACK_CHANNEL = "C123TEST";
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<string, unknown> {
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<void> {
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}`);
}
function formatStartedSlackDirective(d: Date): string {
const epoch = Math.floor(d.getTime() / 1000);
const fallback = d.toISOString().replace(/\.\d{3}Z$/, "Z");
return `<!date^${epoch}^{ago}|${fallback}>`;
}
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");
const startedAt = new Date(Date.now() - 2 * 60_000).toISOString();
mgr.touch(task, {
agentId: "bc-child",
status: "running",
startedAt,
});
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 ${formatStartedSlackDirective(new Date(startedAt))} · <https://cursor.com/agents/bc-child|view>`,
});
expect(calls[0].args.icon_emoji).toBeUndefined();
expect(calls[1].args).toMatchObject({
channel: TEST_SLACK_CHANNEL,
ts: "222.333",
text: "✓ completed\n<https://cursor.com/agents/bc-child|view>",
});
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 <https://cursor.com/agents/bc-root-planner|view>"
);
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 just now · <https://cursor.com/agents/bc-child|view>"
);
} 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 just now · <https://cursor.com/agents/bc-child|view>"
);
} 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<void>;
}
).mirrorTaskToSlack(task);
expect(slackWebApiCalls()).toEqual([]);
} finally {
rmSync(workspace, { recursive: true, force: true });
}
});
test("Noticed-idle attention does not flip the badge; stuck escalation does", async () => {
const workspace = mkdtempSync(join(tmpdir(), "orch-slack-mirror-stuck-"));
let postCount = 0;
resetSlackWebApiMock((method, args) => ({
ok: true,
channel: args.channel,
ts: method === "chat.update" ? args.ts : `333.${++postCount}`,
}));
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 running mirror"
);
mgr.logAttention(
"worker-one: SSE idle 300000ms, polled status=running; watchdog still waiting"
);
await new Promise(resolve => setTimeout(resolve, 25));
expect(
slackWebApiCalls().filter(c => c.method === "chat.update")
).toHaveLength(0);
mgr.logAttention("worker-one: SSE idle 1800000ms; stuck");
await waitFor(
() => slackWebApiCalls().some(call => call.method === "chat.update"),
"stuck mirror"
);
const stuck = slackWebApiCalls().find(
call => call.method === "chat.update"
);
expect(stuck?.args.text).toBe(
"⚠ stuck\nno activity for 30m · <https://cursor.com/agents/bc-child|view>"
);
} finally {
rmSync(workspace, { recursive: true, force: true });
}
});
});
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<SlackMessageRef> {
throw new Error("not used");
},
async lookupFirstNameByEmail(): Promise<string | undefined> {
return undefined;
},
async postInThread(): Promise<SlackMessageRef> {
throw new Error("not used");
},
async editThreadMessage(): Promise<SlackMessageRef> {
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<SlackMessageRef> {
throw new Error("not used");
},
async addReaction(): Promise<void> {
throw new Error("not used");
},
async removeReaction(): Promise<void> {
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<https://cursor.com/agents/bc-abc|view>",
},
]
),
{ 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 });
}
});
});
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 });
}
});
});
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 <name> or --thread-ts <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 });
}
});
});
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);
}
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,
},
});
}
{
"$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" }
}
}
}