@tablation/crew 0.58.0 → 0.60.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 +6 -1
  2. package/dist/cli.js +615 -239
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -31,6 +31,84 @@ function doctorBaseReport(r, base, health) {
31
31
  }
32
32
  }
33
33
 
34
+ // src/doctor-checks.ts
35
+ import { execFileSync } from "child_process";
36
+ var PROBE_BRANCH = "crew-probe-branch";
37
+ var realGit = (cwd, args, env) => {
38
+ execFileSync("git", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout: 3e4 });
39
+ };
40
+ function isSshRemote(url) {
41
+ return /^ssh:\/\//.test(url) || /^[\w.-]+@[\w.-]+:/.test(url);
42
+ }
43
+ function pushAuthHint(remoteUrl) {
44
+ return isSshRemote(remoteUrl) ? "make sure an SSH key is loaded for this host (ssh-add), and that the scheduler user can reach it" : "run `gh auth login`, then `gh auth setup-git`";
45
+ }
46
+ function probePushAuth(o, run2 = realGit, baseEnv = process.env) {
47
+ if (o.offline) return { state: "skipped", detail: "skipped (--offline)" };
48
+ if (!o.remoteUrl) return { state: "skipped", detail: `no remote "${o.remote}" configured` };
49
+ const env = { ...baseEnv, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never", GIT_SSH_COMMAND: `${baseEnv.GIT_SSH_COMMAND ?? "ssh"} -o BatchMode=yes` };
50
+ try {
51
+ run2(o.dir, ["push", "--dry-run", o.remote, `HEAD:refs/heads/${PROBE_BRANCH}`], env);
52
+ return { state: "ok" };
53
+ } catch (e) {
54
+ const err = e;
55
+ const raw = (err.stderr ? String(err.stderr) : err.message).trim().split("\n").filter(Boolean).slice(-2).join(" / ");
56
+ return { state: "failed", detail: raw || "push dry-run failed", hint: pushAuthHint(o.remoteUrl) };
57
+ }
58
+ }
59
+ var INSTALL_HINTS = {
60
+ gh: "install GitHub CLI: brew install gh (https://cli.github.com)",
61
+ node: "install Node: brew install node, or https://nodejs.org",
62
+ docker: "install Docker Desktop: https://docs.docker.com/get-docker/"
63
+ };
64
+ function mentionsNode(script) {
65
+ return !!script && /\b(node|npm|pnpm|npx|yarn)\b/.test(script);
66
+ }
67
+ function isPrBased(hooks) {
68
+ return !!hooks.pr || /\bgh\b/.test(hooks.merged ?? "");
69
+ }
70
+ function toolChecks(o, find) {
71
+ const mk = (name, need) => {
72
+ const path = find(name);
73
+ return { name, need, found: !!path, ...path ? { path } : {}, hint: INSTALL_HINTS[name] };
74
+ };
75
+ const node = mk("node", "optional");
76
+ if (o.hooksMentionNode) node.hint = `your repositories' hooks may need it \u2014 ${node.hint}`;
77
+ return [mk("gh", o.prBased ? "required" : "optional"), node, mk("docker", o.hostPassengers ? "required" : "optional")];
78
+ }
79
+ function formatToolCheck(t) {
80
+ const label = `${t.name}:`.padEnd(19);
81
+ if (t.found) return `${label}OK (${t.path}) [${t.need}]
82
+ `;
83
+ return `${label}${t.need === "required" ? "NOT FOUND" : "not found"} [${t.need}] \u2014 ${t.hint}
84
+ `;
85
+ }
86
+ function formatPushAuth(repo, r) {
87
+ const label = `push auth ${repo}:`.padEnd(19);
88
+ if (r.state === "ok") return `${label}ok
89
+ `;
90
+ if (r.state === "skipped") return `${label}${r.detail}
91
+ `;
92
+ return `${label}FAILED \u2014 ${r.detail}; ${r.hint}
93
+ `;
94
+ }
95
+ async function settleRepos(items, nameOf, resolve6) {
96
+ const out = await Promise.all(items.map(async (t) => {
97
+ try {
98
+ return { ok: true, value: await resolve6(t) };
99
+ } catch (e) {
100
+ return { ok: false, repo: nameOf(t), detail: e.message };
101
+ }
102
+ }));
103
+ const settled = [];
104
+ const errors = [];
105
+ for (const o of out) {
106
+ if (o.ok) settled.push(o.value);
107
+ else errors.push({ repo: o.repo, detail: o.detail });
108
+ }
109
+ return { settled, errors };
110
+ }
111
+
34
112
  // src/selftest.ts
35
113
  import { spawn as spawn2 } from "child_process";
36
114
  import { existsSync as existsSync12, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "fs";
@@ -5104,7 +5182,7 @@ function doctorSelftestLines(stored, toolchains) {
5104
5182
  }
5105
5183
 
5106
5184
  // src/cli.ts
5107
- import { resolve as resolve5, dirname as dirname15, join as join29, basename as basename10 } from "path";
5185
+ import { resolve as resolve5, dirname as dirname16, join as join29, basename as basename10 } from "path";
5108
5186
  import { fileURLToPath } from "url";
5109
5187
  import { hostname as hostname2, homedir as homedir8 } from "os";
5110
5188
  import { spawnSync as spawnSync6 } from "child_process";
@@ -5133,7 +5211,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
5133
5211
  "tail",
5134
5212
  "ticket",
5135
5213
  "workspace",
5136
- "workspace-id"
5214
+ "workspace-id",
5215
+ "write"
5137
5216
  ]);
