@tablation/crew 0.63.2 → 0.65.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 +373 -29
  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",
@@ -13026,6 +13027,62 @@ function configuredRepos(text, route, base) {
13026
13027
  const dir = m.get("dir");
13027
13028
  return dir ? [{ name: basename7(String(dir)), dir: expandDir(String(dir), base) }] : [];
13028
13029
  }
13030
+ function routeSetsHooks(text, route, name) {
13031
+ const m = routeMapOf(parseDocument(text), route);
13032
+ const repos = m.get("repos", true);
13033
+ let target = m;
13034
+ if (isMap(repos)) target = repos.get(name, true);
13035
+ if (!isMap(target)) return false;
13036
+ const hooks = target.get("hooks", true);
13037
+ return isMap(hooks) && hooks.items.length > 0;
13038
+ }
13039
+ function listRepoRows(i) {
13040
+ const checkouts = configuredRepos(i.text, i.route, i.base);
13041
+ const claimed = /* @__PURE__ */ new Set();
13042
+ const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
13043
+ const originFor = (c, remote) => {
13044
+ const o = i.originOf(c.dir) ?? null;
13045
+ if (!o) return null;
13046
+ return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
13047
+ };
13048
+ const rows = [];
13049
+ const names = i.resolved?.repoNames ?? {};
13050
+ const remotes = i.resolved?.repoRemotes ?? {};
13051
+ for (const [id, name] of Object.entries(names)) {
13052
+ const remote = remotes[id] ?? null;
13053
+ const want = normalizeRemote(remote);
13054
+ let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
13055
+ if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
13056
+ const c = idx >= 0 ? checkouts[idx] : void 0;
13057
+ if (idx >= 0) claimed.add(idx);
13058
+ rows.push({
13059
+ name,
13060
+ remote,
13061
+ repoRowId: id,
13062
+ attached: c !== void 0,
13063
+ dir: c?.dir ?? null,
13064
+ exists: c ? i.exists(c.dir) : false,
13065
+ origin: c ? originFor(c, remote) : null,
13066
+ matchedRow: c ? name : null,
13067
+ hooksSource: c ? hooksSourceOf(c) : "none"
13068
+ });
13069
+ }
13070
+ checkouts.forEach((c, n) => {
13071
+ if (claimed.has(n)) return;
13072
+ rows.push({
13073
+ name: c.name,
13074
+ remote: null,
13075
+ repoRowId: null,
13076
+ attached: true,
13077
+ dir: c.dir,
13078
+ exists: i.exists(c.dir),
13079
+ origin: originFor(c, null),
13080
+ matchedRow: null,
13081
+ hooksSource: hooksSourceOf(c)
13082
+ });
13083
+ });
13084
+ return rows;
13085
+ }
13029
13086
  function planRepoAdd(i) {
13030
13087
  const doc = parseDocument(i.text);
13031
13088
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
@@ -13204,17 +13261,62 @@ var DETECTORS = [
13204
13261
  { source: "pyproject.toml", detect: fromPyproject },
13205
13262
  { source: "Makefile", detect: fromMakefile }
13206
13263
  ];
13264
+ var WORKFLOW_RE = /\.ya?ml$/;
13265
+ function detectRecipes(f) {
13266
+ const workflows = (f.list?.(".github/workflows") ?? []).filter((n) => WORKFLOW_RE.test(n)).sort();
13267
+ if (workflows.length === 0) return [];
13268
+ const evidence = workflows.map((n) => `.github/workflows/${n}`);
13269
+ return [
13270
+ {
13271
+ id: "github-actions-release",
13272
+ title: "Releases run in GitHub Actions",
13273
+ description: "CI publishes the release after the crew merges; the crew watches `gh release view` for the live tag.",
13274
+ evidence,
13275
+ // `ltrimstr` because `verify.match: version` compares the bare version, not `v1.2.3`.
13276
+ hooks: { released: `gh release view --json tagName -q '.tagName | ltrimstr("v")'` },
13277
+ release: { mode: "ci_auto", ci: "github", verifyMatch: "version" },
13278
+ writable: true,
13279
+ defaultOn: true
13280
+ },
13281
+ {
13282
+ id: "github-actions-ci-status",
13283
+ title: "Check CI status before hand-off",
13284
+ 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.",
13285
+ evidence,
13286
+ hooks: {},
13287
+ check: `gh run list --branch "$CREW_BRANCH" --limit 1 --json conclusion -q '.[0].conclusion == "success"'`,
13288
+ writable: false,
13289
+ defaultOn: false
13290
+ }
13291
+ ];
13292
+ }
13207
13293
  function suggestHooks(f) {
13294
+ const recipes = detectRecipes(f);
13208
13295
  const found = DETECTORS.flatMap((d) => {
13209
13296
  const hooks = d.detect(f);
13210
13297
  return hooks && Object.keys(hooks).length > 0 ? [{ source: d.source, hooks }] : [];
13211
13298
  });
13212
13299
  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"] };
13300
+ 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
13301
  }
13215
13302
  const [first, ...others] = found;
13216
13303
  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 };
