Skip to content

Allow for env var override of /orchestrate model catalog - #173

Open
JonnyPower wants to merge 8 commits into
cursor:mainfrom
Traction-Rec:main
Open

Allow for env var override of /orchestrate model catalog#173
JonnyPower wants to merge 8 commits into
cursor:mainfrom
Traction-Rec:main

Conversation

@JonnyPower

Copy link
Copy Markdown

Summary

Addresses cursor/plugins#171. A single environment variable, ORCHESTRATE_MODEL_CATALOG, replaces the built-in model catalog with the repo's own. When it is set, that JSON array 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.

The value is the same shape as MODEL_CATALOG in code, validated by a zod schema and published as schemas/model-catalog.schema.json for editor validation. bun cli.ts models --json emits the built-in catalog in exactly that shape, so configuring a repo is copy-then-edit rather than writing entries from scratch.

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

Why the catalog and not a per-role default

A per-role fallback would only apply when tasks[].model is omitted, but the subplanner prompt actively tells planners to pick a model from the rendered catalog, and that catalog came from the hardcoded constant. Planners kept selecting the expensive built-ins regardless of any fallback. Replacing the catalog puts the operator's models in the list planners actually read, and gives them slugs that round-trip through resolveModelSelection with their params intact.

Entries

Every entry needs slug, selection, summary, strengths, speed, and use; defaultFor and selection.params are optional. summary, strengths, and use are required because planners select by capability rather than model name, and an entry with thin prose gets 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 aren't part of the catalog; they keep taking kickoff --model, defaulting to claude-opus-4-8.

Error handling

Validation is the shared parseJsonWithSchema helper, so config problems surface as field-level zod issues through the existing PlanValidationError exit-2 path, at CLI startup rather than mid-run:

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

Every entry needs slug, selection, summary, strengths, speed, and use. Unset the
variable and run `bun cli.ts models --json` to copy the built-in catalog as a
starting point.

A task type left without a default is caught by the same startup check.

Scope

models.ts is +79/-29, and most of that is wiring rather than new logic: the catalog is either MODEL_CATALOG or one parseModelCatalogJson call. agent-manager.ts and the kickoff CLI are untouched, since defaultModelForType and resolveModelSelection keep their signatures. The rest is the schema in schemas.ts, a startup check in cli/index.ts, --json in cli/inspect.ts, generator wiring, and docs. The subplanner prompt now tells planners the list is this repo's catalog and to omit tasks[].model when the marked default fits.

Known limits

This shapes what planners choose from; it is not a spend ceiling, since a planner can still write an arbitrary model id into tasks[].model, which passes through as a bare { id }. A hard clamp would be a separate opt-in knob. Also, each spawned agent reads its own environment, so this belongs in Cursor Cloud secrets for the repo rather than only the dispatcher's shell. Both are documented in the README.

Test plan

  • bun test (224 pass), including 15 new tests covering catalog replacement, task-type defaults, selection round-tripping, pass-through of unknown speed values, and each rejected config
  • A test feeds MODEL_CATALOG back through the schema, so the documented --json starting point is guaranteed to be valid input
  • bun run check (biome + tsc)
  • CLI smoke: models, models --json, a full round-trip of --json output back through the env var, and an incomplete entry
  • Live run: confirm a spawned worker uses the configured model end to end
Open in Web Open in Cursor 

cursoragent and others added 6 commits July 27, 2026 17:41
Support ORCHESTRATE_MODEL_{WORKER,SUBPLANNER,VERIFIER,ROOT} so cost-efficient
pairings can be set without editing MODEL_CATALOG. Values accept a catalog
slug, bare model id, or JSON ModelSelection for out-of-catalog models.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
Merge MODEL_CATALOG with ORCHESTRATE_MODEL_* env config so planners select
from the operator's list rather than the hardcoded constant. Env-named models
join the catalog by slug and round-trip through resolveModelSelection.

Adds ORCHESTRATE_MODEL_CATALOG for extra entries and
ORCHESTRATE_MODEL_CATALOG_MODE=env-only to drop the built-ins entirely, so a
team can publish an exact menu per task type. Config errors fail fast at CLI
startup instead of surfacing mid-run as a spawn failure.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
…ALOG

Drop the per-role env vars and the catalog mode flag. A single JSON array
replaces the built-in catalog outright when set, which removes merge
precedence entirely: the configured list is the complete menu and its
defaultFor entries supply every role default, including the root planner.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
Reuse TaskType instead of a parallel role union: all three task types require
a default, and the root planner keeps its hardcoded kickoff default rather
than becoming a catalog role.

Stop validating descriptive fields. Speed, strengths, and defaultFor values
are passed through as written, so new model vocabulary doesn't need a plugin
release; only config the CLI genuinely can't read is rejected. Also drops the
JSON-in-model-field parsing that the removed per-role env vars needed, which
lets agent-manager and the kickoff CLI go back to their original code.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
Require ORCHESTRATE_MODEL_CATALOG to hold entries in the same shape as
MODEL_CATALOG, so the hand-rolled entry builder goes away: no id-only
shorthand, no built-in slug references, no synthesized prose. Parsing is now
one call to the shared parseJsonWithSchema helper, which also means field-level
errors and the existing PlanValidationError exit path instead of a bespoke
error class.

Publishes schemas/model-catalog.schema.json for editor validation, and adds
`models --json` to emit the catalog in that shape as a starting point.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
…overrides-1a9d

feat(orchestrate): configurable model catalog via ORCHESTRATE_MODEL_CATALOG (cursor#171)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Startup allows ambiguous catalog entries
    • assertModelEnvConfig now rejects duplicate slugs and multiple defaultFor owners per task type before a run starts.
  • ✅ Fixed: Catalog schema strips typos silently
    • ModelSelectionSchema, ModelProfileSchema, and selection params objects now use .strict() so unknown keys fail validation instead of being stripped.

Create PR

Or push these changes by commenting:

@cursor push 6e23e4c542
Preview (6e23e4c542)
diff --git a/orchestrate/README.md b/orchestrate/README.md
--- a/orchestrate/README.md
+++ b/orchestrate/README.md
@@ -10,6 +10,61 @@
 - 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).

