Skip to content
Open
61 changes: 59 additions & 2 deletions orchestrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,61 @@ The skill itself lives in [`skills/orchestrate/SKILL.md`](./skills/orchestrate/S
- A Cursor API key in `CURSOR_API_KEY`.
- Optional Slack app and bot token if you want a Slack thread mirroring the run.

## Model catalog (optional)

`ORCHESTRATE_MODEL_CATALOG` replaces the built-in model catalog with your own. When it is set, that list is the complete menu: it is what planners choose `tasks[].model` from, what `bun cli.ts models` prints, and where each task type's default comes from. Nothing is merged with the built-in catalog, so what you write is exactly what runs. Use it to steer cost without editing the plugin.

The value is a JSON array in the same shape as the built-in catalog, validated against [`skills/orchestrate/schemas/model-catalog.schema.json`](./skills/orchestrate/schemas/model-catalog.schema.json). Start from the built-in list rather than writing entries by hand:

```bash
bun skills/orchestrate/scripts/cli.ts models --json > catalog.json
# edit catalog.json: drop what you don't want, move defaultFor where you want it
export ORCHESTRATE_MODEL_CATALOG="$(cat catalog.json)"
```

Every entry needs `slug`, `selection`, `summary`, `strengths`, `speed`, and `use`. `defaultFor` and `selection.params` are optional:

```json
[
{
"slug": "house-worker",
"selection": { "id": "composer-2.5", "params": [{ "id": "fast", "value": "true" }] },
"summary": "Cheap, fast worker.",
"strengths": ["throughput", "well-scoped implementation"],
"speed": "fast",
"use": "Use for all bounded implementation work.",
"defaultFor": ["worker"]
},
{
"slug": "house-planner",
"selection": { "id": "claude-opus-4-8" },
"summary": "Frontier judgment for decomposition and acceptance checks.",
"strengths": ["judgment", "ambiguity resolution"],
"speed": "slow",
"use": "Use when the work needs design decisions rather than execution.",
"defaultFor": ["subplanner", "verifier"]
}
]
```

`summary`, `strengths`, and `use` are required because planners select by capability, not by model name. An entry with thin prose tends to get passed over. `speed` is a free-form string, so new model vocabulary doesn't need a plugin release.

Each of `worker`, `subplanner`, and `verifier` needs a `defaultFor` somewhere in the list. Root planners are not part of the catalog; they take their model from kickoff `--model`, which defaults to `claude-opus-4-8`.

### Precedence

1. Explicit `tasks[].model` in the plan
2. The `defaultFor` entry for that task's type

Run `bun cli.ts models` to print the catalog in effect, and `bun cli.ts models --check` to probe every entry against `/v1/agents`. Invalid config exits 2 at startup, naming the offending entry and field, rather than failing mid-run:

```
ORCHESTRATE_MODEL_CATALOG failed zod validation:
[0].summary: Required
```

Two caveats. This shapes what planners choose from, but a planner can still write any model id into `tasks[].model`, so it is guidance rather than a spend ceiling. And each spawned agent reads its own environment: set the variable as a Cursor Cloud secret for the repo so subplanners and workers inherit it, not just in the dispatcher's local shell.

## Cursor API key

1. Open [https://cursor.com/dashboard/integrations](https://cursor.com/dashboard/integrations).
Expand All @@ -22,6 +77,8 @@ Team service-account keys (Team Settings → Service accounts) also work for bot

Slack visibility is opt-in. When the token is unset, the script logs once and runs without Slack; correctness does not change. To enable it:

> **Renamed:** these were `SLACK_BOT_TOKEN` and `SLACK_CHANNEL_ID`. They are now `ORCHESTRATE_SLACK_BOT_TOKEN` and `ORCHESTRATE_SLACK_CHANNEL_ID` so orchestrate can't pick up another tool's Slack app from a shared environment. The unprefixed names are no longer read; if only the old token is set, the CLI says so instead of going quiet.

1. Create a Slack app at [https://api.slack.com/apps](https://api.slack.com/apps) → **From scratch**. Pick a name and a workspace.
2. Under **OAuth & Permissions** → **Bot Token Scopes**, add:

Expand All @@ -42,9 +99,9 @@ Slack visibility is opt-in. When the token is unset, the script logs once and ru
| `users:read.email` | Resolve the dispatcher's first name from `git config user.email`. Without it, pass `--dispatcher-name` explicitly. |

3. **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`).
4. Export it: `export SLACK_BOT_TOKEN="xoxb-..."`.
4. Export it: `export ORCHESTRATE_SLACK_BOT_TOKEN="xoxb-..."`.
5. Invite the bot to the channel where you want runs to thread (`/invite @your-bot`). Public channels with `chat:write.public` skip this; private channels require the invite.
6. Grab the channel ID. In Slack: right-click the channel → **View channel details** → bottom of the dialog. Pass it via `--slack-channel <id>` on `kickoff` (or set `SLACK_CHANNEL_ID`). The first kickoff persists the id on the plan; subplanners and later `run` invocations inherit it.
6. Grab the channel ID. In Slack: right-click the channel → **View channel details** → bottom of the dialog. Pass it via `--slack-channel <id>` on `kickoff` (or set `ORCHESTRATE_SLACK_CHANNEL_ID`). The first kickoff persists the id on the plan; subplanners and later `run` invocations inherit it.

## Install

Expand Down
3 changes: 2 additions & 1 deletion orchestrate/skills/orchestrate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ An explicit `/orchestrate <goal>` fans out a large task across parallel Cursor c
## Setup

- `CURSOR_API_KEY` must be a personal/user key. Create it from [Cursor Dashboard > Integrations](https://cursor.com/dashboard/integrations), then read `cursor-sdk` Auth before using it.
- `SLACK_BOT_TOKEN` is optional. When set, pass `--slack-channel <id>` to `kickoff` or the first `run --root`, or set `SLACK_CHANNEL_ID`. The script stores the channel in `plan.slackChannel`, posts the kickoff thread there, mirrors task status, and reads Andon reactions. When the token is unset, the script logs once and runs without Slack visibility; correctness does not change.
- `ORCHESTRATE_SLACK_BOT_TOKEN` is optional. When set, pass `--slack-channel <id>` to `kickoff` or the first `run --root`, or set `ORCHESTRATE_SLACK_CHANNEL_ID`. The script stores the channel in `plan.slackChannel`, posts the kickoff thread there, mirrors task status, and reads Andon reactions. When the token is unset, the script logs once and runs without Slack visibility; correctness does not change.
- `ORCHESTRATE_MODEL_CATALOG` is optional. When set, its JSON array replaces the built-in model catalog outright: it becomes the list planners pick `tasks[].model` from, and its `defaultFor` entries supply each task type's default. It is validated against `schemas/model-catalog.schema.json`; `bun cli.ts models` prints whichever catalog is in effect and `--json` emits it in that shape. See the plugin README.

## Core principles

Expand Down
2 changes: 1 addition & 1 deletion orchestrate/skills/orchestrate/prompts/subplanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Paths you must NOT modify (owned by siblings):

Acceptance criteria for your subtree:
{{accept}}{{verifyPlan}}{{upstream}}
Model selection: pick `tasks[].model` per task by capability. Available models:
Model selection: pick `tasks[].model` per task by capability, choosing only from the list below. That list is this repo's effective catalog, not a generic menu. Omit `tasks[].model` to accept the marked default for that task type; set it explicitly when the task needs a different capability.

{{modelCatalog}}

Expand Down
6 changes: 4 additions & 2 deletions orchestrate/skills/orchestrate/references/dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ One-time setup: run `bun install` inside this skill's `scripts/` directory if `n
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.
A repo can replace the model catalog planners choose from, including each task type's default, with `ORCHESTRATE_MODEL_CATALOG` (see the plugin README). That does not cover the root planner: pass `--model` to set it, otherwise it stays `claude-opus-4-8`. Run `bun cli.ts models` to print the catalog in effect; config the CLI can't read exits 2 with the offending entry named.

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 `ORCHESTRATE_SLACK_BOT_TOKEN` is set, also pass `--slack-channel <id>` or set `ORCHESTRATE_SLACK_CHANNEL_ID`; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled.

## Dispatcher identity

Expand Down Expand Up @@ -45,6 +47,6 @@ Progress is observable after dispatch:

- `bun cli.ts crawl <repo-path> <branch> <root-slug>` for a deep tree view.
- `bun cli.ts status` for top-level state.
- The Slack kickoff thread in `plan.slackChannel`, when `SLACK_BOT_TOKEN` is set.
- The Slack kickoff thread in `plan.slackChannel`, when `ORCHESTRATE_SLACK_BOT_TOKEN` is set.

`syncStateToGit` defaults to true. Set `syncStateToGit: false` on the root plan when goals or handoffs should not be committed.
4 changes: 2 additions & 2 deletions orchestrate/skills/orchestrate/references/planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Scripts expect `bun` on PATH. Install dependencies with `bun install` inside thi

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:
Slack visibility uses `ORCHESTRATE_SLACK_BOT_TOKEN`. Required scopes:

- `chat:write` — post and edit messages.
- `chat:write.customize` — set custom username and icon on bot messages.
Expand Down Expand Up @@ -69,7 +69,7 @@ Write `plan.json` at `<workspace>`; the default workspace is `.orchestrate/<root

`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.
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 `ORCHESTRATE_SLACK_CHANNEL_ID`. Subplanners inherit both fields so the whole tree mirrors into one thread.

Planning rules:

Expand Down
2 changes: 1 addition & 1 deletion orchestrate/skills/orchestrate/references/spawning.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Cloud-agent VMs may redact environment variable values as a prompt-injection def

## 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.
When `ORCHESTRATE_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:

Expand Down
92 changes: 92 additions & 0 deletions orchestrate/skills/orchestrate/schemas/model-catalog.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://cursor/orchestrate/model-catalog.schema.json",
"title": "orchestrate ORCHESTRATE_MODEL_CATALOG",
"description": "Optional operator-authored model catalog. When ORCHESTRATE_MODEL_CATALOG is set, it replaces the built-in catalog planners choose `tasks[].model` from.",
"type": "array",
"items": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"minLength": 1,
"description": "Authoring name planners write into `tasks[].model`."
},
"selection": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Model id as accepted by the Cursor API."
},
"params": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"id",
"value"
],
"additionalProperties": false
},
"description": "Model parameters, e.g. reasoning, effort, thinking, fast."
}
},
"required": [
"id"
],
"additionalProperties": false,
"description": "Canonical SDK selection passed to `Agent.create({ model })`."
},
"summary": {
"type": "string",
"description": "One-line description planners read."
},
"strengths": {
"type": "array",
"items": {
"type": "string"
},
"description": "Capability keywords planners match a task against."
},
"speed": {
"type": "string",
"description": "Relative latency, e.g. fast, medium, slow."
},
"use": {
"type": "string",
"description": "When a planner should pick this model."
},
"defaultFor": {
"type": "array",
"items": {
"type": "string",
"enum": [
"worker",
"subplanner",
"verifier"
]
},
"description": "Task types that use this model when `tasks[].model` is omitted."
}
},
"required": [
"slug",
"selection",
"summary",
"strengths",
"speed",
"use"
],
"additionalProperties": false
}
}
2 changes: 1 addition & 1 deletion orchestrate/skills/orchestrate/schemas/plan.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"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."
"description": "Slack channel id for run visibility. Set from --slack-channel or ORCHESTRATE_SLACK_CHANNEL_ID by kickoff or the first root run."
},
"slackKickoffRef": {
"type": "object",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ 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;
const ORIGINAL_SLACK_TOKEN = process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
process.env.CURSOR_API_KEY = "test-key";
process.env.SLACK_BOT_TOKEN = "xoxb-test";
process.env.ORCHESTRATE_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;
delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
process.env.ORCHESTRATE_SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
}
});

Expand Down Expand Up @@ -391,8 +391,8 @@ describe("AgentManager Slack status mirror", () => {
});

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 original = process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
const errors: string[] = [];
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
Expand Down Expand Up @@ -428,7 +428,9 @@ describe("AgentManager Slack status mirror", () => {
expect(mgr.slackAdapter).toBeUndefined();
expect(slackWebApiCalls()).toHaveLength(0);
expect(
errors.filter(line => line.includes("SLACK_BOT_TOKEN not set"))
errors.filter(line =>
line.includes("ORCHESTRATE_SLACK_BOT_TOKEN not set")
)
).toHaveLength(1);
expect(readState(workspace).attention).toEqual([]);

Expand All @@ -443,9 +445,9 @@ describe("AgentManager Slack status mirror", () => {
rmSync(workspace, { recursive: true, force: true });
console.error = originalConsoleError;
if (original === undefined) {
delete process.env.SLACK_BOT_TOKEN;
delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = original;
process.env.ORCHESTRATE_SLACK_BOT_TOKEN = original;
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
});

Expand Down Expand Up @@ -73,7 +73,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down Expand Up @@ -130,7 +130,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down Expand Up @@ -187,7 +187,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";

import {
defaultModelForType,
isKnownModel,
MODEL_CATALOG,
MODEL_ENV_CATALOG,
resolveModelSelection,
} from "../models.ts";

let savedCatalogEnv: string | undefined;

// These assertions describe the built-in catalog, so an env-provided catalog
// from the surrounding shell must not leak in.
beforeEach(() => {
savedCatalogEnv = process.env[MODEL_ENV_CATALOG];
delete process.env[MODEL_ENV_CATALOG];
});

afterEach(() => {
if (savedCatalogEnv === undefined) delete process.env[MODEL_ENV_CATALOG];
else process.env[MODEL_ENV_CATALOG] = savedCatalogEnv;
});

describe("MODEL_CATALOG", () => {
test("every catalog entry passes isKnownModel", () => {
for (const profile of MODEL_CATALOG) {
Expand Down
Loading