@tablation/crew 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,9 +7,9 @@ import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
8
8
 
9
9
  // src/config.ts
10
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
11
- import { resolve, dirname as dirname2, isAbsolute, join as join3, basename as basename2 } from "path";
12
- import { homedir, tmpdir } from "os";
10
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
11
+ import { resolve, dirname as dirname2, isAbsolute, join as join4, basename as basename2 } from "path";
12
+ import { homedir as homedir2, tmpdir } from "os";
13
13
  import { parse as parse2 } from "yaml";
14
14
 
15
15
  // src/events.ts
@@ -157,6 +157,261 @@ var Emitter = class _Emitter {
157
157
  };
158
158
  var eventFileFor = (stateDir) => join(stateDir, "events.jsonl");
159
159
 
160
+ // src/stream.ts
161
+ function toolTarget(name, input) {
162
+ if (!input) return void 0;
163
+ for (const key of ["file_path", "path", "notebook_path", "url", "pattern"]) {
164
+ const v = input[key];
165
+ if (typeof v === "string" && v) return v;
166
+ }
167
+ if (name === "Bash" && typeof input.command === "string") {
168
+ return input.command.trim().split(/\s+/)[0];
169
+ }
170
+ return void 0;
171
+ }
172
+ function mapStreamLine(line) {
173
+ const obj = line;
174
+ if (!obj || obj.type !== "assistant") return [];
175
+ const content = obj.message?.content;
176
+ if (!Array.isArray(content)) return [];
177
+ const out = [];
178
+ for (const block of content) {
179
+ if (block.type === "thinking") out.push({ kind: "thought", text: block.thinking });
180
+ else if (block.type === "text") out.push({ kind: "text", text: block.text });
181
+ else if (block.type === "tool_use") {
182
+ out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input) });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ function extractTurnTokens(line) {
188
+ const obj = line;
189
+ if (!obj || obj.type !== "assistant") return void 0;
190
+ const usage2 = obj.message?.usage;
191
+ if (!usage2) return void 0;
192
+ return (usage2.input_tokens ?? 0) + (usage2.output_tokens ?? 0) + (usage2.cache_read_input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
193
+ }
194
+ function extractResult(line) {
195
+ const obj = line;
196
+ if (!obj || obj.type !== "result") return void 0;
197
+ return {
198
+ sessionId: obj.session_id,
199
+ numTurns: obj.num_turns,
200
+ totalCostUsd: obj.total_cost_usd,
201
+ inputTokens: obj.usage?.input_tokens,
202
+ outputTokens: obj.usage?.output_tokens,
203
+ cacheReadTokens: obj.usage?.cache_read_input_tokens,
204
+ cacheCreationTokens: obj.usage?.cache_creation_input_tokens
205
+ };
206
+ }
207
+
208
+ // src/claude-mcp.ts
209
+ import { spawnSync } from "child_process";
210
+ import { readFileSync as readFileSync2 } from "fs";
211
+ import { homedir } from "os";
212
+ import { join as join2 } from "path";
213
+ var MCP_SERVER_NAME = "tablation";
214
+ var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
215
+ function mcpUrlFor(baseUrl) {
216
+ return `${baseUrl.replace(/\/+$/, "")}/api/mcp`;
217
+ }
218
+ function runClaude(bin, args) {
219
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 3e4 });
220
+ const missing = r.error?.code === "ENOENT";
221
+ return { status: r.status, missing, stderr: r.stderr ?? "" };
222
+ }
223
+ function userScopeEntry(home) {
224
+ try {
225
+ const j = JSON.parse(readFileSync2(join2(home, ".claude.json"), "utf8"));
226
+ return j.mcpServers?.[MCP_SERVER_NAME];
227
+ } catch {
228
+ return void 0;
229
+ }
230
+ }
231
+ function inspectClaudeMcp(o) {
232
+ const got = runClaude(o.bin, ["mcp", "get", MCP_SERVER_NAME]);
233
+ if (got.missing) return { state: "no-claude" };
234
+ if (got.status !== 0) return { state: "missing" };
235
+ const entry = userScopeEntry(o.home ?? homedir());
236
+ if (!entry) {
237
+ return { state: "different", detail: `a "${MCP_SERVER_NAME}" server exists, but not in user scope (~/.claude.json) \u2014 a project or local registration` };
238
+ }
239
+ if (entry.url !== o.url) return { state: "different", detail: `it points at ${entry.url ?? "(no url)"}, not ${o.url}` };
240
+ const auth = Object.entries(entry.headers ?? {}).find(([k]) => k.toLowerCase() === "authorization")?.[1];
241
+ if (auth !== `Bearer ${o.key}`) return { state: "different", detail: `it is registered with a different key than this route's` };
242
+ return { state: "registered", url: o.url };
243
+ }
244
+ function ensureClaudeMcp(o) {
245
+ const manual = `claude mcp add --transport http ${MCP_SERVER_NAME} ${o.url} --header "Authorization: Bearer <key>" --scope user`;
246
+ const st = inspectClaudeMcp(o);
247
+ if (st.state === "no-claude") return `mcp: \`${o.bin}\` not found, so Claude Code was not configured. To do it by hand: ${manual}`;
248
+ if (st.state === "registered") return `mcp: ${MCP_SERVER_NAME} already registered with Claude Code \u2192 ${st.url}`;
249
+ if (st.state === "different" && !o.replace) {
250
+ return `mcp: left Claude Code's "${MCP_SERVER_NAME}" server alone \u2014 ${st.detail}. Re-run with --mcp replace to overwrite it.`;
251
+ }
252
+ if (o.dryRun) return `(dry run) would register the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url}`;
253
+ if (st.state === "different") {
254
+ runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
255
+ }
256
+ const add = runClaude(o.bin, [
257
+ "mcp",
258
+ "add",
259
+ "--transport",
260
+ "http",
261
+ MCP_SERVER_NAME,
262
+ o.url,
263
+ "--header",
264
+ `Authorization: Bearer ${o.key}`,
265
+ "--scope",
266
+ "user"
267
+ ]);
268
+ if (add.status !== 0) {
269
+ return `mcp: \`claude mcp add\` failed (${redact(add.stderr.trim(), o.key) || `exit ${add.status}`}). To do it by hand: ${manual}`;
270
+ }
271
+ return `mcp: registered the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url} (user scope, ~/.claude.json)`;
272
+ }
273
+
274
+ // src/capability.ts
275
+ function profileFor(role) {
276
+ if (role === "triage") return { level: "read", designTools: false };
277
+ if (role === "qa") return { level: "write", designTools: false };
278
+ return { level: "edit", designTools: role === "design" };
279
+ }
280
+
281
+ // src/adapters/claude.ts
282
+ var BILLING_VARS_TO_UNSET = [
283
+ "ANTHROPIC_API_KEY",
284
+ "ANTHROPIC_AUTH_TOKEN",
285
+ "CLAUDE_CODE_USE_VERTEX",
286
+ "ANTHROPIC_VERTEX_PROJECT_ID",
287
+ "ANTHROPIC_VERTEX_REGION",
288
+ "CLOUD_ML_REGION",
289
+ "ANTHROPIC_MODEL"
290
+ ];
291
+ var DISALLOWED_TOOLS = [
292
+ "Bash(rm -rf:*)",
293
+ "Bash(rm -fr:*)",
294
+ "Bash(git push --force*)",
295
+ "Bash(git push -f*)",
296
+ "Bash(curl*| sh)",
297
+ "Bash(curl*|sh)",
298
+ "Bash(curl*| bash)",
299
+ "Bash(curl*|bash)",
300
+ "Bash(sudo:*)"
301
+ ];
302
+ function claudeToolsFor(p) {
303
+ const tablation = "mcp__tablation__*";
304
+ if (p.level === "read") return ["Bash", "Read", tablation];
305
+ if (p.level === "write") return ["Bash", "Read", "Write", "Grep", "Glob", tablation];
306
+ const base = ["Bash", "Read", "Edit", "Write", "Grep", "Glob", tablation];
307
+ return p.designTools ? [...base, "Skill", "Artifact"] : base;
308
+ }
309
+ function allowedTools(role) {
310
+ return claudeToolsFor(profileFor(role));
311
+ }
312
+ var claudeAdapter = {
313
+ harness: "claude",
314
+ displayName: "Claude Code",
315
+ defaultBin: "claude",
316
+ defaultModel: "claude-sonnet-5",
317
+ defaultTiers: {
318
+ light: { model: "claude-haiku-4-5-20251001" },
319
+ standard: { model: "claude-sonnet-5-5" },
320
+ deep: { model: "claude-opus-5-5" }
321
+ },
322
+ limitations: [
323
+ "Bash is not sandboxed: the deny list refuses known destructive patterns, but a one-liner reaching the same end another way is not caught",
324
+ "the write profile (QA) withholds the Edit tool, not file writes \u2014 Bash can still write files",
325
+ "git push is not blocked, only forced pushes"
326
+ ],
327
+ invocation(spec, o) {
328
+ const args = [
329
+ "-p",
330
+ "--allowedTools",
331
+ ...allowedTools(o.role),
332
+ "--disallowedTools",
333
+ ...DISALLOWED_TOOLS,
334
+ "--model",
335
+ o.model,
336
+ "--output-format",
337
+ "stream-json",
338
+ "--verbose",
339
+ // Registers the tracker's own MCP tool surface for this run only —
340
+ // `${CREW_API_KEY}` is interpolated by the CLI from the environment,
341
+ // so the resolved key is never written to an argv string or a config
342
+ // file on disk. Skipped when there's no key to send: a route `crew
343
+ // connect` hasn't resolved yet has nothing to authenticate with.
344
+ ...o.mcp ? ["--mcp-config", JSON.stringify({
345
+ mcpServers: {
346
+ tablation: {
347
+ type: "http",
348
+ url: o.mcp.url,
349
+ headers: { Authorization: `Bearer \${${o.mcp.keyVar}}` }
350
+ }
351
+ }
352
+ })] : [],
353
+ ...spec.args
354
+ ];
355
+ return spec.launcher ? { bin: spec.launcher, args: [spec.bin, ...args] } : { bin: spec.bin, args };
356
+ },
357
+ runEnv(spec) {
358
+ return {
359
+ // Extended thinking is what makes the CLI emit `thinking` stream
360
+ // blocks at all — without it, Agent Log Cycles reporting has nothing
361
+ // to report (ISSUE-558).
362
+ ...spec.maxThinkingTokens > 0 ? { MAX_THINKING_TOKENS: String(spec.maxThinkingTokens) } : {},
363
+ ...spec.env
364
+ };
365
+ },
366
+ unsetEnv: BILLING_VARS_TO_UNSET,
367
+ mapStreamLine,
368
+ extractResult,
369
+ extractTurnTokens,
370
+ inspectMcp(o) {
371
+ const s = inspectClaudeMcp(o);
372
+ return s.state === "no-claude" ? { state: "no-binary" } : s;
373
+ },
374
+ ensureMcp: ensureClaudeMcp
375
+ };
376
+
377
+ // src/toolchain.ts
378
+ var TIERS = ["light", "standard", "deep"];
379
+ var ADAPTERS = {
380
+ [claudeAdapter.harness]: claudeAdapter
381
+ };
382
+ var HARNESS_NAMES = Object.keys(ADAPTERS);
383
+ function isHarness(name) {
384
+ return Object.hasOwn(ADAPTERS, name);
385
+ }
386
+ function adapterFor(harness) {
387
+ const a = Object.hasOwn(ADAPTERS, harness) ? ADAPTERS[harness] : void 0;
388
+ if (!a) throw new Error(`unknown harness "${harness}" (have: ${HARNESS_NAMES.join(", ")})`);
389
+ return a;
390
+ }
391
+ var LEGACY_TOOLCHAIN = "claude";
392
+ function shipToolchains(ship) {
393
+ const explicit = ship.toolchains ?? [];
394
+ if (explicit.some((t) => t.name === LEGACY_TOOLCHAIN)) return explicit;
395
+ const a = claudeAdapter;
396
+ const legacy = {
397
+ name: LEGACY_TOOLCHAIN,
398
+ harness: a.harness,
399
+ bin: ship.agent.bin,
400
+ model: ship.agent.model,
401
+ tiers: a.defaultTiers,
402
+ args: [],
403
+ env: {},
404
+ maxThinkingTokens: ship.agent.maxThinkingTokens
405
+ };
406
+ return [legacy, ...explicit];
407
+ }
408
+ function resolveToolchain(ship, route) {
409
+ const name = route?.toolchain ?? ship.toolchain ?? LEGACY_TOOLCHAIN;
410
+ const found = shipToolchains(ship).find((t) => t.name === name);
411
+ if (!found) throw new Error(`toolchain "${name}" is not defined (have: ${shipToolchains(ship).map((t) => t.name).join(", ")})`);
412
+ return found;
413
+ }
414
+
160
415
  // src/config.ts
