Allow for env var override of /orchestrate model catalog - #173
Allow for env var override of /orchestrate model catalog#173JonnyPower wants to merge 8 commits into
Conversation
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)
There was a problem hiding this comment.
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.
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[].modelfrom.", - "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");
- });
- //
speedis 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 --jsonis 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 settingtasks[].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[].modeland--modelflags. */ - 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.
+//defaultForsupplies the model for a task type whentasks[].modelis
+// 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[].modelfrom.",
+});
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); | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit c45c506. Configure here.
| .describe( | ||
| "Task types that use this model when `tasks[].model` is omitted." | ||
| ), | ||
| }); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit c45c506. Configure here.
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_
There was a problem hiding this comment.
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).
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.
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 statusfor top-level state.
-- The Slack kickoff thread inplan.slackChannel, whenSLACK_BOT_TOKENis set.
+- The Slack kickoff thread inplan.slackChannel, whenORCHESTRATE_SLACK_BOT_TOKENis 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[].modelfrom.", - "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");
- });
- //
speedis 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 --jsonis 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` |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit bb783a1. Configure here.



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 choosetasks[].modelfrom, whatbun cli.ts modelsprints, 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_CATALOGin code, validated by a zod schema and published asschemas/model-catalog.schema.jsonfor editor validation.bun cli.ts models --jsonemits the built-in catalog in exactly that shape, so configuring a repo is copy-then-edit rather than writing entries from scratch.Why the catalog and not a per-role default
A per-role fallback would only apply when
tasks[].modelis 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 throughresolveModelSelectionwith their params intact.Entries
Every entry needs
slug,selection,summary,strengths,speed, anduse;defaultForandselection.paramsare optional.summary,strengths, anduseare required because planners select by capability rather than model name, and an entry with thin prose gets passed over.speedis a free-form string so new model vocabulary doesn't need a plugin release.Each of
worker,subplanner, andverifierneeds adefaultForsomewhere in the list. Root planners aren't part of the catalog; they keep taking kickoff--model, defaulting toclaude-opus-4-8.Error handling
Validation is the shared
parseJsonWithSchemahelper, so config problems surface as field-level zod issues through the existingPlanValidationErrorexit-2 path, at CLI startup rather than mid-run:A task type left without a default is caught by the same startup check.
Scope
models.tsis +79/-29, and most of that is wiring rather than new logic: the catalog is eitherMODEL_CATALOGor oneparseModelCatalogJsoncall.agent-manager.tsand the kickoff CLI are untouched, sincedefaultModelForTypeandresolveModelSelectionkeep their signatures. The rest is the schema inschemas.ts, a startup check incli/index.ts,--jsonincli/inspect.ts, generator wiring, and docs. The subplanner prompt now tells planners the list is this repo's catalog and to omittasks[].modelwhen 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 unknownspeedvalues, and each rejected configMODEL_CATALOGback through the schema, so the documented--jsonstarting point is guaranteed to be valid inputbun run check(biome + tsc)models,models --json, a full round-trip of--jsonoutput back through the env var, and an incomplete entry