5138
5217
  function positionalArgs(argv2) {
5139
5218
  const out = [];
@@ -6078,7 +6157,7 @@ function recoveredFailureHeadlines(o, route) {
6078
6157
  import { existsSync as existsSync15, statfsSync } from "fs";
6079
6158
 
6080
6159
  // src/git.ts
6081
- import { execFileSync } from "child_process";
6160
+ import { execFileSync as execFileSync2 } from "child_process";
6082
6161
  import {
6083
6162
  existsSync as existsSync14,
6084
6163
  mkdirSync as mkdirSync7,
@@ -6100,7 +6179,7 @@ function streamText(x) {
6100
6179
  }
6101
6180
  function gitRaw(cwd, args) {
6102
6181
  try {
6103
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6182
+ return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6104
6183
  } catch (e) {
6105
6184
  const err = e;
6106
6185
  const why = [err.stderr, err.stdout].map(streamText).filter(Boolean).join(" / ");
@@ -6109,7 +6188,7 @@ function gitRaw(cwd, args) {
6109
6188
  }
6110
6189
  function git(cwd, args) {
6111
6190
  try {
6112
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
6191
+ return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
6113
6192
  } catch (e) {
6114
6193
  const err = e;
6115
6194
  const why = [err.stderr, err.stdout].map(streamText).filter(Boolean).join(" / ");
@@ -6266,9 +6345,9 @@ function keyCommitsInRange(cwd, key, range) {
6266
6345
  }
6267
6346
  function patchIdOf(cwd, from, to) {
6268
6347
  try {
6269
- const diff = execFileSync("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
6348
+ const diff = execFileSync2("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
6270
6349
  if (!diff) return null;
6271
- const out = execFileSync("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
6350
+ const out = execFileSync2("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
6272
6351
  return out.split(" ")[0]?.trim() || null;
6273
6352
  } catch {
6274
6353
  return null;
@@ -6532,7 +6611,7 @@ function ensureRepoCheckout(dir, remote, opts = {}) {
6532
6611
  mkdirSync7(dirname6(dir), { recursive: true });
6533
6612
  const url = cloneUrlFor(remote, opts);
6534
6613
  try {
6535
- execFileSync("git", ["clone", url, dir], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6614
+ execFileSync2("git", ["clone", url, dir], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6536
6615
  } catch (e) {
6537
6616
  const err = e;
6538
6617
  const why = [err.stderr, err.stdout].map(streamText).filter(Boolean).join(" / ");
@@ -9243,12 +9322,12 @@ import { existsSync as existsSync17 } from "fs";
9243
9322
  import { dirname as dirname7, join as join17 } from "path";
9244
9323
 
9245
9324
  // src/ports.ts
9246
- import { execFileSync as execFileSync2 } from "child_process";
9325
+ import { execFileSync as execFileSync3 } from "child_process";
9247
9326
  import { existsSync as existsSync16 } from "fs";
9248
9327
  import { join as join16 } from "path";
9249
9328
  var run = (cmd, args) => {
9250
9329
  try {
9251
- return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
9330
+ return execFileSync3(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
9252
9331
  } catch {
9253
9332
  return "";
9254
9333
  }
@@ -12439,6 +12518,60 @@ async function canMintAgentKeys(route, o = {}) {
12439
12518
  }
12440
12519
  }
12441
12520
 
12521
+ // src/agents-sync-run.ts
12522
+ import { existsSync as existsSync21, mkdirSync as mkdirSync10, readFileSync as readFileSync15, writeFileSync as writeFileSync12 } from "fs";
12523
+ import { dirname as dirname11 } from "path";
12524
+ async function runAgentsSync(target, o) {
12525
+ const dryRun = o.dryRun ?? false;
12526
+ const force = o.force ?? false;
12527
+ const keyRole = target.resolved?.role ?? await fetchKeyRole(target, o.userAgent);
12528
+ let readOnly = !isAdminRole(keyRole);
12529
+ const runPersonas = (ro) => syncPersonas(target, { userAgent: o.userAgent, dryRun, force, readOnly: ro });
12530
+ let personaResult;
12531
+ try {
12532
+ personaResult = await runPersonas(readOnly);
12533
+ } catch (e) {
12534
+ if (readOnly || !isForbidden(e)) throw e;
12535
+ readOnly = true;
12536
+ personaResult = await runPersonas(true);
12537
+ }
12538
+ o.say(`route: ${target.route}`);
12539
+ for (const p of personaResult.outcomes) o.say(describeSyncOutcome(p));
12540
+ for (const l of personaResult.crewLinks) o.say(describeCrewLink(l));
12541
+ if (personaResult.releaseNote) o.say(personaResult.releaseNote);
12542
+ const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
12543
+ for (const w of skillMarkerWarnings(target.promptsDir, briefs)) o.warn(`warning: ${w}`);
12544
+ let skillResult;
12545
+ if (!readOnly) {
12546
+ try {
12547
+ skillResult = await syncSkills(target, { userAgent: o.userAgent, dryRun, force });
12548
+ } catch (e) {
12549
+ if (isForbidden(e)) readOnly = true;
12550
+ else throw e;
12551
+ }
12552
+ }
12553
+ for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
12554
+ if (!dryRun) {
12555
+ const resolvedPath = resolvedPathFor(o.stateDir, target.route);
12556
+ const raw = existsSync21(resolvedPath) ? JSON.parse(readFileSync15(resolvedPath, "utf8")) : {};
12557
+ raw.agentPersonas = personaResult.agentPersonas;
12558
+ if (skillResult) raw.agentSkills = skillResult.agentSkills;
12559
+ if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
12560
+ mkdirSync10(dirname11(resolvedPath), { recursive: true });
12561
+ writeFileSync12(resolvedPath, `${JSON.stringify(raw, null, 2)}
12562
+ `);
12563
+ if (target.resolved) {
12564
+ target.resolved.agentPersonas = personaResult.agentPersonas;
12565
+ if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
12566
+ }
12567
+ }
12568
+ if (readOnly) {
12569
+ o.say("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)");
12570
+ }
12571
+ const diverged = personaResult.outcomes.some((p) => p.action === "diverged") || (skillResult?.outcomes.some((s) => s.action === "diverged") ?? false);
12572
+ return { personaResult, skillResult, readOnly, diverged };
12573
+ }
12574
+
12442
12575
  // src/logbook.ts
12443
12576
  import { createHash as createHash3 } from "crypto";
12444
12577
  import { TablationClient as TablationClient4 } from "@tablation/client";
@@ -12527,77 +12660,12 @@ async function showLogEntry(route, entryId, opts = {}) {
12527
12660
  };
12528
12661
  }
12529
12662
 
12530
- // src/sample-ticket.ts
12531
- import { basename as basename7, dirname as dirname11, join as join20 } from "path";
12532
- var SAMPLE_REPO = "crew-sample";
12533
- var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
12534
- var SAMPLE_MARKER = "sample: true";
12535
- var SAMPLE_CREW_YAML = `version: 1
12536
- platform: unix
12537
- branch:
12538
- base: main
12539
- hooks:
12540
- test: "true"
12541
- build: "true"
12542
- release:
12543
- mode: integrate
12544
- versioning: none
12545
- changelog: false
12546
- `;
12547
- var SAMPLE_README = `# crew-sample
12548
-
12549
- A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
12550
- has something safe to work on: its test and build hooks always pass, it has no
12551
- remote, and \`crew sample-ticket --remove\` deletes it.
12552
- `;
12553
- function sampleDir(existing, reposBase, workspace) {
12554
- if (existing.length > 0) return join20(dirname11(existing[0]), SAMPLE_REPO);
12555
- return join20(reposBase, workspace, SAMPLE_REPO);
12556
- }
12557
- var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
12558
- function sampleDescription() {
12559
- return [
12560
- SAMPLE_MARKER,
12561
- "",
12562
- "> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
12563
- "",
12564
- "## What",
12565
- "",
12566
- "Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
12567
- "with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
12568
- "",
12569
- "## Acceptance",
12570
- "",
12571
- '- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
12572
- "- The test file runs with `node --test` and passes.",
12573
- "- Add a line to the README saying the function exists.",
12574
- "",
12575
- "## Notes",
12576
- "",
12577
- "The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
12578
- "diff against the acceptance list above."
12579
- ].join("\n");
12580
- }
12581
- function isSampleTicket(description) {
12582
- return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
12583
- }
12584
- function planSampleTicket(i) {
12585
- return {
12586
- title: SAMPLE_TITLE,
12587
- description: sampleDescription(),
12588
- report_type: "feature",
12589
- severity: "s4",
12590
- reporter_name: "crew sample-ticket",
12591
- status: i.approvedStatus,
12592
- project_id: i.projectId,
12593
- repo_id: i.repoId,
12594
- ...i.filedBy ? { filed_by_id: i.filedBy } : {}
12595
- };
12596
- }
12663
+ // src/repos-hooks.ts
12664
+ import { isMap as isMap2, parseDocument as parseDocument2, YAMLMap } from "yaml";
12597
12665
 
12598
12666
  // src/repos-cmd.ts
12599
12667
  import { homedir as homedir7 } from "os";
12600
- import { basename as basename8, join as join21, resolve as resolve3 } from "path";
12668
+ import { basename as basename7, join as join20, resolve as resolve3 } from "path";
12601
12669
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
12602
12670
  var ReposError = class extends Error {
12603
12671
  };
@@ -12669,7 +12737,7 @@ function configuredRepos(text, route, base) {
12669
12737
  });
12670
12738
  }
12671
12739
  const dir = m.get("dir");
12672
- return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12740
+ return dir ? [{ name: basename7(String(dir)), dir: expandDir(String(dir), base) }] : [];
12673
12741
  }
12674
12742
  function planRepoAdd(i) {
12675
12743
  const doc = parseDocument(i.text);
@@ -12681,7 +12749,7 @@ function planRepoAdd(i) {
12681
12749
  throw new ReposError(`${target} is already configured on route ${i.route}`);
12682
12750
  }
12683
12751
  const matched = matchRepoName(i.origin, i.resolved);
12684
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
12752
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename7(target);
12685
12753
  let warning;
12686
12754
  if (!matched) {
12687
12755
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -12795,13 +12863,218 @@ function planRepoClone(i) {
12795
12863
  }
12796
12864
  const ship = doc.get("ship", true);
12797
12865
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
12798
- const target = i.dir ? expandDir(i.dir, i.base) : join21(expandDir(basePath, i.base), i.route.split("/")[0], name);
12866
+ const target = i.dir ? expandDir(i.dir, i.base) : join20(expandDir(basePath, i.base), i.route.split("/")[0], name);
12799
12867
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name });
12800
12868
  return { ...add, remote, target };
12801
12869
  }
12802
12870
 
12871
+ // src/repos-hooks.ts
12872
+ function packageManager(f) {
12873
+ if (f.exists("pnpm-lock.yaml")) return "pnpm";
12874
+ if (f.exists("yarn.lock")) return "yarn";
12875
+ if (f.exists("bun.lock") || f.exists("bun.lockb")) return "bun";
12876
+ return "npm";
12877
+ }
12878
+ function fromPackageJson(f) {
12879
+ const text = f.read("package.json");
12880
+ if (text === void 0) return void 0;
12881
+ let pkg;
12882
+ try {
12883
+ pkg = JSON.parse(text);
12884
+ } catch {
12885
+ return void 0;
12886
+ }
12887
+ if (!pkg || typeof pkg !== "object") return void 0;
12888
+ const pm = packageManager(f);
12889
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
12890
+ const out = {};
12891
+ if (typeof scripts.test === "string") out.test = `${pm} test`;
12892
+ if (typeof scripts.build === "string") out.build = pm === "yarn" ? "yarn build" : `${pm} run build`;
12893
+ if (typeof pkg.version === "string") {
12894
+ out.version = `node -p "require('./package.json').version"`;
12895
+ out.bump = `npm version "$CREW_BUMP" --no-git-tag-version | sed 's/^v//'`;
12896
+ }
12897
+ return out;
12898
+ }
12899
+ function fromPackageSwift(f) {
12900
+ return f.exists("Package.swift") ? { test: "swift test", build: "swift build" } : void 0;
12901
+ }
12902
+ function fromPyproject(f) {
12903
+ return f.exists("pyproject.toml") ? { test: "pytest" } : void 0;
12904
+ }
12905
+ function fromMakefile(f) {
12906
+ const text = f.read("Makefile") ?? f.read("makefile") ?? f.read("GNUmakefile");
12907
+ if (text === void 0) return void 0;
12908
+ const out = {};
12909
+ for (const target of ["test", "build"]) {
12910
+ if (new RegExp(`^${target}\\s*:`, "m").test(text)) out[target] = `make ${target}`;
12911
+ }
12912
+ return out;
12913
+ }
12914
+ var DETECTORS = [
12915
+ { source: "package.json", detect: fromPackageJson },
12916
+ { source: "Package.swift", detect: fromPackageSwift },
12917
+ { source: "pyproject.toml", detect: fromPyproject },
12918
+ { source: "Makefile", detect: fromMakefile }
12919
+ ];
12920
+ function suggestHooks(f) {
12921
+ const found = DETECTORS.flatMap((d) => {
12922
+ const hooks = d.detect(f);
12923
+ return hooks && Object.keys(hooks).length > 0 ? [{ source: d.source, hooks }] : [];
12924
+ });
12925
+ if (found.length === 0) {
12926
+ return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"] };
12927
+ }
12928
+ const [first, ...others] = found;
12929
+ const notes = others.length > 0 ? [`also found ${others.map((o) => o.source).join(", ")}; proposing from ${first.source} only`] : [];
12930
+ return { source: first.source, hooks: first.hooks, notes };
12931
+ }
12932
+ function parseHooksArgs(args, routes) {
12933
+ const rest = [...args];
12934
+ let route;
12935
+ if (rest.length > 1 && routes.includes(rest[0])) route = rest.shift();
12936
+ if (!route) {
12937
+ if (routes.length === 1) route = routes[0];
12938
+ 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(", ")}`);
12939
+ }
12940
+ if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--write route|repo] [--json]");
12941
+ return { route, name: rest[0] };
12942
+ }
12943
+ function repoDirOf(text, base, route, name) {
12944
+ const repos = configuredRepos(text, route, base);
12945
+ const hit = repos.find((r) => r.name === name);
12946
+ if (!hit) {
12947
+ throw new ReposError(`route ${route} has no repo named "${name}" (have: ${repos.map((r) => r.name).join(", ") || "none"})`);
12948
+ }
12949
+ return hit.dir;
12950
+ }
12951
+ function planRouteHooks(text, route, name, hooks) {
12952
+ const doc = parseDocument2(text);
12953
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12954
+ const m = routeMapOf(doc, route);
12955
+ const repos = m.get("repos", true);
12956
+ let target = m;
12957
+ if (isMap2(repos)) {
12958
+ const entry = repos.get(name, true);
12959
+ if (isMap2(entry)) target = entry;
12960
+ else {
12961
+ const created = new YAMLMap();
12962
+ created.set("dir", String(repos.get(name)));
12963
+ repos.set(name, created);
12964
+ target = created;
12965
+ }
12966
+ }
12967
+ const current = target.get("hooks", true);
12968
+ let hooksMap;
12969
+ if (isMap2(current)) hooksMap = current;
12970
+ else {
12971
+ hooksMap = new YAMLMap();
12972
+ target.set("hooks", hooksMap);
12973
+ }
12974
+ const written = [];
12975
+ const kept = [];
12976
+ for (const [k, v] of Object.entries(hooks)) {
12977
+ if (v === void 0) continue;
12978
+ if (hooksMap.has(k)) kept.push(k);
12979
+ else {
12980
+ hooksMap.set(k, v);
12981
+ written.push(k);
12982
+ }
12983
+ }
12984
+ return { text: String(doc), written, kept };
12985
+ }
12986
+ function starterRepoConfig(hooks) {
12987
+ const lines = [
12988
+ "# Starter .crew.yaml proposed by `crew repos hooks --write repo`.",
12989
+ "# Review the hooks, then commit this file; it is the durable home for them",
12990
+ "# (docs/REPO_SPEC.md). A route-level copy in crew.yaml is shadowed by it.",
12991
+ `version: ${SPEC_VERSION}`,
12992
+ "platform: any",
12993
+ "",
12994
+ "hooks:"
12995
+ ];
12996
+ const entries = Object.entries(hooks).filter(([, v]) => v !== void 0);
12997
+ if (entries.length === 0) lines.push(' # test: "..."', ' # build: "..."');
12998
+ for (const [k, v] of entries) lines.push(` ${k}: ${JSON.stringify(v)}`);
12999
+ lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
13000
+ const text = lines.join("\n");
13001
+ parseRepoConfig(text, ".crew.yaml");
13002
+ return text;
13003
+ }
13004
+ function existingRepoConfig(f) {
13005
+ return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
13006
+ }
13007
+
13008
+ // src/sample-ticket.ts
13009
+ import { basename as basename8, dirname as dirname13, join as join21 } from "path";
13010
+ var SAMPLE_REPO = "crew-sample";
13011
+ var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
13012
+ var SAMPLE_MARKER = "sample: true";
13013
+ var SAMPLE_CREW_YAML = `version: 1
13014
+ platform: unix
13015
+ branch:
13016
+ base: main
13017
+ hooks:
13018
+ test: "true"
13019
+ build: "true"
13020
+ release:
13021
+ mode: integrate
13022
+ versioning: none
13023
+ changelog: false
13024
+ `;
13025
+ var SAMPLE_README = `# crew-sample
13026
+
13027
+ A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
13028
+ has something safe to work on: its test and build hooks always pass, it has no
13029
+ remote, and \`crew sample-ticket --remove\` deletes it.
13030
+ `;
13031
+ function sampleDir(existing, reposBase, workspace) {
13032
+ if (existing.length > 0) return join21(dirname13(existing[0]), SAMPLE_REPO);
13033
+ return join21(reposBase, workspace, SAMPLE_REPO);
13034
+ }
13035
+ var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
13036
+ function sampleDescription() {
13037
+ return [
13038
+ SAMPLE_MARKER,
13039
+ "",
13040
+ "> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
13041
+ "",
13042
+ "## What",
13043
+ "",
13044
+ "Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
13045
+ "with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
13046
+ "",
13047
+ "## Acceptance",
13048
+ "",
13049
+ '- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
13050
+ "- The test file runs with `node --test` and passes.",
13051
+ "- Add a line to the README saying the function exists.",
13052
+ "",
13053
+ "## Notes",
13054
+ "",
13055
+ "The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
13056
+ "diff against the acceptance list above."
13057
+ ].join("\n");
13058
+ }
13059
+ function isSampleTicket(description) {
13060
+ return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
13061
+ }
13062
+ function planSampleTicket(i) {
13063
+ return {
13064
+ title: SAMPLE_TITLE,
13065
+ description: sampleDescription(),
13066
+ report_type: "feature",
13067
+ severity: "s4",
13068
+ reporter_name: "crew sample-ticket",
13069
+ status: i.approvedStatus,
13070
+ project_id: i.projectId,
13071
+ repo_id: i.repoId,
13072
+ ...i.filedBy ? { filed_by_id: i.filedBy } : {}
13073
+ };
13074
+ }
13075
+
12803
13076
  // src/doctor-fix.ts
12804
- import { parseDocument as parseDocument2 } from "yaml";
13077
+ import { parseDocument as parseDocument3 } from "yaml";
12805
13078
  var sound = (r) => r.hasRepos && r.problems.length === 0;
12806
13079
  function planFix(f) {
12807
13080
  const actions = [];
@@ -12828,7 +13101,7 @@ function routesInScope(routes, named) {
12828
13101
  return named ? routes.filter((r) => r.route === named) : routes;
12829
13102
  }
12830
13103
  function planEnable(text, route) {
12831
- const doc = parseDocument2(text);
13104
+ const doc = parseDocument3(text);
12832
13105
  routeMapOf(doc, route).set("enabled", true);
12833
13106
  return String(doc);
12834
13107
  }
@@ -12838,8 +13111,8 @@ function schedulerInstalled(plan, exists, crontab) {
12838
13111
  }
12839
13112
 
12840
13113
  // src/worktree-sweep.ts
12841
- import { existsSync as existsSync21, readFileSync as readFileSync15, readdirSync as readdirSync5 } from "fs";
12842
- import { basename as basename9, dirname as dirname13, join as join22, resolve as resolve4 } from "path";
13114
+ import { existsSync as existsSync22, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "fs";
13115
+ import { basename as basename9, dirname as dirname14, join as join22, resolve as resolve4 } from "path";
12843
13116
  function planWorktreeSweep(target, tickets, contract) {
12844
13117
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
12845
13118
  const verified = contract.statuses.verified;
@@ -12893,7 +13166,7 @@ function worktreeTicketNumbers(target) {
12893
13166
  return [...nums];
12894
13167
  }
12895
13168
  function siblingWorktreeDirs(target) {
12896
- const parent = dirname13(target.dir.replace(/[/\\]+$/, ""));
13169
+ const parent = dirname14(target.dir.replace(/[/\\]+$/, ""));
12897
13170
  const stem = `${basename9(target.dir.replace(/[/\\]+$/, ""))}-`;
12898
13171
  const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
12899
13172
  let names;
@@ -12941,7 +13214,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12941
13214
  let removed = 0;
12942
13215
  let keptBranches = 0;
12943
13216
  for (const a of actions) {
12944
- if (!dryRun && base && !a.keepBranch && existsSync21(a.path) && isHandBackMerge(a.path, base)) {
13217
+ if (!dryRun && base && !a.keepBranch && existsSync22(a.path) && isHandBackMerge(a.path, base)) {
12945
13218
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
12946
13219
  log.emit(
12947
13220
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -12949,7 +13222,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12949
13222
  );
12950
13223
  }
12951
13224
  }
12952
- if (!dryRun && existsSync21(a.path)) {
13225
+ if (!dryRun && existsSync22(a.path)) {
12953
13226
  let dirty = false;
12954
13227
  try {
12955
13228
  dirty = status(a.path).length > 0;
@@ -12960,7 +13233,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12960
13233
  continue;
12961
13234
  }
12962
13235
  }
12963
- if (!dryRun && a.requirePushed && a.branch && existsSync21(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
13236
+ if (!dryRun && a.requirePushed && a.branch && existsSync22(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
12964
13237
  refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
12965
13238
  continue;
12966
13239
  }
@@ -13009,7 +13282,7 @@ function isHandBackMerge(path, base) {
13009
13282
  if (!msgPath) return false;
13010
13283
  let first;
13011
13284
  try {
13012
- first = readFileSync15(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
13285
+ first = readFileSync16(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
13013
13286
  } catch {
13014
13287
  return false;
13015
13288
  }
@@ -13114,12 +13387,12 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
13114
13387
  }
13115
13388
 
13116
13389
  // src/stream-sweep.ts
13117
- import { existsSync as existsSync22, readdirSync as readdirSync6, rmSync as rmSync9, statSync as statSync7 } from "fs";
13390
+ import { existsSync as existsSync23, readdirSync as readdirSync6, rmSync as rmSync9, statSync as statSync7 } from "fs";
13118
13391
  import { join as join23 } from "path";
13119
13392
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
13120
13393
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
13121
13394
  const dir = join23(stateDir, "streams");
13122
- if (!existsSync22(dir)) return [];
13395
+ if (!existsSync23(dir)) return [];
13123
13396
  const cutoff = now - retentionDays * DAY_MS2;
13124
13397
  return readdirSync6(dir).map((name) => join23(dir, name)).filter((path) => {
13125
13398
  try {
@@ -13147,7 +13420,7 @@ function applyStreamSweep(paths, dryRun, log) {
13147
13420
  return removed;
13148
13421
  }
13149
13422
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13150
- if (!existsSync22(stateDir)) return [];
13423
+ if (!existsSync23(stateDir)) return [];
13151
13424
  const cutoff = now - retentionDays * DAY_MS2;
13152
13425
  return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join23(stateDir, name)).filter((path) => {
13153
13426
  try {
@@ -13159,14 +13432,14 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13159
13432
  }
13160
13433
 
13161
13434
  // src/cli.ts
13162
- import { readFileSync as readFileSync21, existsSync as existsSync28, mkdirSync as mkdirSync14, writeFileSync as writeFileSync15, rmSync as rmSync11, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
13435
+ import { readFileSync as readFileSync22, existsSync as existsSync29, mkdirSync as mkdirSync15, writeFileSync as writeFileSync16, rmSync as rmSync11, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
13163
13436
  import { createInterface } from "readline/promises";
13164
13437
  import { dirname as dirOf, resolve as resolvePath } from "path";
13165
13438
 
13166
13439
  // src/ssh-keys.ts
13167
- import { execFileSync as execFileSync3 } from "child_process";
13168
- import { existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync16, chmodSync } from "fs";
13169
- import { dirname as dirname14, join as join24 } from "path";
13440
+ import { execFileSync as execFileSync4 } from "child_process";
13441
+ import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, chmodSync } from "fs";
13442
+ import { dirname as dirname15, join as join24 } from "path";
13170
13443
  var SshKeyError = class extends Error {
13171
13444
  };
13172
13445
  function shipSshKeyPathFor(stateDir) {
@@ -13175,10 +13448,10 @@ function shipSshKeyPathFor(stateDir) {
13175
13448
  function ensureShipSshKeypair(stateDir) {
13176
13449
  const privateKeyPath = shipSshKeyPathFor(stateDir);
13177
13450
  const publicKeyPath = `${privateKeyPath}.pub`;
13178
- if (!existsSync23(privateKeyPath)) {
13179
- mkdirSync10(dirname14(privateKeyPath), { recursive: true });
13451
+ if (!existsSync24(privateKeyPath)) {
13452
+ mkdirSync11(dirname15(privateKeyPath), { recursive: true });
13180
13453
  try {
13181
- execFileSync3("ssh-keygen", [
13454
+ execFileSync4("ssh-keygen", [
13182
13455
  "-t",
13183
13456
  "ed25519",
13184
13457
  "-N",
@@ -13196,15 +13469,15 @@ function ensureShipSshKeypair(stateDir) {
13196
13469
  }
13197
13470
  chmodSync(privateKeyPath, 384);
13198
13471
  }
13199
- if (!existsSync23(publicKeyPath)) {
13472
+ if (!existsSync24(publicKeyPath)) {
13200
13473
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
13201
13474
  }
13202
- const publicKey = readFileSync16(publicKeyPath, "utf8").trim();
13475
+ const publicKey = readFileSync17(publicKeyPath, "utf8").trim();
13203
13476
  return { privateKeyPath, publicKeyPath, publicKey };
13204
13477
  }
13205
13478
  function sshKeygenAvailable() {
13206
13479
  try {
13207
- execFileSync3("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
13480
+ execFileSync4("ssh-keygen", ["-V"], { stdio: ["ignore", "ignore", "ignore"] });
13208
13481
  return true;
13209
13482
  } catch (e) {
13210
13483
  return e.code !== "ENOENT";
@@ -13212,11 +13485,11 @@ function sshKeygenAvailable() {
13212
13485
  }
13213
13486
 
13214
13487
  // src/passenger-containers.ts
13215
- import { execFileSync as execFileSync4 } from "child_process";
13488
+ import { execFileSync as execFileSync5 } from "child_process";
13216
13489
  import { createHash as createHash4 } from "crypto";
13217
13490
  var realExec = (cmd, args) => {
13218
13491
  try {
13219
- return execFileSync4(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
13492
+ return execFileSync5(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
13220
13493
  } catch (e) {
13221
13494
  const err = e;
13222
13495
  const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
@@ -13415,7 +13688,7 @@ function syncAllPassengerCheckouts(cfg) {
13415
13688
 
13416
13689
  // src/tunnel.ts
13417
13690
  import { spawn as spawn4 } from "child_process";
13418
- import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
13691
+ import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync13, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
13419
13692
  import { randomInt } from "crypto";
13420
13693
  import { join as join25 } from "path";
13421
13694
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -13488,17 +13761,17 @@ function tunnelStatePath(stateDir, workspaceId) {
13488
13761
  }
13489
13762
  function readPersistedTunnel(stateDir, workspaceId) {
13490
13763
  const path = tunnelStatePath(stateDir, workspaceId);
13491
- if (!existsSync24(path)) return void 0;
13764
+ if (!existsSync25(path)) return void 0;
13492
13765
  try {
13493
- return JSON.parse(readFileSync17(path, "utf8"));
13766
+ return JSON.parse(readFileSync18(path, "utf8"));
13494
13767
  } catch {
13495
13768
  return void 0;
13496
13769
  }
13497
13770
  }
13498
13771
  function writePersistedTunnel(stateDir, state) {
13499
13772
  const path = tunnelStatePath(stateDir, state.workspaceId);
13500
- mkdirSync11(join25(stateDir, "passengers"), { recursive: true });
13501
- writeFileSync12(path, `${JSON.stringify(state, null, 2)}
13773
+ mkdirSync12(join25(stateDir, "passengers"), { recursive: true });
13774
+ writeFileSync13(path, `${JSON.stringify(state, null, 2)}
13502
13775
  `);
13503
13776
  }
13504
13777
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -13596,7 +13869,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
13596
13869
  }
13597
13870
  function readAllPersistedWorkspaceIds(stateDir) {
13598
13871
  const dir = join25(stateDir, "passengers");
13599
- if (!existsSync24(dir)) return [];
13872
+ if (!existsSync25(dir)) return [];
13600
13873
  return readdirSync7(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
13601
13874
  }
13602
13875
 
@@ -13717,7 +13990,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
13717
13990
  import { createServer as createHttpServer } from "http";
13718
13991
  import { spawn as spawn5 } from "child_process";
13719
13992
  import { randomBytes } from "crypto";
13720
- import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync13, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13993
+ import { existsSync as existsSync26, mkdirSync as mkdirSync13, readFileSync as readFileSync19, writeFileSync as writeFileSync14, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13721
13994
  import { join as join26 } from "path";
13722
13995
 
13723
13996
  // src/secret-compare.ts
@@ -13811,17 +14084,17 @@ function syncDaemonStatePath(stateDir, workspaceId) {
13811
14084
  }
13812
14085
  function readPersistedSyncDaemon(stateDir, workspaceId) {
13813
14086
  const path = syncDaemonStatePath(stateDir, workspaceId);
13814
- if (!existsSync25(path)) return void 0;
14087
+ if (!existsSync26(path)) return void 0;
13815
14088
  try {
13816
- return JSON.parse(readFileSync18(path, "utf8"));
14089
+ return JSON.parse(readFileSync19(path, "utf8"));
13817
14090
  } catch {
13818
14091
  return void 0;
13819
14092
  }
13820
14093
  }
13821
14094
  function writePersistedSyncDaemon(stateDir, state) {
13822
14095
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
13823
- mkdirSync12(join26(stateDir, "passengers"), { recursive: true });
13824
- writeFileSync13(path, `${JSON.stringify(state, null, 2)}
14096
+ mkdirSync13(join26(stateDir, "passengers"), { recursive: true });
14097
+ writeFileSync14(path, `${JSON.stringify(state, null, 2)}
13825
14098
  `);
13826
14099
  }
13827
14100
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -13842,7 +14115,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
13842
14115
  }
13843
14116
  function readAllPersistedSyncWorkspaceIds(stateDir) {
13844
14117
  const dir = join26(stateDir, "passengers");
13845
- if (!existsSync25(dir)) return [];
14118
+ if (!existsSync26(dir)) return [];
13846
14119
  const suffix = "-sync.json";
13847
14120
  return readdirSync8(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
13848
14121
  }
@@ -13899,11 +14172,11 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
13899
14172
  }
13900
14173
 
13901
14174
  // src/daemon.ts
13902
- import { existsSync as existsSync27, mkdirSync as mkdirSync13, readFileSync as readFileSync20, readdirSync as readdirSync10, rmSync as rmSync10, statSync as statSync8, writeFileSync as writeFileSync14 } from "fs";
14175
+ import { existsSync as existsSync28, mkdirSync as mkdirSync14, readFileSync as readFileSync21, readdirSync as readdirSync10, rmSync as rmSync10, statSync as statSync8, writeFileSync as writeFileSync15 } from "fs";
13903
14176
  import { join as join28 } from "path";
13904
14177
 
13905
14178
  // src/interrupted.ts
13906
- import { existsSync as existsSync26, readdirSync as readdirSync9, readFileSync as readFileSync19 } from "fs";
14179
+ import { existsSync as existsSync27, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
13907
14180
  import { join as join27 } from "path";
13908
14181
  var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
13909
14182
  function interruptedComment(b) {
@@ -13923,16 +14196,16 @@ var pidAlive = (pid) => {
13923
14196
  };
13924
14197
  function runFinished(streamPath) {
13925
14198
  const path = `${streamPath}.meta.json`;
13926
- if (!existsSync26(path)) return false;
14199
+ if (!existsSync27(path)) return false;
13927
14200
  try {
13928
- return JSON.parse(readFileSync19(path, "utf8")).running !== true;
14201
+ return JSON.parse(readFileSync20(path, "utf8")).running !== true;
13929
14202
  } catch {
13930
14203
  return true;
13931
14204
  }
13932
14205
  }
13933
14206
  function orphanedStreams(o) {
13934
14207
  const dir = join27(o.stateDir, "streams");
13935
- if (!existsSync26(dir)) return [];
14208
+ if (!existsSync27(dir)) return [];
13936
14209
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
13937
14210
  const out = [];
13938
14211
  for (const name of readdirSync9(dir)) {
@@ -14046,7 +14319,7 @@ function computeTreeSignature(paths) {
14046
14319
  }
14047
14320
  function makeStaleChecker(paths, opts = {}) {
14048
14321
  const initial = computeTreeSignature(paths);
14049
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync27(opts.reloadSentinel);
14322
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync28(opts.reloadSentinel);
14050
14323
  }
14051
14324
  function reloadSentinelFor(stateDir) {
14052
14325
  return join28(stateDir, "reload-requested");
@@ -14055,14 +14328,14 @@ function daemonStampFor(stateDir) {
14055
14328
  return join28(stateDir, "daemon.started");
14056
14329
  }
14057
14330
  function noteDaemonStarted(stateDir, now = Date.now()) {
14058
- mkdirSync13(stateDir, { recursive: true });
14331
+ mkdirSync14(stateDir, { recursive: true });
14059
14332
  rmSync10(reloadSentinelFor(stateDir), { force: true });
14060
- writeFileSync14(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
14333
+ writeFileSync15(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
14061
14334
  `);
14062
14335
  }
14063
14336
  function readDaemonStarted(stateDir) {
14064
14337
  try {
14065
- const v = JSON.parse(readFileSync20(daemonStampFor(stateDir), "utf8")).startedAt;
14338
+ const v = JSON.parse(readFileSync21(daemonStampFor(stateDir), "utf8")).startedAt;
14066
14339
  return typeof v === "number" ? v : null;
14067
14340
  } catch {
14068
14341
  return null;
@@ -14418,7 +14691,7 @@ async function buildEnvironment(route, ship, ticket) {
14418
14691
  }
14419
14692
 
14420
14693
  // src/cli.ts
14421
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname15(process.execPath) : resolve5(dirname15(fileURLToPath(import.meta.url)), "..");
14694
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname16(process.execPath) : resolve5(dirname16(fileURLToPath(import.meta.url)), "..");
14422
14695
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
14423
14696
  var CYCLE_SECONDS2 = 120;
14424
14697
  var STALE_CYCLES = 3;
@@ -14549,7 +14822,8 @@ function usage() {
14549
14822
  crew status [route] [--json] paused/running state; --json for a machine reader
14550
14823
  crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
14551
14824
  crew reload make the running daemon re-read crew.yaml (--now: restart it immediately)
14552
- crew doctor [route] [--fix] preflight; --fix enables clean routes and offers crew install
14825
+ crew doctor [route] [--fix] [--offline] [--json] preflight; --fix enables clean routes and offers crew install;
14826
+ --offline skips push probes; --json prints push-auth and tool checks only
14553
14827
  crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
14554
14828
  crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
14555
14829
  crew ports [route] which checkout owns which ports, and what is up
@@ -14563,13 +14837,14 @@ function usage() {
14563
14837
  resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
14564
14838
  crew log [route] tail the log
14565
14839
  crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
14566
- crew connect resolve a workspace's ids into a crew.yaml block (--no-mcp skips registering the Tablation MCP server with Claude Code; --mcp replace overwrites a different one)
14840
+ crew connect resolve a workspace's ids into a crew.yaml block, then (admin key) sync personas/skills and mint seat keys (--no-agents-sync skips that; --no-mcp skips registering the Tablation MCP server with Claude Code; --mcp replace overwrites a different one)
14567
14841
  crew disconnect remove what connect installed for Pair (Claude MCP; Codex/Gemini instructions block and MCP entry per ship.pair)
14568
14842
  crew agents sync [route] [--force] [--revoke-seat-keys] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona or skill; never one marked Customize prompt)
14569
14843
  crew agents prompt R [route] print one persona's current prompt (e.g. R=pair, for a SessionStart hook; --harness H adds which CLI it serves)
14570
14844
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
14571
14845
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
14572
14846
  crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
14847
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo, --json)
14573
14848
  crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
14574
14849
  crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
14575
14850
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
@@ -14976,7 +15251,7 @@ if (command === "passenger-sync-daemon") {
14976
15251
  if (host === "linux" && hasSystemd) {
14977
15252
  const label = labelFor(CREW_HOME, "run");
14978
15253
  const staleTimer = join29(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14979
- if (existsSync28(staleTimer)) {
15254
+ if (existsSync29(staleTimer)) {
14980
15255
  process.stdout.write(
14981
15256
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
14982
15257
  `
@@ -15002,7 +15277,7 @@ if (command === "passenger-sync-daemon") {
15002
15277
  const tracker = new Tracker(c, cfg.ship);
15003
15278
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
15004
15279
  for (const r of await resolvedRepos(c)) {
15005
- if (!existsSync28(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
15280
+ if (!existsSync29(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
15006
15281
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
15007
15282
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
15008
15283
  }
@@ -15020,7 +15295,7 @@ if (command === "passenger-sync-daemon") {
15020
15295
  }
15021
15296
  async function doctorFix(o) {
15022
15297
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
15023
- const unitExists = (p) => existsSync28(p);
15298
+ const unitExists = (p) => existsSync29(p);
15024
15299
  const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
15025
15300
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
15026
15301
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -15037,7 +15312,7 @@ if (command === "passenger-sync-daemon") {
15037
15312
  for (const a of plan.actions) {
15038
15313
  if (a.kind === "enable") {
15039
15314
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
15040
- writeFileSync15(cfg.configFile, planEnable(readFileSync21(cfg.configFile, "utf8"), a.route));
15315
+ writeFileSync16(cfg.configFile, planEnable(readFileSync22(cfg.configFile, "utf8"), a.route));
15041
15316
  enabledSound.add(a.route);
15042
15317
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
15043
15318
  } else {
@@ -15054,7 +15329,10 @@ ${changed2.map((c) => ` - ${c}
15054
15329
  `).join("")}` : "fix: nothing changed\n");
15055
15330
  }
15056
15331
  async function resolvedRepos(c) {
15057
- return Promise.all(reposOf(c).map(async (t) => {
15332
+ return Promise.all(reposOf(c).map((t) => resolveOneRepo(c, t)));
15333
+ }
15334
+ async function resolveOneRepo(c, t) {
15335
+ {
15058
15336
  const id = repoIdForName(c, t.name);
15059
15337
  const o = c.repoOverrides[t.name];
15060
15338
  return {
@@ -15078,7 +15356,7 @@ ${changed2.map((c) => ` - ${c}
15078
15356
  worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id), nameOverride: o?.worktreeName }
15079
15357
  }, t.dir)
15080
15358
  };
15081
- }));
15359
+ }
15082
15360
  }
15083
15361
  const worktreeLocations = async (c) => (await resolvedRepos(c)).map((r) => ({
15084
15362
  parent: resolvePath(r.dir, ".."),
@@ -16160,69 +16438,27 @@ ${changed2.map((c) => ` - ${c}
16160
16438
  `);
16161
16439
  process.exit(2);
16162
16440
  }
16163
- const keyRole = target.resolved.role ?? await fetchKeyRole(target, cfg.ship.userAgent);
16164
- let readOnly = !isAdminRole(keyRole);
16165
- let personaResult;
16166
- const runPersonas = (ro) => syncPersonas(target, { userAgent: cfg.ship.userAgent, dryRun, force, readOnly: ro });
16441
+ let run2;
16167
16442
  try {
16168
- try {
16169
- personaResult = await runPersonas(readOnly);
16170
- } catch (e) {
16171
- if (readOnly || !isForbidden(e)) throw e;
16172
- readOnly = true;
16173
- personaResult = await runPersonas(true);
16174
- }
16443
+ run2 = await runAgentsSync(target, {
16444
+ userAgent: cfg.ship.userAgent,
16445
+ stateDir: cfg.ship.stateDir,
16446
+ dryRun,
16447
+ force,
16448
+ say: (l) => process.stdout.write(`${l}
16449
+ `),
16450
+ warn: (l) => process.stderr.write(`crew agents sync: ${l}
16451
+ `)
16452
+ });
16175
16453
  } catch (e) {
16176
- if (e instanceof AgentsSyncError) {
16454
+ if (e instanceof AgentsSyncError || e instanceof SkillSyncError) {
16177
16455
  process.stderr.write(`crew agents sync: ${e.message}
16178
16456
  `);
16179
16457
  process.exit(2);
16180
16458
  }
16181
16459
  throw e;
16182
16460
  }
16183
- process.stdout.write(`route: ${target.route}
16184
- `);
16185
- for (const o of personaResult.outcomes) process.stdout.write(`${describeSyncOutcome(o)}
16186
- `);
16187
- for (const o of personaResult.crewLinks) process.stdout.write(`${describeCrewLink(o)}
16188
- `);
16189
- if (personaResult.releaseNote) process.stdout.write(`${personaResult.releaseNote}
16190
- `);
16191
- const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
16192
- for (const w of skillMarkerWarnings(target.promptsDir, briefs)) process.stderr.write(`crew agents sync: warning: ${w}
16193
- `);
16194
- let skillResult;
16195
- if (!readOnly) {
16196
- try {
16197
- skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun, force });
16198
- } catch (e) {
16199
- if (isForbidden(e)) readOnly = true;
16200
- else if (e instanceof SkillSyncError) {
16201
- process.stderr.write(`crew agents sync: ${e.message}
16202
- `);
16203
- process.exit(2);
16204
- } else throw e;
16205
- }
16206
- }
16207
- for (const o of skillResult?.outcomes ?? []) process.stdout.write(`${describeSkillSyncOutcome(o)}
16208
- `);
16209
- if (!dryRun) {
16210
- const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
16211
- const raw = existsSync28(resolvedPath) ? JSON.parse(readFileSync21(resolvedPath, "utf8")) : {};
16212
- raw.agentPersonas = personaResult.agentPersonas;
16213
- if (skillResult) raw.agentSkills = skillResult.agentSkills;
16214
- if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
16215
- mkdirSync14(dirOf(resolvedPath), { recursive: true });
16216
- writeFileSync15(resolvedPath, `${JSON.stringify(raw, null, 2)}
16217
- `);
16218
- }
16219
- if (!dryRun) {
16220
- target.resolved.agentPersonas = personaResult.agentPersonas;
16221
- if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
16222
- }
16223
- if (readOnly) {
16224
- process.stdout.write("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)\n");
16225
- }
16461
+ const { personaResult, skillResult } = run2;
16226
16462
  if (flag("revoke-seat-keys")) {
16227
16463
  for (const o of await revokeSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun })) {
16228
16464
  process.stdout.write(`${describeRevokeOutcome(o)}
@@ -16234,9 +16470,7 @@ ${changed2.map((c) => ` - ${c}
16234
16470
  `);
16235
16471
  }
16236
16472
  }
16237
- if (personaResult.outcomes.some((o) => o.action === "diverged") || (skillResult?.outcomes.some((o) => o.action === "diverged") ?? false)) {
16238
- process.exitCode = 1;
16239
- }
16473
+ if (run2.diverged) process.exitCode = 1;
16240
16474
  break;
16241
16475
  }
16242
16476
  case "skills": {
@@ -16278,10 +16512,10 @@ ${changed2.map((c) => ` - ${c}
16278
16512
  `);
16279
16513
  if (!dryRun) {
16280
16514
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
16281
- const raw = existsSync28(resolvedPath) ? JSON.parse(readFileSync21(resolvedPath, "utf8")) : {};
16515
+ const raw = existsSync29(resolvedPath) ? JSON.parse(readFileSync22(resolvedPath, "utf8")) : {};
16282
16516
  raw.agentSkills = result.agentSkills;
16283
- mkdirSync14(dirOf(resolvedPath), { recursive: true });
16284
- writeFileSync15(resolvedPath, `${JSON.stringify(raw, null, 2)}
16517
+ mkdirSync15(dirOf(resolvedPath), { recursive: true });
16518
+ writeFileSync16(resolvedPath, `${JSON.stringify(raw, null, 2)}
16285
16519
  `);
16286
16520
  }
16287
16521
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -16290,18 +16524,74 @@ ${changed2.map((c) => ` - ${c}
16290
16524
  case "repos": {
16291
16525
  const sub = positional[1];
16292
16526
  const usage2 = () => {
16293
- 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>\n");
16527
+ 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");
16294
16528
  process.exit(2);
16295
16529
  };
16296
- if (sub !== "add" && sub !== "list" && sub !== "clone") usage2();
16297
- const configText = readFileSync21(cfg.configFile, "utf8");
16530
+ if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks") usage2();
16531
+ const configText = readFileSync22(cfg.configFile, "utf8");
16532
+ if (sub === "hooks") {
16533
+ try {
16534
+ const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
16535
+ const dir = repoDirOf(configText, dirname16(cfg.configFile), a.route, a.name);
16536
+ if (!existsSync29(dir)) throw new ReposError(`${dir} does not exist on disk`);
16537
+ const files = { exists: (r) => existsSync29(resolvePath(dir, r)), read: (r) => {
16538
+ try {
16539
+ return readFileSync22(resolvePath(dir, r), "utf8");
16540
+ } catch {
16541
+ return void 0;
16542
+ }
16543
+ } };
16544
+ const suggestion = suggestHooks(files);
16545
+ const write = value("write");
16546
+ if (write !== void 0 && write !== "route" && write !== "repo") throw new ReposError(`--write takes "route" or "repo" (got "${write}")`);
16547
+ let written;
16548
+ if (write && suggestion.source === null) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
16549
+ if (write === "route") {
16550
+ const plan = planRouteHooks(configText, a.route, a.name, suggestion.hooks);
16551
+ writeFileSync16(cfg.configFile, plan.text);
16552
+ written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept };
16553
+ } else if (write === "repo") {
16554
+ const have = existingRepoConfig(files);
16555
+ if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
16556
+ const file = resolvePath(dir, ".crew.yaml");
16557
+ writeFileSync16(file, starterRepoConfig(suggestion.hooks), { flag: "wx" });
16558
+ written = { target: "repo", file, hooks: Object.keys(suggestion.hooks), kept: [] };
16559
+ }
16560
+ if (flag("json")) {
16561
+ process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, written: written ?? null })}
16562
+ `);
16563
+ } else {
16564
+ if (suggestion.source) process.stdout.write(`${a.name}: proposed from ${suggestion.source}
16565
+ `);
16566
+ for (const [k, v] of Object.entries(suggestion.hooks)) process.stdout.write(` ${k}: ${v}
16567
+ `);
16568
+ for (const n of suggestion.notes) process.stdout.write(`note: ${n}
16569
+ `);
16570
+ if (written) {
16571
+ process.stdout.write(`wrote ${written.hooks.join(", ") || "nothing new"} to ${written.file}
16572
+ `);
16573
+ if (written.kept.length) process.stdout.write(`kept existing route hooks: ${written.kept.join(", ")}
16574
+ `);
16575
+ if (written.target === "repo") process.stdout.write("review it and commit it to the repository\n");
16576
+ }
16577
+ }
16578
+ } catch (e) {
16579
+ if (e instanceof ReposError) {
16580
+ process.stderr.write(`crew repos: ${e.message}
16581
+ `);
16582
+ process.exit(2);
16583
+ }
16584
+ throw e;
16585
+ }
16586
+ break;
16587
+ }
16298
16588
  let routeName = positional[2];
16299
16589
  let addPath = ".";
16300
16590
  let addName;
16301
16591
  if (sub === "list" && !routeName) usage2();
16302
16592
  if (sub === "add") {
16303
16593
  try {
16304
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync28(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
16594
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync29(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
16305
16595
  routeName = a.route;
16306
16596
  addPath = a.path;
16307
16597
  addName = a.name;
@@ -16330,15 +16620,15 @@ ${changed2.map((c) => ` - ${c}
16330
16620
  }
16331
16621
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
16332
16622
  let resolvedRemotes;
16333
- if (existsSync28(resolvedFile)) {
16623
+ if (existsSync29(resolvedFile)) {
16334
16624
  try {
16335
- resolvedRemotes = JSON.parse(readFileSync21(resolvedFile, "utf8"));
16625
+ resolvedRemotes = JSON.parse(readFileSync22(resolvedFile, "utf8"));
16336
16626
  } catch {
16337
16627
  }
16338
16628
  }
16339
16629
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
16340
16630
  const text = configText;
16341
- const base = dirname15(cfg.configFile);
16631
+ const base = dirname16(cfg.configFile);
16342
16632
  try {
16343
16633
  if (sub === "list") {
16344
16634
  const repos = configuredRepos(text, routeName, base);
@@ -16347,7 +16637,7 @@ ${changed2.map((c) => ` - ${c}
16347
16637
  for (const r of repos) {
16348
16638
  const origin2 = originOf(r.dir);
16349
16639
  const row = matchRepoName(origin2, resolvedRemotes);
16350
- const note = !existsSync28(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
16640
+ const note = !existsSync29(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
16351
16641
  process.stdout.write(`${r.name} ${r.dir} ${note}
16352
16642
  `);
16353
16643
  }
@@ -16359,9 +16649,9 @@ ${changed2.map((c) => ` - ${c}
16359
16649
  ` : `${human}
16360
16650
  `);
16361
16651
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir") });
16362
- if (existsSync28(plan2.target) && (existsSync28(`${plan2.target}/.git`) || readdirSync11(plan2.target).length > 0)) {
16652
+ if (existsSync29(plan2.target) && (existsSync29(`${plan2.target}/.git`) || readdirSync11(plan2.target).length > 0)) {
16363
16653
  throw new ReposError(
16364
- existsSync28(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
16654
+ existsSync29(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
16365
16655
  );
16366
16656
  }
16367
16657
  if (dryRun) {
@@ -16379,12 +16669,12 @@ ${changed2.map((c) => ` - ${c}
16379
16669
  throw new ReposError(`${e.message}${authHint}
16380
16670
  crew.yaml was not changed`);
16381
16671
  }
16382
- writeFileSync15(cfg.configFile, plan2.text);
16672
+ writeFileSync16(cfg.configFile, plan2.text);
16383
16673
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
16384
16674
  break;
16385
16675
  }
16386
16676
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
16387
- const top = existsSync28(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
16677
+ const top = existsSync29(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
16388
16678
  if (!top) throw new ReposError(`${target} is not a git checkout`);
16389
16679
  const origin = originOf(top);
16390
16680
  const plan = planRepoAdd({
@@ -16433,15 +16723,15 @@ ${changed2.map((c) => ` - ${c}
16433
16723
  const created = await rowTracker.createRepoRow(rowPayload);
16434
16724
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
16435
16725
  `);
16436
- if (existsSync28(resolvedFile)) {
16437
- const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
16726
+ if (existsSync29(resolvedFile)) {
16727
+ const raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
16438
16728
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
16439
16729
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
16440
- writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
16730
+ writeFileSync16(resolvedFile, `${JSON.stringify(raw, null, 2)}
16441
16731
  `);
16442
16732
  }
16443
16733
  }
16444
- writeFileSync15(cfg.configFile, plan.text);
16734
+ writeFileSync16(cfg.configFile, plan.text);
16445
16735
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
16446
16736
  `);
16447
16737
  } catch (e) {
@@ -16751,10 +17041,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16751
17041
  }
16752
17042
  }
16753
17043
  let previous;
16754
- const hadPreviousFile = existsSync28(resolvedPath);
17044
+ const hadPreviousFile = existsSync29(resolvedPath);
16755
17045
  if (hadPreviousFile) {
16756
17046
  try {
16757
- previous = JSON.parse(readFileSync21(resolvedPath, "utf8"));
17047
+ previous = JSON.parse(readFileSync22(resolvedPath, "utf8"));
16758
17048
  } catch {
16759
17049
  }
16760
17050
  }
@@ -16862,10 +17152,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16862
17152
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
16863
17153
  `);
16864
17154
  } else {
16865
- mkdirSync14(dirname15(resolvedPath), { recursive: true });
17155
+ mkdirSync15(dirname16(resolvedPath), { recursive: true });
16866
17156
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
16867
17157
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
16868
- writeFileSync15(tmpPath, `${JSON.stringify(resolved, null, 2)}
17158
+ writeFileSync16(tmpPath, `${JSON.stringify(resolved, null, 2)}
16869
17159
  `);
16870
17160
  renameSync2(tmpPath, resolvedPath);
16871
17161
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -16900,8 +17190,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16900
17190
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
16901
17191
  `);
16902
17192
  } else {
16903
- mkdirSync14(dirname15(apiKeyFile), { recursive: true });
16904
- writeFileSync15(apiKeyFile, `${apiKeyVar}=${apiKey}
17193
+ mkdirSync15(dirname16(apiKeyFile), { recursive: true });
17194
+ writeFileSync16(apiKeyFile, `${apiKeyVar}=${apiKey}
16905
17195
  `, { mode: 384 });
16906
17196
  process.stderr.write(`API key written to ${apiKeyFile}
16907
17197
  `);
@@ -17002,6 +17292,56 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17002
17292
  `
17003
17293
  );
17004
17294
  }
17295
+ if (!dryRun && !flag("no-agents-sync")) {
17296
+ const skip = (why) => {
17297
+ process.stderr.write(`
17298
+ agents sync: skipped (${why})
17299
+ `);
17300
+ connectEvents.emit("agents_synced", `agents sync skipped: ${why}`, { route: route2, skipped: why });
17301
+ };
17302
+ let syncRoute;
17303
+ try {
17304
+ const reloaded = loadConfig(CREW_HOME, wizard?.configPath ?? cfg.configFile, { allowNoRoutes: true });
17305
+ syncRoute = reloaded.routes.find((r) => r.route === route2);
17306
+ if (syncRoute) await hydrateApiKeys([syncRoute]);
17307
+ } catch (e) {
17308
+ skip(`config did not load: ${e.message}`);
17309
+ }
17310
+ if (syncRoute?.resolved) {
17311
+ try {
17312
+ process.stderr.write("\n");
17313
+ const run2 = await runAgentsSync(syncRoute, {
17314
+ userAgent: cfg.ship.userAgent,
17315
+ stateDir: cfg.ship.stateDir,
17316
+ say: (l) => process.stderr.write(` ${l}
17317
+ `),
17318
+ warn: (l) => process.stderr.write(` agents sync: ${l}
17319
+ `)
17320
+ });
17321
+ const minted = await mintMissingSeatKeys(syncRoute, { userAgent: cfg.ship.userAgent, shipName: cfg.ship.name });
17322
+ for (const m of minted) process.stderr.write(` ${describeMintOutcome(m)}
17323
+ `);
17324
+ if (run2.readOnly) {
17325
+ const missing = run2.personaResult.outcomes.some((p) => p.action === "missing");
17326
+ process.stderr.write(missing ? " A workspace admin has to open the Crew app or run `crew agents sync` once before this ship's seats can run.\n" : " Seats are linked to the workspace's existing personas.\n");
17327
+ }
17328
+ connectEvents.emit("agents_synced", run2.readOnly ? "agents linked (read-only key)" : "agents synced", {
17329
+ route: route2,
17330
+ admin: !run2.readOnly,
17331
+ personas: run2.personaResult.outcomes,
17332
+ skills: run2.skillResult?.outcomes ?? [],
17333
+ diverged: run2.diverged
17334
+ });
17335
+ for (const m of minted) connectEvents.emit("seat_key", describeMintOutcome(m), { route: route2, ...m });
17336
+ if (run2.diverged) process.exitCode = 1;
17337
+ } catch (e) {
17338
+ if (e instanceof AgentsSyncError || e instanceof SkillSyncError) skip(e.message);
17339
+ else throw e;
17340
+ }
17341
+ } else if (!syncRoute) {
17342
+ skip(`"${route2}" is not in crew.yaml yet; add the block above, then run \`crew agents sync\``);
17343
+ }
17344
+ }
17005
17345
  connectEvents.emit("done", `connected ${route2}`, {
17006
17346
  route: route2,
17007
17347
  resolvedPath: dryRun ? void 0 : resolvedPath,
@@ -17480,8 +17820,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17480
17820
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
17481
17821
  break;
17482
17822
  }
17483
- mkdirSync14(cfg.ship.stateDir, { recursive: true });
17484
- writeFileSync15(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
17823
+ mkdirSync15(cfg.ship.stateDir, { recursive: true });
17824
+ writeFileSync16(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
17485
17825
  `);
17486
17826
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
17487
17827
  if (flag("now") && control?.stopCommand && control.startCommand) {
@@ -17532,7 +17872,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17532
17872
  }
17533
17873
  case "log": {
17534
17874
  try {
17535
- const text = readFileSync21(cfg.ship.logFile, "utf8").split("\n");
17875
+ const text = readFileSync22(cfg.ship.logFile, "utf8").split("\n");
17536
17876
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
17537
17877
  `);
17538
17878
  } catch {
@@ -17555,8 +17895,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17555
17895
  };
17556
17896
  if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
17557
17897
  const tracker = new Tracker(route, cfg.ship);
17558
- const configText = readFileSync21(cfg.configFile, "utf8");
17559
- const cfgBase = dirname15(cfg.configFile);
17898
+ const configText = readFileSync22(cfg.configFile, "utf8");
17899
+ const cfgBase = dirname16(cfg.configFile);
17560
17900
  const configured = configuredRepos(configText, route.route, cfgBase);
17561
17901
  const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
17562
17902
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, route.route);
@@ -17584,7 +17924,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17584
17924
  }
17585
17925
  if (configured.some((r) => r.dir === dir)) {
17586
17926
  try {
17587
- writeFileSync15(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
17927
+ writeFileSync16(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
17588
17928
  } catch (e) {
17589
17929
  if (e instanceof ReposError) fail(e.message);
17590
17930
  throw e;
@@ -17592,29 +17932,29 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17592
17932
  }
17593
17933
  if (row) {
17594
17934
  await tracker.deleteRepoRow(row.id);
17595
- if (existsSync28(resolvedFile)) {
17596
- const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
17935
+ if (existsSync29(resolvedFile)) {
17936
+ const raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
17597
17937
  if (raw.repoNames) delete raw.repoNames[row.id];
17598
17938
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
17599
- writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
17939
+ writeFileSync16(resolvedFile, `${JSON.stringify(raw, null, 2)}
17600
17940
  `);
17601
17941
  }
17602
17942
  }
17603
- if (existsSync28(join29(dir, ".git")) && basename10(dir) === SAMPLE_REPO) rmSync11(dir, { recursive: true, force: true });
17943
+ if (existsSync29(join29(dir, ".git")) && basename10(dir) === SAMPLE_REPO) rmSync11(dir, { recursive: true, force: true });
17604
17944
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
17605
17945
  break;
17606
17946
  }
17607
17947
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
17608
- const fresh = !existsSync28(join29(dir, ".git"));
17609
- if (existsSync28(dir) && fresh && readdirSync11(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
17948
+ const fresh = !existsSync29(join29(dir, ".git"));
17949
+ if (existsSync29(dir) && fresh && readdirSync11(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
17610
17950
  if (dryRun) {
17611
17951
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
17612
17952
  break;
17613
17953
  }
17614
17954
  if (fresh) {
17615
- mkdirSync14(dir, { recursive: true });
17616
- writeFileSync15(join29(dir, "README.md"), SAMPLE_README);
17617
- writeFileSync15(join29(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
17955
+ mkdirSync15(dir, { recursive: true });
17956
+ writeFileSync16(join29(dir, "README.md"), SAMPLE_README);
17957
+ writeFileSync16(join29(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
17618
17958
  const g = (...a) => {
17619
17959
  const r = spawnSync6("git", a, { cwd: dir, encoding: "utf8" });
17620
17960
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -17632,17 +17972,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17632
17972
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
17633
17973
  });
17634
17974
  repoId = (await tracker.createRepoRow(payload)).id;
17635
- if (existsSync28(resolvedFile)) {
17636
- const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
17975
+ if (existsSync29(resolvedFile)) {
17976
+ const raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
17637
17977
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
17638
17978
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
17639
- writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
17979
+ writeFileSync16(resolvedFile, `${JSON.stringify(raw, null, 2)}
17640
17980
  `);
17641
17981
  }
17642
17982
  }
17643
17983
  if (!configured.some((r) => r.dir === dir)) {
17644
17984
  try {
17645
- writeFileSync15(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
17985
+ writeFileSync16(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
17646
17986
  } catch (e) {
17647
17987
  if (e instanceof ReposError) fail(e.message);
17648
17988
  throw e;
@@ -17919,6 +18259,40 @@ watermark: ${state.watermark()}
17919
18259
  }
17920
18260
  case "doctor": {
17921
18261
  const host = hostPlatform();
18262
+ const doctorSchedulerPath = pathFor(cfg.ship);
18263
+ const pushAuth = [];
18264
+ const toolRows = [];
18265
+ {
18266
+ const prBased = /* @__PURE__ */ new Set();
18267
+ let mentionNode = false;
18268
+ for (const c of [route]) {
18269
+ const { settled: repos, errors } = await settleRepos(reposOf(c), (t) => t.name, (t) => resolveOneRepo(c, t));
18270
+ for (const e of errors) {
18271
+ pushAuth.push({ route: c.route, repo: e.repo, result: { state: "failed", detail: `could not resolve this repo's configuration: ${e.detail}`, hint: "fix the repo's .crew.yaml" } });
18272
+ }
18273
+ for (const r of repos) {
18274
+ const remote = r.config.branch.remote;
18275
+ if (existsSync29(r.dir)) {
18276
+ pushAuth.push({
18277
+ route: c.route,
18278
+ repo: r.name,
18279
+ result: probePushAuth({ dir: r.dir, remote, remoteUrl: originUrlOf(r.dir, remote), offline: flag("offline") })
18280
+ });
18281
+ }
18282
+ if (isPrBased(r.config.hooks)) prBased.add(r.name);
18283
+ if (Object.values(r.config.hooks).some((h) => typeof h === "string" && mentionsNode(h))) mentionNode = true;
18284
+ }
18285
+ }
18286
+ toolRows.push(...toolChecks(
18287
+ { prBased: prBased.size > 0, hostPassengers: cfg.routes.some((x) => x.enabled && x.hostPassengers), hooksMentionNode: mentionNode },
18288
+ (bin) => findOnPath(bin, doctorSchedulerPath)
18289
+ ));
18290
+ }
18291
+ if (flag("json")) {
18292
+ process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows }, null, 2)}
18293
+ `);
18294
+ break;
18295
+ }
17922
18296
  process.stdout.write(
17923
18297
  `ship: ${cfg.ship.name} (${host})
17924
18298
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
@@ -17926,7 +18300,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
17926
18300
  );
17927
18301
  try {
17928
18302
  const evFile = eventFileFor(cfg.ship.stateDir);
17929
- const evSize = existsSync28(evFile) ? statSync9(evFile).size : 0;
18303
+ const evSize = existsSync29(evFile) ? statSync9(evFile).size : 0;
17930
18304
  process.stdout.write(
17931
18305
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
17932
18306
  ` : "events log: ok\n"
@@ -17937,7 +18311,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
17937
18311
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
17938
18312
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
17939
18313
  try {
17940
- return readFileSync21(p, "utf8");
18314
+ return readFileSync22(p, "utf8");
17941
18315
  } catch {
17942
18316
  return null;
17943
18317
  }
@@ -18142,6 +18516,8 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
18142
18516
  if (ov.toolchain) process.stdout.write(`repo toolchain: ${repoName} \u2192 ${ov.toolchain} (binary and MCP checked above, as for every ship toolchain)
18143
18517
  `);
18144
18518
  }
18519
+ for (const p of pushAuth) process.stdout.write(formatPushAuth(`${p.repo}`, p.result));
18520
+ for (const t of toolRows) process.stdout.write(formatToolCheck(t));
18145
18521
  process.stdout.write(`git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler PATH"}
18146
18522
  `);
18147
18523
  if (!allBinsFound || !gitPath) {