diff --git a/orchestrate/skills/orchestrate/SKILL.md b/orchestrate/skills/orchestrate/SKILL.md
--- a/orchestrate/skills/orchestrate/SKILL.md
+++ b/orchestrate/skills/orchestrate/SKILL.md
@@ -14,6 +14,7 @@
 
 - `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_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
 

diff --git a/orchestrate/skills/orchestrate/prompts/subplanner.md b/orchestrate/skills/orchestrate/prompts/subplanner.md
--- a/orchestrate/skills/orchestrate/prompts/subplanner.md
+++ b/orchestrate/skills/orchestrate/prompts/subplanner.md
@@ -30,7 +30,7 @@
 
 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}}
 

diff --git a/orchestrate/skills/orchestrate/references/dispatcher.md b/orchestrate/skills/orchestrate/references/dispatcher.md
--- a/orchestrate/skills/orchestrate/references/dispatcher.md
+++ b/orchestrate/skills/orchestrate/references/dispatcher.md
@@ -16,6 +16,8 @@
 bun cli.ts kickoff "<goal>" [--repo <url>] [--ref main] [--model claude-opus-4-8] [--slack-channel C123] [--dispatcher-name "Alex"]

+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 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

diff --git a/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json b/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json
new file mode 100644
--- /dev/null
+++ b/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json
@@ -1,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
  • }
    +}

diff --git a/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
@@ -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) {

diff --git a/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts
new file mode 100644
--- /dev/null
+++ b/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts
@@ -1,0 +1,217 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
+import { PlanValidationError } from "../errors.ts";
+import {

  • assertModelEnvConfig,
  • defaultModelForType,
  • effectiveModelCatalog,
  • isKnownModel,
  • MODEL_CATALOG,
  • MODEL_ENV_CATALOG,
  • renderModelCatalog,
  • resolveModelSelection,
    +} from "../models.ts";

+let saved: string | undefined;
+
+/** A minimally complete entry; every field the schema requires. */
+function entry(

  • overrides: Record<string, unknown> = {}
    +): Record<string, unknown> {
  • return {
  • slug: "house-worker",
  • selection: { id: "composer-2.5" },
  • summary: "House worker model.",
  • strengths: ["throughput"],
  • speed: "fast",
  • use: "Use for all bounded implementation work.",
  • ...overrides,
  • };
    +}

+function setCatalog(entries: unknown): void {

  • process.env[MODEL_ENV_CATALOG] = JSON.stringify(entries);
    +}

+beforeEach(() => {

  • saved = process.env[MODEL_ENV_CATALOG];
  • delete process.env[MODEL_ENV_CATALOG];
    +});

+afterEach(() => {

  • if (saved === undefined) delete process.env[MODEL_ENV_CATALOG];
  • else process.env[MODEL_ENV_CATALOG] = saved;
    +});

+describe("ORCHESTRATE_MODEL_CATALOG unset", () => {

  • test("the built-in catalog is in effect", () => {
  • expect(effectiveModelCatalog()).toBe(MODEL_CATALOG);
  • expect(defaultModelForType("worker")).toBe("gpt-5.5-high-fast");
  • expect(renderModelCatalog()).not.toContain("exact model menu");
  • });
  • test("whitespace-only value is treated as unset", () => {
  • process.env[MODEL_ENV_CATALOG] = " ";
  • expect(effectiveModelCatalog()).toBe(MODEL_CATALOG);
  • });
    +});

+describe("ORCHESTRATE_MODEL_CATALOG replaces the built-in catalog", () => {

  • test("only the listed models are published", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(effectiveModelCatalog().map(m => m.slug)).toEqual(["house-worker"]);
  • expect(isKnownModel("gpt-5.5-high-fast")).toBe(false);
  • expect(isKnownModel("house-worker")).toBe(true);
  • });
  • test("entries supply every task type's default", () => {
  • setCatalog([
  •  entry({ defaultFor: ["worker"] }),
    
  •  entry({
    
  •    slug: "house-planner",
    
  •    selection: { id: "claude-opus-4-8" },
    
  •    defaultFor: ["subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(defaultModelForType("worker")).toBe("house-worker");
  • expect(defaultModelForType("subplanner")).toBe("house-planner");
  • expect(defaultModelForType("verifier")).toBe("house-planner");
  • });
  • test("a slug resolves to its full selection, params included", () => {
  • setCatalog([
  •  entry({
    
  •    selection: {
    
  •      id: "composer-2.5",
    
  •      params: [{ id: "fast", value: "true" }],
    
  •    },
    
  •    defaultFor: ["worker", "subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(resolveModelSelection("house-worker")).toEqual({
  •  id: "composer-2.5",
    
  •  params: [{ id: "fast", value: "true" }],
    
  • });
  • });
  • test("a model outside the catalog still passes through as a bare id", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(resolveModelSelection("gpt-5.5")).toEqual({ id: "gpt-5.5" });
  • });
  • test("the rendered catalog is what planners see", () => {
  • setCatalog([entry({ defaultFor: ["worker"] })]);
  • const text = renderModelCatalog();
  • expect(text).toContain("exact model menu");
  • expect(text).toContain("house-worker — House worker model.");
  • expect(text).toContain("(default for worker)");
  • expect(text).toContain("speed: fast; strengths: throughput");
  • });
  • // speed is a free-form string so new model vocabulary doesn't require a
  • // plugin release.
  • test("unrecognized speed values are passed through", () => {
  • setCatalog([
  •  entry({
    
  •    speed: "blistering",
    
  •    defaultFor: ["worker", "subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(renderModelCatalog()).toContain("speed: blistering");
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
  • test("the built-in catalog round-trips through the schema", () => {
  • // bun cli.ts models --json is documented as a starting point, so its
  • // output has to be valid input.
  • setCatalog(MODEL_CATALOG);
  • expect(effectiveModelCatalog()).toEqual(MODEL_CATALOG);
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
    +});

+describe("catalog config errors", () => {

  • test("a missing task-type default fails fast at startup", () => {
  • setCatalog([entry({ defaultFor: ["worker"] })]);
  • expect(() => assertModelEnvConfig()).toThrow(PlanValidationError);
  • expect(() => assertModelEnvConfig()).toThrow(
  •  /no subplanner default.*"defaultFor": \["subplanner"\]/s
    
  • );
  • });
  • test("assertModelEnvConfig passes when every task type resolves", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
  • test("duplicate slugs fail at startup", () => {
  • setCatalog([
  •  entry({ defaultFor: ["worker"] }),
    
  •  entry({
    
  •    slug: "house-worker",
    
  •    selection: { id: "gpt-5.5" },
    
  •    defaultFor: ["subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(() => assertModelEnvConfig()).toThrow(PlanValidationError);
  • expect(() => assertModelEnvConfig()).toThrow(
  •  /duplicate slug "house-worker"/
    
  • );
  • });
  • test("multiple defaults for the same task type fail at startup", () => {
  • setCatalog([
  •  entry({ defaultFor: ["worker"] }),
    
  •  entry({
    
  •    slug: "house-planner",
    
  •    selection: { id: "claude-opus-4-8" },
    
  •    defaultFor: ["worker", "subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(() => assertModelEnvConfig()).toThrow(PlanValidationError);
  • expect(() => assertModelEnvConfig()).toThrow(
  •  /assigns worker default to both "house-worker" and "house-planner"/
    
  • );
  • });
  • test("malformed JSON is rejected", () => {
  • process.env[MODEL_ENV_CATALOG] = "[{slug:}]";
  • expect(() => effectiveModelCatalog()).toThrow(/is not valid JSON/);
  • });
  • test("an incomplete entry is rejected with the offending field", () => {
  • const { summary, ...withoutSummary } = entry();
  • expect(summary).toBeDefined();
  • setCatalog([withoutSummary]);
  • expect(() => effectiveModelCatalog()).toThrow(PlanValidationError);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].summary/);
  • });
  • test("a bad selection or defaultFor is rejected", () => {
  • setCatalog([entry({ selection: { id: "" } })]);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].selection.id/);
  • setCatalog([entry({ defaultFor: ["planner"] })]);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].defaultFor/);
  • });
  • test("unknown keys on an entry or selection are rejected", () => {
  • setCatalog([entry({ summry: "typo" })]);
  • expect(() => effectiveModelCatalog()).toThrow(PlanValidationError);
  • setCatalog([
  •  entry({
    
  •    selection: {
    
  •      id: "composer-2.5",
    
  •      param: [{ id: "fast", value: "true" }],
    
  •    },
    
  •  }),
    
  • ]);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].selection/);
  • });
  • test("a non-array value is rejected", () => {
  • process.env[MODEL_ENV_CATALOG] = '{"slug":"x"}';
  • expect(() => effectiveModelCatalog()).toThrow(PlanValidationError);
  • });
    +});

diff --git a/orchestrate/skills/orchestrate/scripts/cli/index.ts b/orchestrate/skills/orchestrate/scripts/cli/index.ts
--- a/orchestrate/skills/orchestrate/scripts/cli/index.ts
+++ b/orchestrate/skills/orchestrate/scripts/cli/index.ts
@@ -1,6 +1,8 @@
#!/usr/bin/env bun
import { Command } from "commander";

+import { PlanValidationError } from "../errors.ts";
+import { assertModelEnvConfig } from "../models.ts";
import { registerAndonCommands } from "./andon.ts";
import { registerCommentCommands } from "./comments.ts";
import { registerForensicsCommands } from "./forensics.ts";
@@ -8,6 +10,16 @@
import { registerTaskCommands } from "./task.ts";

export async function main(argv: string[] = process.argv): Promise {

  • try {

  • assertModelEnvConfig();

  • } catch (err) {

  • if (err instanceof PlanValidationError) {

  •  console.error(err.message);
    
  •  process.exit(2);
    
  • }

  • throw err;

  • }

  • const program = new Command();

    program

diff --git a/orchestrate/skills/orchestrate/scripts/cli/inspect.ts b/orchestrate/skills/orchestrate/scripts/cli/inspect.ts
--- a/orchestrate/skills/orchestrate/scripts/cli/inspect.ts
+++ b/orchestrate/skills/orchestrate/scripts/cli/inspect.ts
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { isAndonActive } from "../core/andon.ts";
import { renderPrompt } from "../core/prompts.ts";
-import { renderModelCatalog } from "../models.ts";
+import { effectiveModelCatalog, renderModelCatalog } from "../models.ts";
import type { TaskState } from "../schemas.ts";
import { firstChars, loadOrBail, parsePositiveIntegerOrBail } from "./util.ts";

@@ -156,10 +156,18 @@
"--check",
"Validate each catalog entry against /v1/agents. Run after SDK or backend model-schema changes, or when kickoff/spawn returns invalid_model."
)

  • .option(
  •  "--json",
    
  •  "Print the catalog as JSON in ORCHESTRATE_MODEL_CATALOG's shape. Copy this to start a repo-specific catalog."
    
  • )
    .description(
    "Print the model catalog. Planners consult this when setting tasks[].model."
    )
  • .action(async (opts: { check?: boolean }) => {
  • .action(async (opts: { check?: boolean; json?: boolean }) => {
  •  if (opts.json) {
    
  •    console.log(JSON.stringify(effectiveModelCatalog(), null, 2));
    
  •    return;
    
  •  }
     if (!opts.check) {
       console.log(renderModelCatalog());
       return;
    

diff --git a/orchestrate/skills/orchestrate/scripts/models.ts b/orchestrate/skills/orchestrate/scripts/models.ts
--- a/orchestrate/skills/orchestrate/scripts/models.ts
+++ b/orchestrate/skills/orchestrate/scripts/models.ts
@@ -1,23 +1,23 @@
import type { ModelSelection } from "@cursor/sdk";

import type { TaskType } from "./adapters/types.ts";
+import { PlanValidationError } from "./errors.ts";
+import { type ModelProfile, parseModelCatalogJson } from "./schemas.ts";

-// Model catalog. Source of truth for tasks[].model choices; defaultFor
-// entries supply the fallback when tasks[].model is omitted.
+export type { ModelProfile };

-export interface ModelProfile {

  • /** User-facing slug for tasks[].model and --model flags. */
  • slug: string;
  • /** Canonical SDK selection passed to Agent.create({ model }). */
  • selection: ModelSelection;
  • summary: string;
  • strengths: string[];
  • speed: "fast" | "medium" | "slow";
  • use: string;
  • /** Task types this profile is the default for. */
  • defaultFor?: TaskType[];
    -}
    +// Built-in model catalog, used when ORCHESTRATE_MODEL_CATALOG is unset.
    +// defaultFor supplies the model for a task type when tasks[].model is
    +// omitted. Root planners take their model from kickoff --model, not here.

+/**

    • Env var holding the whole catalog as JSON, in the same shape as
    • MODEL_CATALOG below. When set it replaces MODEL_CATALOG outright.
  • */
    +export const MODEL_ENV_CATALOG = "ORCHESTRATE_MODEL_CATALOG";

+const TASK_TYPES: TaskType[] = ["worker", "subplanner", "verifier"];
+
// slug is the stable authoring name; selection is the canonical SDK form.
// Run bun cli.ts models --check after SDK or backend model-schema drift.
export const MODEL_CATALOG: ModelProfile[] = [
@@ -164,32 +164,94 @@
},
];

+function envCatalogJson(): string | undefined {

  • return process.env[MODEL_ENV_CATALOG]?.trim() || undefined;
    +}

+/**

    • The catalog planners choose from. ORCHESTRATE_MODEL_CATALOG replaces
    • MODEL_CATALOG outright when set; there is no merging, so the configured
    • list is the complete menu. Read per call so env changes apply on the spot.
  • */
    +export function effectiveModelCatalog(): ModelProfile[] {
  • const raw = envCatalogJson();
  • return raw ? parseModelCatalogJson(raw, MODEL_ENV_CATALOG) : MODEL_CATALOG;
    +}

+/** Model slug for a task type when tasks[].model is omitted. */
export function defaultModelForType(type: TaskType): string {

  • const match = MODEL_CATALOG.find(m => m.defaultFor?.includes(type));
  • if (!match)
  • throw new Error(MODEL_CATALOG missing default for TaskType "${type}");
  • return match.slug;
  • const match = effectiveModelCatalog().find(m => m.defaultFor?.includes(type));
  • if (match) return match.slug;
  • throw new PlanValidationError(
  • envCatalogJson()
  •  ? `${MODEL_ENV_CATALOG} has no ${type} default. Add "defaultFor": ["${type}"] to one entry.`
    
  •  : `MODEL_CATALOG missing default for TaskType "${type}"`
    
  • );
    }

export function isKnownModel(slug: string): boolean {

  • return MODEL_CATALOG.some(m => m.slug === slug);
  • return effectiveModelCatalog().some(m => m.slug === slug);
    }

/** Unknown slugs pass through as a bare { id } so planners can reach

  • server-side models that aren't in our prescriptive catalog. */
    export function resolveModelSelection(slug: string): ModelSelection {
  • const profile = MODEL_CATALOG.find(m => m.slug === slug);
  • const profile = effectiveModelCatalog().find(m => m.slug === slug);
    return profile ? profile.selection : { id: slug };
    }

+/**

    • Surface a broken catalog at CLI startup rather than as a spawn failure
    • partway through a run. Rejects duplicate slugs and multiple defaults for
    • the same task type so resolution can't silently pick the first match.
  • */
    +export function assertModelEnvConfig(): void {
  • const catalog = effectiveModelCatalog();
  • const source = envCatalogJson() ? MODEL_ENV_CATALOG : "MODEL_CATALOG";
  • const seenSlugs = new Set();
  • for (const m of catalog) {
  • if (seenSlugs.has(m.slug)) {
  •  throw new PlanValidationError(
    
  •    `${source} has duplicate slug "${m.slug}". Each slug must appear once.`
    
  •  );
    
  • }
  • seenSlugs.add(m.slug);
  • }
  • const defaultOwners = new Map<TaskType, string>();
  • for (const m of catalog) {
  • for (const type of m.defaultFor ?? []) {
  •  const existing = defaultOwners.get(type);
    
  •  if (existing !== undefined) {
    
  •    throw new PlanValidationError(
    
  •      `${source} assigns ${type} default to both "${existing}" and "${m.slug}". Only one entry may list each task type in defaultFor.`
    
  •    );
    
  •  }
    
  •  defaultOwners.set(type, m.slug);
    
  • }
  • }
  • for (const type of TASK_TYPES) defaultModelForType(type);
    +}

export function renderModelCatalog(): string {
const lines: string[] = [];

  • for (const m of MODEL_CATALOG) {
  • if (envCatalogJson()) {
  • lines.push(
  •  "This repo publishes an exact model menu. Use only the slugs listed below; do not reach for models outside this list."
    
  • );
  • lines.push("");
  • }
  • for (const m of effectiveModelCatalog()) {
    const defaults = m.defaultFor?.length
    ? (default for ${m.defaultFor.join(", ")})
    : "";
    lines.push(- \${m.slug}` — ${m.summary}${defaults}`);
  • lines.push( speed: ${m.speed}; strengths: ${m.strengths.join(", ")});
  • const strengths = m.strengths.length
  •  ? `; strengths: ${m.strengths.join(", ")}`
    
  •  : "";
    
  • lines.push( speed: ${m.speed}${strengths});
    lines.push( use: ${m.use});
    }
    return lines.join("\n");

diff --git a/orchestrate/skills/orchestrate/scripts/schemas.ts b/orchestrate/skills/orchestrate/scripts/schemas.ts
--- a/orchestrate/skills/orchestrate/scripts/schemas.ts
+++ b/orchestrate/skills/orchestrate/scripts/schemas.ts
@@ -500,6 +500,41 @@
NoopStopResultSchema,
]);

+const ModelSelectionSchema = z

  • .object({
  • id: z.string().min(1).describe("Model id as accepted by the Cursor API."),
  • params: z
  •  .array(z.object({ id: z.string(), value: z.string() }).strict())
    
  •  .optional()
    
  •  .describe("Model parameters, e.g. reasoning, effort, thinking, fast."),
    
  • })
  • .strict()
  • .describe("Canonical SDK selection passed to Agent.create({ model }).");

+const ModelProfileSchema = z

  • .object({
  • slug: z
  •  .string()
    
  •  .min(1)
    
  •  .describe("Authoring name planners write into `tasks[].model`."),
    
  • selection: ModelSelectionSchema,
  • summary: z.string().describe("One-line description planners read."),
  • strengths: z
  •  .array(z.string())
    
  •  .describe("Capability keywords planners match a task against."),
    
  • speed: z.string().describe("Relative latency, e.g. fast, medium, slow."),
  • use: z.string().describe("When a planner should pick this model."),
  • defaultFor: z
  •  .array(TaskTypeSchema)
    
  •  .optional()
    
  •  .describe(
    
  •    "Task types that use this model when `tasks[].model` is omitted."
    
  •  ),
    
  • })
  • .strict();

+export const ModelCatalogSchema = z.array(ModelProfileSchema);
+
const TreeTaskSchema = z
.object({
name: taskNameSchema,
@@ -534,6 +569,7 @@
export type RecoverResult = z.infer;
export type StopResult = z.infer;
export type TreeTask = z.infer;
+export type ModelProfile = z.infer;
export interface TreeState {
rootSlug: string | null;
tasks: TreeTask[];
@@ -577,6 +613,19 @@
});
}

+export function parseModelCatalogJson(

  • text: string,
  • source: string
    +): ModelProfile[] {
  • return parseJsonWithSchema({
  • schema: ModelCatalogSchema,
  • text,
  • source,
  • recoveryHint:
  •  "Every entry needs slug, selection, summary, strengths, speed, and use. Unset the variable and run `bun cli.ts models --json` to copy the built-in catalog as a starting point.",
    
  • });
    +}

export function parseStateJson(text: string, source: string): State {
return parseJsonWithSchema({
schema: StateSchema,

diff --git a/orchestrate/skills/orchestrate/scripts/tools/generate-json-schemas.ts b/orchestrate/skills/orchestrate/scripts/tools/generate-json-schemas.ts
--- a/orchestrate/skills/orchestrate/scripts/tools/generate-json-schemas.ts
+++ b/orchestrate/skills/orchestrate/scripts/tools/generate-json-schemas.ts
@@ -6,7 +6,7 @@
import type { z } from "zod/v3";
import { zodToJsonSchema } from "zod-to-json-schema";

-import { PlanSchema, StateSchema } from "../schemas.ts";
+import { ModelCatalogSchema, PlanSchema, StateSchema } from "../schemas.ts";

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const SCHEMA_DIR = resolve(SCRIPT_DIR, "../../schemas");
@@ -29,6 +29,15 @@
"Written by scripts/orchestrate.ts. Live task rows; read-only unless you must edit by hand to unstick state.",
});

+writeSchema({

  • path: "model-catalog.schema.json",
  • schema: ModelCatalogSchema,
  • 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.",
    +});

function writeSchema(args: {
path: string;
schema: z.ZodTypeAny;

diff --git a/orchestrate/skills/orchestrate/scripts/tools/probe-models.ts b/orchestrate/skills/orchestrate/scripts/tools/probe-models.ts
--- a/orchestrate/skills/orchestrate/scripts/tools/probe-models.ts
+++ b/orchestrate/skills/orchestrate/scripts/tools/probe-models.ts
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { Agent } from "@cursor/sdk";
-import { MODEL_CATALOG, type ModelProfile } from "../models.ts";
+import { effectiveModelCatalog, type ModelProfile } from "../models.ts";

const PROBE_REPO = "https://github.com/example-org/example-repo";

@@ -33,7 +33,7 @@
agentApi?: ProbeAgentApi;
} = {}
): Promise<ProbeResult[]> {

  • const catalog = opts.catalog ?? MODEL_CATALOG;
  • const catalog = opts.catalog ?? effectiveModelCatalog();
    const agentApi = opts.agentApi ?? Agent;
    const results: ProbeResult[] = [];
    for (const profile of catalog) {

</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-c7e6c79a-83fe-4a56-a87c-322c2192ce33">here</a>.</sub>

</details>

*/
export function assertModelEnvConfig(): void {
for (const type of TASK_TYPES) defaultModelForType(type);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Startup allows ambiguous catalog entries

Medium Severity · Logic Bug

The assertModelEnvConfig function doesn't validate the uniqueness of model slugs or defaultFor assignments. This means defaultModelForType and resolveModelSelection will silently pick the first matching entry, while renderModelCatalog displays all, leading to unexpected model selections during task execution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c45c506. Configure here.

.describe(
"Task types that use this model when `tasks[].model` is omitted."
),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catalog schema strips typos silently

Medium Severity · Logic Bug

ModelSelectionSchema and ModelProfileSchema parse without .strict(), so mistyped keys (e.g. a misspelled params field under selection) are dropped instead of failing validation. The published JSON schema rejects those keys, but runtime ORCHESTRATE_MODEL_CATALOG parsing can pass startup and spawn agents without intended model parameters.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c45c506. Configure here.

cursoragent and others added 2 commits July 27, 2026 19:37
SLACK_BOT_TOKEN and SLACK_CHANNEL_ID are generic enough that orchestrate
could pick up an unrelated tool's Slack app from a shared environment. They
are now ORCHESTRATE_SLACK_BOT_TOKEN and ORCHESTRATE_SLACK_CHANNEL_ID,
matching the ORCHESTRATE_ prefix the skill's other settings already use.

Both env names now live in constants rather than scattered string literals.
The old names are not read as a fallback, since honoring them would defeat
the isolation; instead, a token found only under the old name prints a rename
notice, so the change can't present as a silent Slack outage mid-run.

Co-authored-by: Jonny Alexander Power <JonnyPower@users.noreply.github.com>
…prefix-1a9d

refactor(orchestrate): prefix Slack env vars with ORCHESTRATE_

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Whitespace token bypasses Slack guards
    • Shared a trimmed readSlackToken() helper so createSlackWebClient() and slackTokenConfigured() agree that whitespace-only tokens are unset.

Create PR

Or push these changes by commenting:

@cursor push 9520630dbf
Preview (9520630dbf)
diff --git a/orchestrate/README.md b/orchestrate/README.md
--- a/orchestrate/README.md
+++ b/orchestrate/README.md
@@ -10,6 +10,61 @@
 - 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).
@@ -22,6 +77,8 @@
 
 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:
 
@@ -42,9 +99,9 @@
    | `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
 

