@tablation/crew 0.63.2 → 0.64.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +296 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -263,7 +263,7 @@ crew inbox [--member NAME] your tickets across every workspace
263
263
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
264
264
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
265
265
  crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
266
- crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo writes them, --json)
266
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo writes them, --recipes, --agent, --json)
267
267
  crew install [--replace] write and load this platform's scheduler unit (macOS: clears a crew installed from another path)
268
268
  crew uninstall [--all] unload and remove it (--all: every crew launchd unit, whichever path it came from)
269
269
  ```
package/dist/cli.js CHANGED
@@ -5209,6 +5209,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
5209
5209
  "repo",
5210
5210
  "role",
5211
5211
  "route",
5212
+ "recipes",
5212
5213
  "ship-name",
5213
5214
  "tail",
5214
5215
  "ticket",
@@ -13204,17 +13205,62 @@ var DETECTORS = [
13204
13205
  { source: "pyproject.toml", detect: fromPyproject },
13205
13206
  { source: "Makefile", detect: fromMakefile }
13206
13207
  ];
13208
+ var WORKFLOW_RE = /\.ya?ml$/;
13209
+ function detectRecipes(f) {
13210
+ const workflows = (f.list?.(".github/workflows") ?? []).filter((n) => WORKFLOW_RE.test(n)).sort();
13211
+ if (workflows.length === 0) return [];
13212
+ const evidence = workflows.map((n) => `.github/workflows/${n}`);
13213
+ return [
13214
+ {
13215
+ id: "github-actions-release",
13216
+ title: "Releases run in GitHub Actions",
13217
+ description: "CI publishes the release after the crew merges; the crew watches `gh release view` for the live tag.",
13218
+ evidence,
13219
+ // `ltrimstr` because `verify.match: version` compares the bare version, not `v1.2.3`.
13220
+ hooks: { released: `gh release view --json tagName -q '.tagName | ltrimstr("v")'` },
13221
+ release: { mode: "ci_auto", ci: "github", verifyMatch: "version" },
13222
+ writable: true,
13223
+ defaultOn: true
13224
+ },
13225
+ {
13226
+ id: "github-actions-ci-status",
13227
+ title: "Check CI status before hand-off",
13228
+ description: "Ask GitHub whether the latest run on the branch passed before a ticket is handed to QA. The repo spec has no hook for this yet, so it is reported but cannot be written.",
13229
+ evidence,
13230
+ hooks: {},
13231
+ check: `gh run list --branch "$CREW_BRANCH" --limit 1 --json conclusion -q '.[0].conclusion == "success"'`,
13232
+ writable: false,
13233
+ defaultOn: false
13234
+ }
13235
+ ];
13236
+ }
13207
13237
  function suggestHooks(f) {
13238
+ const recipes = detectRecipes(f);
13208
13239
  const found = DETECTORS.flatMap((d) => {
13209
13240
  const hooks = d.detect(f);
13210
13241
  return hooks && Object.keys(hooks).length > 0 ? [{ source: d.source, hooks }] : [];
13211
13242
  });
13212
13243
  if (found.length === 0) {
13213
- return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"] };
13244
+ return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"], recipes };
13214
13245
  }
13215
13246
  const [first, ...others] = found;
13216
13247
  const notes = others.length > 0 ? [`also found ${others.map((o) => o.source).join(", ")}; proposing from ${first.source} only`] : [];
13217
- return { source: first.source, hooks: first.hooks, notes };
13248
+ return { source: first.source, hooks: first.hooks, notes, recipes };
13249
+ }
13250
+ function selectRecipes(s, ids) {
13251
+ const chosen = ids.map((id) => {
13252
+ const r = s.recipes.find((x) => x.id === id);
13253
+ if (!r) throw new ReposError(`no recipe "${id}" here (have: ${s.recipes.map((x) => x.id).join(", ") || "none"})`);
13254
+ if (!r.writable) throw new ReposError(`recipe "${id}" cannot be written: ${r.description}`);
13255
+ return r;
13256
+ });
13257
+ let hooks = { ...s.hooks };
13258
+ let release;
13259
+ for (const r of chosen) {
13260
+ hooks = { ...r.hooks, ...hooks };
13261
+ release ??= r.release;
13262
+ }
13263
+ return { hooks, ...release ? { release } : {} };
13218
13264
  }
13219
13265
  function parseHooksArgs(args, routes) {
13220
13266
  const rest = [...args];
@@ -13224,7 +13270,7 @@ function parseHooksArgs(args, routes) {
13224
13270
  if (routes.length === 1) route = routes[0];
13225
13271
  else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
13226
13272
  }
13227
- if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--write route|repo] [--json]");
13273
+ if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]");
13228
13274
  return { route, name: rest[0] };
13229
13275
  }
13230
13276
  function repoDirOf(text, base, route, name) {
@@ -13270,7 +13316,7 @@ function planRouteHooks(text, route, name, hooks) {
13270
13316
  }
13271
13317
  return { text: String(doc), written, kept };
13272
13318
  }
13273
- function starterRepoConfig(hooks) {
13319
+ function starterRepoConfig(hooks, release) {
13274
13320
  const lines = [
13275
13321
  "# Starter .crew.yaml proposed by `crew repos hooks --write repo`.",
13276
13322
  "# Review the hooks, then commit this file; it is the durable home for them",
@@ -13283,7 +13329,12 @@ function starterRepoConfig(hooks) {
13283
13329
  const entries = Object.entries(hooks).filter(([, v]) => v !== void 0);
13284
13330
  if (entries.length === 0) lines.push(' # test: "..."', ' # build: "..."');
13285
13331
  for (const [k, v] of entries) lines.push(` ${k}: ${JSON.stringify(v)}`);
13286
- lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
13332
+ if (release) {
13333
+ lines.push("", "release:", ` mode: ${release.mode}`);
13334
+ if (release.ci) lines.push(` ci: { provider: ${release.ci} }`);
13335
+ if (release.verifyMatch) lines.push(" verify:", ` match: ${release.verifyMatch}`);
13336
+ lines.push("");
13337
+ } else lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
13287
13338
  const text = lines.join("\n");
13288
13339
  parseRepoConfig(text, ".crew.yaml");
13289
13340
  return text;
@@ -13292,6 +13343,178 @@ function existingRepoConfig(f) {
13292
13343
  return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
13293
13344
  }
13294
13345
 
13346
+ // src/repos-hooks-agent.ts
13347
+ import { spawn as spawn4, execFileSync as execFileSync4 } from "child_process";
13348
+ import { realpathSync as realpathSync2 } from "fs";
13349
+ import { isAbsolute as isAbsolute2, relative } from "path";
13350
+ var AgentSuggestError = class extends Error {
13351
+ reason;
13352
+ constructor(reason, message) {
13353
+ super(message);
13354
+ this.reason = reason;
13355
+ }
13356
+ };
13357
+ var HOOK_KEYS = ["test", "build", "version", "bump", "released"];
13358
+ var READ_ONLY_TOOLS = ["Read", "Grep", "Glob"];
13359
+ var WRITING_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent"];
13360
+ var DEFAULT_TIMEOUT_MS = 18e4;
13361
+ function buildAgentPrompt(sniff) {
13362
+ return [
13363
+ "You are reviewing a git checkout (the current directory) to work out how to test and build it.",
13364
+ "Read README*, CLAUDE.md, AGENTS.md, GEMINI.md, package/build manifests and .github/workflows/* as needed.",
13365
+ "You may only read files. Do not modify anything and do not run commands.",
13366
+ "",
13367
+ `A file sniff already proposed: ${JSON.stringify(sniff.hooks)}.`,
13368
+ "Propose the commands a contributor should run, using the repo's own documented ones over guesses.",
13369
+ "Omit any hook you cannot support from a file you read.",
13370
+ "",
13371
+ "Answer with ONLY one JSON object, no prose and no code fence:",
13372
+ '{"hooks": {"test": "...", "build": "...", "version": "...", "bump": "...", "released": "..."}, "notes": ["..."]}',
13373
+ "`version` prints the bare version, `bump` bumps by $CREW_BUMP (patch|minor|major) and prints the new version,",
13374
+ "`released` prints what is live (a version or sha). Keys you cannot support are left out."
13375
+ ].join("\n");
13376
+ }
13377
+ function agentInvocation(spec, model) {
13378
+ if (spec.harness !== "claude") {
13379
+ throw new AgentSuggestError("unsupported_harness", `--agent supports the claude harness only (toolchain "${spec.name}" is ${spec.harness})`);
13380
+ }
13381
+ return wrapInvocation(spec, {
13382
+ model,
13383
+ args: [
13384
+ "-p",
13385
+ "--allowedTools",
13386
+ ...READ_ONLY_TOOLS,
13387
+ "--disallowedTools",
13388
+ ...WRITING_TOOLS,
13389
+ "--model",
13390
+ model,
13391
+ "--output-format",
13392
+ "stream-json",
13393
+ "--verbose",
13394
+ ...spec.args
13395
+ ]
13396
+ });
13397
+ }
13398
+ function parseAgentStream(out, dir) {
13399
+ let text = "";
13400
+ let isError = false;
13401
+ const files = /* @__PURE__ */ new Set();
13402
+ let base = dir;
13403
+ try {
13404
+ base = realpathSync2(dir);
13405
+ } catch {
13406
+ }
13407
+ for (const line of out.split("\n")) {
13408
+ if (!line.trim()) continue;
13409
+ let ev;
13410
+ try {
13411
+ ev = JSON.parse(line);
13412
+ } catch {
13413
+ continue;
13414
+ }
13415
+ if (ev?.type === "assistant" && Array.isArray(ev.message?.content)) {
13416
+ for (const b of ev.message.content) {
13417
+ const p = b?.type === "tool_use" && b.name === "Read" ? b.input?.file_path : void 0;
13418
+ if (typeof p === "string") files.add(isAbsolute2(p) ? relative(base, p) : p);
13419
+ }
13420
+ } else if (ev?.type === "result") {
13421
+ text = typeof ev.result === "string" ? ev.result : "";
13422
+ isError = ev.is_error === true;
13423
+ }
13424
+ }
13425
+ return { text, isError, filesRead: [...files].sort() };
13426
+ }
13427
+ function parseAgentHooks(text) {
13428
+ const start = text.indexOf("{");
13429
+ const end = text.lastIndexOf("}");
13430
+ if (start < 0 || end <= start) throw new AgentSuggestError("bad_output", "the toolchain did not answer with a JSON object");
13431
+ let obj;
13432
+ try {
13433
+ obj = JSON.parse(text.slice(start, end + 1));
13434
+ } catch {
13435
+ throw new AgentSuggestError("bad_output", "the toolchain's answer was not valid JSON");
13436
+ }
13437
+ const raw = obj && typeof obj === "object" && obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
13438
+ const hooks = {};
13439
+ for (const k of HOOK_KEYS) if (typeof raw[k] === "string" && raw[k].trim()) hooks[k] = raw[k].trim();
13440
+ const notes = Array.isArray(obj.notes) ? obj.notes.filter((n) => typeof n === "string") : [];
13441
+ return { hooks, notes };
13442
+ }
13443
+ var SIGNED_OUT = /not logged in|\/login|invalid api key|authentication|unauthori[sz]ed|\b401\b|credit balance/i;
13444
+ function classifyFailure(o) {
13445
+ if (o.spawnError?.code === "ENOENT") return "not_installed";
13446
+ return SIGNED_OUT.test(o.output) ? "not_signed_in" : "failed";
13447
+ }
13448
+ function porcelain(dir) {
13449
+ try {
13450
+ return execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8" });
13451
+ } catch {
13452
+ return void 0;
13453
+ }
13454
+ }
13455
+ async function runAgentSuggest(o) {
13456
+ const model = o.model ?? o.spec.model;
13457
+ const { bin, args } = agentInvocation(o.spec, model);
13458
+ const adapter = adapterFor(o.spec.harness);
13459
+ const env = { ...process.env, ...adapter.runEnv(o.spec) };
13460
+ for (const v of adapter.unsetEnv) delete env[v];
13461
+ delete env.CREW_API_KEY;
13462
+ const before = porcelain(o.dir);
13463
+ const timeout = AbortSignal.timeout(o.timeoutMs ?? DEFAULT_TIMEOUT_MS);
13464
+ const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
13465
+ const run2 = await new Promise((resolve6) => {
13466
+ let out = "";
13467
+ let err = "";
13468
+ const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
13469
+ let settled = false;
13470
+ const settle = (r) => {
13471
+ if (settled) return;
13472
+ settled = true;
13473
+ signal.removeEventListener("abort", onAbort);
13474
+ resolve6(r);
13475
+ };
13476
+ const onAbort = () => {
13477
+ try {
13478
+ if (child.pid) process.kill(-child.pid, "SIGTERM");
13479
+ } catch {
13480
+ child.kill("SIGTERM");
13481
+ }
13482
+ child.stdout.destroy();
13483
+ child.stderr.destroy();
13484
+ settle({ code: null, out, err, aborted: true });
13485
+ };
13486
+ if (signal.aborted) onAbort();
13487
+ else signal.addEventListener("abort", onAbort, { once: true });
13488
+ child.stdout.on("data", (d) => {
13489
+ out += d.toString();
13490
+ });
13491
+ child.stderr.on("data", (d) => {
13492
+ err += d.toString();
13493
+ });
13494
+ child.on("error", (e) => settle({ code: null, out, err, spawnError: e, aborted: false }));
13495
+ child.on("close", (code) => settle({ code, out, err, aborted: false }));
13496
+ child.stdin.on("error", () => {
13497
+ });
13498
+ child.stdin.end(buildAgentPrompt(o.sniff));
13499
+ });
13500
+ if (run2.aborted || signal.aborted) {
13501
+ throw new AgentSuggestError(o.signal?.aborted ? "cancelled" : "timeout", o.signal?.aborted ? "cancelled" : "the toolchain did not answer in time");
13502
+ }
13503
+ const after = porcelain(o.dir);
13504
+ if (before !== void 0 && after !== before) {
13505
+ throw new AgentSuggestError("modified_checkout", "the checkout changed during --agent; inspect it with `git status` (the agent result was discarded)");
13506
+ }
13507
+ const parsed = parseAgentStream(run2.out, o.dir);
13508
+ if (run2.spawnError || run2.code !== 0 || parsed.isError) {
13509
+ const reason = classifyFailure({ code: run2.code, ...run2.spawnError ? { spawnError: run2.spawnError } : {}, output: `${parsed.text}
13510
+ ${run2.err}` });
13511
+ throw new AgentSuggestError(reason, reason === "not_installed" ? `${bin} is not installed` : reason === "not_signed_in" ? `${o.spec.name} is not signed in` : (parsed.text || run2.err).trim().slice(0, 300) || `exited ${run2.code}`);
13512
+ }
13513
+ const { hooks, notes } = parseAgentHooks(parsed.text);
13514
+ const differsFromSniff = HOOK_KEYS.filter((k) => hooks[k] !== void 0 && hooks[k] !== o.sniff.hooks[k]);
13515
+ return { source: "agent", hooks, filesRead: parsed.filesRead, differsFromSniff, notes, toolchain: o.spec.name, model };
13516
+ }
13517
+
13295
13518
  // src/sample-ticket.ts
13296
13519
  import { basename as basename8, dirname as dirname15, join as join23 } from "path";
13297
13520
  var SAMPLE_REPO = "crew-sample";
@@ -13719,12 +13942,12 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13719
13942
  }
13720
13943
 
13721
13944
  // src/cli.ts
13722
- import { realpathSync as realpathSync2, readFileSync as readFileSync25, existsSync as existsSync31, mkdirSync as mkdirSync18, writeFileSync as writeFileSync19, rmSync as rmSync12, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync10, readdirSync as readdirSync12 } from "fs";
13945
+ import { realpathSync as realpathSync3, readFileSync as readFileSync25, existsSync as existsSync31, mkdirSync as mkdirSync18, writeFileSync as writeFileSync19, rmSync as rmSync12, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync10, readdirSync as readdirSync12 } from "fs";
13723
13946
  import { createInterface } from "readline/promises";
13724
13947
  import { dirname as dirOf, resolve as resolvePath } from "path";
13725
13948
 
13726
13949
  // src/ssh-keys.ts
13727
- import { execFileSync as execFileSync4 } from "child_process";
13950
+ import { execFileSync as execFileSync5 } from "child_process";
13728
13951
  import { existsSync as existsSync25, mkdirSync as mkdirSync13, readFileSync as readFileSync19, chmodSync } from "fs";
13729
13952
  import { dirname as dirname17, join as join26 } from "path";
13730
13953
  var SshKeyError = class extends Error {
@@ -13738,7 +13961,7 @@ function ensureShipSshKeypair(stateDir) {
13738
13961
  if (!existsSync25(privateKeyPath)) {
13739
13962
  mkdirSync13(dirname17(privateKeyPath), { recursive: true });
13740
13963
  try {
13741
- execFileSync4("ssh-keygen", [
13964
+ execFileSync5("ssh-keygen", [
13742
13965
  "-t",
13743
13966
  "ed25519",
13744
13967
  "-N",
@@ -13764,7 +13987,7 @@ function ensureShipSshKeypair(stateDir) {
13764
13987
  }
13765
13988
  function sshKeygenAvailable() {
13766
13989
  try {
13767
- execFileSync4("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
13990
+ execFileSync5("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
13768
13991
  return true;
13769
13992
  } catch (e) {
13770
13993
  return e.code !== "ENOENT";
@@ -13772,7 +13995,7 @@ function sshKeygenAvailable() {
13772
13995
  }
13773
13996
 
13774
13997
  // src/machine-id.ts
13775
- import { execFileSync as execFileSync5 } from "child_process";
13998
+ import { execFileSync as execFileSync6 } from "child_process";
13776
13999
  import { createHash as createHash4 } from "crypto";
13777
14000
  import { existsSync as existsSync26, mkdirSync as mkdirSync14, readFileSync as readFileSync20, writeFileSync as writeFileSync15 } from "fs";
13778
14001
  import { dirname as dirname18, join as join27 } from "path";
@@ -13783,7 +14006,7 @@ var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
13783
14006
  function readRawMachineId(platform2 = process.platform) {
13784
14007
  try {
13785
14008
  if (platform2 === "darwin") {
13786
- const out = execFileSync5("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
14009
+ const out = execFileSync6("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
13787
14010
  encoding: "utf8",
13788
14011
  stdio: ["ignore", "pipe", "ignore"]
13789
14012
  });
@@ -13822,11 +14045,11 @@ function matchShipRows(ships, shipName2, machineId) {
13822
14045
  }
13823
14046
 
13824
14047
  // src/passenger-containers.ts
13825
- import { execFileSync as execFileSync6 } from "child_process";
14048
+ import { execFileSync as execFileSync7 } from "child_process";
13826
14049
  import { createHash as createHash5 } from "crypto";
13827
14050
  var realExec = (cmd, args) => {
13828
14051
  try {
13829
- return execFileSync6(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
14052
+ return execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
13830
14053
  } catch (e) {
13831
14054
  const err = e;
13832
14055
  const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
@@ -14024,12 +14247,12 @@ function syncAllPassengerCheckouts(cfg) {
14024
14247
  }
14025
14248
 
14026
14249
  // src/tunnel.ts
14027
- import { spawn as spawn4 } from "child_process";
14250
+ import { spawn as spawn5 } from "child_process";
14028
14251
  import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync16, unlinkSync as unlinkSync3, readdirSync as readdirSync8 } from "fs";
14029
14252
  import { randomInt } from "crypto";
14030
14253
  import { join as join28 } from "path";
14031
14254
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
14032
- var defaultSpawn = (cmd, args) => spawn4(cmd, args, DEFAULT_SPAWN_OPTIONS);
14255
+ var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
14033
14256
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
14034
14257
  var SLUG_LENGTH = 12;
14035
14258
  function generateTunnelSlug() {
@@ -14325,7 +14548,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
14325
14548
 
14326
14549
  // src/passenger-sync-daemon.ts
14327
14550
  import { createServer as createHttpServer } from "http";
14328
- import { spawn as spawn5 } from "child_process";
14551
+ import { spawn as spawn6 } from "child_process";
14329
14552
  import { randomBytes } from "crypto";
14330
14553
  import { existsSync as existsSync28, mkdirSync as mkdirSync16, readFileSync as readFileSync22, writeFileSync as writeFileSync17, unlinkSync as unlinkSync4, readdirSync as readdirSync9 } from "fs";
14331
14554
  import { join as join29 } from "path";
@@ -14412,7 +14635,7 @@ function runSyncDaemonFromEnv(env = process.env) {
14412
14635
  return server;
14413
14636
  }
14414
14637
  var defaultSpawn2 = (cmd, args, env) => {
14415
- const child = spawn5(cmd, args, { detached: true, stdio: "ignore", env });
14638
+ const child = spawn6(cmd, args, { detached: true, stdio: "ignore", env });
14416
14639
  child.unref();
14417
14640
  return child;
14418
14641
  };
@@ -14732,7 +14955,7 @@ async function runOnePassUnguarded(o) {
14732
14955
  await hydrateApiKeys([o.route], o.getSessionStore);
14733
14956
  const emit = o.newEmitter();
14734
14957
  const decide = o.decide ?? decideCycle;
14735
- const spawn6 = o.spawnRoleAgent ?? runRoleAgent;
14958
+ const spawn7 = o.spawnRoleAgent ?? runRoleAgent;
14736
14959
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
14737
14960
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
14738
14961
  try {
@@ -14793,7 +15016,7 @@ async function runOnePassUnguarded(o) {
14793
15016
  continue;
14794
15017
  }
14795
15018
  started.push(role);
14796
- const run2 = spawn6(role, decision, ctx).then(() => {
15019
+ const run2 = spawn7(role, decision, ctx).then(() => {
14797
15020
  breaker.recordSuccess(role);
14798
15021
  clearShipAttention(o.state, `role_parked:${role}`);
14799
15022
  }).catch(async (e) => {
@@ -15192,7 +15415,7 @@ function usage() {
15192
15415
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
15193
15416
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
15194
15417
  crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
15195
- crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo, --json)
15418
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--agent, --write route|repo, --recipes, --json)
15196
15419
  crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
15197
15420
  crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
15198
15421
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
@@ -16121,10 +16344,12 @@ ${changed2.map((c) => ` - ${c}
16121
16344
  lock?.release();
16122
16345
  lock = void 0;
16123
16346
  };
16347
+ let sigintOwnedByCommand = false;
16124
16348
  if (!dryRun) {
16125
16349
  process.on("exit", dropLock);
16126
16350
  for (const sig of ["SIGINT", "SIGTERM"]) {
16127
16351
  process.on(sig, () => {
16352
+ if (sig === "SIGINT" && sigintOwnedByCommand) return;
16128
16353
  dropLock();
16129
16354
  process.exit(0);
16130
16355
  });
@@ -16879,7 +17104,7 @@ ${changed2.map((c) => ` - ${c}
16879
17104
  case "repos": {
16880
17105
  const sub = positional[1];
16881
17106
  const usage2 = () => {
16882
- process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project>\ncrew repos hooks [workspace/project] <name> [--suggest] [--write route|repo] [--json]\n");
17107
+ process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project>\ncrew repos hooks [workspace/project] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]\n");
16883
17108
  process.exit(2);
16884
17109
  };
16885
17110
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks") usage2();
@@ -16889,7 +17114,13 @@ ${changed2.map((c) => ` - ${c}
16889
17114
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
16890
17115
  const dir = repoDirOf(configText, dirname19(cfg.configFile), a.route, a.name);
16891
17116
  if (!existsSync31(dir)) throw new ReposError(`${dir} does not exist on disk`);
16892
- const files = { exists: (r) => existsSync31(resolvePath(dir, r)), read: (r) => {
17117
+ const files = { exists: (r) => existsSync31(resolvePath(dir, r)), list: (r) => {
17118
+ try {
17119
+ return readdirSync12(resolvePath(dir, r));
17120
+ } catch {
17121
+ return [];
17122
+ }
17123
+ }, read: (r) => {
16893
17124
  try {
16894
17125
  return readFileSync25(resolvePath(dir, r), "utf8");
16895
17126
  } catch {
@@ -16899,21 +17130,43 @@ ${changed2.map((c) => ` - ${c}
16899
17130
  const suggestion = suggestHooks(files);
16900
17131
  const write = value("write");
16901
17132
  if (write !== void 0 && write !== "route" && write !== "repo") throw new ReposError(`--write takes "route" or "repo" (got "${write}")`);
17133
+ const recipeIds = (value("recipes") ?? "").split(",").map((x) => x.trim()).filter((x) => x && x !== "none");
17134
+ if (recipeIds.length > 0 && !write) throw new ReposError("--recipes only applies with --write route|repo");
17135
+ if (flag("agent") && write) throw new ReposError("--agent is read-only; drop --write (write the sniffed hooks, then re-run --agent to compare)");
17136
+ let agent = null;
17137
+ let agentError = null;
17138
+ if (flag("agent")) {
17139
+ const ac = new AbortController();
17140
+ const onSigint = () => ac.abort();
17141
+ process.once("SIGINT", onSigint);
17142
+ sigintOwnedByCommand = true;
17143
+ try {
17144
+ const routeCfg = cfg.routes.find((r) => r.route === a.route);
17145
+ agent = await runAgentSuggest({ spec: resolveToolchain(cfg.ship, routeCfg, a.name), dir, sniff: suggestion, signal: ac.signal });
17146
+ } catch (e) {
17147
+ if (!(e instanceof AgentSuggestError)) throw e;
17148
+ agentError = { reason: e.reason, message: e.message };
17149
+ } finally {
17150
+ process.off("SIGINT", onSigint);
17151
+ sigintOwnedByCommand = false;
17152
+ }
17153
+ }
17154
+ const picked = selectRecipes(suggestion, recipeIds);
16902
17155
  let written;
16903
- if (write && suggestion.source === null) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
17156
+ if (write && suggestion.source === null && recipeIds.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
16904
17157
  if (write === "route") {
16905
- const plan = planRouteHooks(configText, a.route, a.name, suggestion.hooks);
17158
+ const plan = planRouteHooks(configText, a.route, a.name, picked.hooks);
16906
17159
  writeFileSync19(cfg.configFile, plan.text);
16907
17160
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept };
16908
17161
  } else if (write === "repo") {
16909
17162
  const have = existingRepoConfig(files);
16910
17163
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
16911
17164
  const file = resolvePath(dir, ".crew.yaml");
16912
- writeFileSync19(file, starterRepoConfig(suggestion.hooks), { flag: "wx" });
16913
- written = { target: "repo", file, hooks: Object.keys(suggestion.hooks), kept: [] };
17165
+ writeFileSync19(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
17166
+ written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [] };
16914
17167
  }
16915
17168
  if (flag("json")) {
16916
- process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, written: written ?? null })}
17169
+ process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, agent, agentError, written: written ?? null })}
16917
17170
  `);
16918
17171
  } else {
16919
17172
  if (suggestion.source) process.stdout.write(`${a.name}: proposed from ${suggestion.source}
@@ -16921,6 +17174,18 @@ ${changed2.map((c) => ` - ${c}
16921
17174
  for (const [k, v] of Object.entries(suggestion.hooks)) process.stdout.write(` ${k}: ${v}
16922
17175
  `);
16923
17176
  for (const n of suggestion.notes) process.stdout.write(`note: ${n}
17177
+ `);
17178
+ for (const r of suggestion.recipes) process.stdout.write(`recipe ${r.id}${r.defaultOn ? " (default on)" : ""}${r.writable ? "" : " (advisory)"}: ${r.title} [${r.evidence.join(", ")}]
17179
+ `);
17180
+ if (agent) {
17181
+ process.stdout.write(`agent (${agent.toolchain}, ${agent.model}) read ${agent.filesRead.join(", ") || "no files"}
17182
+ `);
17183
+ for (const [k, v] of Object.entries(agent.hooks)) process.stdout.write(` ${k}: ${v}${agent.differsFromSniff.includes(k) ? " (differs from file sniff)" : ""}
17184
+ `);
17185
+ for (const n of agent.notes) process.stdout.write(`note: ${n}
17186
+ `);
17187
+ }
17188
+ if (agentError) process.stderr.write(`crew repos: --agent failed (${agentError.reason}): ${agentError.message}
16924
17189
  `);
16925
17190
  if (written) {
16926
17191
  process.stdout.write(`wrote ${written.hooks.join(", ") || "nothing new"} to ${written.file}
@@ -16928,8 +17193,11 @@ ${changed2.map((c) => ` - ${c}
16928
17193
  if (written.kept.length) process.stdout.write(`kept existing route hooks: ${written.kept.join(", ")}
16929
17194
  `);
16930
17195
  if (written.target === "repo") process.stdout.write("review it and commit it to the repository\n");
17196
+ if (written.target === "route" && picked.release) process.stdout.write(`note: release.mode ${picked.release.mode} is repo-level; use --write repo to set it
17197
+ `);
16931
17198
  }
16932
17199
  }
17200
+ if (agentError) process.exitCode = 1;
16933
17201
  } catch (e) {
16934
17202
  if (e instanceof ReposError) {
16935
17203
  process.stderr.write(`crew repos: ${e.message}
@@ -18239,7 +18507,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
18239
18507
  },
18240
18508
  inspectCheckout(dir) {
18241
18509
  const dirty = status(dir).map((c) => c.path);
18242
- const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) => realpathSync2(p) !== realpathSync2(dir));
18510
+ const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) => realpathSync3(p) !== realpathSync3(dir));
18243
18511
  return { dirty, linkedWorktrees };
18244
18512
  }
18245
18513
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.63.2",
3
+ "version": "0.64.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",