161
416
  import { getSessionStore, hostFromUrl } from "@tablation/client";
162
417
 
@@ -197,8 +452,8 @@ function explain(ship, required) {
197
452
  }
198
453
 
199
454
  // src/repo-config.ts
200
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
201
- import { join as join2, basename } from "path";
455
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
456
+ import { join as join3, basename } from "path";
202
457
  import { parse } from "yaml";
203
458
  var SPEC_VERSION = 1;
204
459
  var REPO_CONFIG_FILENAMES = [".crew.yaml", ".crew.yml", ".crew.json"];
@@ -271,7 +526,7 @@ var MODES = ["local", "integrate", "ci_manual", "ci_auto", "external"];
271
526
  var PROVIDERS = ["github", "buildkite", "other", "none"];
272
527
  function findRepoConfig(dir) {
273
528
  for (const name of REPO_CONFIG_FILENAMES) {
274
- const p = join2(dir, name);
529
+ const p = join3(dir, name);
275
530
  if (existsSync2(p)) return p;
276
531
  }
277
532
  return null;
@@ -502,7 +757,7 @@ function parseRepoConfig(text, file) {
502
757
  function loadRepoConfig(dir) {
503
758
  const file = findRepoConfig(dir);
504
759
  if (!file) return null;
505
- return parseRepoConfig(readFileSync2(file, "utf8"), file);
760
+ return parseRepoConfig(readFileSync3(file, "utf8"), file);
506
761
  }
507
762
  function hookLabel(cfg, hook2) {
508
763
  return cfg.labels[hook2] ?? cfg.hooks[hook2] ?? hook2;
@@ -767,6 +1022,8 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
767
1022
  "name",
768
1023
  "platform",
769
1024
  "agent",
1025
+ "toolchains",
1026
+ "toolchain",
770
1027
  "shell",
771
1028
  "extraPath",
772
1029
  "useNvm",
@@ -806,7 +1063,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
806
1063
  "contract",
807
1064
  "resolved",
808
1065
  "promptSet",
809
- "hostPassengers"
1066
+ "hostPassengers",
1067
+ "toolchain"
810
1068
  ]);
811
1069
  function parseWorktreeName(v, where, file) {
812
1070
  const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
@@ -910,16 +1168,16 @@ function mergeRouteRelease(route, override) {
910
1168
  };
911
1169
  }
912
1170
  function expand(p, base) {
913
- let out = p.startsWith("~") ? p.replace(/^~/, homedir()) : p;
914
- out = out.replace(/\$\{?HOME\}?/g, homedir());
1171
+ let out = p.startsWith("~") ? p.replace(/^~/, homedir2()) : p;
1172
+ out = out.replace(/\$\{?HOME\}?/g, homedir2());
915
1173
  return isAbsolute(out) ? out : resolve(base, out);
916
1174
  }
917
1175
  function resolvePromptsDir(promptSet, crewHome, base) {
918
- if (!promptSet) return join3(crewHome, "prompts", "default");
1176
+ if (!promptSet) return join4(crewHome, "prompts", "default");
919
1177
  if (promptSet.includes("/") || promptSet.startsWith(".") || promptSet.startsWith("~")) {
920
1178
  return expand(promptSet, base);
921
1179
  }
922
- return join3(crewHome, "prompts", promptSet);
1180
+ return join4(crewHome, "prompts", promptSet);
923
1181
  }
924
1182
  var Missing = class {
925
1183
  paths = [];
@@ -943,7 +1201,7 @@ var Missing = class {
943
1201
  };
944
1202
  function crewVersion(crewHome) {
945
1203
  try {
946
- const pkg = JSON.parse(readFileSync3(join3(crewHome, "package.json"), "utf8"));
1204
+ const pkg = JSON.parse(readFileSync4(join4(crewHome, "package.json"), "utf8"));
947
1205
  return pkg.version ?? "0.0.0";
948
1206
  } catch {
949
1207
  return "0.0.0";
@@ -953,8 +1211,8 @@ function configSearchPath(crewHome) {
953
1211
  if (process.env.CREW_CONFIG) return [resolve(process.env.CREW_CONFIG)];
954
1212
  const xdg = process.env.XDG_CONFIG_HOME;
955
1213
  return [
956
- ...xdg ? [join3(xdg, "crew", "crew.yaml")] : [],
957
- join3(homedir(), ".config", "crew", "crew.yaml"),
1214
+ ...xdg ? [join4(xdg, "crew", "crew.yaml")] : [],
1215
+ join4(homedir2(), ".config", "crew", "crew.yaml"),
958
1216
  resolve(crewHome, "crew.yaml")
959
1217
  ];
960
1218
  }
@@ -962,17 +1220,17 @@ function findConfigFile(crewHome) {
962
1220
  const candidates = configSearchPath(crewHome);
963
1221
  return candidates.find((c) => existsSync3(c)) ?? candidates[candidates.length - 1];
964
1222
  }
965
- var defaultConfigPath = () => join3(process.env.XDG_CONFIG_HOME ?? join3(homedir(), ".config"), "crew", "crew.yaml");
1223
+ var defaultConfigPath = () => join4(process.env.XDG_CONFIG_HOME ?? join4(homedir2(), ".config"), "crew", "crew.yaml");
966
1224
  function routeSlug(route) {
967
1225
  return route.replace(/\//g, "-");
968
1226
  }
969
1227
  function resolvedPathFor(stateDir, route) {
970
1228
  const [workspace, project] = splitRoute(route);
971
- return join3(stateDir, "resolved", workspace, `${project}.json`);
1229
+ return join4(stateDir, "resolved", workspace, `${project}.json`);
972
1230
  }
973
1231
  function apiKeyPathFor(stateDir, route) {
974
1232
  const [workspace, project] = splitRoute(route);
975
- return join3(stateDir, "keys", workspace, `${project}.env`);
1233
+ return join4(stateDir, "keys", workspace, `${project}.env`);
976
1234
  }
977
1235
  function splitRoute(route) {
978
1236
  const parts = route.split("/");
@@ -985,7 +1243,7 @@ function readResolvedFile(stateDir, route) {
985
1243
  const path = resolvedPathFor(stateDir, route);
986
1244
  if (!existsSync3(path)) return void 0;
987
1245
  try {
988
- return JSON.parse(readFileSync3(path, "utf8"));
1246
+ return JSON.parse(readFileSync4(path, "utf8"));
989
1247
  } catch (e) {
990
1248
  throw new ConfigError(`${path}: not valid JSON \u2014 ${e.message}`);
991
1249
  }
@@ -1047,12 +1305,12 @@ function loadConfig(crewHome, configFile, opts = {}) {
1047
1305
  ` + configSearchPath(crewHome).map((c) => ` - ${c}`).join("\n") + `
1048
1306
 
1049
1307
  Create the first of those:
1050
- mkdir -p ${dirname2(defaultConfigPath())} && cp ${join3(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
1308
+ mkdir -p ${dirname2(defaultConfigPath())} && cp ${join4(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
1051
1309
  );
1052
1310
  }
1053
1311
  let raw;
1054
1312
  try {
1055
- raw = parse2(opts.text ?? readFileSync3(file, "utf8"));
1313
+ raw = parse2(opts.text ?? readFileSync4(file, "utf8"));
1056
1314
  } catch (e) {
1057
1315
  throw new ConfigError(`${file}: not valid YAML \u2014 ${e.message}`);
1058
1316
  }
@@ -1082,6 +1340,12 @@ Create the first of those:
1082
1340
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
1083
1341
  missing.add(`ship.agent.maxThinkingTokens must be a non-negative integer (got "${shipRaw.agent?.maxThinkingTokens}")`);
1084
1342
  }
1343
+ const toolchains = parseToolchains(shipRaw.toolchains, base, missing, file);
1344
+ const toolchainNames = /* @__PURE__ */ new Set([LEGACY_TOOLCHAIN, ...toolchains.map((x) => x.name)]);
1345
+ const shipToolchain = shipRaw.toolchain === void 0 ? void 0 : String(shipRaw.toolchain);
1346
+ if (shipToolchain !== void 0 && !toolchainNames.has(shipToolchain)) {
1347
+ missing.add(`ship.toolchain "${shipToolchain}" is not defined (have: ${[...toolchainNames].join(", ")})`);
1348
+ }
1085
1349
  const stateDir = expand(shipRaw.stateDir ?? "state", base);
1086
1350
  const routesRaw = raw.routes;
1087
1351
  if (!opts.allowNoRoutes && (!Array.isArray(routesRaw) || routesRaw.length === 0)) {
@@ -1092,7 +1356,7 @@ Create the first of those:
1092
1356
  const where = `routes[${i}]`;
1093
1357
  const routeMissing = new Missing();
1094
1358
  try {
1095
- return [parseOneRoute(c, where, file, base, stateDir, raw, routeMissing, crewHome)];
1359
+ return [parseOneRoute(c, where, file, base, stateDir, raw, routeMissing, crewHome, toolchainNames)];
1096
1360
  } catch (e) {
1097
1361
  const label = typeof c?.route === "string" && c.route ? `"${c.route}"` : where;
1098
1362
  routeWarnings.push(`route ${label} dropped \u2014 ${e.message}`);
@@ -1115,12 +1379,14 @@ Create the first of those:
1115
1379
  model: shipRaw.agent?.model ?? "claude-sonnet-5",
1116
1380
  maxThinkingTokens
1117
1381
  },
1382
+ ...toolchains.length > 0 ? { toolchains } : {},
1383
+ ...shipToolchain !== void 0 ? { toolchain: shipToolchain } : {},
1118
1384
  shell: shipRaw.shell,
1119
1385
  extraPath: shipRaw.extraPath,
1120
1386
  useNvm: shipRaw.useNvm !== false,
1121
1387
  nvmSh: shipRaw.nvmSh,
1122
1388
  stateDir,
1123
- logFile: expand(shipRaw.logFile ?? join3(tmpdir(), "crew.log"), base),
1389
+ logFile: expand(shipRaw.logFile ?? join4(tmpdir(), "crew.log"), base),
1124
1390
  userAgent: shipRaw.userAgent ?? `Mozilla/5.0 CrewAgent/${crewVersion(crewHome)}`,
1125
1391
  maxConcurrentAgents,
1126
1392
  streamRetentionDays,
@@ -1138,8 +1404,70 @@ Create the first of those:
1138
1404
  warnings: routeWarnings
1139
1405
  };
1140
1406
  }
1141
- function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
1407
+ function expandCommand(cmd, base) {
1408
+ return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
1409
+ }
1410
+ var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "model", "tiers", "args", "env", "maxThinkingTokens"]);
1411
+ function parseToolchains(raw, base, missing, file) {
1412
+ if (raw === void 0 || raw === null) return [];
1413
+ if (typeof raw !== "object" || Array.isArray(raw)) {
1414
+ missing.add("ship.toolchains must be a mapping of name \u2192 toolchain");
1415
+ return [];
1416
+ }
1417
+ const out = [];
1418
+ for (const [name, entryRaw] of Object.entries(raw)) {
1419
+ const where = `ship.toolchains.${name}`;
1420
+ if (!entryRaw || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
1421
+ missing.add(`${where} must be a mapping`);
1422
+ continue;
1423
+ }
1424
+ rejectUnknownKeys(entryRaw, TOOLCHAIN_KEYS, where, file);
1425
+ const harness = String(entryRaw.harness ?? "");
1426
+ if (!isHarness(harness)) {
1427
+ missing.add(`${where}.harness must be one of ${HARNESS_NAMES.join("|")} (got "${harness}")`);
1428
+ continue;
1429
+ }
1430
+ const adapter = adapterFor(harness);
1431
+ const tiers = { ...adapter.defaultTiers };
1432
+ for (const [tier, v] of Object.entries(entryRaw.tiers ?? {})) {
1433
+ if (!TIERS.includes(tier)) {
1434
+ missing.add(`${where}.tiers.${tier} is not a tier (have: ${TIERS.join(", ")})`);
1435
+ continue;
1436
+ }
1437
+ if (!v || typeof v.model !== "string" || !v.model) {
1438
+ missing.add(`${where}.tiers.${tier}.model is required`);
1439
+ continue;
1440
+ }
1441
+ tiers[tier] = { model: v.model, ...v.reasoning !== void 0 ? { reasoning: String(v.reasoning) } : {} };
1442
+ }
1443
+ const args = entryRaw.args ?? [];
1444
+ if (!Array.isArray(args)) missing.add(`${where}.args must be a list of strings`);
1445
+ const env = entryRaw.env ?? {};
1446
+ if (typeof env !== "object" || Array.isArray(env)) missing.add(`${where}.env must be a mapping of name \u2192 value`);
1447
+ const maxThinkingTokens = entryRaw.maxThinkingTokens === void 0 ? DEFAULT_MAX_THINKING_TOKENS : Number(entryRaw.maxThinkingTokens);
1448
+ if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
1449
+ missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
1450
+ }
1451
+ out.push({
1452
+ name,
1453
+ harness,
1454
+ bin: expandCommand(String(entryRaw.bin ?? adapter.defaultBin), base),
1455
+ ...entryRaw.launcher ? { launcher: expandCommand(String(entryRaw.launcher), base) } : {},
1456
+ model: String(entryRaw.model ?? tiers.standard?.model ?? adapter.defaultModel),
1457
+ tiers,
1458
+ args: Array.isArray(args) ? args.map(String) : [],
1459
+ env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
1460
+ maxThinkingTokens
1461
+ });
1462
+ }
1463
+ return out;
1464
+ }
1465
+ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
1142
1466
  rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
1467
+ const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
1468
+ if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
1469
+ missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
1470
+ }
1143
1471
  const route = missing.req(c?.route, `${where}.route`);
1144
1472
  const routeParts = route ? route.split("/") : [];
1145
1473
  const routeWellFormed = routeParts.length === 2 && routeParts.every(Boolean);
@@ -1188,6 +1516,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
1188
1516
  reposBasePath: expand(String(c?.reposBasePath ?? raw.ship?.reposBasePath ?? DEFAULT_REPOS_BASE_PATH), base),
1189
1517
  repoOverrides,
1190
1518
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
1519
+ ...toolchain !== void 0 ? { toolchain } : {},
1191
1520
  promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
1192
1521
  weight,
1193
1522
  baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -1256,7 +1585,7 @@ function repoIdForName(r, name) {
1256
1585
  function resolveApiKey(r) {
1257
1586
  if (r.apiKey) return r.apiKey;
1258
1587
  if (r.apiKeyFile && r.apiKeyVar && existsSync3(r.apiKeyFile)) {
1259
- for (const line of readFileSync3(r.apiKeyFile, "utf8").split("\n")) {
1588
+ for (const line of readFileSync4(r.apiKeyFile, "utf8").split("\n")) {
1260
1589
  const m = line.match(new RegExp(`^${r.apiKeyVar}=(.*)$`));
1261
1590
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1262
1591
  }
@@ -1319,7 +1648,7 @@ function configuredMembers(rt) {
1319
1648
  return out;
1320
1649
  }
1321
1650
  function defaultRepoDir(basePath, workspace, repoName) {
1322
- return join3(basePath, workspace, repoName);
1651
+ return join4(basePath, workspace, repoName);
1323
1652
  }
1324
1653
  function reposOf(r) {
1325
1654
  const named = Object.entries(r.repos);
@@ -1343,7 +1672,7 @@ function passengerRepoTargets(r) {
1343
1672
  return reposOf(r).filter((t) => r.repoOverrides[t.name]?.hostPassengers !== false);
1344
1673
  }
1345
1674
  function passengerCheckoutDir(basePath, workspace, repoName) {
1346
- return join3(basePath, workspace, ".passenger-checkouts", repoName);
1675
+ return join4(basePath, workspace, ".passenger-checkouts", repoName);
1347
1676
  }
1348
1677
  function passengerCheckoutTargets(r) {
1349
1678
  if (!r.hostPassengers) return [];
@@ -1475,8 +1804,8 @@ function rosterMarkdown(roster, meId) {
1475
1804
  }
1476
1805
 
1477
1806
  // src/state.ts
1478
- import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
1479
- import { join as join4 } from "path";
1807
+ import { readFileSync as readFileSync5, writeFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
1808
+ import { join as join5 } from "path";
1480
1809
  var EPOCH = "1970-01-01T00:00:00.000Z";
1481
1810
  var State = class _State {
1482
1811
  dir;
@@ -1485,7 +1814,7 @@ var State = class _State {
1485
1814
  mkdirSync2(dir, { recursive: true });
1486
1815
  }
1487
1816
  path(...parts) {
1488
- return join4(this.dir, ...parts);
1817
+ return join5(this.dir, ...parts);
1489
1818
  }
1490
1819
  /**
1491
1820
  * The timestamp every "new since last poll" check is measured against.
@@ -1496,7 +1825,7 @@ var State = class _State {
1496
1825
  */
1497
1826
  watermark() {
1498
1827
  try {
1499
- const v = readFileSync4(this.path(".poll-watermark"), "utf8").trim();
1828
+ const v = readFileSync5(this.path(".poll-watermark"), "utf8").trim();
1500
1829
  return v || EPOCH;
1501
1830
  } catch {
1502
1831
  return EPOCH;
@@ -1582,7 +1911,7 @@ var State = class _State {
1582
1911
  const name = _State.safe(rawName);
1583
1912
  const file = this.path(`.${name}.lock`);
1584
1913
  try {
1585
- const existing = Number.parseInt(readFileSync4(file, "utf8").trim(), 10);
1914
+ const existing = Number.parseInt(readFileSync5(file, "utf8").trim(), 10);
1586
1915
  if (Number.isFinite(existing) && existing > 0) {
1587
1916
  try {
1588
1917
  process.kill(existing, 0);
@@ -1676,7 +2005,7 @@ var State = class _State {
1676
2005
  const heldReported = `.release-held-${suffix}`;
1677
2006
  const read = (f) => {
1678
2007
  try {
1679
- return readFileSync4(this.path(f), "utf8").trim() || null;
2008
+ return readFileSync5(this.path(f), "utf8").trim() || null;
1680
2009
  } catch {
1681
2010
  return null;
1682
2011
  }
@@ -1726,7 +2055,7 @@ var State = class _State {
1726
2055
  /** Whether the board was already told about this head's red gate. */
1727
2056
  heldReported: () => {
1728
2057
  try {
1729
- return readFileSync4(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
2058
+ return readFileSync5(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1730
2059
  } catch {
1731
2060
  return null;
1732
2061
  }
@@ -1763,7 +2092,7 @@ var State = class _State {
1763
2092
  const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
1764
2093
  const read = (f) => {
1765
2094
  try {
1766
- return readFileSync4(this.path(f), "utf8").trim() || null;
2095
+ return readFileSync5(this.path(f), "utf8").trim() || null;
1767
2096
  } catch {
1768
2097
  return null;
1769
2098
  }
@@ -1828,7 +2157,7 @@ var State = class _State {
1828
2157
  return {
1829
2158
  previous: () => {
1830
2159
  try {
1831
- const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
2160
+ const parsed = JSON.parse(readFileSync5(this.path(file), "utf8"));
1832
2161
  return parsed && typeof parsed === "object" ? parsed : {};
1833
2162
  } catch {
1834
2163
  return {};
@@ -1849,7 +2178,7 @@ var State = class _State {
1849
2178
  return {
1850
2179
  previous: () => {
1851
2180
  try {
1852
- const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
2181
+ const parsed = JSON.parse(readFileSync5(this.path(file), "utf8"));
1853
2182
  return Array.isArray(parsed) ? parsed : [];
1854
2183
  } catch {
1855
2184
  return [];
@@ -1882,7 +2211,7 @@ var State = class _State {
1882
2211
  }
1883
2212
  readInt(file) {
1884
2213
  try {
1885
- return Number.parseInt(readFileSync4(this.path(file), "utf8").trim(), 10) || 0;
2214
+ return Number.parseInt(readFileSync5(this.path(file), "utf8").trim(), 10) || 0;
1886
2215
  } catch {
1887
2216
  return 0;
1888
2217
  }
@@ -2186,10 +2515,10 @@ function describeRelease(o, route) {
2186
2515
  }
2187
2516
 
2188
2517
  // src/install.ts
2189
- import { dirname as dirname3, join as join5, extname } from "path";
2190
- import { homedir as homedir2 } from "os";
2191
- import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync3, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2192
- import { spawnSync } from "child_process";
2518
+ import { dirname as dirname3, join as join6, extname } from "path";
2519
+ import { homedir as homedir3 } from "os";
2520
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2521
+ import { spawnSync as spawnSync2 } from "child_process";
2193
2522
  import { createHash } from "crypto";
2194
2523
 
2195
2524
  // src/runtime-info.ts
@@ -2200,7 +2529,7 @@ function isCompiledBinary(moduleUrl) {
2200
2529
  // src/install.ts
2201
2530
  function crewInvocation(crewHome) {
2202
2531
  if (isCompiledBinary(import.meta.url)) return [process.execPath];
2203
- return [process.execPath, join5(crewHome, "bin", "crew")];
2532
+ return [process.execPath, join6(crewHome, "bin", "crew")];
2204
2533
  }
2205
2534
  var INTERVAL_SECONDS = 120;
2206
2535
  function labelFor(crewHome, job) {
@@ -2238,7 +2567,7 @@ function pathFor(ship) {
2238
2567
  return parts.join(":");
2239
2568
  }
2240
2569
  function findOnPath(bin, searchPath) {
2241
- const candidates = bin.includes("/") ? [bin] : searchPath.split(":").filter(Boolean).map((dir) => join5(dir, bin));
2570
+ const candidates = bin.includes("/") ? [bin] : searchPath.split(":").filter(Boolean).map((dir) => join6(dir, bin));
2242
2571
  for (const candidate of candidates) {
2243
2572
  try {
2244
2573
  accessSync(candidate, constants.X_OK);
@@ -2248,7 +2577,7 @@ function findOnPath(bin, searchPath) {
2248
2577
  }
2249
2578
  return void 0;
2250
2579
  }
2251
- var COMMON_DOCKER_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", join5(homedir2(), ".docker", "bin")].join(":");
2580
+ var COMMON_DOCKER_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", join6(homedir3(), ".docker", "bin")].join(":");
2252
2581
  function dockerPathProblem(ship, shellPath = process.env.PATH ?? "") {
2253
2582
  const schedulerPath = pathFor(ship);
2254
2583
  if (findOnPath("docker", schedulerPath)) return void 0;
@@ -2260,7 +2589,7 @@ function xmlEscape(s) {
2260
2589
  }
2261
2590
  function planLaunchd(ship, crewHome, job) {
2262
2591
  const label = labelFor(crewHome, job);
2263
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2592
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2264
2593
  const crewLog = ship.logFile;
2265
2594
  const schedulerLog = schedulerLogFor(crewLog, job);
2266
2595
  const persistent = job === "run";
@@ -2312,8 +2641,8 @@ ${scheduleKeys}
2312
2641
  }
2313
2642
  function planSystemd(ship, crewHome, job) {
2314
2643
  const label = labelFor(crewHome, job);
2315
- const unitDir = join5(homedir2(), ".config", "systemd", "user");
2316
- const servicePath = join5(unitDir, `${label}.service`);
2644
+ const unitDir = join6(homedir3(), ".config", "systemd", "user");
2645
+ const servicePath = join6(unitDir, `${label}.service`);
2317
2646
  const crewLog = ship.logFile;
2318
2647
  const schedulerLog = schedulerLogFor(crewLog, job);
2319
2648
  const description = job === "release" ? "Tablation crew \u2014 release" : job === "passengers" ? "Tablation crew \u2014 Host Passengers sync" : "Tablation crew \u2014 persistent supervisor loop";
@@ -2350,7 +2679,7 @@ WantedBy=default.target
2350
2679
  schedulerLog
2351
2680
  };
2352
2681
  }
2353
- const timerPath = join5(unitDir, `${label}.timer`);
2682
+ const timerPath = join6(unitDir, `${label}.timer`);
2354
2683
  const service = `# Written by \`crew install\` \u2014 re-run it rather than hand-editing this file.
2355
2684
  [Unit]
2356
2685
  Description=${description}
@@ -2417,7 +2746,7 @@ function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2417
2746
  function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2418
2747
  const label = labelFor(crewHome, job);
2419
2748
  if (hostShip === "macos") {
2420
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2749
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2421
2750
  return {
2422
2751
  mechanism: "launchd",
2423
2752
  unitPaths: [unitPath],
@@ -2427,18 +2756,18 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2427
2756
  };
2428
2757
  }
2429
2758
  if (hostShip === "linux" && hasSystemd) {
2430
- const unitDir = join5(homedir2(), ".config", "systemd", "user");
2759
+ const unitDir = join6(homedir3(), ".config", "systemd", "user");
2431
2760
  if (job === "run") {
2432
2761
  return {
2433
2762
  mechanism: "systemd",
2434
- unitPaths: [join5(unitDir, `${label}.service`)],
2763
+ unitPaths: [join6(unitDir, `${label}.service`)],
2435
2764
  crontabLine: null,
2436
2765
  unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.service`]]
2437
2766
  };
2438
2767
  }
2439
2768
  return {
2440
2769
  mechanism: "systemd",
2441
- unitPaths: [join5(unitDir, `${label}.service`), join5(unitDir, `${label}.timer`)],
2770
+ unitPaths: [join6(unitDir, `${label}.service`), join6(unitDir, `${label}.timer`)],
2442
2771
  crontabLine: null,
2443
2772
  unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.timer`]]
2444
2773
  };
@@ -2456,7 +2785,7 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2456
2785
  function planDaemonControl(crewHome, hostShip, hasSystemd) {
2457
2786
  const label = labelFor(crewHome, "run");
2458
2787
  if (hostShip === "macos") {
2459
- const unitPath = join5(homedir2(), "Library", "LaunchAgents", `${label}.plist`);
2788
+ const unitPath = join6(homedir3(), "Library", "LaunchAgents", `${label}.plist`);
2460
2789
  return {
2461
2790
  mechanism: "launchd",
2462
2791
  startCommand: ["launchctl", "load", unitPath],
@@ -2480,7 +2809,7 @@ function planDaemonControl(crewHome, hostShip, hasSystemd) {
2480
2809
  }
2481
2810
  function runAll(commands, cwd, log) {
2482
2811
  for (const [bin, ...args] of commands) {
2483
- const res = spawnSync(bin, args, { cwd, encoding: "utf8" });
2812
+ const res = spawnSync2(bin, args, { cwd, encoding: "utf8" });
2484
2813
  if (res.error || res.status !== 0) {
2485
2814
  log.warn(`${bin} ${args.join(" ")} exited ${res.status ?? "error"}`, {
2486
2815
  output: `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? String(res.error) : ""}`
@@ -2545,7 +2874,7 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2545
2874
  if (dryRun) {
2546
2875
  log.emit(`would run: launchctl remove ${plan.removeLabel}`);
2547
2876
  } else {
2548
- if (spawnSync("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
2877
+ if (spawnSync2("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
2549
2878
  log.emit(`removed loaded job ${plan.removeLabel}`);
2550
2879
  }
2551
2880
  }
@@ -2570,7 +2899,7 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2570
2899
  }
2571
2900
  var CREW_LAUNCHD_PREFIX = "com.tablation.crew";
2572
2901
  function launchAgentsDir() {
2573
- return join5(homedir2(), "Library", "LaunchAgents");
2902
+ return join6(homedir3(), "Library", "LaunchAgents");
2574
2903
  }
2575
2904
  function xmlUnescape(s) {
2576
2905
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
@@ -2582,17 +2911,17 @@ function programFromPlist(text) {
2582
2911
  return args.find((a) => a.endsWith("/bin/crew")) ?? args[0] ?? null;
2583
2912
  }
2584
2913
  function loadedCrewLabels() {
2585
- const list = spawnSync("launchctl", ["list"], { encoding: "utf8" });
2914
+ const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
2586
2915
  if (list.status !== 0) return [];
2587
2916
  return [...parseLaunchctlList(list.stdout ?? "").keys()].filter((l) => l.startsWith(CREW_LAUNCHD_PREFIX));
2588
2917
  }
2589
2918
  function listLaunchdUnits(dir = launchAgentsDir(), loaded = []) {
2590
2919
  const files = existsSync5(dir) ? readdirSync(dir) : [];
2591
2920
  const units = files.filter((f) => f.startsWith(CREW_LAUNCHD_PREFIX) && f.endsWith(".plist")).sort().map((f) => {
2592
- const plistPath = join5(dir, f);
2921
+ const plistPath = join6(dir, f);
2593
2922
  let program = null;
2594
2923
  try {
2595
- program = programFromPlist(readFileSync5(plistPath, "utf8"));
2924
+ program = programFromPlist(readFileSync6(plistPath, "utf8"));
2596
2925
  } catch {
2597
2926
  }
2598
2927
  return {
@@ -2605,7 +2934,7 @@ function listLaunchdUnits(dir = launchAgentsDir(), loaded = []) {
2605
2934
  const seen = new Set(units.map((u) => u.label));
2606
2935
  for (const label of [...loaded].sort()) {
2607
2936
  if (!label.startsWith(CREW_LAUNCHD_PREFIX) || seen.has(label)) continue;
2608
- units.push({ label, plistPath: join5(dir, `${label}.plist`), program: null, programExists: false, orphan: true });
2937
+ units.push({ label, plistPath: join6(dir, `${label}.plist`), program: null, programExists: false, orphan: true });
2609
2938
  }
2610
2939
  return units;
2611
2940
  }
@@ -2618,8 +2947,8 @@ function removeLaunchdUnit(unit, dryRun, log) {
2618
2947
  log.emit(`would unload and remove ${unit.label}`);
2619
2948
  return;
2620
2949
  }
2621
- if (existsSync5(unit.plistPath)) spawnSync("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
2622
- spawnSync("launchctl", ["remove", unit.label], { encoding: "utf8" });
2950
+ if (existsSync5(unit.plistPath)) spawnSync2("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
2951
+ spawnSync2("launchctl", ["remove", unit.label], { encoding: "utf8" });
2623
2952
  if (existsSync5(unit.plistPath)) unlinkSync(unit.plistPath);
2624
2953
  log.emit(`removed ${unit.label} (${unit.plistPath})`);
2625
2954
  }
@@ -2652,7 +2981,7 @@ function parseLaunchctlList(text) {
2652
2981
  return jobs;
2653
2982
  }
2654
2983
  function describeForeignLaunchdUnits(crewHome, dir) {
2655
- const list = spawnSync("launchctl", ["list"], { encoding: "utf8" });
2984
+ const list = spawnSync2("launchctl", ["list"], { encoding: "utf8" });
2656
2985
  const jobs = parseLaunchctlList(list.status === 0 ? list.stdout ?? "" : "");
2657
2986
  const foreign = foreignLaunchdUnits(crewHome, dir, jobs.keys());
2658
2987
  return foreign.map((u) => {
@@ -2665,11 +2994,11 @@ function describeForeignLaunchdUnits(crewHome, dir) {
2665
2994
  });
2666
2995
  }
2667
2996
  function readCrontab() {
2668
- const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
2997
+ const res = spawnSync2("crontab", ["-l"], { encoding: "utf8" });
2669
2998
  return res.status === 0 ? res.stdout ?? "" : "";
2670
2999
  }
2671
3000
  function writeCrontab(text) {
2672
- spawnSync("crontab", ["-"], { input: text, encoding: "utf8" });
3001
+ spawnSync2("crontab", ["-"], { input: text, encoding: "utf8" });
2673
3002
  }
2674
3003
  function addCrontabLine(marker, line) {
2675
3004
  const existing = readCrontab();
@@ -2693,7 +3022,7 @@ function removeCrontabLine(marker) {
2693
3022
  }
2694
3023
  function detectSystemd() {
2695
3024
  if (process.platform !== "linux") return false;
2696
- const res = spawnSync("systemctl", ["--user", "status"], { encoding: "utf8" });
3025
+ const res = spawnSync2("systemctl", ["--user", "status"], { encoding: "utf8" });
2697
3026
  return !res.error;
2698
3027
  }
2699
3028
 
@@ -4813,7 +5142,7 @@ function materializeBranchForTicket(dir, cfg, t, role) {
4813
5142
 
4814
5143
  // src/poll.ts
4815
5144
  import { writeFileSync as writeFileSync3 } from "fs";
4816
- import { join as join6 } from "path";
5145
+ import { join as join7 } from "path";
4817
5146
 
4818
5147
  // src/stalled.ts
4819
5148
  var STALLED_AFTER_CYCLES = 30;
@@ -5085,7 +5414,7 @@ async function decideCycle(o) {
5085
5414
  }
5086
5415
  return decision;
5087
5416
  }
5088
- var digestPath = (stateDir, route, role) => join6(stateDir, `digest-${route}-${role}.md`);
5417
+ var digestPath = (stateDir, route, role) => join7(stateDir, `digest-${route}-${role}.md`);
5089
5418
  function writeDigest(o, d, role, stateDir) {
5090
5419
  const me = o.route.resolved?.seats[role];
5091
5420
  if (!me) return false;
@@ -5455,9 +5784,9 @@ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id
5455
5784
 
5456
5785
  // src/agent.ts
5457
5786
  import { spawn as spawn2 } from "child_process";
5458
- import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5787
+ import { readFileSync as readFileSync7, existsSync as existsSync7, statSync as statSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5459
5788
  import { finished } from "stream/promises";
5460
- import { join as join7, dirname as dirname5 } from "path";
5789
+ import { join as join8, dirname as dirname5 } from "path";
5461
5790
  import { createHash as createHash2 } from "crypto";
5462
5791
 
5463
5792
  // src/environment.ts
@@ -5742,54 +6071,6 @@ function renderEnvironment(i) {
5742
6071
  return lines.join("\n");
5743
6072
  }
5744
6073
 
5745
- // src/stream.ts
5746
- function toolTarget(name, input) {
5747
- if (!input) return void 0;
5748
- for (const key of ["file_path", "path", "notebook_path", "url", "pattern"]) {
5749
- const v = input[key];
5750
- if (typeof v === "string" && v) return v;
5751
- }
5752
- if (name === "Bash" && typeof input.command === "string") {
5753
- return input.command.trim().split(/\s+/)[0];
5754
- }
5755
- return void 0;
5756
- }
5757
- function mapStreamLine(line) {
5758
- const obj = line;
5759
- if (!obj || obj.type !== "assistant") return [];
5760
- const content = obj.message?.content;
5761
- if (!Array.isArray(content)) return [];
5762
- const out = [];
5763
- for (const block of content) {
5764
- if (block.type === "thinking") out.push({ kind: "thought", text: block.thinking });
5765
- else if (block.type === "text") out.push({ kind: "text", text: block.text });
5766
- else if (block.type === "tool_use") {
5767
- out.push({ kind: "tool", tool: block.name, target: toolTarget(block.name, block.input) });
5768
- }
5769
- }
5770
- return out;
5771
- }
5772
- function extractTurnTokens(line) {
5773
- const obj = line;
5774
- if (!obj || obj.type !== "assistant") return void 0;
5775
- const usage2 = obj.message?.usage;
5776
- if (!usage2) return void 0;
5777
- return (usage2.input_tokens ?? 0) + (usage2.output_tokens ?? 0) + (usage2.cache_read_input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
5778
- }
5779
- function extractResult(line) {
5780
- const obj = line;
5781
- if (!obj || obj.type !== "result") return void 0;
5782
- return {
5783
- sessionId: obj.session_id,
5784
- numTurns: obj.num_turns,
5785
- totalCostUsd: obj.total_cost_usd,
5786
- inputTokens: obj.usage?.input_tokens,
5787
- outputTokens: obj.usage?.output_tokens,
5788
- cacheReadTokens: obj.usage?.cache_read_input_tokens,
5789
- cacheCreationTokens: obj.usage?.cache_creation_input_tokens
5790
- };
5791
- }
5792
-
5793
6074
  // src/agent-log.ts
5794
6075
  async function post(target, path, body) {
5795
6076
  const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
@@ -5871,36 +6152,10 @@ var TicketAttributionTracker = class {
5871
6152
 
5872
6153
  // src/agent.ts
5873
6154
  var DIGEST_MAX_AGE_SECONDS = 600;
5874
- function allowedTools(role) {
5875
- const tablation = "mcp__tablation__*";
5876
- if (role === "triage") return ["Bash", "Read", tablation];
5877
- if (role === "qa") return ["Bash", "Read", "Write", "Grep", "Glob", tablation];
5878
- const base = ["Bash", "Read", "Edit", "Write", "Grep", "Glob", tablation];
5879
- return role === "design" ? [...base, "Skill", "Artifact"] : base;
5880
- }
5881
- var DISALLOWED_TOOLS = [
5882
- "Bash(rm -rf:*)",
5883
- "Bash(rm -fr:*)",
5884
- "Bash(git push --force*)",
5885
- "Bash(git push -f*)",
5886
- "Bash(curl*| sh)",
5887
- "Bash(curl*|sh)",
5888
- "Bash(curl*| bash)",
5889
- "Bash(curl*|bash)",
5890
- "Bash(sudo:*)"
5891
- ];
5892
- var BILLING_VARS_TO_UNSET = [
5893
- "ANTHROPIC_API_KEY",
5894
- "ANTHROPIC_AUTH_TOKEN",
5895
- "CLAUDE_CODE_USE_VERTEX",
5896
- "ANTHROPIC_VERTEX_PROJECT_ID",
5897
- "ANTHROPIC_VERTEX_REGION",
5898
- "CLOUD_ML_REGION",
5899
- "ANTHROPIC_MODEL"
5900
- ];
5901
- function scrubbedEnv(env = process.env) {
6155
+ var LEGACY_TOOLCHAIN_HARNESS = "claude";
6156
+ function scrubbedEnv(env = process.env, harness = LEGACY_TOOLCHAIN_HARNESS) {
5902
6157
  const out = { ...env };
5903
- for (const v of BILLING_VARS_TO_UNSET) delete out[v];
6158
+ for (const v of adapterFor(harness).unsetEnv) delete out[v];
5904
6159
  return out;
5905
6160
  }
5906
6161
  var CREW_LANE_ROLE_VAR = "CREW_LANE_ROLE";
@@ -5914,23 +6169,23 @@ var AgentError = class extends Error {
5914
6169
  var AgentSpawnError = class extends AgentError {
5915
6170
  };
5916
6171
  function planAgentRun(o) {
5917
- const briefPath = join7(o.route.promptsDir, "personas", `lane-${o.role}.md`);
6172
+ const briefPath = join8(o.route.promptsDir, "personas", `lane-${o.role}.md`);
5918
6173
  if (!existsSync7(briefPath)) {
5919
6174
  throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
5920
6175
  }
5921
- const commonPath = join7(o.route.promptsDir, "personas", "common.md");
6176
+ const commonPath = join8(o.route.promptsDir, "personas", "common.md");
5922
6177
  if (!existsSync7(commonPath)) throw new AgentError(`no shared policy at ${commonPath}`);
5923
6178
  const now = o.now ?? (() => Date.now());
5924
6179
  const routeLabel = routeSlug(o.route.route);
5925
- const digestPath2 = join7(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
6180
+ const digestPath2 = join8(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
5926
6181
  let digest;
5927
6182
  let ageSeconds;
5928
6183
  if (existsSync7(digestPath2)) {
5929
6184
  ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
5930
- if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync6(digestPath2, "utf8");
6185
+ if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync7(digestPath2, "utf8");
5931
6186
  }
5932
- const common = readFileSync6(commonPath, "utf8");
5933
- const brief = readFileSync6(briefPath, "utf8");
6187
+ const common = readFileSync7(commonPath, "utf8");
6188
+ const brief = readFileSync7(briefPath, "utf8");
5934
6189
  const effectiveCommon = o.divergedPrompt ? "" : common;
5935
6190
  const effectiveBrief = o.divergedPrompt ?? brief;
5936
6191
  const prompt = assemblePrompt({
@@ -5941,9 +6196,18 @@ function planAgentRun(o) {
5941
6196
  digest
5942
6197
  });
5943
6198
  const promptSha = createHash2("sha256").update(`${effectiveCommon}${effectiveBrief}`).digest("hex");
5944
- const streamsDir = join7(o.stateDir, "streams");
6199
+ const streamsDir = join8(o.stateDir, "streams");
5945
6200
  const base = `${routeLabel}-${o.role}-${o.cycle}`;
5946
- const model = o.agentModel ?? o.ship.agent.model;
6201
+ const toolchain = resolveToolchain(o.ship, o.route);
6202
+ const adapter = adapterFor(toolchain.harness);
6203
+ const model = o.agentModel ?? toolchain.model;
6204
+ const invocation = adapter.invocation(toolchain, {
6205
+ role: o.role,
6206
+ model,
6207
+ // Skipped when there's no key to send: a route `crew connect` hasn't
6208
+ // resolved yet has nothing to authenticate the MCP connection with either.
6209
+ mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
6210
+ });
5947
6211
  const agentLog = o.route.resolved && o.apiKey ? {
5948
6212
  baseUrl: o.route.baseUrl,
5949
6213
  workspaceId: o.route.resolved.workspaceId,
@@ -5961,43 +6225,16 @@ function planAgentRun(o) {
5961
6225
  // (config.ts), and the digest's `repo` column is what actually places
5962
6226
  // the work. Kept deliberately rather than guessed at (ISSUE-349).
5963
6227
  cwd: o.route.dir,
5964
- bin: o.ship.agent.bin,
5965
- args: [
5966
- "-p",
5967
- "--allowedTools",
5968
- ...allowedTools(o.role),
5969
- "--disallowedTools",
5970
- ...DISALLOWED_TOOLS,
5971
- "--model",
5972
- model,
5973
- "--output-format",
5974
- "stream-json",
5975
- "--verbose",
5976
- // Registers the tracker's own MCP tool surface for this run only —
5977
- // `${CREW_API_KEY}` is interpolated by the CLI from the environment
5978
- // (set below), so the resolved key is never written to an argv string
5979
- // or a config file on disk. Skipped when there's no key to send: a
5980
- // route `crew connect` hasn't resolved yet has nothing to authenticate
5981
- // the MCP connection with either.
5982
- ...o.apiKey ? ["--mcp-config", JSON.stringify({
5983
- mcpServers: {
5984
- tablation: {
5985
- type: "http",
5986
- url: `${o.route.baseUrl}/api/mcp`,
5987
- headers: { Authorization: `Bearer \${${API_KEY_VAR}}` }
5988
- }
5989
- }
5990
- })] : []
5991
- ],
6228
+ bin: invocation.bin,
6229
+ args: invocation.args,
6230
+ toolchain: toolchain.name,
6231
+ harness: toolchain.harness,
5992
6232
  prompt,
5993
6233
  promptBytes: Buffer.byteLength(prompt, "utf8"),
5994
- unsetEnv: BILLING_VARS_TO_UNSET,
6234
+ unsetEnv: adapter.unsetEnv,
5995
6235
  setEnv: {
5996
6236
  ...o.apiKey ? { [API_KEY_VAR]: o.apiKey } : {},
5997
- // Extended thinking is what makes the CLI emit `thinking` stream
5998
- // blocks at all — without it, `run.cycles` (below) is always empty
5999
- // and Agent Log Cycles reporting has nothing to report (ISSUE-558).
6000
- ...o.ship.agent.maxThinkingTokens > 0 ? { MAX_THINKING_TOKENS: String(o.ship.agent.maxThinkingTokens) } : {},
6237
+ ...adapter.runEnv(toolchain),
6001
6238
  // The operator's global SessionStart hook (pair-context-hook.sh) injects
6002
6239
  // the interactive Pair persona's prompt for any session it can't tell
6003
6240
  // apart from a person's own terminal. Every headless lane run this
@@ -6011,8 +6248,8 @@ function planAgentRun(o) {
6011
6248
  digestAttached: digest !== void 0,
6012
6249
  digestAgeSeconds: ageSeconds,
6013
6250
  model,
6014
- streamPath: join7(streamsDir, `${base}.jsonl`),
6015
- eventsPath: join7(streamsDir, `${base}.events.jsonl`),
6251
+ streamPath: join8(streamsDir, `${base}.jsonl`),
6252
+ eventsPath: join8(streamsDir, `${base}.events.jsonl`),
6016
6253
  ticket: o.ticket,
6017
6254
  agentLog,
6018
6255
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
@@ -6022,6 +6259,8 @@ function planAgentRun(o) {
6022
6259
  function describePlan(p) {
6023
6260
  return [
6024
6261
  `role: ${p.role}`,
6262
+ // Only for a non-default toolchain, so a plain Claude run's output is unchanged.
6263
+ ...p.toolchain && p.toolchain !== "claude" ? [`toolchain: ${p.toolchain} (${p.harness ?? "claude"})`] : [],
6025
6264
  `cwd: ${p.cwd}`,
6026
6265
  `command: ${p.bin} ${p.args.join(" ")}`,
6027
6266
  `prompt: ${p.promptBytes} bytes on stdin (never argv)`,
@@ -6047,6 +6286,7 @@ function endSink(sink) {
6047
6286
  return finished(sink).catch(() => void 0);
6048
6287
  }
6049
6288
  function spawnAgent(plan, emit) {
6289
+ const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
6050
6290
  const started = Date.now();
6051
6291
  const startedAt = new Date(started).toISOString();
6052
6292
  const rawSink = openSink(plan.streamPath);
@@ -6054,7 +6294,7 @@ function spawnAgent(plan, emit) {
6054
6294
  return new Promise((resolve5, reject) => {
6055
6295
  const child = spawn2(plan.bin, plan.args, {
6056
6296
  cwd: plan.cwd,
6057
- env: { ...scrubbedEnv(), ...plan.setEnv },
6297
+ env: { ...scrubbedEnv(process.env, adapter.harness), ...plan.setEnv },
6058
6298
  stdio: ["pipe", "pipe", "inherit"]
6059
6299
  });
6060
6300
  let buf = "";
@@ -6075,9 +6315,9 @@ function spawnAgent(plan, emit) {
6075
6315
  return;
6076
6316
  }
6077
6317
  try {
6078
- result = extractResult(parsed) ?? result;
6318
+ result = adapter.extractResult(parsed) ?? result;
6079
6319
  let turnText = "";
6080
- for (const ev of mapStreamLine(parsed)) {
6320
+ for (const ev of adapter.mapStreamLine(parsed)) {
6081
6321
  const e = {
6082
6322
  at: (/* @__PURE__ */ new Date()).toISOString(),
6083
6323
  cycle: emit.cycle,
@@ -6115,7 +6355,7 @@ function spawnAgent(plan, emit) {
6115
6355
  `);
6116
6356
  }
6117
6357
  }
6118
- const turnTokens = extractTurnTokens(parsed);
6358
+ const turnTokens = adapter.extractTurnTokens(parsed);
6119
6359
  if (turnTokens !== void 0) attribution.add(turnText, turnTokens);
6120
6360
  } catch {
6121
6361
  }
@@ -6193,12 +6433,12 @@ function spawnAgent(plan, emit) {
6193
6433
 
6194
6434
  // src/run-worktree.ts
6195
6435
  import { existsSync as existsSync9 } from "fs";
6196
- import { dirname as dirname6, join as join9 } from "path";
6436
+ import { dirname as dirname6, join as join10 } from "path";
6197
6437
 
6198
6438
  // src/ports.ts
6199
6439
  import { execFileSync as execFileSync2 } from "child_process";
6200
6440
  import { existsSync as existsSync8 } from "fs";
6201
- import { join as join8 } from "path";
6441
+ import { join as join9 } from "path";
6202
6442
  var run = (cmd, args) => {
6203
6443
  try {
6204
6444
  return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -6243,7 +6483,7 @@ function ticketForPort(port, s = DEFAULT_PORTS) {
6243
6483
  }
6244
6484
  function worktreeExists(parent, prefix, n) {
6245
6485
  for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
6246
- if (existsSync8(join8(parent, name, ".git"))) return true;
6486
+ if (existsSync8(join9(parent, name, ".git"))) return true;
6247
6487
  }
6248
6488
  return false;
6249
6489
  }
@@ -6331,7 +6571,7 @@ function planRunWorktree(dir, cfg, t, role) {
6331
6571
  if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
6332
6572
  return { action: "recut", path: existing.path, branch, base: baseRef };
6333
6573
  }
6334
- const path = join9(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
6574
+ const path = join10(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
6335
6575
  if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
6336
6576
  return { action: "cut-remote", path, branch: located.branch };
6337
6577
  }
@@ -6539,7 +6779,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
6539
6779
  }
6540
6780
 
6541
6781
  // src/connect-wizard.ts
6542
- import { delimiter, dirname as dirname7, join as join10 } from "path";
6782
+ import { delimiter, dirname as dirname7, join as join11 } from "path";
6543
6783
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync10, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
6544
6784
  import { stringify } from "yaml";
6545
6785
  var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
@@ -6562,7 +6802,7 @@ function defaultIsExecutable(p) {
6562
6802
  function findOnPath2(name, pathEnv, isExecutable = defaultIsExecutable) {
6563
6803
  for (const dir of pathEnv.split(delimiter)) {
6564
6804
  if (!dir) continue;
6565
- const candidate = join10(dir, name);
6805
+ const candidate = join11(dir, name);
6566
6806
  if (isExecutable(candidate)) return candidate;
6567
6807
  }
6568
6808
  return void 0;
@@ -6657,8 +6897,8 @@ Wrote ${o.configPath}.
6657
6897
  }
6658
6898
 
6659
6899
  // src/release-run.ts
6660
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync3 } from "fs";
6661
- import { dirname as dirname8, join as join11 } from "path";
6900
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync3 } from "fs";
6901
+ import { dirname as dirname8, join as join12 } from "path";
6662
6902
  import { tmpdir as tmpdir2 } from "os";
6663
6903
 
6664
6904
  // src/release.ts
@@ -7316,13 +7556,13 @@ async function bump(o, size) {
7316
7556
  }
7317
7557
  return printed;
7318
7558
  }
7319
- const files = o.repo.release.versionFiles.map((f) => join11(o.cwd, f));
7559
+ const files = o.repo.release.versionFiles.map((f) => join12(o.cwd, f));
7320
7560
  const first = files[0];
7321
7561
  if (!existsSync11(first)) {
7322
7562
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
7323
7563
  return null;
7324
7564
  }
7325
- const current = JSON.parse(readFileSync7(first, "utf8")).version;
7565
+ const current = JSON.parse(readFileSync8(first, "utf8")).version;
7326
7566
  if (!current) {
7327
7567
  o.emit.error(`no version field in ${o.repo.release.versionFiles[0]}`);
7328
7568
  return null;
@@ -7335,7 +7575,7 @@ async function bump(o, size) {
7335
7575
  return version;
7336
7576
  }
7337
7577
  for (const f of files) {
7338
- const src = readFileSync7(f, "utf8");
7578
+ const src = readFileSync8(f, "utf8");
7339
7579
  const out = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
7340
7580
  if (out === src) {
7341
7581
  o.emit.error(`no version field in ${f}`);
@@ -7348,13 +7588,13 @@ async function bump(o, size) {
7348
7588
  function writeChangelog(o, version, entries) {
7349
7589
  const file = o.repo.release.changelog;
7350
7590
  if (!file || entries.length === 0) return;
7351
- const path = join11(o.cwd, file);
7591
+ const path = join12(o.cwd, file);
7352
7592
  const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
7353
7593
  if (o.dryRun) {
7354
7594
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
7355
7595
  return;
7356
7596
  }
7357
- const existing = existsSync11(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
7597
+ const existing = existsSync11(path) ? readFileSync8(path, "utf8") : "# Changelog\n";
7358
7598
  writeFileSync6(path, insertChangelogSection(existing, section));
7359
7599
  }
7360
7600
  async function confirm(o, expected) {
@@ -7436,8 +7676,8 @@ async function confirmLastRelease(o, decision) {
7436
7676
  function openReleaseCheckout(o) {
7437
7677
  const { remote, base } = o.repo.branch;
7438
7678
  const branch = `crew/release-${Date.now()}`;
7439
- const root = mkdtempSync(join11(tmpdir2(), "crew-release-"));
7440
- const dir = join11(root, "checkout");
7679
+ const root = mkdtempSync(join12(tmpdir2(), "crew-release-"));
7680
+ const dir = join12(root, "checkout");
7441
7681
  const close = () => {
7442
7682
  gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
7443
7683
  rmSync3(root, { recursive: true, force: true });
@@ -7451,10 +7691,10 @@ function openReleaseCheckout(o) {
7451
7691
  throw e;
7452
7692
  }
7453
7693
  for (const f of o.repo.worktrees.copy) {
7454
- const src = join11(o.cwd, f);
7694
+ const src = join12(o.cwd, f);
7455
7695
  if (!existsSync11(src)) continue;
7456
- mkdirSync7(dirname8(join11(dir, f)), { recursive: true });
7457
- copyFileSync(src, join11(dir, f));
7696
+ mkdirSync7(dirname8(join12(dir, f)), { recursive: true });
7697
+ copyFileSync(src, join12(dir, f));
7458
7698
  }
7459
7699
  return { dir, branch, close };
7460
7700
  }
@@ -8264,8 +8504,8 @@ function nextRoles(decision, ran) {
8264
8504
  }
8265
8505
 
8266
8506
  // src/agents.ts
8267
- import { readFileSync as readFileSync8, existsSync as existsSync13 } from "fs";
8268
- import { join as join12 } from "path";
8507
+ import { readFileSync as readFileSync9, existsSync as existsSync13 } from "fs";
8508
+ import { join as join13 } from "path";
8269
8509
  import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
8270
8510
  var PERSONA_NAME = {
8271
8511
  dev: "Developer",
@@ -8279,12 +8519,12 @@ var CREW_AGENT_COLUMN = "agent_id";
8279
8519
  var AgentsSyncError = class extends Error {
8280
8520
  };
8281
8521
  function personaDefaultPrompt(promptsDir, role) {
8282
- const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
8522
+ const briefPath = join13(promptsDir, "personas", `lane-${role}.md`);
8283
8523
  if (!existsSync13(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
8284
- if (role === "pair") return readFileSync8(briefPath, "utf8");
8285
- const commonPath = join12(promptsDir, "personas", "common.md");
8524
+ if (role === "pair") return readFileSync9(briefPath, "utf8");
8525
+ const commonPath = join13(promptsDir, "personas", "common.md");
8286
8526
  if (!existsSync13(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
8287
- return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
8527
+ return `${readFileSync9(commonPath, "utf8")}${readFileSync9(briefPath, "utf8")}`;
8288
8528
  }
8289
8529
  async function resolveAgentId(route, role, opts) {
8290
8530
  const cached = route.resolved?.agentPersonas?.[role]?.agentId;
@@ -8950,14 +9190,14 @@ import {
8950
9190
  } from "@tablation/client";
8951
9191
 
8952
9192
  // src/skills.ts
8953
- import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
8954
- import { join as join13 } from "path";
9193
+ import { readFileSync as readFileSync10, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
9194
+ import { join as join14 } from "path";
8955
9195
  import { parse as parseYaml } from "yaml";
8956
9196
  import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
8957
9197
  var SkillSyncError = class extends Error {
8958
9198
  };
8959
9199
  function parseSkillFile(path) {
8960
- const text = readFileSync9(path, "utf8");
9200
+ const text = readFileSync10(path, "utf8");
8961
9201
  const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
8962
9202
  if (!match) throw new SkillSyncError(`${path}: missing YAML frontmatter (expected a leading --- block)`);
8963
9203
  const frontmatter = match[1];
@@ -8970,9 +9210,9 @@ function parseSkillFile(path) {
8970
9210
  return { name: meta.name, description: meta.description, prompt: body, file: path };
8971
9211
  }
8972
9212
  function listLocalSkills(promptsDir) {
8973
- const dir = join13(promptsDir, "skills");
9213
+ const dir = join14(promptsDir, "skills");
8974
9214
  if (!existsSync14(dir)) return [];
8975
- return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
9215
+ return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join14(dir, f)));
8976
9216
  }
8977
9217
  async function syncSkills(route, opts) {
8978
9218
  if (!route.resolved) {
@@ -9151,7 +9391,7 @@ async function showLogEntry(route, entryId, opts = {}) {
9151
9391
  }
9152
9392
 
9153
9393
  // src/repos-cmd.ts
9154
- import { homedir as homedir3 } from "os";
9394
+ import { homedir as homedir4 } from "os";
9155
9395
  import { basename as basename6, resolve as resolve3 } from "path";
9156
9396
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
9157
9397
  var ReposError = class extends Error {
@@ -9198,7 +9438,7 @@ function parseAddArgs(args, routes, isDir) {
9198
9438
  return { route, path: "." };
9199
9439
  }
9200
9440
  function expandDir(p, base) {
9201
- const home = p === "~" ? homedir3() : p.startsWith("~/") ? `${homedir3()}/${p.slice(2)}` : p;
9441
+ const home = p === "~" ? homedir4() : p.startsWith("~/") ? `${homedir4()}/${p.slice(2)}` : p;
9202
9442
  return resolve3(base, home);
9203
9443
  }
9204
9444
  function routeMapOf(doc, route) {
@@ -9295,72 +9535,6 @@ function resolveRepoProject(projects, opt, routeAreaId) {
9295
9535
  );
9296
9536
  }
9297
9537
 
9298
- // src/claude-mcp.ts
9299
- import { spawnSync as spawnSync2 } from "child_process";
9300
- import { readFileSync as readFileSync10 } from "fs";
9301
- import { homedir as homedir4 } from "os";
9302
- import { join as join14 } from "path";
9303
- var MCP_SERVER_NAME = "tablation";
9304
- var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
9305
- function mcpUrlFor(baseUrl) {
9306
- return `${baseUrl.replace(/\/+$/, "")}/api/mcp`;
9307
- }
9308
- function runClaude(bin, args) {
9309
- const r = spawnSync2(bin, args, { encoding: "utf8", timeout: 3e4 });
9310
- const missing = r.error?.code === "ENOENT";
9311
- return { status: r.status, missing, stderr: r.stderr ?? "" };
9312
- }
9313
- function userScopeEntry(home) {
9314
- try {
9315
- const j = JSON.parse(readFileSync10(join14(home, ".claude.json"), "utf8"));
9316
- return j.mcpServers?.[MCP_SERVER_NAME];
9317
- } catch {
9318
- return void 0;
9319
- }
9320
- }
9321
- function inspectClaudeMcp(o) {
9322
- const got = runClaude(o.bin, ["mcp", "get", MCP_SERVER_NAME]);
9323
- if (got.missing) return { state: "no-claude" };
9324
- if (got.status !== 0) return { state: "missing" };
9325
- const entry = userScopeEntry(o.home ?? homedir4());
9326
- if (!entry) {
9327
- return { state: "different", detail: `a "${MCP_SERVER_NAME}" server exists, but not in user scope (~/.claude.json) \u2014 a project or local registration` };
9328
- }
9329
- if (entry.url !== o.url) return { state: "different", detail: `it points at ${entry.url ?? "(no url)"}, not ${o.url}` };
9330
- const auth = Object.entries(entry.headers ?? {}).find(([k]) => k.toLowerCase() === "authorization")?.[1];
9331
- if (auth !== `Bearer ${o.key}`) return { state: "different", detail: `it is registered with a different key than this route's` };
9332
- return { state: "registered", url: o.url };
9333
- }
9334
- function ensureClaudeMcp(o) {
9335
- const manual = `claude mcp add --transport http ${MCP_SERVER_NAME} ${o.url} --header "Authorization: Bearer <key>" --scope user`;
9336
- const st = inspectClaudeMcp(o);
9337
- if (st.state === "no-claude") return `mcp: \`${o.bin}\` not found, so Claude Code was not configured. To do it by hand: ${manual}`;
9338
- if (st.state === "registered") return `mcp: ${MCP_SERVER_NAME} already registered with Claude Code \u2192 ${st.url}`;
9339
- if (st.state === "different" && !o.replace) {
9340
- return `mcp: left Claude Code's "${MCP_SERVER_NAME}" server alone \u2014 ${st.detail}. Re-run with --mcp replace to overwrite it.`;
9341
- }
9342
- if (o.dryRun) return `(dry run) would register the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url}`;
9343
- if (st.state === "different") {
9344
- runClaude(o.bin, ["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]);
9345
- }
9346
- const add = runClaude(o.bin, [
9347
- "mcp",
9348
- "add",
9349
- "--transport",
9350
- "http",
9351
- MCP_SERVER_NAME,
9352
- o.url,
9353
- "--header",
9354
- `Authorization: Bearer ${o.key}`,
9355
- "--scope",
9356
- "user"
9357
- ]);
9358
- if (add.status !== 0) {
9359
- return `mcp: \`claude mcp add\` failed (${redact(add.stderr.trim(), o.key) || `exit ${add.status}`}). To do it by hand: ${manual}`;
9360
- }
9361
- return `mcp: registered the ${MCP_SERVER_NAME} MCP server with Claude Code \u2192 ${o.url} (user scope, ~/.claude.json)`;
9362
- }
9363
-
9364
9538
  // src/doctor-fix.ts
9365
9539
  import { parseDocument as parseDocument2 } from "yaml";
9366
9540
  var sound = (r) => r.hasRepos && r.problems.length === 0;
@@ -12746,8 +12920,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12746
12920
  if (!wroteConfig) process.stdout.write(routeBlock);
12747
12921
  if (!flag("no-mcp") && apiKey) {
12748
12922
  const replace = value("mcp") === "replace";
12749
- process.stderr.write(`${ensureClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
12923
+ const seen = /* @__PURE__ */ new Set();
12924
+ for (const tc of shipToolchains(cfg.ship)) {
12925
+ const id = `${tc.harness}\0${tc.bin}`;
12926
+ if (seen.has(id)) continue;
12927
+ seen.add(id);
12928
+ process.stderr.write(`${adapterFor(tc.harness).ensureMcp({ bin: tc.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
12750
12929
  `);
12930
+ }
12751
12931
  }
12752
12932
  if (found.holds.length) {
12753
12933
  process.stderr.write(
@@ -13303,6 +13483,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13303
13483
  name: cfg.ship.name,
13304
13484
  platform: cfg.ship.platform,
13305
13485
  agent: { bin: cfg.ship.agent.bin, model: cfg.ship.agent.model },
13486
+ // Additive (CREW-1511): the configured toolchains and the ship's
13487
+ // default. `agent` above stays for older crew-macos builds.
13488
+ toolchain: resolveToolchain(cfg.ship).name,
13489
+ toolchains: shipToolchains(cfg.ship).map((x) => ({
13490
+ name: x.name,
13491
+ harness: x.harness,
13492
+ bin: x.bin,
13493
+ launcher: x.launcher ?? null,
13494
+ model: x.model
13495
+ })),
13306
13496
  stateDir: cfg.ship.stateDir,
13307
13497
  logFile: cfg.ship.logFile,
13308
13498
  eventFile: eventFileFor(cfg.ship.stateDir),
@@ -13328,6 +13518,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13328
13518
  dir: route.dir,
13329
13519
  enabled: route.enabled,
13330
13520
  area: route.area ?? null,
13521
+ // Additive (CREW-1511): the toolchain this route's sessions run on.
13522
+ toolchain: resolveToolchain(cfg.ship, route).name,
13331
13523
  baseUrl: route.baseUrl,
13332
13524
  repos: repos.map((r) => ({
13333
13525
  name: r.name,
@@ -13553,14 +13745,24 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
13553
13745
  }
13554
13746
  }
13555
13747
  const schedulerPath = pathFor(cfg.ship);
13556
- const agentBinPath = findOnPath(cfg.ship.agent.bin, schedulerPath);
13557
13748
  const gitPath = findOnPath("git", schedulerPath);
13558
- process.stdout.write(
13559
- `agent binary: ${cfg.ship.agent.bin} \u2014 ${agentBinPath ? `OK (${agentBinPath})` : "NOT FOUND on the scheduler PATH"}
13560
- git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler PATH"}
13749
+ const defaultToolchain = resolveToolchain(cfg.ship).name;
13750
+ let allBinsFound = true;
13751
+ for (const tc of shipToolchains(cfg.ship)) {
13752
+ const adapter = adapterFor(tc.harness);
13753
+ const binPath = findOnPath(tc.launcher ?? tc.bin, schedulerPath);
13754
+ if (!binPath) allBinsFound = false;
13755
+ const label = tc.name === defaultToolchain ? "agent binary: " : `toolchain ${tc.name}:`.padEnd(18);
13756
+ process.stdout.write(
13757
+ `${label} ${tc.launcher ?? tc.bin} \u2014 ${binPath ? `OK (${binPath})` : "NOT FOUND on the scheduler PATH"}${tc.name === defaultToolchain ? "" : ` [${adapter.displayName}]`}
13561
13758
  `
13562
- );
13563
- if (!agentBinPath || !gitPath) {
13759
+ );
13760
+ for (const limit of adapter.limitations) process.stdout.write(` cannot enforce: ${limit}
13761
+ `);
13762
+ }
13763
+ process.stdout.write(`git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler PATH"}
13764
+ `);
13765
+ if (!allBinsFound || !gitPath) {
13564
13766
  process.stdout.write(
13565
13767
  ` scheduler PATH is ${schedulerPath} \u2014 add the directory holding the missing binary to ship.extraPath in crew.yaml, then re-run \`crew doctor\`.
13566
13768
  `
@@ -13568,14 +13770,21 @@ git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler P
13568
13770
  }
13569
13771
  if (route.apiKey) {
13570
13772
  const mcpUrl = mcpUrlFor(route.baseUrl);
13571
- const m = inspectClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrl, key: route.apiKey });
13572
- process.stdout.write(
13573
- m.state === "registered" ? `mcp: tablation registered (user scope) \u2192 ${m.url}
13574
- ` : m.state === "no-claude" ? `mcp: claude not found \u2014 Claude Code was not checked
13773
+ const seenMcp = /* @__PURE__ */ new Set();
13774
+ for (const tc of shipToolchains(cfg.ship)) {
13775
+ const id = `${tc.harness}\0${tc.bin}`;
13776
+ if (seenMcp.has(id)) continue;
13777
+ seenMcp.add(id);
13778
+ const adapter = adapterFor(tc.harness);
13779
+ const m = adapter.inspectMcp({ bin: tc.bin, url: mcpUrl, key: route.apiKey });
13780
+ process.stdout.write(
13781
+ m.state === "registered" ? `mcp: tablation registered (user scope) \u2192 ${m.url}
13782
+ ` : m.state === "no-binary" ? `mcp: ${tc.bin} not found \u2014 ${adapter.displayName} was not checked
13575
13783
  ` : m.state === "different" ? `mcp: tablation registered but not for this route \u2014 ${m.detail}
13576
13784
  ` : `mcp: not registered \u2014 run crew connect
13577
13785
  `
13578
- );
13786
+ );
13787
+ }
13579
13788
  }
13580
13789
  for (const c of cfg.routes.filter((r) => r.enabled)) {
13581
13790
  let repos;