diff --git a/orchestrate/skills/orchestrate/SKILL.md b/orchestrate/skills/orchestrate/SKILL.md
--- a/orchestrate/skills/orchestrate/SKILL.md
+++ b/orchestrate/skills/orchestrate/SKILL.md
@@ -13,7 +13,8 @@
 ## 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
 

diff --git a/orchestrate/skills/orchestrate/prompts/subplanner.md b/orchestrate/skills/orchestrate/prompts/subplanner.md
--- a/orchestrate/skills/orchestrate/prompts/subplanner.md
+++ b/orchestrate/skills/orchestrate/prompts/subplanner.md
@@ -30,7 +30,7 @@
 
 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}}
 

diff --git a/orchestrate/skills/orchestrate/references/dispatcher.md b/orchestrate/skills/orchestrate/references/dispatcher.md
--- a/orchestrate/skills/orchestrate/references/dispatcher.md
+++ b/orchestrate/skills/orchestrate/references/dispatcher.md
@@ -16,8 +16,10 @@
 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

Kickoff bot username is <firstName>'s bot when the first name resolves, otherwise orchestrate. Resolution order:
@@ -45,6 +47,6 @@

  • 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.

diff --git a/orchestrate/skills/orchestrate/references/planner.md b/orchestrate/skills/orchestrate/references/planner.md
--- a/orchestrate/skills/orchestrate/references/planner.md
+++ b/orchestrate/skills/orchestrate/references/planner.md
@@ -14,7 +14,7 @@

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.
    @@ -69,7 +69,7 @@

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:

