Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions orchestrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Team service-account keys (Team Settings → Service accounts) also work for bot

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

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

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

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

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

## Install

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

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

## Core principles
Expand Down
4 changes: 2 additions & 2 deletions orchestrate/skills/orchestrate/references/dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ bun cli.ts kickoff "<goal>" [--repo <url>] [--ref main] [--model claude-opus-4-8

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.
The CLI reads `CURSOR_API_KEY`, auto-detects the repo from `git config --get remote.origin.url`, builds the spawn prompt, spawns via `cursor-sdk`, and prints `{ agentId, runId, status, url, dispatcherFirstName }` JSON. Slack is optional. If `ORCHESTRATE_SLACK_BOT_TOKEN` is set, also pass `--slack-channel <id>` or set `ORCHESTRATE_SLACK_CHANNEL_ID`; otherwise kickoff fails before spawning. If the token is unset, Slack stays disabled.

## Dispatcher identity

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

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

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

Regenerate `schemas/*.json` from `scripts/schemas.ts` with `bun run generate-schemas` in `scripts/` after plan or state shape changes.

Slack visibility uses `SLACK_BOT_TOKEN`. Required scopes:
Slack visibility uses `ORCHESTRATE_SLACK_BOT_TOKEN`. Required scopes:

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

`summary` is for the human in the Slack thread; `goal` is the agent's full context. Kickoff falls back to a truncated `goal` when `summary` is unset.

On the first `run --root`, the script uses `plan.slackChannel` for the Slack kickoff and writes `plan.slackKickoffRef`. The root plan gets `slackChannel` from `kickoff --slack-channel`, `run --root --slack-channel`, or `SLACK_CHANNEL_ID`. Subplanners inherit both fields so the whole tree mirrors into one thread.
On the first `run --root`, the script uses `plan.slackChannel` for the Slack kickoff and writes `plan.slackKickoffRef`. The root plan gets `slackChannel` from `kickoff --slack-channel`, `run --root --slack-channel`, or `ORCHESTRATE_SLACK_CHANNEL_ID`. Subplanners inherit both fields so the whole tree mirrors into one thread.

Planning rules:

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

## Slack visibility

When `SLACK_BOT_TOKEN` is set, Slack traffic is owned by the script. Agents do not drive lifecycle. The script posts the kickoff thread to `plan.slackChannel`, records the result in `plan.slackKickoffRef`, mirrors task status messages in that thread, and reads `:rotating_light:` on the kickoff message for Andon.
When `ORCHESTRATE_SLACK_BOT_TOKEN` is set, Slack traffic is owned by the script. Agents do not drive lifecycle. The script posts the kickoff thread to `plan.slackChannel`, records the result in `plan.slackKickoffRef`, mirrors task status messages in that thread, and reads `:rotating_light:` on the kickoff message for Andon.

Spawn prompts still include a Slack block because workers may need to leave notes:

Expand Down
2 changes: 1 addition & 1 deletion orchestrate/skills/orchestrate/schemas/plan.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"slackChannel": {
"type": "string",
"minLength": 1,
"description": "Slack channel id for run visibility. Set from --slack-channel or SLACK_CHANNEL_ID by kickoff or the first root run."
"description": "Slack channel id for run visibility. Set from --slack-channel or ORCHESTRATE_SLACK_CHANNEL_ID by kickoff or the first root run."
},
"slackKickoffRef": {
"type": "object",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ installSlackWebApiMock();
const { AgentManager } = await import("../core/agent-manager.ts");

const ORIGINAL_API_KEY = process.env.CURSOR_API_KEY;
const ORIGINAL_SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const ORIGINAL_SLACK_TOKEN = process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
process.env.CURSOR_API_KEY = "test-key";
process.env.SLACK_BOT_TOKEN = "xoxb-test";
process.env.ORCHESTRATE_SLACK_BOT_TOKEN = "xoxb-test";

afterAll(() => {
if (ORIGINAL_API_KEY === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = ORIGINAL_API_KEY;
if (ORIGINAL_SLACK_TOKEN === undefined) {
delete process.env.SLACK_BOT_TOKEN;
delete process.env.ORCHESTRATE_SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
process.env.ORCHESTRATE_SLACK_BOT_TOKEN = ORIGINAL_SLACK_TOKEN;
}
});

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

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

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

Expand Down Expand Up @@ -73,7 +73,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down Expand Up @@ -130,7 +130,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down Expand Up @@ -187,7 +187,7 @@ describe("comment CLI", () => {
encoding: "utf8",
env: {
...process.env,
SLACK_BOT_TOKEN: "xoxb-test",
ORCHESTRATE_SLACK_BOT_TOKEN: "xoxb-test",
},
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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([]);
});
});
17 changes: 15 additions & 2 deletions orchestrate/skills/orchestrate/scripts/adapters/slack/client.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion orchestrate/skills/orchestrate/scripts/cli/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down
4 changes: 2 additions & 2 deletions orchestrate/skills/orchestrate/scripts/cli/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function registerTaskCommands(program: Command): void {
)
.option(
"--slack-channel <id>",
"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 <number>",
Expand Down Expand Up @@ -115,7 +115,7 @@ export function registerTaskCommands(program: Command): void {
)
.option(
"--slack-channel <id>",
"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 <name>",
Expand Down
Loading