13304
+ return { source: first.source, hooks: first.hooks, notes, recipes };
13305
+ }
13306
+ function selectRecipes(s, ids) {
13307
+ const chosen = ids.map((id) => {
13308
+ const r = s.recipes.find((x) => x.id === id);
13309
+ if (!r) throw new ReposError(`no recipe "${id}" here (have: ${s.recipes.map((x) => x.id).join(", ") || "none"})`);
13310
+ if (!r.writable) throw new ReposError(`recipe "${id}" cannot be written: ${r.description}`);
13311
+ return r;
13312
+ });
13313
+ let hooks = { ...s.hooks };
13314
+ let release;
13315
+ for (const r of chosen) {
13316
+ hooks = { ...r.hooks, ...hooks };
13317
+ release ??= r.release;
13318
+ }
13319
+ return { hooks, ...release ? { release } : {} };
13218
13320
  }
13219
13321
  function parseHooksArgs(args, routes) {
13220
13322
  const rest = [...args];
@@ -13224,7 +13326,7 @@ function parseHooksArgs(args, routes) {
13224
13326
  if (routes.length === 1) route = routes[0];
13225
13327
  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
13328
  }
13227
- if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--write route|repo] [--json]");
13329
+ if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]");
13228
13330
  return { route, name: rest[0] };
13229
13331
  }
13230
13332
  function repoDirOf(text, base, route, name) {
@@ -13270,7 +13372,7 @@ function planRouteHooks(text, route, name, hooks) {
13270
13372
  }
13271
13373
  return { text: String(doc), written, kept };
13272
13374
  }