diff --git a/orchestrate/skills/orchestrate/references/spawning.md b/orchestrate/skills/orchestrate/references/spawning.md
--- a/orchestrate/skills/orchestrate/references/spawning.md
+++ b/orchestrate/skills/orchestrate/references/spawning.md
@@ -57,7 +57,7 @@

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:

diff --git a/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json b/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json
new file mode 100644
--- /dev/null
+++ b/orchestrate/skills/orchestrate/schemas/model-catalog.schema.json
@@ -1,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
  • }
    +}

diff --git a/orchestrate/skills/orchestrate/schemas/plan.schema.json b/orchestrate/skills/orchestrate/schemas/plan.schema.json
--- a/orchestrate/skills/orchestrate/schemas/plan.schema.json
+++ b/orchestrate/skills/orchestrate/schemas/plan.schema.json
@@ -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",

diff --git a/orchestrate/skills/orchestrate/scripts/tests/agent-manager-slack-mirror.test.ts b/orchestrate/skills/orchestrate/scripts/tests/agent-manager-slack-mirror.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/agent-manager-slack-mirror.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/agent-manager-slack-mirror.test.ts
@@ -16,17 +16,17 @@
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;
    }
    });

@@ -391,8 +391,8 @@
});

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[]) => {
    @@ -428,7 +428,9 @@
    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([]);
    

@@ -443,9 +445,9 @@
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;
     }
    
    }
    });

