diff --git a/orchestrate/README.md b/orchestrate/README.md index 2cdece73..cb5db523 100644 --- a/orchestrate/README.md +++ b/orchestrate/README.md @@ -10,6 +10,61 @@ The skill itself lives in [`skills/orchestrate/SKILL.md`](./skills/orchestrate/S - A Cursor API key in `CURSOR_API_KEY`. - Optional Slack app and bot token if you want a Slack thread mirroring the run. +## Model catalog (optional) + +`ORCHESTRATE_MODEL_CATALOG` replaces the built-in model catalog with your own. When it is set, that list is the complete menu: it is what planners choose `tasks[].model` from, what `bun cli.ts models` prints, and where each task type's default comes from. Nothing is merged with the built-in catalog, so what you write is exactly what runs. Use it to steer cost without editing the plugin. + +The value is a JSON array in the same shape as the built-in catalog, validated against [`skills/orchestrate/schemas/model-catalog.schema.json`](./skills/orchestrate/schemas/model-catalog.schema.json). Start from the built-in list rather than writing entries by hand: + +```bash +bun skills/orchestrate/scripts/cli.ts models --json > catalog.json +# edit catalog.json: drop what you don't want, move defaultFor where you want it +export ORCHESTRATE_MODEL_CATALOG="$(cat catalog.json)" +``` + +Every entry needs `slug`, `selection`, `summary`, `strengths`, `speed`, and `use`. `defaultFor` and `selection.params` are optional: + +```json +[ + { + "slug": "house-worker", + "selection": { "id": "composer-2.5", "params": [{ "id": "fast", "value": "true" }] }, + "summary": "Cheap, fast worker.", + "strengths": ["throughput", "well-scoped implementation"], + "speed": "fast", + "use": "Use for all bounded implementation work.", + "defaultFor": ["worker"] + }, + { + "slug": "house-planner", + "selection": { "id": "claude-opus-4-8" }, + "summary": "Frontier judgment for decomposition and acceptance checks.", + "strengths": ["judgment", "ambiguity resolution"], + "speed": "slow", + "use": "Use when the work needs design decisions rather than execution.", + "defaultFor": ["subplanner", "verifier"] + } +] +``` + +`summary`, `strengths`, and `use` are required because planners select by capability, not by model name. An entry with thin prose tends to get passed over. `speed` is a free-form string, so new model vocabulary doesn't need a plugin release. + +Each of `worker`, `subplanner`, and `verifier` needs a `defaultFor` somewhere in the list. Root planners are not part of the catalog; they take their model from kickoff `--model`, which defaults to `claude-opus-4-8`. + +### Precedence + +1. Explicit `tasks[].model` in the plan +2. The `defaultFor` entry for that task's type + +Run `bun cli.ts models` to print the catalog in effect, and `bun cli.ts models --check` to probe every entry against `/v1/agents`. Invalid config exits 2 at startup, naming the offending entry and field, rather than failing mid-run: + +``` +ORCHESTRATE_MODEL_CATALOG failed zod validation: + [0].summary: Required +``` + +Two caveats. This shapes what planners choose from, but a planner can still write any model id into `tasks[].model`, so it is guidance rather than a spend ceiling. And each spawned agent reads its own environment: set the variable as a Cursor Cloud secret for the repo so subplanners and workers inherit it, not just in the dispatcher's local shell. + ## Cursor API key 1. Open [https://cursor.com/dashboard/integrations](https://cursor.com/dashboard/integrations). @@ -22,6 +77,8 @@ Team service-account keys (Team Settings → Service accounts) also work for bot Slack visibility is opt-in. When the token is unset, the script logs once and runs without Slack; correctness does not change. To enable it: +> **Renamed:** these were `SLACK_BOT_TOKEN` and `SLACK_CHANNEL_ID`. They are now `ORCHESTRATE_SLACK_BOT_TOKEN` and `ORCHESTRATE_SLACK_CHANNEL_ID` so orchestrate can't pick up another tool's Slack app from a shared environment. The unprefixed names are no longer read; if only the old token is set, the CLI says so instead of going quiet. + 1. Create a Slack app at [https://api.slack.com/apps](https://api.slack.com/apps) → **From scratch**. Pick a name and a workspace. 2. Under **OAuth & Permissions** → **Bot Token Scopes**, add: @@ -42,9 +99,9 @@ Slack visibility is opt-in. When the token is unset, the script logs once and ru | `users:read.email` | Resolve the dispatcher's first name from `git config user.email`. Without it, pass `--dispatcher-name` explicitly. | 3. **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`). -4. Export it: `export SLACK_BOT_TOKEN="xoxb-..."`. +4. Export it: `export ORCHESTRATE_SLACK_BOT_TOKEN="xoxb-..."`. 5. Invite the bot to the channel where you want runs to thread (`/invite @your-bot`). Public channels with `chat:write.public` skip this; private channels require the invite. -6. Grab the channel ID. In Slack: right-click the channel → **View channel details** → bottom of the dialog. Pass it via `--slack-channel ` 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 ` 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 index ff7fc8e3..13380163 100644 --- a/orchestrate/skills/orchestrate/SKILL.md +++ b/orchestrate/skills/orchestrate/SKILL.md @@ -13,7 +13,8 @@ An explicit `/orchestrate ` fans out a large task across parallel Cursor c ## Setup - `CURSOR_API_KEY` must be a personal/user key. Create it from [Cursor Dashboard > Integrations](https://cursor.com/dashboard/integrations), then read `cursor-sdk` Auth before using it. -- `SLACK_BOT_TOKEN` is optional. When set, pass `--slack-channel ` 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 ` 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 index ea4814b7..6df220f3 100644 --- a/orchestrate/skills/orchestrate/prompts/subplanner.md +++ b/orchestrate/skills/orchestrate/prompts/subplanner.md @@ -30,7 +30,7 @@ Paths you must NOT modify (owned by siblings): Acceptance criteria for your subtree: {{accept}}{{verifyPlan}}{{upstream}} -Model selection: pick `tasks[].model` per task by capability. Available models: +Model selection: pick `tasks[].model` per task by capability, choosing only from the list below. That list is this repo's effective catalog, not a generic menu. Omit `tasks[].model` to accept the marked default for that task type; set it explicitly when the task needs a different capability. {{modelCatalog}} diff --git a/orchestrate/skills/orchestrate/references/dispatcher.md b/orchestrate/skills/orchestrate/references/dispatcher.md index 54d18a64..342a14fe 100644 --- a/orchestrate/skills/orchestrate/references/dispatcher.md +++ b/orchestrate/skills/orchestrate/references/dispatcher.md @@ -16,7 +16,9 @@ One-time setup: run `bun install` inside this skill's `scripts/` directory if `n bun cli.ts kickoff "" [--repo ] [--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 ` 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 ` or set `ORCHESTRATE_SLACK_CHANNEL_ID`; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled. ## Dispatcher identity @@ -45,6 +47,6 @@ Progress is observable after dispatch: - `bun cli.ts crawl ` for a deep tree view. - `bun cli.ts status` for top-level state. -- The Slack kickoff thread in `plan.slackChannel`, when `SLACK_BOT_TOKEN` is set. +- 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 index d8ea8fe6..0c85a89d 100644 --- a/orchestrate/skills/orchestrate/references/planner.md +++ b/orchestrate/skills/orchestrate/references/planner.md @@ -14,7 +14,7 @@ Scripts expect `bun` on PATH. Install dependencies with `bun install` inside thi Regenerate `schemas/*.json` from `scripts/schemas.ts` with `bun run generate-schemas` in `scripts/` after plan or state shape changes. -Slack visibility uses `SLACK_BOT_TOKEN`. Required scopes: +Slack visibility uses `ORCHESTRATE_SLACK_BOT_TOKEN`. Required scopes: - `chat:write` — post and edit messages. - `chat:write.customize` — set custom username and icon on bot messages. @@ -69,7 +69,7 @@ Write `plan.json` at ``; the default workspace is `.orchestrate/ { 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 @@ describe("AgentManager Slack status mirror", () => { }); test("No token: load works, slackAdapter undefined, attention log + console.error once", async () => { - const original = process.env.SLACK_BOT_TOKEN; - delete process.env.SLACK_BOT_TOKEN; + const original = process.env.ORCHESTRATE_SLACK_BOT_TOKEN; + delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN; const errors: string[] = []; const originalConsoleError = console.error; console.error = ((...args: unknown[]) => { @@ -428,7 +428,9 @@ describe("AgentManager Slack status mirror", () => { expect(mgr.slackAdapter).toBeUndefined(); expect(slackWebApiCalls()).toHaveLength(0); expect( - errors.filter(line => line.includes("SLACK_BOT_TOKEN not set")) + errors.filter(line => + line.includes("ORCHESTRATE_SLACK_BOT_TOKEN not set") + ) ).toHaveLength(1); expect(readState(workspace).attention).toEqual([]); @@ -443,9 +445,9 @@ describe("AgentManager Slack status mirror", () => { rmSync(workspace, { recursive: true, force: true }); console.error = originalConsoleError; if (original === undefined) { - delete process.env.SLACK_BOT_TOKEN; + delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN; } else { - process.env.SLACK_BOT_TOKEN = original; + process.env.ORCHESTRATE_SLACK_BOT_TOKEN = original; } } }); diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts index e3c6954d..eb285b7a 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/comment-cli.test.ts @@ -18,7 +18,7 @@ describe("comment CLI", () => { encoding: "utf8", env: { ...process.env, - SLACK_BOT_TOKEN: "xoxb-test", + ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test", }, }); @@ -73,7 +73,7 @@ describe("comment CLI", () => { encoding: "utf8", env: { ...process.env, - SLACK_BOT_TOKEN: "xoxb-test", + ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test", }, } ); @@ -130,7 +130,7 @@ describe("comment CLI", () => { encoding: "utf8", env: { ...process.env, - SLACK_BOT_TOKEN: "xoxb-test", + ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test", }, } ); @@ -187,7 +187,7 @@ describe("comment CLI", () => { 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 index 54b05338..de23c07b 100644 --- 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 index 00000000..463a4e5a --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/models-env-catalog.test.ts @@ -0,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 = {} +): Record { + 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 index bb2a5e28..1a8b5ad2 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-adapter.test.ts @@ -12,14 +12,14 @@ installSlackWebApiMock(); const { createSlackAdapter } = await import("../adapters/index.ts"); const TEST_SLACK_CHANNEL = "C123TEST"; -const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN; -process.env.SLACK_BOT_TOKEN = "xoxb-test"; +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 index 8c3303ce..59956f3f 100644 --- 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 @@ import { 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 index 00000000..7c3ffb08 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/slack-env-rename.test.ts @@ -0,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 = {}; +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 index 918ac531..7e169e8d 100644 --- a/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts +++ b/orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts @@ -1,10 +1,23 @@ 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"; + +export function slackTokenConfigured(): boolean { + return Boolean(process.env[SLACK_TOKEN_ENV]?.trim()); +} + export function createSlackWebClient(): WebClient | undefined { - const token = process.env.SLACK_BOT_TOKEN; + const token = process.env[SLACK_TOKEN_ENV]; 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 index da8ac9ef..ed03c2ae 100644 --- a/orchestrate/skills/orchestrate/scripts/cli/comments.ts +++ b/orchestrate/skills/orchestrate/scripts/cli/comments.ts @@ -3,6 +3,7 @@ import { basename, isAbsolute, join, relative, resolve } from "node:path"; import type { Command } from "commander"; import { createSlackAdapter } from "../adapters/index.ts"; +import { SLACK_TOKEN_ENV } from "../adapters/slack/client.ts"; import { appendAgentFooter } from "../core/agent-manager.ts"; import { postOrQueueComment } from "../core/comment-retry-queue.ts"; import { redactBody } from "../core/redact-body.ts"; @@ -147,7 +148,7 @@ async function uploadFileToThread(args: { } const slack = createSlackAdapter(args.channel); if (!slack) { - throw new Error("SLACK_BOT_TOKEN not set; cannot upload Slack file"); + throw new Error(`${SLACK_TOKEN_ENV} not set; cannot upload Slack file`); } const content = readFileSync(args.filePath); const initial = diff --git a/orchestrate/skills/orchestrate/scripts/cli/index.ts b/orchestrate/skills/orchestrate/scripts/cli/index.ts index eca2f1af..c915c8f2 100644 --- 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 { registerInspectCommands } from "./inspect.ts"; 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 index 0e2db70a..4cda15f4 100644 --- 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 @@ export function registerInspectCommands(program: Command): void { "--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/cli/task.ts b/orchestrate/skills/orchestrate/scripts/cli/task.ts index 3093c913..655160f9 100644 --- a/orchestrate/skills/orchestrate/scripts/cli/task.ts +++ b/orchestrate/skills/orchestrate/scripts/cli/task.ts @@ -59,7 +59,7 @@ export function registerTaskCommands(program: Command): void { ) .option( "--slack-channel ", - "Slack channel id for the root run. Falls back to SLACK_CHANNEL_ID." + "Slack channel id for the root run. Falls back to ORCHESTRATE_SLACK_CHANNEL_ID." ) .option( "--max-runtime-sec ", @@ -115,7 +115,7 @@ export function registerTaskCommands(program: Command): void { ) .option( "--slack-channel ", - "Slack channel id for run visibility. Falls back to SLACK_CHANNEL_ID." + "Slack channel id for run visibility. Falls back to ORCHESTRATE_SLACK_CHANNEL_ID." ) .option( "--dispatcher-name ", diff --git a/orchestrate/skills/orchestrate/scripts/cli/util.ts b/orchestrate/skills/orchestrate/scripts/cli/util.ts index bffd2407..d9d7c2f8 100644 --- a/orchestrate/skills/orchestrate/scripts/cli/util.ts +++ b/orchestrate/skills/orchestrate/scripts/cli/util.ts @@ -4,6 +4,10 @@ import { userInfo } from "node:os"; import { join, resolve } from "node:path"; import { createSlackAdapter } from "../adapters/index.ts"; +import { + SLACK_TOKEN_ENV, + slackTokenConfigured, +} from "../adapters/slack/client.ts"; import type { CommentCriticality, SlackAdapter } from "../adapters/types.ts"; import { AgentManager, type RespawnSource } from "../core/agent-manager.ts"; import type { CommentDestinations } from "../core/comment-retry-queue.ts"; @@ -37,8 +41,10 @@ export interface SpawnOptions { wait?: boolean; } -const SLACK_CHANNEL_REQUIRED_MESSAGE = - "set --slack-channel or SLACK_CHANNEL_ID, or unset SLACK_BOT_TOKEN to disable Slack"; +/** Channel env var. Prefixed so it can't collide with another tool's Slack app. */ +export const SLACK_CHANNEL_ENV = "ORCHESTRATE_SLACK_CHANNEL_ID"; + +const SLACK_CHANNEL_REQUIRED_MESSAGE = `set --slack-channel or ${SLACK_CHANNEL_ENV}, or unset ${SLACK_TOKEN_ENV} to disable Slack`; export interface TreeVictim { taskName: string; @@ -439,7 +445,7 @@ export function resolveSlackChannelOption( ): string | undefined { const flag = explicit?.trim(); if (flag) return flag; - const env = process.env.SLACK_CHANNEL_ID?.trim(); + const env = process.env[SLACK_CHANNEL_ENV]?.trim(); return env || undefined; } @@ -469,7 +475,7 @@ export function resolveWorkspaceSlackChannelOrBail(args: { } function requireSlackChannelIfTokenSet(channel: string | undefined): void { - if (process.env.SLACK_BOT_TOKEN && !channel) { + if (slackTokenConfigured() && !channel) { throw new PlanValidationError(SLACK_CHANNEL_REQUIRED_MESSAGE); } } @@ -565,7 +571,7 @@ export function loadAndonTargetOrBail(opts: { workspace?: string }): { const slack = createSlackAdapter(plan.slackKickoffRef.channel); if (!slack) { throw new PlanValidationError( - "set SLACK_BOT_TOKEN before running andon commands" + `set ${SLACK_TOKEN_ENV} before running andon commands` ); } return { slack, ref: plan.slackKickoffRef }; diff --git a/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts b/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts index 32ce7199..f19c578c 100644 --- a/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts +++ b/orchestrate/skills/orchestrate/scripts/core/agent-manager.ts @@ -18,7 +18,10 @@ import type { SDKMessage, } from "@cursor/sdk"; import { createSlackAdapter } from "../adapters/index.ts"; -import { createSlackWebClient } from "../adapters/slack/client.ts"; +import { + createSlackWebClient, + slackTokenConfigured, +} from "../adapters/slack/client.ts"; import type { SlackAdapter, TaskStatus } from "../adapters/types.ts"; import { PlanValidationError } from "../errors.ts"; import type { @@ -1648,7 +1651,7 @@ function planSlackChannel(plan: Plan): string | undefined { } function slackAdapterForPlan(plan: Plan): SlackAdapter | undefined { - // SLACK_BOT_TOKEN missing is signalled once via console.error inside + // A missing token is signalled once via console.error inside // createSlackWebClient. Don't double-log to attention.log: env config // belongs to the operator surface, not workspace-visible state. const channel = planSlackChannel(plan); @@ -1656,7 +1659,7 @@ function slackAdapterForPlan(plan: Plan): SlackAdapter | undefined { // Surface the missing-token signal even when the channel is also unset, // so an operator who expected Slack visibility sees a single console line // explaining why it's off. - if (!process.env.SLACK_BOT_TOKEN) createSlackWebClient(); + if (!slackTokenConfigured()) createSlackWebClient(); return undefined; } return createSlackAdapter(channel); diff --git a/orchestrate/skills/orchestrate/scripts/core/comment-retry-queue.ts b/orchestrate/skills/orchestrate/scripts/core/comment-retry-queue.ts index f9a3b3bf..7aa6692f 100644 --- a/orchestrate/skills/orchestrate/scripts/core/comment-retry-queue.ts +++ b/orchestrate/skills/orchestrate/scripts/core/comment-retry-queue.ts @@ -8,6 +8,7 @@ import { } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { SLACK_TOKEN_ENV } from "../adapters/slack/client.ts"; import type { CommentCriticality, SlackAdapter } from "../adapters/types.ts"; const COMMENT_RETRY_BACKOFF_MS = [1_000, 5_000, 30_000, 300_000, 1_800_000]; @@ -266,7 +267,7 @@ async function postComment(args: { } if (!args.destinations.slack) { throw new Error( - "Slack destination requested but SLACK_BOT_TOKEN is not set" + `Slack destination requested but ${SLACK_TOKEN_ENV} is not set` ); } const target = parseSlackDestination(args.destination); diff --git a/orchestrate/skills/orchestrate/scripts/models.ts b/orchestrate/skills/orchestrate/scripts/models.ts index de71c926..9db98506 100644 --- a/orchestrate/skills/orchestrate/scripts/models.ts +++ b/orchestrate/skills/orchestrate/scripts/models.ts @@ -1,22 +1,22 @@ 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. @@ -164,32 +164,67 @@ export const MODEL_CATALOG: ModelProfile[] = [ }, ]; +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. + */ +export function assertModelEnvConfig(): void { + 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 index dc77a59d..fa125c30 100644 --- a/orchestrate/skills/orchestrate/scripts/schemas.ts +++ b/orchestrate/skills/orchestrate/scripts/schemas.ts @@ -242,7 +242,7 @@ const PlanObjectSchema = z slackChannel: nonEmptyStringSchema .optional() .describe( - "Slack channel id for run visibility. Set from --slack-channel or SLACK_CHANNEL_ID by kickoff or the first root run." + "Slack channel id for run visibility. Set from --slack-channel or ORCHESTRATE_SLACK_CHANNEL_ID by kickoff or the first root run." ), slackKickoffRef: z .object({ @@ -500,6 +500,38 @@ export const StopResultSchema = z.discriminatedUnion("action", [ 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() })) + .optional() + .describe("Model parameters, e.g. reasoning, effort, thinking, fast."), + }) + .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." + ), +}); + +export const ModelCatalogSchema = z.array(ModelProfileSchema); + const TreeTaskSchema = z .object({ name: taskNameSchema, @@ -534,6 +566,7 @@ export type SpawnResult = z.infer; 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 +610,19 @@ export function parsePlanTaskValue(value: unknown, source: string): PlanTask { }); } +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 index 35c5fd84..cc728a7c 100644 --- 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 { fileURLToPath } from "node:url"; 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 @@ writeSchema({ "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 index 7b172690..2e0decd2 100644 --- 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 @@ export async function probeModelCatalog( agentApi?: ProbeAgentApi; } = {} ): Promise { - const catalog = opts.catalog ?? MODEL_CATALOG; + const catalog = opts.catalog ?? effectiveModelCatalog(); const agentApi = opts.agentApi ?? Agent; const results: ProbeResult[] = []; for (const profile of catalog) {