13273
- function starterRepoConfig(hooks) {
13375
+ function starterRepoConfig(hooks, release) {
13274
13376
  const lines = [
13275
13377
  "# Starter .crew.yaml proposed by `crew repos hooks --write repo`.",
13276
13378
  "# Review the hooks, then commit this file; it is the durable home for them",
@@ -13283,7 +13385,12 @@ function starterRepoConfig(hooks) {
13283
13385
  const entries = Object.entries(hooks).filter(([, v]) => v !== void 0);
13284
13386
  if (entries.length === 0) lines.push(' # test: "..."', ' # build: "..."');
13285
13387
  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", "");
13388
+ if (release) {
13389
+ lines.push("", "release:", ` mode: ${release.mode}`);
13390
+ if (release.ci) lines.push(` ci: { provider: ${release.ci} }`);
13391
+ if (release.verifyMatch) lines.push(" verify:", ` match: ${release.verifyMatch}`);
13392
+ lines.push("");
13393
+ } else lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
13287
13394
  const text = lines.join("\n");
13288
13395
  parseRepoConfig(text, ".crew.yaml");
13289
13396
  return text;
@@ -13292,6 +13399,178 @@ function existingRepoConfig(f) {
13292
13399
  return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
13293
13400
  }
13294
13401
 
13402
+ // src/repos-hooks-agent.ts
13403
+ import { spawn as spawn4, execFileSync as execFileSync4 } from "child_process";
13404
+ import { realpathSync as realpathSync2 } from "fs";
13405
+ import { isAbsolute as isAbsolute2, relative } from "path";
13406
+ var AgentSuggestError = class extends Error {
13407
+ reason;
13408
+ constructor(reason, message) {
13409
+ super(message);
13410
+ this.reason = reason;
13411
+ }
13412
+ };
13413
+ var HOOK_KEYS = ["test", "build", "version", "bump", "released"];
13414
+ var READ_ONLY_TOOLS = ["Read", "Grep", "Glob"];
13415
+ var WRITING_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "WebFetch", "WebSearch", "Task", "Agent"];
13416
+ var DEFAULT_TIMEOUT_MS = 18e4;
13417
+ function buildAgentPrompt(sniff) {
13418
+ return [
13419
+ "You are reviewing a git checkout (the current directory) to work out how to test and build it.",
13420
+ "Read README*, CLAUDE.md, AGENTS.md, GEMINI.md, package/build manifests and .github/workflows/* as needed.",
13421
+ "You may only read files. Do not modify anything and do not run commands.",
13422
+ "",
13423
+ `A file sniff already proposed: ${JSON.stringify(sniff.hooks)}.`,
13424
+ "Propose the commands a contributor should run, using the repo's own documented ones over guesses.",
13425
+ "Omit any hook you cannot support from a file you read.",
13426
+ "",
13427
+ "Answer with ONLY one JSON object, no prose and no code fence:",
13428
+ '{"hooks": {"test": "...", "build": "...", "version": "...", "bump": "...", "released": "..."}, "notes": ["..."]}',
13429
+ "`version` prints the bare version, `bump` bumps by $CREW_BUMP (patch|minor|major) and prints the new version,",
13430
+ "`released` prints what is live (a version or sha). Keys you cannot support are left out."
13431
+ ].join("\n");
13432
+ }
13433
+ function agentInvocation(spec, model) {
13434
+ if (spec.harness !== "claude") {
13435
+ throw new AgentSuggestError("unsupported_harness", `--agent supports the claude harness only (toolchain "${spec.name}" is ${spec.harness})`);
13436
+ }
13437
+ return wrapInvocation(spec, {
13438
+ model,
13439
+ args: [
13440
+ "-p",
13441
+ "--allowedTools",
13442
+ ...READ_ONLY_TOOLS,
13443
+ "--disallowedTools",
13444
+ ...WRITING_TOOLS,
13445
+ "--model",
13446
+ model,
13447
+ "--output-format",
13448
+ "stream-json",
13449
+ "--verbose",
13450
+ ...spec.args
13451
+ ]
13452
+ });
13453
+ }
13454
+ function parseAgentStream(out, dir) {
13455
+ let text = "";
13456
+ let isError = false;
13457
+ const files = /* @__PURE__ */ new Set();
13458
+ let base = dir;
13459
+ try {
13460
+ base = realpathSync2(dir);
13461
+ } catch {
13462
+ }
13463
+ for (const line of out.split("\n")) {
13464
+ if (!line.trim()) continue;
13465
+ let ev;
13466
+ try {
13467
+ ev = JSON.parse(line);
13468
+ } catch {
13469
+ continue;
13470
+ }
13471
+ if (ev?.type === "assistant" && Array.isArray(ev.message?.content)) {
13472
+ for (const b of ev.message.content) {
13473
+ const p = b?.type === "tool_use" && b.name === "Read" ? b.input?.file_path : void 0;
13474
+ if (typeof p === "string") files.add(isAbsolute2(p) ? relative(base, p) : p);
13475
+ }
13476
+ } else if (ev?.type === "result") {
13477
+ text = typeof ev.result === "string" ? ev.result : "";
13478
+ isError = ev.is_error === true;
13479
+ }
13480
+ }
13481
+ return { text, isError, filesRead: [...files].sort() };
13482
+ }
13483
+ function parseAgentHooks(text) {
13484
+ const start = text.indexOf("{");
13485
+ const end = text.lastIndexOf("}");
13486
+ if (start < 0 || end <= start) throw new AgentSuggestError("bad_output", "the toolchain did not answer with a JSON object");
13487
+ let obj;
13488
+ try {
13489
+ obj = JSON.parse(text.slice(start, end + 1));
13490
+ } catch {
13491
+ throw new AgentSuggestError("bad_output", "the toolchain's answer was not valid JSON");
13492
+ }
13493
+ const raw = obj && typeof obj === "object" && obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
13494
+ const hooks = {};
13495
+ for (const k of HOOK_KEYS) if (typeof raw[k] === "string" && raw[k].trim()) hooks[k] = raw[k].trim();
13496
+ const notes = Array.isArray(obj.notes) ? obj.notes.filter((n) => typeof n === "string") : [];
13497
+ return { hooks, notes };
13498
+ }
13499
+ var SIGNED_OUT = /not logged in|\/login|invalid api key|authentication|unauthori[sz]ed|\b401\b|credit balance/i;
13500
+ function classifyFailure(o) {
13501
+ if (o.spawnError?.code === "ENOENT") return "not_installed";
13502
+ return SIGNED_OUT.test(o.output) ? "not_signed_in" : "failed";
13503
+ }
13504
+ function porcelain(dir) {
13505
+ try {
13506
+ return execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8" });
13507
+ } catch {
13508
+ return void 0;
13509
+ }
13510
+ }
13511
+ async function runAgentSuggest(o) {
13512
+ const model = o.model ?? o.spec.model;
13513
+ const { bin, args } = agentInvocation(o.spec, model);
13514
+ const adapter = adapterFor(o.spec.harness);
13515
+ const env = { ...process.env, ...adapter.runEnv(o.spec) };
13516
+ for (const v of adapter.unsetEnv) delete env[v];
13517
+ delete env.CREW_API_KEY;
13518
+ const before = porcelain(o.dir);
13519
+ const timeout = AbortSignal.timeout(o.timeoutMs ?? DEFAULT_TIMEOUT_MS);
13520
+ const signal = o.signal ? AbortSignal.any([o.signal, timeout]) : timeout;
13521
+ const run2 = await new Promise((resolve6) => {
13522
+ let out = "";
13523
+ let err = "";
13524
+ const child = spawn4(bin, args, { cwd: o.dir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
13525
+ let settled = false;
13526
+ const settle = (r) => {
13527
+ if (settled) return;
13528
+ settled = true;
13529
+ signal.removeEventListener("abort", onAbort);
13530
+ resolve6(r);
13531
+ };
13532
+ const onAbort = () => {
13533
+ try {
13534
+ if (child.pid) process.kill(-child.pid, "SIGTERM");
13535
+ } catch {
13536
+ child.kill("SIGTERM");
13537
+ }
13538
+ child.stdout.destroy();
13539
+ child.stderr.destroy();
13540
+ settle({ code: null, out, err, aborted: true });
13541
+ };
13542
+ if (signal.aborted) onAbort();
13543
+ else signal.addEventListener("abort", onAbort, { once: true });
13544
+ child.stdout.on("data", (d) => {
13545
+ out += d.toString();
13546
+ });
13547
+ child.stderr.on("data", (d) => {
13548
+ err += d.toString();
13549
+ });
13550
+ child.on("error", (e) => settle({ code: null, out, err, spawnError: e, aborted: false }));
13551
+ child.on("close", (code) => settle({ code, out, err, aborted: false }));
13552
+ child.stdin.on("error", () => {
13553
+ });
13554
+ child.stdin.end(buildAgentPrompt(o.sniff));
13555
+ });
13556
+ if (run2.aborted || signal.aborted) {
13557
+ throw new AgentSuggestError(o.signal?.aborted ? "cancelled" : "timeout", o.signal?.aborted ? "cancelled" : "the toolchain did not answer in time");
13558
+ }
13559
+ const after = porcelain(o.dir);
13560
+ if (before !== void 0 && after !== before) {
13561
+ throw new AgentSuggestError("modified_checkout", "the checkout changed during --agent; inspect it with `git status` (the agent result was discarded)");
13562
+ }
13563
+ const parsed = parseAgentStream(run2.out, o.dir);
13564
+ if (run2.spawnError || run2.code !== 0 || parsed.isError) {
13565
+ const reason = classifyFailure({ code: run2.code, ...run2.spawnError ? { spawnError: run2.spawnError } : {}, output: `${parsed.text}
13566
+ ${run2.err}` });
13567
+ 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}`);
13568
+ }
13569
+ const { hooks, notes } = parseAgentHooks(parsed.text);
13570
+ const differsFromSniff = HOOK_KEYS.filter((k) => hooks[k] !== void 0 && hooks[k] !== o.sniff.hooks[k]);
13571
+ return { source: "agent", hooks, filesRead: parsed.filesRead, differsFromSniff, notes, toolchain: o.spec.name, model };
13572
+ }
13573
+
13295
13574
  // src/sample-ticket.ts
13296
13575
  import { basename as basename8, dirname as dirname15, join as join23 } from "path";
13297
13576
  var SAMPLE_REPO = "crew-sample";
@@ -13719,12 +13998,12 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13719
13998
  }
13720
13999
 
13721
14000
  // 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";
14001
+ 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
14002
  import { createInterface } from "readline/promises";
13724
14003
  import { dirname as dirOf, resolve as resolvePath } from "path";
13725
14004
 
13726
14005
  // src/ssh-keys.ts
13727
- import { execFileSync as execFileSync4 } from "child_process";
14006
+ import { execFileSync as execFileSync5 } from "child_process";
13728
14007
  import { existsSync as existsSync25, mkdirSync as mkdirSync13, readFileSync as readFileSync19, chmodSync } from "fs";
13729
14008
  import { dirname as dirname17, join as join26 } from "path";
13730
14009
  var SshKeyError = class extends Error {
@@ -13738,7 +14017,7 @@ function ensureShipSshKeypair(stateDir) {
13738
14017
  if (!existsSync25(privateKeyPath)) {
13739
14018
  mkdirSync13(dirname17(privateKeyPath), { recursive: true });
13740
14019
  try {
13741
- execFileSync4("ssh-keygen", [
14020
+ execFileSync5("ssh-keygen", [
13742
14021
  "-t",
13743
14022
  "ed25519",
13744
14023
  "-N",
@@ -13764,7 +14043,7 @@ function ensureShipSshKeypair(stateDir) {
13764
14043
  }
13765
14044
  function sshKeygenAvailable() {
13766
14045
  try {
13767
- execFileSync4("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
14046
+ execFileSync5("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
13768
14047
  return true;
13769
14048
  } catch (e) {
13770
14049
  return e.code !== "ENOENT";
@@ -13772,7 +14051,7 @@ function sshKeygenAvailable() {
13772
14051
  }
13773
14052
 
13774
14053
  // src/machine-id.ts
13775
- import { execFileSync as execFileSync5 } from "child_process";
14054
+ import { execFileSync as execFileSync6 } from "child_process";
13776
14055
  import { createHash as createHash4 } from "crypto";
13777
14056
  import { existsSync as existsSync26, mkdirSync as mkdirSync14, readFileSync as readFileSync20, writeFileSync as writeFileSync15 } from "fs";
13778
14057
  import { dirname as dirname18, join as join27 } from "path";
@@ -13783,7 +14062,7 @@ var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
13783
14062
  function readRawMachineId(platform2 = process.platform) {
13784
14063
  try {
13785
14064
  if (platform2 === "darwin") {
13786
- const out = execFileSync5("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
14065
+ const out = execFileSync6("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
13787
14066
  encoding: "utf8",
13788
14067
  stdio: ["ignore", "pipe", "ignore"]
13789
14068
  });
@@ -13822,11 +14101,11 @@ function matchShipRows(ships, shipName2, machineId) {
13822
14101
  }
13823
14102
 
13824
14103
  // src/passenger-containers.ts
13825
- import { execFileSync as execFileSync6 } from "child_process";
14104
+ import { execFileSync as execFileSync7 } from "child_process";
13826
14105
  import { createHash as createHash5 } from "crypto";
13827
14106
  var realExec = (cmd, args) => {
13828
14107
  try {
13829
- return execFileSync6(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
14108
+ return execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
13830
14109
  } catch (e) {
13831
14110
  const err = e;
13832
14111
  const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
@@ -14024,12 +14303,12 @@ function syncAllPassengerCheckouts(cfg) {
14024
14303
  }
14025
14304
 
14026
14305
  // src/tunnel.ts
14027
- import { spawn as spawn4 } from "child_process";
14306
+ import { spawn as spawn5 } from "child_process";
14028
14307
  import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync16, unlinkSync as unlinkSync3, readdirSync as readdirSync8 } from "fs";
14029
14308
  import { randomInt } from "crypto";
14030
14309
  import { join as join28 } from "path";
14031
14310
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
14032
- var defaultSpawn = (cmd, args) => spawn4(cmd, args, DEFAULT_SPAWN_OPTIONS);
14311
+ var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
14033
14312
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
14034
14313
  var SLUG_LENGTH = 12;
14035
14314
  function generateTunnelSlug() {
@@ -14325,7 +14604,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
14325
14604
 
14326
14605
  // src/passenger-sync-daemon.ts
14327
14606
  import { createServer as createHttpServer } from "http";
14328
- import { spawn as spawn5 } from "child_process";
14607
+ import { spawn as spawn6 } from "child_process";
14329
14608
  import { randomBytes } from "crypto";
14330
14609
  import { existsSync as existsSync28, mkdirSync as mkdirSync16, readFileSync as readFileSync22, writeFileSync as writeFileSync17, unlinkSync as unlinkSync4, readdirSync as readdirSync9 } from "fs";
14331
14610
  import { join as join29 } from "path";
@@ -14412,7 +14691,7 @@ function runSyncDaemonFromEnv(env = process.env) {
14412
14691
  return server;
14413
14692
  }
14414
14693
  var defaultSpawn2 = (cmd, args, env) => {
14415
- const child = spawn5(cmd, args, { detached: true, stdio: "ignore", env });
14694
+ const child = spawn6(cmd, args, { detached: true, stdio: "ignore", env });
14416
14695
  child.unref();
14417
14696
  return child;
14418
14697
  };
@@ -14732,7 +15011,7 @@ async function runOnePassUnguarded(o) {
14732
15011
  await hydrateApiKeys([o.route], o.getSessionStore);
14733
15012
  const emit = o.newEmitter();
14734
15013
  const decide = o.decide ?? decideCycle;
14735
- const spawn6 = o.spawnRoleAgent ?? runRoleAgent;
15014
+ const spawn7 = o.spawnRoleAgent ?? runRoleAgent;
14736
15015
  const breaker = o.spawnBreaker ?? new SpawnBreaker();
14737
15016
  const ctx = { route: o.route, ship: o.ship, state: o.state, emit, getSessionStore: o.getSessionStore };
14738
15017
  try {
@@ -14793,7 +15072,7 @@ async function runOnePassUnguarded(o) {
14793
15072
  continue;
14794
15073
  }
14795
15074
  started.push(role);
14796
- const run2 = spawn6(role, decision, ctx).then(() => {
15075
+ const run2 = spawn7(role, decision, ctx).then(() => {
14797
15076
  breaker.recordSuccess(role);
14798
15077
  clearShipAttention(o.state, `role_parked:${role}`);
14799
15078
  }).catch(async (e) => {
@@ -15192,8 +15471,8 @@ function usage() {
15192
15471
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
15193
15472
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
15194
15473
  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)
15196
- crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
15474
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--agent, --write route|repo, --recipes, --json)
15475
+ crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches (--json: every Repos row too)
15197
15476
  crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
15198
15477
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
15199
15478
  crew passengers [route] sync Host Passengers containers/tunnels once; what the passengers unit invokes
@@ -16121,10 +16400,12 @@ ${changed2.map((c) => ` - ${c}
16121
16400
  lock?.release();
16122
16401
  lock = void 0;
16123
16402
  };
16403
+ let sigintOwnedByCommand = false;
16124
16404
  if (!dryRun) {
16125
16405
  process.on("exit", dropLock);
16126
16406
  for (const sig of ["SIGINT", "SIGTERM"]) {
16127
16407
  process.on(sig, () => {
16408
+ if (sig === "SIGINT" && sigintOwnedByCommand) return;
16128
16409
  dropLock();
16129
16410
  process.exit(0);
16130
16411
  });
@@ -16879,7 +17160,7 @@ ${changed2.map((c) => ` - ${c}
16879
17160
  case "repos": {
16880
17161
  const sub = positional[1];
16881
17162
  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");
17163
+ 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> [--json]\ncrew repos hooks [workspace/project] <name> [--suggest] [--agent] [--write route|repo] [--recipes ID,ID] [--json]\n");
16883
17164
  process.exit(2);
16884
17165
  };
16885
17166
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks") usage2();
@@ -16889,7 +17170,13 @@ ${changed2.map((c) => ` - ${c}
16889
17170
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
16890
17171
  const dir = repoDirOf(configText, dirname19(cfg.configFile), a.route, a.name);
16891
17172
  if (!existsSync31(dir)) throw new ReposError(`${dir} does not exist on disk`);
16892
- const files = { exists: (r) => existsSync31(resolvePath(dir, r)), read: (r) => {
17173
+ const files = { exists: (r) => existsSync31(resolvePath(dir, r)), list: (r) => {
17174
+ try {
17175
+ return readdirSync12(resolvePath(dir, r));
17176
+ } catch {
17177
+ return [];
17178
+ }
17179
+ }, read: (r) => {
16893
17180
  try {
16894
17181
  return readFileSync25(resolvePath(dir, r), "utf8");
16895
17182
  } catch {
@@ -16899,21 +17186,43 @@ ${changed2.map((c) => ` - ${c}
16899
17186
  const suggestion = suggestHooks(files);
16900
17187
  const write = value("write");
16901
17188
  if (write !== void 0 && write !== "route" && write !== "repo") throw new ReposError(`--write takes "route" or "repo" (got "${write}")`);
17189
+ const recipeIds = (value("recipes") ?? "").split(",").map((x) => x.trim()).filter((x) => x && x !== "none");
17190
+ if (recipeIds.length > 0 && !write) throw new ReposError("--recipes only applies with --write route|repo");
17191
+ if (flag("agent") && write) throw new ReposError("--agent is read-only; drop --write (write the sniffed hooks, then re-run --agent to compare)");
17192
+ let agent = null;
17193
+ let agentError = null;
17194
+ if (flag("agent")) {
17195
+ const ac = new AbortController();
17196
+ const onSigint = () => ac.abort();
17197
+ process.once("SIGINT", onSigint);
17198
+ sigintOwnedByCommand = true;
17199
+ try {
17200
+ const routeCfg = cfg.routes.find((r) => r.route === a.route);
17201
+ agent = await runAgentSuggest({ spec: resolveToolchain(cfg.ship, routeCfg, a.name), dir, sniff: suggestion, signal: ac.signal });
17202
+ } catch (e) {
17203
+ if (!(e instanceof AgentSuggestError)) throw e;
17204
+ agentError = { reason: e.reason, message: e.message };
17205
+ } finally {
17206
+ process.off("SIGINT", onSigint);
17207
+ sigintOwnedByCommand = false;
17208
+ }
17209
+ }
17210
+ const picked = selectRecipes(suggestion, recipeIds);
16902
17211
  let written;
16903
- if (write && suggestion.source === null) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
17212
+ if (write && suggestion.source === null && recipeIds.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
16904
17213
  if (write === "route") {
16905
- const plan = planRouteHooks(configText, a.route, a.name, suggestion.hooks);
17214
+ const plan = planRouteHooks(configText, a.route, a.name, picked.hooks);
16906
17215
  writeFileSync19(cfg.configFile, plan.text);
16907
17216
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept };
16908
17217
  } else if (write === "repo") {
16909
17218
  const have = existingRepoConfig(files);
16910
17219
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
16911
17220
  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: [] };
17221
+ writeFileSync19(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
17222
+ written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [] };
16914
17223
  }
16915
17224
  if (flag("json")) {
16916
- process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, written: written ?? null })}
17225
+ process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, agent, agentError, written: written ?? null })}
16917
17226
  `);
16918
17227
  } else {
16919
17228
  if (suggestion.source) process.stdout.write(`${a.name}: proposed from ${suggestion.source}
@@ -16921,6 +17230,18 @@ ${changed2.map((c) => ` - ${c}
16921
17230
  for (const [k, v] of Object.entries(suggestion.hooks)) process.stdout.write(` ${k}: ${v}
16922
17231
  `);
16923
17232
  for (const n of suggestion.notes) process.stdout.write(`note: ${n}
17233
+ `);
17234
+ for (const r of suggestion.recipes) process.stdout.write(`recipe ${r.id}${r.defaultOn ? " (default on)" : ""}${r.writable ? "" : " (advisory)"}: ${r.title} [${r.evidence.join(", ")}]
17235
+ `);
17236
+ if (agent) {
17237
+ process.stdout.write(`agent (${agent.toolchain}, ${agent.model}) read ${agent.filesRead.join(", ") || "no files"}
17238
+ `);
17239
+ for (const [k, v] of Object.entries(agent.hooks)) process.stdout.write(` ${k}: ${v}${agent.differsFromSniff.includes(k) ? " (differs from file sniff)" : ""}
17240
+ `);
17241
+ for (const n of agent.notes) process.stdout.write(`note: ${n}
17242
+ `);
17243
+ }
17244
+ if (agentError) process.stderr.write(`crew repos: --agent failed (${agentError.reason}): ${agentError.message}
16924
17245
  `);
16925
17246
  if (written) {
16926
17247
  process.stdout.write(`wrote ${written.hooks.join(", ") || "nothing new"} to ${written.file}
@@ -16928,8 +17249,11 @@ ${changed2.map((c) => ` - ${c}
16928
17249
  if (written.kept.length) process.stdout.write(`kept existing route hooks: ${written.kept.join(", ")}
16929
17250
  `);
16930
17251
  if (written.target === "repo") process.stdout.write("review it and commit it to the repository\n");
17252
+ if (written.target === "route" && picked.release) process.stdout.write(`note: release.mode ${picked.release.mode} is repo-level; use --write repo to set it
17253
+ `);
16931
17254
  }
16932
17255
  }
17256
+ if (agentError) process.exitCode = 1;
16933
17257
  } catch (e) {
16934
17258
  if (e instanceof ReposError) {
16935
17259
  process.stderr.write(`crew repos: ${e.message}
@@ -16986,6 +17310,26 @@ ${changed2.map((c) => ` - ${c}
16986
17310
  const base = dirname19(cfg.configFile);
16987
17311
  try {
16988
17312
  if (sub === "list") {
17313
+ if (flag("json")) {
17314
+ const rows = listRepoRows({
17315
+ text,
17316
+ route: routeName,
17317
+ base,
17318
+ resolved: resolvedRemotes,
17319
+ originOf,
17320
+ exists: (d) => existsSync31(d),
17321
+ repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
17322
+ try {
17323
+ return Object.keys(parseRepoConfig(readFileSync25(resolvePath(d, f), "utf8"), f).hooks).length > 0;
17324
+ } catch {
17325
+ return false;
17326
+ }
17327
+ })
17328
+ });
17329
+ process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows })}
17330
+ `);
17331
+ break;
17332
+ }
16989
17333
  const repos = configuredRepos(text, routeName, base);
16990
17334
  if (repos.length === 0) process.stdout.write(`route ${routeName} has no checkout configured \u2014 \`crew repos add ${routeName} <path>\`
16991
17335
  `);
@@ -18239,7 +18583,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
18239
18583
  },
18240
18584
  inspectCheckout(dir) {
18241
18585
  const dirty = status(dir).map((c) => c.path);
18242
- const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) => realpathSync2(p) !== realpathSync2(dir));
18586
+ const linkedWorktrees = worktrees(dir).map((w) => w.path).filter((p) => realpathSync3(p) !== realpathSync3(dir));
18243
18587
  return { dirty, linkedWorktrees };
18244
18588
  }
18245
18589
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.63.2",
3
+ "version": "0.65.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",