diff --git a/orchestrate/skills/orchestrate/scripts/tests/comment-cli.test.ts b/orchestrate/skills/orchestrate/scripts/tests/comment-cli.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/comment-cli.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/comment-cli.test.ts
@@ -18,7 +18,7 @@
encoding: "utf8",
env: {
...process.env,

  •    SLACK_BOT_TOKEN: "xoxb-test",
    
  •    ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
     },
    
    });

@@ -73,7 +73,7 @@
encoding: "utf8",
env: {
...process.env,

  •        SLACK_BOT_TOKEN: "xoxb-test",
    
  •        ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
         },
       }
     );
    

@@ -130,7 +130,7 @@
encoding: "utf8",
env: {
...process.env,

  •        SLACK_BOT_TOKEN: "xoxb-test",
    
  •        ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
         },
       }
     );
    

@@ -187,7 +187,7 @@
encoding: "utf8",
env: {
...process.env,

  •        SLACK_BOT_TOKEN: "xoxb-test",
    
  •        ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
         },
       }
     );
    

diff --git a/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/models-catalog.test.ts
@@ -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) {

diff --git a/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts b/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts
new file mode 100644
--- /dev/null
+++ b/orchestrate/skills/orchestrate/scripts/tests/models-env-catalog.test.ts
@@ -1,0 +1,172 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
+import { PlanValidationError } from "../errors.ts";
+import {

  • assertModelEnvConfig,
  • defaultModelForType,
  • effectiveModelCatalog,
  • isKnownModel,
  • MODEL_CATALOG,
  • MODEL_ENV_CATALOG,
  • renderModelCatalog,
  • resolveModelSelection,
    +} from "../models.ts";

+let saved: string | undefined;
+
+/** A minimally complete entry; every field the schema requires. */
+function entry(

  • overrides: Record<string, unknown> = {}
    +): Record<string, unknown> {
  • return {
  • slug: "house-worker",
  • selection: { id: "composer-2.5" },
  • summary: "House worker model.",
  • strengths: ["throughput"],
  • speed: "fast",
  • use: "Use for all bounded implementation work.",
  • ...overrides,
  • };
    +}

+function setCatalog(entries: unknown): void {

  • process.env[MODEL_ENV_CATALOG] = JSON.stringify(entries);
    +}

+beforeEach(() => {

  • saved = process.env[MODEL_ENV_CATALOG];
  • delete process.env[MODEL_ENV_CATALOG];
    +});

+afterEach(() => {

  • if (saved === undefined) delete process.env[MODEL_ENV_CATALOG];
  • else process.env[MODEL_ENV_CATALOG] = saved;
    +});

+describe("ORCHESTRATE_MODEL_CATALOG unset", () => {

  • test("the built-in catalog is in effect", () => {
  • expect(effectiveModelCatalog()).toBe(MODEL_CATALOG);
  • expect(defaultModelForType("worker")).toBe("gpt-5.5-high-fast");
  • expect(renderModelCatalog()).not.toContain("exact model menu");
  • });
  • test("whitespace-only value is treated as unset", () => {
  • process.env[MODEL_ENV_CATALOG] = " ";
  • expect(effectiveModelCatalog()).toBe(MODEL_CATALOG);
  • });
    +});

+describe("ORCHESTRATE_MODEL_CATALOG replaces the built-in catalog", () => {

  • test("only the listed models are published", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(effectiveModelCatalog().map(m => m.slug)).toEqual(["house-worker"]);
  • expect(isKnownModel("gpt-5.5-high-fast")).toBe(false);
  • expect(isKnownModel("house-worker")).toBe(true);
  • });
  • test("entries supply every task type's default", () => {
  • setCatalog([
  •  entry({ defaultFor: ["worker"] }),
    
  •  entry({
    
  •    slug: "house-planner",
    
  •    selection: { id: "claude-opus-4-8" },
    
  •    defaultFor: ["subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(defaultModelForType("worker")).toBe("house-worker");
  • expect(defaultModelForType("subplanner")).toBe("house-planner");
  • expect(defaultModelForType("verifier")).toBe("house-planner");
  • });
  • test("a slug resolves to its full selection, params included", () => {
  • setCatalog([
  •  entry({
    
  •    selection: {
    
  •      id: "composer-2.5",
    
  •      params: [{ id: "fast", value: "true" }],
    
  •    },
    
  •    defaultFor: ["worker", "subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(resolveModelSelection("house-worker")).toEqual({
  •  id: "composer-2.5",
    
  •  params: [{ id: "fast", value: "true" }],
    
  • });
  • });
  • test("a model outside the catalog still passes through as a bare id", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(resolveModelSelection("gpt-5.5")).toEqual({ id: "gpt-5.5" });
  • });
  • test("the rendered catalog is what planners see", () => {
  • setCatalog([entry({ defaultFor: ["worker"] })]);
  • const text = renderModelCatalog();
  • expect(text).toContain("exact model menu");
  • expect(text).toContain("house-worker — House worker model.");
  • expect(text).toContain("(default for worker)");
  • expect(text).toContain("speed: fast; strengths: throughput");
  • });
  • // speed is a free-form string so new model vocabulary doesn't require a
  • // plugin release.
  • test("unrecognized speed values are passed through", () => {
  • setCatalog([
  •  entry({
    
  •    speed: "blistering",
    
  •    defaultFor: ["worker", "subplanner", "verifier"],
    
  •  }),
    
  • ]);
  • expect(renderModelCatalog()).toContain("speed: blistering");
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
  • test("the built-in catalog round-trips through the schema", () => {
  • // bun cli.ts models --json is documented as a starting point, so its
  • // output has to be valid input.
  • setCatalog(MODEL_CATALOG);
  • expect(effectiveModelCatalog()).toEqual(MODEL_CATALOG);
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
    +});

+describe("catalog config errors", () => {

  • test("a missing task-type default fails fast at startup", () => {
  • setCatalog([entry({ defaultFor: ["worker"] })]);
  • expect(() => assertModelEnvConfig()).toThrow(PlanValidationError);
  • expect(() => assertModelEnvConfig()).toThrow(
  •  /no subplanner default.*"defaultFor": \["subplanner"\]/s
    
  • );
  • });
  • test("assertModelEnvConfig passes when every task type resolves", () => {
  • setCatalog([entry({ defaultFor: ["worker", "subplanner", "verifier"] })]);
  • expect(() => assertModelEnvConfig()).not.toThrow();
  • });
  • test("malformed JSON is rejected", () => {
  • process.env[MODEL_ENV_CATALOG] = "[{slug:}]";
  • expect(() => effectiveModelCatalog()).toThrow(/is not valid JSON/);
  • });
  • test("an incomplete entry is rejected with the offending field", () => {
  • const { summary, ...withoutSummary } = entry();
  • expect(summary).toBeDefined();
  • setCatalog([withoutSummary]);
  • expect(() => effectiveModelCatalog()).toThrow(PlanValidationError);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].summary/);
  • });
  • test("a bad selection or defaultFor is rejected", () => {
  • setCatalog([entry({ selection: { id: "" } })]);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].selection.id/);
  • setCatalog([entry({ defaultFor: ["planner"] })]);
  • expect(() => effectiveModelCatalog()).toThrow(/[0].defaultFor/);
  • });
  • test("a non-array value is rejected", () => {
  • process.env[MODEL_ENV_CATALOG] = '{"slug":"x"}';
  • expect(() => effectiveModelCatalog()).toThrow(PlanValidationError);
  • });
    +});

diff --git a/orchestrate/skills/orchestrate/scripts/tests/slack-adapter.test.ts b/orchestrate/skills/orchestrate/scripts/tests/slack-adapter.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/slack-adapter.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/slack-adapter.test.ts
@@ -12,14 +12,14 @@
const { createSlackAdapter } = await import("../adapters/index.ts");
const TEST_SLACK_CHANNEL = "C123TEST";

-const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
-process.env.SLACK_BOT_TOKEN = "xoxb-test";
+const ORIGINAL_SLACK_TOKEN = process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
+process.env.ORCHESTRATE_SLACK_BOT_TOKEN = "xoxb-test";

afterAll(() => {
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;
    }
    });

diff --git a/orchestrate/skills/orchestrate/scripts/tests/slack-channel-boundary.test.ts b/orchestrate/skills/orchestrate/scripts/tests/slack-channel-boundary.test.ts
--- a/orchestrate/skills/orchestrate/scripts/tests/slack-channel-boundary.test.ts
+++ b/orchestrate/skills/orchestrate/scripts/tests/slack-channel-boundary.test.ts
@@ -8,43 +8,45 @@
resolveWorkspaceSlackChannelOrBail,
} from "../cli/util.ts";

-const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
-const ORIGINAL_SLACK_CHANNEL = process.env.SLACK_CHANNEL_ID;
+const ORIGINAL_SLACK_TOKEN = process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
+const ORIGINAL_SLACK_CHANNEL = process.env.ORCHESTRATE_SLACK_CHANNEL_ID;

afterEach(() => {

  • if (ORIGINAL_SLACK_TOKEN === undefined) delete process.env.SLACK_BOT_TOKEN;
  • else process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
  • if (ORIGINAL_SLACK_CHANNEL === undefined) delete process.env.SLACK_CHANNEL_ID;
  • else process.env.SLACK_CHANNEL_ID = ORIGINAL_SLACK_CHANNEL;
  • if (ORIGINAL_SLACK_TOKEN === undefined)
  • delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
  • else process.env.ORCHESTRATE_SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
  • if (ORIGINAL_SLACK_CHANNEL === undefined)
  • delete process.env.ORCHESTRATE_SLACK_CHANNEL_ID;
  • else process.env.ORCHESTRATE_SLACK_CHANNEL_ID = ORIGINAL_SLACK_CHANNEL;
    });

describe("Slack channel boundary", () => {
test("Token set without a channel fails before Slack initialization", () => {

  • process.env.SLACK_BOT_TOKEN = "xoxb-test";
  • delete process.env.SLACK_CHANNEL_ID;
  • process.env.ORCHESTRATE_SLACK_BOT_TOKEN = "xoxb-test";

  • delete process.env.ORCHESTRATE_SLACK_CHANNEL_ID;

    expect(() => resolveKickoffSlackChannelOrBail(undefined)).toThrow(

  •  "set --slack-channel or SLACK_CHANNEL_ID, or unset SLACK_BOT_TOKEN to disable Slack"
    
  •  "set --slack-channel or ORCHESTRATE_SLACK_CHANNEL_ID, or unset ORCHESTRATE_SLACK_BOT_TOKEN to disable Slack"
    

    );
    });

    test("No token and no channel leaves Slack disabled", () => {

  • delete process.env.SLACK_BOT_TOKEN;
  • delete process.env.SLACK_CHANNEL_ID;
  • delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;

  • delete process.env.ORCHESTRATE_SLACK_CHANNEL_ID;

    expect(resolveKickoffSlackChannelOrBail(undefined)).toBeUndefined();
    });

test("No token with a channel accepts the channel for later plan persistence", () => {

  • delete process.env.SLACK_BOT_TOKEN;
  • delete process.env.SLACK_CHANNEL_ID;
  • delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;

  • delete process.env.ORCHESTRATE_SLACK_CHANNEL_ID;

    expect(resolveKickoffSlackChannelOrBail("C123TEST")).toBe("C123TEST");
    });

test("Workspace run inherits plan.slackChannel", () => {

  • process.env.SLACK_BOT_TOKEN = "xoxb-test";
  • delete process.env.SLACK_CHANNEL_ID;
  • process.env.ORCHESTRATE_SLACK_BOT_TOKEN = "xoxb-test";
  • delete process.env.ORCHESTRATE_SLACK_CHANNEL_ID;
    const workspace = mkdtempSync(join(tmpdir(), "orch-slack-channel-"));
    try {
    writeFileSync(

diff --git a/orchestrate/skills/orchestrate/scripts/tests/slack-env-rename.test.ts b/orchestrate/skills/orchestrate/scripts/tests/slack-env-rename.test.ts
new file mode 100644
--- /dev/null
+++ b/orchestrate/skills/orchestrate/scripts/tests/slack-env-rename.test.ts
@@ -1,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
+import {

  • createSlackWebClient,
  • SLACK_TOKEN_ENV,
  • slackTokenConfigured,
    +} from "../adapters/slack/client.ts";

+const LEGACY_TOKEN_ENV = "SLACK_BOT_TOKEN";
+const KEYS = [SLACK_TOKEN_ENV, LEGACY_TOKEN_ENV] as const;
+
+const saved: Record<string, string | undefined> = {};
+let errors: string[] = [];
+let originalConsoleError: typeof console.error;
+
+beforeEach(() => {

  • for (const key of KEYS) {
  • saved[key] = process.env[key];
  • delete process.env[key];
  • }
  • errors = [];
  • originalConsoleError = console.error;
  • console.error = (...args: unknown[]) => {
  • errors.push(args.map(String).join(" "));
  • };
    +});

+afterEach(() => {

  • console.error = originalConsoleError;
  • for (const key of KEYS) {
  • const prior = saved[key];
  • if (prior === undefined) delete process.env[key];
  • else process.env[key] = prior;
  • }
    +});

+describe("Slack token env var", () => {

  • test("the prefixed variable enables Slack", () => {
  • process.env[SLACK_TOKEN_ENV] = "xoxb-test";
  • expect(slackTokenConfigured()).toBe(true);
  • expect(createSlackWebClient()).toBeDefined();
  • expect(errors).toEqual([]);
  • });
  • test("neither variable set disables Slack with one line", () => {
  • expect(slackTokenConfigured()).toBe(false);
  • expect(createSlackWebClient()).toBeUndefined();
  • expect(errors).toHaveLength(1);
  • expect(errors[0]).toContain(${SLACK_TOKEN_ENV} not set);
  • });
  • // The rename would otherwise look like a Slack outage: the token is present,
  • // but under the old name, so the run goes quiet with no explanation.
  • test("the unprefixed variable is ignored and named in the notice", () => {
  • process.env[LEGACY_TOKEN_ENV] = "xoxb-test";
  • expect(slackTokenConfigured()).toBe(false);
  • expect(createSlackWebClient()).toBeUndefined();
  • expect(errors).toHaveLength(1);
  • expect(errors[0]).toContain(
  •  `${LEGACY_TOKEN_ENV} is no longer read; rename it to ${SLACK_TOKEN_ENV}`
    
  • );
  • });
  • test("the prefixed variable wins when both are set", () => {
  • process.env[LEGACY_TOKEN_ENV] = "xoxb-legacy";
  • process.env[SLACK_TOKEN_ENV] = "xoxb-test";
  • expect(slackTokenConfigured()).toBe(true);
  • expect(createSlackWebClient()).toBeDefined();
  • expect(errors).toEqual([]);
  • });
    +});

diff --git a/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts b/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts
--- a/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts
+++ b/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts
@@ -1,10 +1,28 @@
import { WebClient } from "@slack/web-api";

+/** Bot token env var. Prefixed so it can't collide with another tool's Slack app. */
+export const SLACK_TOKEN_ENV = "ORCHESTRATE_SLACK_BOT_TOKEN";
+
+const LEGACY_SLACK_TOKEN_ENV = "SLACK_BOT_TOKEN";
+
+function readSlackToken(): string | undefined {

  • const token = process.env[SLACK_TOKEN_ENV]?.trim();
  • return token || undefined;
    +}

+export function slackTokenConfigured(): boolean {

  • return Boolean(readSlackToken());
    +}

export function createSlackWebClient(): WebClient | undefined {

  • const token = process.env.SLACK_BOT_TOKEN;
  • const token = readSlackToken();
    if (!token) {
  • // Losing Slack silently after the rename would look like an outage, so
  • // name the old variable when it's the only one set.
    console.error(
  •  "[orchestrate] SLACK_BOT_TOKEN not set; Slack visibility disabled"
    
  •  process.env[LEGACY_SLACK_TOKEN_ENV]
    
  •    ? `[orchestrate] ${LEGACY_SLACK_TOKEN_ENV} is no longer read; rename it to ${SLACK_TOKEN_ENV}. Slack visibility disabled`
    
  •    : `[orchestrate] ${SLACK_TOKEN_ENV} not set; Slack visibility disabled`
    
    );
    return undefined;
    }

diff --git a/orchestrate/skills/orchestrate/scripts/cli/comments.ts b/orchestrate/skills/orchestrate/scripts/cli/comments.ts
--- a/orchestrate/skills/orchestrate/scripts/cli/comments.ts
+++ b/orchestrate/skills/orchestrate/scripts/cli/comments.ts
@@ -3,6 +3,7 @@
import type { Command } from "commander";
... diff truncated: showing 800 of 1259 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-27733b62-711c-4c57-b730-866298f65b20">here</a>.</sub>
<!-- BUGBOT_AUTOFIX_REVIEW_FOOTNOTE_END -->

<sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bb783a1392efd45a970d49aa38179c51288c0681. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>

"[orchestrate] SLACK_BOT_TOKEN not set; Slack visibility disabled"
process.env[LEGACY_SLACK_TOKEN_ENV]
? `[orchestrate] ${LEGACY_SLACK_TOKEN_ENV} is no longer read; rename it to ${SLACK_TOKEN_ENV}. Slack visibility disabled`
: `[orchestrate] ${SLACK_TOKEN_ENV} not set; Slack visibility disabled`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitespace token bypasses Slack guards

Medium Severity · Logic Bug

When ORCHESTRATE_SLACK_BOT_TOKEN is set to only whitespace, slackTokenConfigured() incorrectly reports Slack as unconfigured. However, createSlackWebClient() still attempts to initialize a client with this invalid token, bypassing early validation and causing runtime API failures.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bb783a1. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants