projectstore-claude 0.29.2 → 0.29.4

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 (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -2,12 +2,30 @@
2
2
  // Pure node, no external deps. Keep this single-file & dependency-free
3
3
  // so plugin install does not require npm install.
4
4
 
5
- import { readFileSync, writeFileSync, writeSync, appendFileSync, existsSync, readdirSync, statSync, lstatSync, mkdirSync, utimesSync, unlinkSync, renameSync, rmSync, realpathSync, cpSync } from "node:fs";
5
+ import {
6
+ readFileSync,
7
+ writeFileSync,
8
+ writeSync,
9
+ appendFileSync,
10
+ existsSync,
11
+ readdirSync,
12
+ statSync,
13
+ lstatSync,
14
+ mkdirSync,
15
+ utimesSync,
16
+ unlinkSync,
17
+ renameSync,
18
+ rmSync,
19
+ realpathSync,
20
+ copyFileSync,
21
+ } from "node:fs";
6
22
  import { readFile as readFileAsync } from "node:fs/promises";
7
23
  import { join, dirname, basename, resolve, relative, isAbsolute } from "node:path";
8
24
  import { fileURLToPath } from "node:url";
9
25
  import { hostname, homedir } from "node:os";
10
26
  import { createHash, randomUUID } from "node:crypto";
27
+ import { parseFrontmatter } from "./stages.mjs";
28
+ import { storyKeyOf } from "./band-shared.mjs";
11
29
  import {
12
30
  projectRoot as harnessProjectRoot,
13
31
  pluginRoot as harnessPluginRoot,
@@ -20,6 +38,7 @@ import {
20
38
  overlayId,
21
39
  hostSettingsPath,
22
40
  layoutPaths,
41
+ cachePaths,
23
42
  pickExisting,
24
43
  LAYOUT,
25
44
  RUNTIME_GITIGNORE_HEADER,
@@ -31,6 +50,8 @@ import {
31
50
  invocation,
32
51
  speakingHarness,
33
52
  packageCommand,
53
+ whichOnPath,
54
+ hostSessionId,
34
55
  } from "./harness.mjs";
35
56
 
36
57
  // ─── Paths ─────────────────────────────────────────────────────────────
@@ -73,8 +94,11 @@ export function updateHint({ env = process.env } = {}) {
73
94
  const m = speakingHarness(env) || {};
74
95
  // A harness that declares no hint gets none — never another harness's advice.
75
96
  const hint = m.update_hint || { line: "", welcome: [] };
76
- // `{upgrade}` is the harness's own shell command, pinned to latest so it runs as printed.
77
- const upgrade = m.install?.shell ? packageCommand(m, "upgrade", { version: "latest", args: '--project "$PWD"' }) : "";
97
+ // `{upgrade}` is the core's upgrade for this harness, pinned to latest so it
98
+ // runs as printed, with no --project: the hint says where to run it (the
99
+ // story "User-facing text says where to run…", rule 2). Any hint that holds
100
+ // the placeholder gets it; one that holds none prints as written.
101
+ const upgrade = m.id ? packageCommand(m, "upgrade", { version: "latest" }) : "";
78
102
  const fill = (t) => String(t).split("{upgrade}").join(upgrade);
79
103
  return { line: fill(hint.line || ""), welcome: (hint.welcome || []).map(fill) };
80
104
  }
@@ -96,17 +120,65 @@ export function bindInherits({ env = process.env } = {}) {
96
120
  export function inheritForm(vaultPath, { layout = null, language = null, env = process.env } = {}) {
97
121
  if (speakingHarness(env)?.capabilities?.bind_inherit) return commandForm("bind", { args: "--inherit", env });
98
122
  const plain = (v) => typeof v === "string" && /^[a-z0-9-]+$/.test(v);
99
- const args = [JSON.stringify(String(vaultPath)), plain(layout) ? `--layout ${layout}` : "", plain(language) ? `--language ${language}` : ""].filter(Boolean).join(" ");
123
+ const args = [
124
+ JSON.stringify(String(vaultPath)),
125
+ plain(layout) ? `--layout ${layout}` : "",
126
+ plain(language) ? `--language ${language}` : "",
127
+ ]
128
+ .filter(Boolean)
129
+ .join(" ");
100
130
  return commandForm("bind", { args, env });
101
131
  }
102
132
  export function speakingDisplayName({ env = process.env } = {}) {
103
133
  return speakingHarness(env)?.display_name || "another";
104
134
  }
105
135
 
136
+ // ─── doctor's last line (presentation spec, contract 11) ───────────────
137
+ //
138
+ // The Summary line ends every text report of doctor's, verbatim from its
139
+ // 0.29.2 report: the write ceremony captures it as the pre-state and compares
140
+ // it after (commands/story.md step 5a, agents/clerk.md), so its bytes are a
141
+ // contract. It lives here, beside commandForm, because every printer of the
142
+ // report needs it — doctor-report.mjs, and the fallback of each caller when
143
+ // that renderer cannot be loaded or throws — and this is the one module all
144
+ // of them already import. `env` decides the command forms.
145
+ export function doctorSummaryLine(findings, { env = process.env } = {}) {
146
+ const issues = findings.filter((f) => f.level === "issue").length;
147
+ const warns = findings.filter((f) => f.level === "warn").length;
148
+ return `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix", env })} (install), ${commandForm("kanban", { env })} / reconcile (vault).` : "Vault and wiring look healthy."}`;
149
+ }
150
+
151
+ // What a reader gets when the renderer cannot draw the findings: each one
152
+ // per line, `level check message — file`, then the Summary line, still last.
153
+ export function doctorFallbackText(findings, { env = process.env } = {}) {
154
+ // A message's first line is prose; each further line is a lead ending with
155
+ // `:` or a command (the story "User-facing text says where to run…", rule 4
156
+ // as amended): the file stays with the first line's words, and every
157
+ // further line keeps a line of its own, as the message has it.
158
+ const lines = findings.map((f) => {
159
+ const [said, ...commands] = String(f.message).split("\n");
160
+ return [`${f.level} ${f.check} ${said}${f.file ? ` — ${f.file}` : ""}`, ...commands].join("\n");
161
+ });
162
+ return [...lines, ...(lines.length ? [""] : []), doctorSummaryLine(findings, { env })].join("\n") + "\n";
163
+ }
164
+
106
165
  // The layout resolver and its constants, re-exported so hooks and scripts
107
166
  // import one module (the layout ADR, 2026-09-06). The active harness's id,
108
- // for the paths keyed by it (state/<id>/…).
109
- export { layoutPaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId, speakingHarness };
167
+ // for the paths keyed by it (state/<id>/…). cachePaths is the user-level
168
+ // class beside it (layout spec contract 0): the shell fetch's scratch.
169
+ // whichOnPath, the PATH walk for a host binary, moved to harness.mjs beside
170
+ // hostPresence (the setup story, rule 5); its importers keep this name.
171
+ export {
172
+ layoutPaths,
173
+ cachePaths,
174
+ pickExisting,
175
+ LAYOUT,
176
+ RUNTIME_GITIGNORE_HEADER,
177
+ hostSettingsPath,
178
+ overlayId,
179
+ speakingHarness,
180
+ whichOnPath,
181
+ };
110
182
 
111
183
  // A hook's payload carries the `cwd` of the session that fired it, and on a
112
184
  // harness that exports no project-dir variable it is the only answer better
@@ -126,16 +198,35 @@ export function readOverlayAt(projectDir, id = overlayId()) {
126
198
  // No id (no manifest at all): nothing is read, and the writer below refuses
127
199
  // the same id rather than inventing a file name for it.
128
200
  const path = id ? layoutPaths(projectDir).overlay(id) : null;
129
- const out = { id: id || null, path, present: Boolean(path) && existsSync(path), unparseable: false, agents: { default: null, per_agent: {} }, rejected: [], raw: null };
201
+ const out = {
202
+ id: id || null,
203
+ path,
204
+ present: Boolean(path) && existsSync(path),
205
+ unparseable: false,
206
+ agents: { default: null, per_agent: {} },
207
+ rejected: [],
208
+ raw: null,
209
+ };
130
210
  if (!out.present) return out;
131
211
  let raw;
132
- try { raw = JSON.parse(readFileSync(path, "utf8")); } catch { out.unparseable = true; return out; }
133
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) { out.unparseable = true; return out; }
212
+ try {
213
+ raw = JSON.parse(readFileSync(path, "utf8"));
214
+ } catch {
215
+ out.unparseable = true;
216
+ return out;
217
+ }
218
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
219
+ out.unparseable = true;
220
+ return out;
221
+ }
134
222
  out.raw = raw;
135
223
  for (const k of Object.keys(raw)) if (k !== "agents") out.rejected.push(k);
136
224
  const a = raw.agents;
137
225
  if (a === undefined) return out;
138
- if (!a || typeof a !== "object" || Array.isArray(a)) { out.rejected.push("agents"); return out; }
226
+ if (!a || typeof a !== "object" || Array.isArray(a)) {
227
+ out.rejected.push("agents");
228
+ return out;
229
+ }
139
230
  for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
140
231
  if (a.default !== undefined) {
141
232
  if (a.default && typeof a.default === "object") {
@@ -146,7 +237,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
146
237
  if (a.per_agent !== undefined) {
147
238
  if (a.per_agent && typeof a.per_agent === "object" && !Array.isArray(a.per_agent)) {
148
239
  for (const [name, v] of Object.entries(a.per_agent)) {
149
- if (!v || typeof v !== "object") { out.rejected.push(`agents.per_agent.${name}`); continue; }
240
+ if (!v || typeof v !== "object") {
241
+ out.rejected.push(`agents.per_agent.${name}`);
242
+ continue;
243
+ }
150
244
  for (const k of Object.keys(v)) if (k !== "model") out.rejected.push(`agents.per_agent.${name}.${k}`);
151
245
  if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
152
246
  }
@@ -171,14 +265,17 @@ export function writeOverlayAt(projectDir, id, agents) {
171
265
  if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
172
266
  const path = layoutPaths(projectDir).overlay(id);
173
267
  let raw = {};
174
- try { raw = JSON.parse(readFileSync(path, "utf8")); } catch {}
268
+ try {
269
+ raw = JSON.parse(readFileSync(path, "utf8"));
270
+ } catch {}
175
271
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
176
272
  const block = {};
177
273
  if (agents.default) block.default = { model: agents.default };
178
274
  const names = Object.keys(agents.per_agent || {}).sort();
179
275
  if (names.length) block.per_agent = Object.fromEntries(names.map((n) => [n, { model: agents.per_agent[n] }]));
180
276
  const next = { ...raw };
181
- if (Object.keys(block).length) next.agents = block; else delete next.agents;
277
+ if (Object.keys(block).length) next.agents = block;
278
+ else delete next.agents;
182
279
  ensureRuntimeDir(projectDir);
183
280
  mkdirSync(dirname(path), { recursive: true });
184
281
  writeFileAtomic(path, JSON.stringify(next, null, 2) + "\n", { sweep: false });
@@ -194,11 +291,13 @@ export function activeHarnessId() {
194
291
  export function ensureGitignoreLines(file, lines, header = null) {
195
292
  mkdirSync(dirname(file), { recursive: true });
196
293
  let cur = "";
197
- try { cur = readFileSync(file, "utf8"); } catch {}
294
+ try {
295
+ cur = readFileSync(file, "utf8");
296
+ } catch {}
198
297
  const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
199
298
  const add = lines.filter((l) => !have.has(l));
200
299
  if (!add.length) return false;
201
- const prefix = cur ? (cur.endsWith("\n") ? cur : cur + "\n") : (header ? `# ${header}\n` : "");
300
+ const prefix = cur ? (cur.endsWith("\n") ? cur : cur + "\n") : header ? `# ${header}\n` : "";
202
301
  writeFileSync(file, prefix + add.join("\n") + "\n", "utf8");
203
302
  return true;
204
303
  }
@@ -217,14 +316,37 @@ export function moveStateDir(from, to, within) {
217
316
  mkdirSync(to, { recursive: true });
218
317
  for (const name of readdirSync(from)) {
219
318
  if (name === ".gitignore") continue;
220
- const src = join(from, name), dst = join(to, name);
221
- let st; try { st = lstatSync(src); } catch { continue; }
222
- if (st.isSymbolicLink()) { out.dropped.push(name); continue; }
223
- if (!existsSync(dst)) { movePath(src, dst, within); out.moved.push(name); continue; }
224
- if (st.isDirectory() || name.startsWith(".")) { rmSync(src, { recursive: true, force: true }); out.kept.push(name); continue; }
319
+ const src = join(from, name),
320
+ dst = join(to, name);
321
+ let st;
322
+ try {
323
+ st = lstatSync(src);
324
+ } catch {
325
+ continue;
326
+ }
327
+ if (st.isSymbolicLink()) {
328
+ out.dropped.push(name);
329
+ continue;
330
+ }
331
+ if (!existsSync(dst)) {
332
+ movePath(src, dst, within);
333
+ out.moved.push(name);
334
+ continue;
335
+ }
336
+ if (st.isDirectory() || name.startsWith(".")) {
337
+ rmSync(src, { recursive: true, force: true });
338
+ out.kept.push(name);
339
+ continue;
340
+ }
225
341
  const newer = st.mtimeMs > statSync(dst).mtimeMs;
226
- if (newer) { rmSync(dst, { force: true }); movePath(src, dst, within); out.replaced.push(name); }
227
- else { rmSync(src, { force: true }); out.kept.push(name); }
342
+ if (newer) {
343
+ rmSync(dst, { force: true });
344
+ movePath(src, dst, within);
345
+ out.replaced.push(name);
346
+ } else {
347
+ rmSync(src, { force: true });
348
+ out.kept.push(name);
349
+ }
228
350
  }
229
351
  return out;
230
352
  }
@@ -233,10 +355,16 @@ export function moveStateDir(from, to, within) {
233
355
  // the legacy file is removed; an absent side is fine.
234
356
  export function mergeEntryLog(from, to, within) {
235
357
  if (!existsSync(from)) return "absent";
236
- const inside = (p) => { const r = relative(resolve(within), resolve(p)); return r !== "" && !r.startsWith("..") && !isAbsolute(r); };
358
+ const inside = (p) => {
359
+ const r = relative(resolve(within), resolve(p));
360
+ return r !== "" && !r.startsWith("..") && !isAbsolute(r);
361
+ };
237
362
  if (!inside(from) || !inside(to)) throw new Error(`mergeEntryLog: ${from} → ${to} leaves ${within}`);
238
363
  const old = readFileSync(from, "utf8");
239
- let cur = ""; try { cur = readFileSync(to, "utf8"); } catch {}
364
+ let cur = "";
365
+ try {
366
+ cur = readFileSync(to, "utf8");
367
+ } catch {}
240
368
  mkdirSync(dirname(to), { recursive: true });
241
369
  const glue = old && !old.endsWith("\n") ? "\n" : "";
242
370
  writeFileAtomic(to, old + glue + cur, { sweep: false });
@@ -256,7 +384,10 @@ export function removeInside(path, within, { recursive = false } = {}) {
256
384
  }
257
385
 
258
386
  export function movePath(from, to, within) {
259
- const inside = (p) => { const r = relative(resolve(within), resolve(p)); return r !== "" && !r.startsWith("..") && !isAbsolute(r); };
387
+ const inside = (p) => {
388
+ const r = relative(resolve(within), resolve(p));
389
+ return r !== "" && !r.startsWith("..") && !isAbsolute(r);
390
+ };
260
391
  if (!inside(from) || !inside(to)) throw new Error(`movePath: ${from} → ${to} leaves ${within}`);
261
392
  if (!existsSync(from)) return "absent";
262
393
  if (existsSync(to)) return "target-exists";
@@ -276,7 +407,7 @@ export function readConfigAt(projectDir) {
276
407
  if (!existsSync(p)) return null;
277
408
  try {
278
409
  return JSON.parse(readFileSync(p, "utf8"));
279
- } catch (e) {
410
+ } catch {
280
411
  return null;
281
412
  }
282
413
  }
@@ -298,7 +429,11 @@ export function isMain(metaUrl) {
298
429
  if (!argv1) return false;
299
430
  const here = fileURLToPath(metaUrl);
300
431
  if (resolve(argv1) === here) return true;
301
- try { return realpathSync(argv1) === here; } catch { return false; }
432
+ try {
433
+ return realpathSync(argv1) === here;
434
+ } catch {
435
+ return false;
436
+ }
302
437
  }
303
438
 
304
439
  // ─── Atomic file writes (spec: atomic-regeneration-of-derived-views) ──
@@ -323,7 +458,9 @@ export function writeFileAtomic(p, content, { sweep = true } = {}) {
323
458
  writeFileSync(tmp, content, "utf8");
324
459
  renameSync(tmp, p);
325
460
  } catch (e) {
326
- try { unlinkSync(tmp); } catch {}
461
+ try {
462
+ unlinkSync(tmp);
463
+ } catch {}
327
464
  throw e;
328
465
  }
329
466
  }
@@ -348,22 +485,36 @@ export function writeExclusiveMetadata(fd, value) {
348
485
  // numeric pid, `.tmp`) can never match `.gitignore` and friends.
349
486
  function sweepOrphanTemps(dir) {
350
487
  let names;
351
- try { names = readdirSync(dir); } catch { return; }
488
+ try {
489
+ names = readdirSync(dir);
490
+ } catch {
491
+ return;
492
+ }
352
493
  for (const n of names) {
353
494
  const m = n.match(/^\..+\.(\d+)\.tmp$/);
354
495
  if (!m) continue;
355
496
  const pid = parseInt(m[1], 10);
356
497
  if (!pid || pid === process.pid) continue;
357
- try {
358
- process.kill(pid, 0); // returns ⇒ alive; EPERM ⇒ alive, not ours
359
- } catch (e) {
360
- if (e.code === "ESRCH") {
361
- try { unlinkSync(join(dir, n)); } catch {}
362
- }
498
+ if (!pidAlive(pid)) {
499
+ try {
500
+ unlinkSync(join(dir, n));
501
+ } catch {}
363
502
  }
364
503
  }
365
504
  }
366
505
 
506
+ // The sweeps' one liveness rule: signal 0 returns ⇒ alive; EPERM ⇒ alive but
507
+ // not ours; only ESRCH is dead. Shared by the temp sweep above and the shell
508
+ // fetch's scratch sweep (fetch-shell.mjs), so the two cannot drift.
509
+ export function pidAlive(pid) {
510
+ try {
511
+ process.kill(pid, 0);
512
+ return true;
513
+ } catch (e) {
514
+ return e?.code !== "ESRCH";
515
+ }
516
+ }
517
+
367
518
  // ─── Installed-plugin resolution ───────────────────────────────────────
368
519
 
369
520
  // The harness's config directory. Almost always ~/.claude, but the harness's
@@ -378,8 +529,12 @@ export function claudeHome(home = homedir()) {
378
529
  // "which release line" (the layout window's sunset); wrong for "which build is
379
530
  // newer" — that is cmpPrecedence.
380
531
  export function cmpVersion(a, b) {
381
- const A = String(a || "0").split(".").map((n) => parseInt(n, 10) || 0);
382
- const B = String(b || "0").split(".").map((n) => parseInt(n, 10) || 0);
532
+ const A = String(a || "0")
533
+ .split(".")
534
+ .map((n) => parseInt(n, 10) || 0);
535
+ const B = String(b || "0")
536
+ .split(".")
537
+ .map((n) => parseInt(n, 10) || 0);
383
538
  for (let i = 0; i < 3; i++) if ((A[i] || 0) !== (B[i] || 0)) return (A[i] || 0) - (B[i] || 0);
384
539
  return 0;
385
540
  }
@@ -392,18 +547,27 @@ export function cmpPrecedence(a, b) {
392
547
  const parse = (v) => {
393
548
  const s = String(v || "0").split("+")[0];
394
549
  const dash = s.indexOf("-");
395
- return { core: (dash < 0 ? s : s.slice(0, dash)).split(".").map((n) => parseInt(n, 10) || 0), pre: dash < 0 ? [] : s.slice(dash + 1).split(".") };
550
+ return {
551
+ core: (dash < 0 ? s : s.slice(0, dash)).split(".").map((n) => parseInt(n, 10) || 0),
552
+ pre: dash < 0 ? [] : s.slice(dash + 1).split("."),
553
+ };
396
554
  };
397
- const A = parse(a), B = parse(b);
555
+ const A = parse(a),
556
+ B = parse(b);
398
557
  for (let i = 0; i < 3; i++) if ((A.core[i] || 0) !== (B.core[i] || 0)) return (A.core[i] || 0) - (B.core[i] || 0);
399
558
  // A release outranks every candidate for it.
400
559
  if (!A.pre.length || !B.pre.length) return B.pre.length - A.pre.length;
401
560
  for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
402
561
  if (i >= A.pre.length) return -1;
403
562
  if (i >= B.pre.length) return 1;
404
- const x = A.pre[i], y = B.pre[i];
405
- const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
406
- if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) - Number(y); continue; }
563
+ const x = A.pre[i],
564
+ y = B.pre[i];
565
+ const nx = /^\d+$/.test(x),
566
+ ny = /^\d+$/.test(y);
567
+ if (nx && ny) {
568
+ if (Number(x) !== Number(y)) return Number(x) - Number(y);
569
+ continue;
570
+ }
407
571
  // A numeric identifier ranks below an alphanumeric one.
408
572
  if (nx !== ny) return nx ? -1 : 1;
409
573
  if (x !== y) return x < y ? -1 : 1;
@@ -428,16 +592,24 @@ export function cmpPrecedence(a, b) {
428
592
  // The host's enablement of a plugin, user settings first and the project's
429
593
  // settings over it (a project may silence a user-scope plugin). Absent → true.
430
594
  export function pluginEnabled(key, home = homedir(), projectDir = null) {
431
- const read = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
595
+ const read = (p) => {
596
+ try {
597
+ return JSON.parse(readFileSync(p, "utf8"));
598
+ } catch {
599
+ return null;
600
+ }
601
+ };
432
602
  let enabled = true;
433
603
  const user = read(join(claudeHome(home), "settings.json"));
434
- if (user && user.enabledPlugins && Object.hasOwn(user.enabledPlugins, key)) enabled = user.enabledPlugins[key] !== false;
604
+ if (user && user.enabledPlugins && Object.hasOwn(user.enabledPlugins, key))
605
+ enabled = user.enabledPlugins[key] !== false;
435
606
  if (projectDir) {
436
607
  // The committed project file, then the checkout's local one over it — the
437
608
  // host's own precedence (measured 2026-09-05, --scope local).
438
609
  for (const f of ["settings.json", "settings.local.json"]) {
439
610
  const proj = read(join(projectDir, harnessProjectConfigDir(), f));
440
- if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key)) enabled = proj.enabledPlugins[key] !== false;
611
+ if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
612
+ enabled = proj.enabledPlugins[key] !== false;
441
613
  }
442
614
  }
443
615
  return enabled;
@@ -474,18 +646,135 @@ export function installedPluginEntries(home = homedir(), projectDir = null) {
474
646
  return out;
475
647
  }
476
648
 
477
- // A pure PATH walk for a host binary — a read, not a subprocess: the analysers
478
- // run on every doctor call. Returns the absolute path or null.
479
- export function whichOnPath(name, env = process.env) {
480
- const sep = process.platform === "win32" ? ";" : ":";
481
- const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
482
- for (const dir of String(env.PATH || "").split(sep).filter(Boolean)) {
483
- for (const ext of exts) {
484
- const p = join(dir, name + ext);
485
- try { const st = statSync(p); if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111))) return p; } catch {}
649
+ // Contract 12's selection (the layout spec, as amended 2026-10-03): the copies
650
+ // of ours the host loads for this project — present, enabled for it, and this
651
+ // checkout's own row or a row with no project (user scope), never another
652
+ // checkout's. This checkout's row first, then the most recently updated. Real
653
+ // paths: the host records the project's cwd as one, and /var against
654
+ // /private/var would otherwise drop the project's own row. One filter for
655
+ // doctor's layout remedy and the convergence story's running copies (the
656
+ // converge story's rule 6), so the two cannot disagree about what a project
657
+ // runs. `own` marks this checkout's row.
658
+ export function projectCopies(home = homedir(), projectDir) {
659
+ const real = (x) => {
660
+ try {
661
+ return realpathSync.native(x);
662
+ } catch {
663
+ return resolve(x);
486
664
  }
665
+ };
666
+ const here = real(projectDir);
667
+ const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
668
+ return installedPluginEntries(home, projectDir)
669
+ .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
670
+ .map((e) => ({ ...e, own: mine(e) }))
671
+ .sort((a, b) => Number(b.own) - Number(a.own) || b.at - a.at);
672
+ }
673
+
674
+ // The same selection, one row per plugin key: the row the host loads for this
675
+ // project — this checkout's own row over a user-scope row of the same key,
676
+ // else the most recent. What "a copy enabled for this project" means for the
677
+ // competing copies the registration silences (surfaces.mjs, contract 13) and
678
+ // for the copies the convergence story's rule 6 compares, so the stop and the
679
+ // deferral of a newer copy always see the same (key, version) pairs.
680
+ export function projectCopiesByKey(home = homedir(), projectDir) {
681
+ const seen = new Set();
682
+ return projectCopies(home, projectDir).filter((e) => !seen.has(e.key) && seen.add(e.key));
683
+ }
684
+
685
+ // Whether a registry copy can run the command named for it. `--no-register`
686
+ // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
687
+ // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
688
+ // amended after the rc.3 tag). Read from the copy's own parse table, never
689
+ // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
690
+ // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
691
+ // file read, because this runs in the SessionStart subset: never an import()
692
+ // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
693
+ // released copies keep their parse there. Moved here from doctor.mjs (the
694
+ // converge story's rule 5), which re-exports it: doctor's layout remedy and
695
+ // the installer's keep read one function.
696
+ export function takesNoRegister(copy) {
697
+ try {
698
+ return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"');
699
+ } catch {
700
+ return false;
487
701
  }
488
- return null;
702
+ }
703
+ // …and whether its CLI takes --verbose, the same way: the flag arrived in
704
+ // 0.29.0, after --no-register, and a copy between them (rc.3 to 0.28.x)
705
+ // parses strictly, so a command naming it would exit 2. Read before DONE names
706
+ // a kept copy's own `plan --verbose`.
707
+ export function takesVerbose(copy) {
708
+ try {
709
+ return /\bverbose\b/.test(readFileSync(join(copy, "scripts", "cli.mjs"), "utf8"));
710
+ } catch {
711
+ return false;
712
+ }
713
+ }
714
+
715
+ // Contract 12's selection with its capability read (the layout spec, contract
716
+ // 12 as amended 2026-10-03; the converge story's rule 5): the first of
717
+ // projectCopies — this checkout's own row, then user-scope rows by recency,
718
+ // never another checkout's — and whether its own bin can run the remedy form
719
+ // (`missing`: "bin" for a copy with no bin/projectstore.mjs, 0.27.x;
720
+ // "no-register" for one whose CLI predates the flag, rc.1 and rc.2; null when
721
+ // it can). `only` limits the rows to one install channel before the first is
722
+ // taken: the keep takes "marketplace", so the npm registration's copy is never
723
+ // the kept one. Doctor's layout remedy and the installer's keep call this one
724
+ // function, so the two never disagree about which copy a project runs.
725
+ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarness(), only = null } = {}) {
726
+ const copy = projectCopies(home, projectDir).find((e) => !only || installChannel(e.path, { home, harness }) === only);
727
+ if (!copy) return null;
728
+ const bin = join(copy.path, "bin", "projectstore.mjs");
729
+ const missing = !existsSync(bin) ? "bin" : !takesNoRegister(copy.path) ? "no-register" : null;
730
+ return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
731
+ }
732
+
733
+ // The advice for a marketplace copy that cannot run what is named for it, in
734
+ // that copy's own channel: the host's update, then a restart. `cannot` says
735
+ // what it cannot do, `then` what follows the update. One text for doctor's
736
+ // layout remedy and the keep's skip row and DONE line.
737
+ export function copyUpdateAdvice(copy, { cannot, then }) {
738
+ const v = copy?.version ? ` (${copy.version})` : "";
739
+ return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
740
+ }
741
+
742
+ // The mark a channel switch leaves in this checkout's machine-local binding
743
+ // (the converge story's rules 2–4): `channel_switch: { by, at }`, two strings.
744
+ // It says this checkout chose the npm install; it lists no copies — the
745
+ // silenced set lives in our manifest and the checkout's local settings.
746
+ // Doctor drops contract 13's "if moving to npm was meant, ignore this" info
747
+ // for a marked checkout, the installer plans no second mark, and uninstall
748
+ // removes the key with the registration. `bind --inherit` never copies it.
749
+ export const CHANNEL_SWITCH_KEY = "channel_switch";
750
+ const stampAt = (cfg, key) => {
751
+ const m = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg[key] : null;
752
+ return m && typeof m === "object" && !Array.isArray(m) && typeof m.by === "string" && typeof m.at === "string"
753
+ ? m
754
+ : null;
755
+ };
756
+ export function channelMark(cfg) {
757
+ return stampAt(cfg, CHANNEL_SWITCH_KEY);
758
+ }
759
+
760
+ // Its counterpart for the other answer (the setup story, rule 6): the keep of
761
+ // the git marketplace, `channel_keep: { by, at }`, recorded when setup's
762
+ // channel question was answered keep. Setup's question then defaults to keep,
763
+ // and a run without a terminal reads it as `--channel git`. Per checkout, as
764
+ // the mark is: `bind --inherit` never copies it. The latest answer wins: a
765
+ // later switch removes it, and recording it removes the mark, so the two
766
+ // never both stay after a run.
767
+ export const CHANNEL_KEEP_KEY = "channel_keep";
768
+ export function channelKeep(cfg) {
769
+ return stampAt(cfg, CHANNEL_KEEP_KEY);
770
+ }
771
+
772
+ // A switch read from the environment the way contract 9 reads CI: set, and
773
+ // not empty, "0" or "false" (any case). One reading for CI and for
774
+ // PROJECTSTORE_NO_UPDATE_CHECK (the converge story's rule 8).
775
+ export function envFlag(env, name) {
776
+ const v = env ? env[name] : undefined;
777
+ return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
489
778
  }
490
779
 
491
780
  // The files a package ships, as the release gate defines them — package.json
@@ -494,15 +783,26 @@ export function whichOnPath(name, env = process.env) {
494
783
  // path outside the tree). Returns the relative paths copied, sorted.
495
784
  export function packageFiles(root) {
496
785
  let pkg;
497
- try { pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { return []; }
786
+ try {
787
+ pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
788
+ } catch {
789
+ return [];
790
+ }
498
791
  const out = new Set(["package.json"]);
499
792
  for (const n of readdirSync(root)) if (/^(README|LICEN[CS]E)(\.|$)/i.test(n)) out.add(n);
500
793
  const walk = (rel) => {
501
794
  const abs = join(root, rel);
502
- let st; try { st = lstatSync(abs); } catch { return; }
795
+ let st;
796
+ try {
797
+ st = lstatSync(abs);
798
+ } catch {
799
+ return;
800
+ }
503
801
  if (st.isSymbolicLink()) return;
504
- if (st.isDirectory()) { for (const n of readdirSync(abs)) if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n); }
505
- else out.add(rel);
802
+ if (st.isDirectory()) {
803
+ for (const n of readdirSync(abs))
804
+ if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n);
805
+ } else out.add(rel);
506
806
  };
507
807
  for (const f of pkg.files || []) walk(f.replace(/\/+$/, ""));
508
808
  return [...out].sort();
@@ -512,8 +812,12 @@ export function packageFiles(root) {
512
812
  // ours (install spec contract 13, amended 2026-09-05). The caller has already
513
813
  // proven the manifest is ours; this refuses anything outside the harness home.
514
814
  export function removeOwnTree(dir, home = homedir()) {
515
- const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
516
- if (!norm(dir).startsWith(norm(claudeHome(home)) + "/")) throw new Error(`removeOwnTree: ${dir} is not under the harness home`);
815
+ const norm = (s) =>
816
+ String(s || "")
817
+ .replace(/\\/g, "/")
818
+ .replace(/\/+$/, "");
819
+ if (!norm(dir).startsWith(norm(claudeHome(home)) + "/"))
820
+ throw new Error(`removeOwnTree: ${dir} is not under the harness home`);
517
821
  rmSync(dir, { recursive: true, force: true });
518
822
  }
519
823
 
@@ -522,7 +826,7 @@ export function copyPackageTree(from, to) {
522
826
  for (const rel of files) {
523
827
  const dst = join(to, rel);
524
828
  mkdirSync(dirname(dst), { recursive: true });
525
- cpSync(join(from, rel), dst);
829
+ copyFileSync(join(from, rel), dst);
526
830
  }
527
831
  return files;
528
832
  }
@@ -532,10 +836,17 @@ export function treeFiles(dir) {
532
836
  const out = [];
533
837
  const walk = (rel) => {
534
838
  const abs = rel ? join(dir, rel) : dir;
535
- let st; try { st = lstatSync(abs); } catch { return; }
839
+ let st;
840
+ try {
841
+ st = lstatSync(abs);
842
+ } catch {
843
+ return;
844
+ }
536
845
  if (st.isSymbolicLink()) return;
537
- if (st.isDirectory()) { for (const n of readdirSync(abs)) if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n); }
538
- else out.push(rel);
846
+ if (st.isDirectory()) {
847
+ for (const n of readdirSync(abs))
848
+ if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n);
849
+ } else out.push(rel);
539
850
  };
540
851
  walk("");
541
852
  return out.sort();
@@ -549,7 +860,11 @@ export function filesDigest(root, files) {
549
860
  const h = createHash("sha256");
550
861
  for (const rel of files) {
551
862
  h.update(rel + "\n");
552
- h.update(createHash("sha256").update(readFileSync(join(root, rel))).digest("hex") + "\n");
863
+ h.update(
864
+ createHash("sha256")
865
+ .update(readFileSync(join(root, rel)))
866
+ .digest("hex") + "\n",
867
+ );
553
868
  }
554
869
  return { count: files.length, sha256: h.digest("hex") };
555
870
  }
@@ -562,8 +877,12 @@ export const treeDigest = (dir) => filesDigest(dir, treeFiles(dir));
562
877
  // contract 4′). An existing directory is removed only after the stage is
563
878
  // complete — and only by the caller's proof that it is ours.
564
879
  export function writeOwnTree(dir, { from, subdir, manifestRel, manifest, home = homedir() }) {
565
- const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
566
- if (!norm(dir).startsWith(norm(claudeHome(home)) + "/")) throw new Error(`writeOwnTree: ${dir} is not under the harness home`);
880
+ const norm = (s) =>
881
+ String(s || "")
882
+ .replace(/\\/g, "/")
883
+ .replace(/\/+$/, "");
884
+ if (!norm(dir).startsWith(norm(claudeHome(home)) + "/"))
885
+ throw new Error(`writeOwnTree: ${dir} is not under the harness home`);
567
886
  const stage = dir + ".staging";
568
887
  rmSync(stage, { recursive: true, force: true });
569
888
  mkdirSync(join(stage, subdir), { recursive: true });
@@ -580,27 +899,49 @@ export function writeOwnTree(dir, { from, subdir, manifestRel, manifest, home =
580
899
  // entry is re-checked as a regular file at apply time, so a symlink swap cannot
581
900
  // escape the source root. The previous directory is retained until the caller
582
901
  // verifies the host cache and returns the backup path for commit/rollback.
583
- export function stagePortableMarketplace(dir, { from, files, subdir, catalogRel, catalog, ownershipRel, ownership, homeBase, token = `${process.pid}-${randomUUID()}` }) {
584
- const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
585
- if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`stagePortableMarketplace: ${dir} is not under ${homeBase}`);
902
+ export function stagePortableMarketplace(
903
+ dir,
904
+ {
905
+ from,
906
+ files,
907
+ subdir,
908
+ catalogRel,
909
+ catalog,
910
+ ownershipRel,
911
+ ownership,
912
+ homeBase,
913
+ token = `${process.pid}-${randomUUID()}`,
914
+ },
915
+ ) {
916
+ const norm = (s) =>
917
+ String(s || "")
918
+ .replace(/\\/g, "/")
919
+ .replace(/\/+$/, "");
920
+ if (!norm(dir).startsWith(norm(homeBase) + "/"))
921
+ throw new Error(`stagePortableMarketplace: ${dir} is not under ${homeBase}`);
586
922
  const stage = `${dir}.staging-${token}`;
587
923
  const backup = existsSync(dir) ? `${dir}.previous-${token}` : null;
588
924
  rmSync(stage, { recursive: true, force: true });
589
925
  for (const rel of files) {
590
926
  const src = join(from, rel);
591
927
  const st = lstatSync(src);
592
- if (!st.isFile() || st.isSymbolicLink()) throw new Error(`portable payload changed under the plan: ${rel} is not a regular file`);
928
+ if (!st.isFile() || st.isSymbolicLink())
929
+ throw new Error(`portable payload changed under the plan: ${rel} is not a regular file`);
593
930
  const dst = join(stage, subdir, rel);
594
931
  mkdirSync(dirname(dst), { recursive: true });
595
- cpSync(src, dst);
932
+ copyFileSync(src, dst);
596
933
  }
597
934
  mkdirSync(dirname(join(stage, catalogRel)), { recursive: true });
598
935
  mkdirSync(dirname(join(stage, ownershipRel)), { recursive: true });
599
936
  writeFileAtomic(join(stage, catalogRel), JSON.stringify(catalog, null, 2) + "\n", { sweep: false });
600
937
  writeFileAtomic(join(stage, ownershipRel), JSON.stringify(ownership, null, 2) + "\n", { sweep: false });
601
938
  if (backup) renameSync(dir, backup);
602
- try { renameSync(stage, dir); }
603
- catch (e) { if (backup && !existsSync(dir)) renameSync(backup, dir); throw e; }
939
+ try {
940
+ renameSync(stage, dir);
941
+ } catch (e) {
942
+ if (backup && !existsSync(dir)) renameSync(backup, dir);
943
+ throw e;
944
+ }
604
945
  return { stage, backup };
605
946
  }
606
947
 
@@ -615,17 +956,28 @@ export function rollbackPortableMarketplace(dir, backup = null) {
615
956
  return dir;
616
957
  }
617
958
 
618
- export function removeTreeUnder(dir, homeBase) {
619
- const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
959
+ // `maxRetries` is rmSync's own: a tree another process may still be writing
960
+ // into (an npm the fetch budget stopped) can fail with ENOTEMPTY or EBUSY
961
+ // once and succeed on the next pass. The path guard holds whatever it is.
962
+ export function removeTreeUnder(dir, homeBase, { maxRetries = 0 } = {}) {
963
+ const norm = (s) =>
964
+ String(s || "")
965
+ .replace(/\\/g, "/")
966
+ .replace(/\/+$/, "");
620
967
  if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`removeTreeUnder: ${dir} is not under ${homeBase}`);
621
- rmSync(dir, { recursive: true, force: true });
968
+ rmSync(dir, { recursive: true, force: true, maxRetries });
622
969
  }
623
970
 
624
971
  export function installedPluginRoot(home = homedir(), preferFamily = null) {
625
972
  try {
626
973
  const found = installedPluginEntries(home)
627
974
  .filter((e) => e.present && e.enabled !== false)
628
- .map((e) => ({ path: e.path, version: e.version, same: preferFamily && dirname(e.path) === preferFamily ? 1 : 0, at: e.at }));
975
+ .map((e) => ({
976
+ path: e.path,
977
+ version: e.version,
978
+ same: preferFamily && dirname(e.path) === preferFamily ? 1 : 0,
979
+ at: e.at,
980
+ }));
629
981
  // Family is a filter, not a tiebreak: when the caller came from a known
630
982
  // marketplace, an install from a DIFFERENT one is not a newer copy of the
631
983
  // same plugin — it is someone else's fork, and we do not execute it.
@@ -646,13 +998,27 @@ export function installedPluginRoot(home = homedir(), preferFamily = null) {
646
998
  // took its own copy for a checkout and planned the package's registration
647
999
  // (measured 2026-10-03, the critic of the layout spec's contract 12 amendment).
648
1000
  function underPluginCache(root, home = homedir()) {
649
- const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
1001
+ const norm = (s) =>
1002
+ String(s || "")
1003
+ .replace(/\\/g, "/")
1004
+ .replace(/\/+$/, "");
650
1005
  const cache = join(claudeHome(home), "plugins", "cache");
651
- const below = (r, c) => { const a = norm(r), b = norm(c); return a.startsWith(b + "/") ? a.slice(b.length + 1) : null; };
1006
+ const below = (r, c) => {
1007
+ const a = norm(r),
1008
+ b = norm(c);
1009
+ return a.startsWith(b + "/") ? a.slice(b.length + 1) : null;
1010
+ };
652
1011
  const direct = below(root, cache);
653
1012
  if (direct !== null || !root) return direct;
654
- const real = (x) => { try { return realpathSync(x); } catch { return null; } };
655
- const rr = real(root), rc = real(cache);
1013
+ const real = (x) => {
1014
+ try {
1015
+ return realpathSync(x);
1016
+ } catch {
1017
+ return null;
1018
+ }
1019
+ };
1020
+ const rr = real(root),
1021
+ rc = real(cache);
656
1022
  return rr && rc ? below(rr, rc) : null;
657
1023
  }
658
1024
 
@@ -668,10 +1034,13 @@ export function isEphemeralRoot(root) {
668
1034
  }
669
1035
 
670
1036
  // Which channel the copy at `root` came through. A remedy that re-runs the
671
- // installer has to ask this first, because the package's shell registers the
672
- // plugin through its OWN channel: run for a git-marketplace user, it adds a
673
- // second copy and turns the installed one off for the checkout (the layout
674
- // spec, contract 12 as amended 2026-10-03). The answers:
1037
+ // installer has to ask this first, because the npm package's upgrade
1038
+ // registers the plugin through the npm channel: run for a git-marketplace
1039
+ // user without --no-register, it adds a second copy and turns the installed
1040
+ // one off for the checkout (the layout spec, contract 12 as amended
1041
+ // 2026-10-03). So the remedy names the copy's own bin for a host copy of
1042
+ // another channel, and the core's form only for the npm registration's. The
1043
+ // answers:
675
1044
  // - "registration": the host's cache copy of the registration marketplace the
676
1045
  // manifest names, i.e. the package's own channel;
677
1046
  // - "marketplace": any other host cache copy (the git marketplace, a fork);
@@ -697,10 +1066,20 @@ export function installChannel(root, { home = homedir(), harness = sourceHarness
697
1066
  // wrote an AGENTS.md into it (2026-10-05). bind refuses the same way
698
1067
  // (NO_PROJECT, binding.mjs).
699
1068
  export function projectDirRefusal(dir) {
700
- try { if (statSync(dir).isDirectory()) return null; } catch {}
1069
+ try {
1070
+ if (statSync(dir).isDirectory()) return null;
1071
+ } catch {}
701
1072
  return `no such project directory: ${JSON.stringify(String(dir))} — nothing is written`;
702
1073
  }
703
1074
 
1075
+ // Where a printed command runs, named in prose (the story "User-facing text
1076
+ // says where to run and names no project path and no shell", rule 1): the
1077
+ // command carries no --project, so a run's own output names the directory,
1078
+ // quoted as the refusal above quotes it — a trailing comma or space shows.
1079
+ export function fromTerminalIn(dir) {
1080
+ return `from a terminal in ${JSON.stringify(String(dir))}`;
1081
+ }
1082
+
704
1083
  // ─── Status line wiring (SessionStart-managed) ─────────────────────────
705
1084
  //
706
1085
  // The Claude Code statusLine slot is single and NOT plugin-declarable, so
@@ -714,7 +1093,8 @@ export function projectDirRefusal(dir) {
714
1093
  // PREVIOUS session start, one restart behind every update. The launcher path
715
1094
  // never changes, and it resolves the installed plugin at render time, so
716
1095
  // `/plugin update` + `/reload-plugins` show up immediately. Dev checkouts and
717
- // --plugin-dir roots carry no version, so those stay wired directly.
1096
+ // --plugin-dir roots carry no version, so install wires those directly; a
1097
+ // session run from one keeps whatever launcher entry install wrote.
718
1098
  //
719
1099
  // Idempotent (writes only when the value changes); never clobbers a foreign
720
1100
  // statusLine; bails on an unparseable settings file. Returns a status string,
@@ -731,7 +1111,9 @@ export function legacyStatusLineLauncherPath(projectDir) {
731
1111
  }
732
1112
  // Both launcher shapes — the new state/<harness>/ one and the legacy one —
733
1113
  // in one place; the recognisers below and doctor read it.
734
- export const LAUNCHER_PATH_RE = new RegExp(`${LAYOUT.root.replace(".", "\\.")}/(${LAYOUT.state}/[^/]+/)?${LAYOUT.launcher.replace(".", "\\.")}$`);
1114
+ export const LAUNCHER_PATH_RE = new RegExp(
1115
+ `${LAYOUT.root.replace(".", "\\.")}/(${LAYOUT.state}/[^/]+/)?${LAYOUT.launcher.replace(".", "\\.")}$`,
1116
+ );
735
1117
  export function isLauncherPath(p) {
736
1118
  return LAUNCHER_PATH_RE.test(String(p || "").replace(/\\/g, "/"));
737
1119
  }
@@ -751,21 +1133,69 @@ export function statusLineScriptPath(cmd) {
751
1133
  return m ? m[1].replace(/\\/g, "/") : null;
752
1134
  }
753
1135
 
1136
+ // Is `p` a launcher path of ours in this project: any harness's launcher in
1137
+ // its state directory, or the legacy one? A path test — nothing is read. The
1138
+ // ownership check below and the SessionStart refresh both ask it.
1139
+ function ourLauncherPath(p, projectDir) {
1140
+ if (!p) return false;
1141
+ const norm = (s) => String(s).replace(/\\/g, "/");
1142
+ const q = norm(p);
1143
+ const lp = layoutPaths(projectDir);
1144
+ return (q.startsWith(norm(lp.state) + "/") && isLauncherPath(q)) || q === norm(lp.legacy.launcher);
1145
+ }
1146
+
1147
+ // The provenance field of a registration directory's manifest, or null — the
1148
+ // install spec's contract 2 for a JSON directory: emitted by one function
1149
+ // (registrationManifest, the installer) and read by this one. The registration
1150
+ // analysis (surfaces.mjs) and statusLineIsOurWiring both call it; the key is
1151
+ // the surface's provenance_key.
1152
+ export function registrationProvenance(manifest, s) {
1153
+ const prov = manifest && s && s.provenance_key ? manifest[s.provenance_key] : null;
1154
+ return prov && typeof prov === "object" && typeof prov.pkg === "string" ? prov : null;
1155
+ }
1156
+
1157
+ // Is `p` the npm registration's own payload script —
1158
+ // <registration dir>/<plugin_subdir>/scripts/statusline.mjs, whose directory's
1159
+ // manifest carries our provenance field? The host loads that registration in
1160
+ // place from its marketplace directory, so a session refreshed the entry to
1161
+ // that script and a package root then read it as someone else's (the story
1162
+ // "The SessionStart hook and install agree on the status line of an npm
1163
+ // registration loaded from its marketplace directory"). A string test first;
1164
+ // the manifest is read only when the path has the payload's shape. Every name
1165
+ // comes from the source manifest's registration surface, and the directory
1166
+ // from `p` itself — never a path built from the home.
1167
+ function isRegistrationPayloadScript(p, harness = sourceHarness()) {
1168
+ const s = Object.values(harness?.surfaces || {}).find(
1169
+ (x) => x && x.kind === "registration" && x.format === "host-plugin-registration" && x.plugin_subdir && x.manifest,
1170
+ );
1171
+ if (!s) return false;
1172
+ const tail = `/${s.plugin_subdir}/scripts/statusline.mjs`;
1173
+ if (!p.endsWith(tail)) return false;
1174
+ try {
1175
+ return Boolean(
1176
+ registrationProvenance(JSON.parse(readFileSync(join(p.slice(0, -tail.length), s.manifest), "utf8")), s),
1177
+ );
1178
+ } catch {
1179
+ return false;
1180
+ }
1181
+ }
1182
+
754
1183
  // Strict test — "did WE write this?". Required wherever we would overwrite or
755
1184
  // delete the entry: the loose shape above also matches a user's own
756
1185
  // ~/.claude/scripts/statusline.mjs, and clobbering that would take their HUD.
757
1186
  // Ours means one of: this project's launcher, the running plugin's own script
758
- // (dev checkouts included), or any versioned install under the plugin cache.
1187
+ // (dev checkouts included), any versioned install under the plugin cache, or
1188
+ // the npm registration's payload script — the one rung that reads a file, so
1189
+ // it runs last, after every cheaper test has failed.
759
1190
  export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root = pluginRoot()) {
760
1191
  const p = statusLineScriptPath(cmd);
761
1192
  if (!p) return false;
762
1193
  const norm = (s) => String(s).replace(/\\/g, "/");
763
1194
  // Ours if it is any harness's launcher in this project's state, or the legacy launcher.
764
- const lp = layoutPaths(projectDir);
765
- if (p.startsWith(norm(lp.state) + "/") && isLauncherPath(p)) return true;
766
- if (p === norm(lp.legacy.launcher)) return true;
1195
+ if (ourLauncherPath(p, projectDir)) return true;
767
1196
  if (p === norm(join(root, "scripts", "statusline.mjs"))) return true;
768
- return isPluginCacheRoot(dirname(dirname(p)), home);
1197
+ if (isPluginCacheRoot(dirname(dirname(p)), home)) return true;
1198
+ return isRegistrationPayloadScript(p);
769
1199
  }
770
1200
 
771
1201
  // The launcher template runs standalone, before it knows which plugin root to
@@ -805,7 +1235,9 @@ export function writeStatusLineLauncher(projectDir, root) {
805
1235
  if (src === null) return null;
806
1236
  const p = statusLineLauncherPath(projectDir);
807
1237
  let cur = null;
808
- try { cur = readFileSync(p, "utf8"); } catch {}
1238
+ try {
1239
+ cur = readFileSync(p, "utf8");
1240
+ } catch {}
809
1241
  if (cur !== src) {
810
1242
  ensureStateDir(projectDir); // carries the nested .gitignore: this path is machine-specific
811
1243
  mkdirSync(dirname(p), { recursive: true });
@@ -826,7 +1258,9 @@ export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home =
826
1258
  const launcher = isPluginCacheRoot(root, home);
827
1259
  return {
828
1260
  launcher,
829
- command: launcher ? `node "${statusLineLauncherPath(projectDir)}"` : `node "${join(root, "scripts", "statusline.mjs")}"`,
1261
+ command: launcher
1262
+ ? `node "${statusLineLauncherPath(projectDir)}"`
1263
+ : `node "${join(root, "scripts", "statusline.mjs")}"`,
830
1264
  };
831
1265
  }
832
1266
 
@@ -867,16 +1301,29 @@ export function syncStatusLine(cfg, projectDir, home = homedir()) {
867
1301
  // nothing into the project, since we are not wiring anything here.
868
1302
  if (cur && !isOurs) return "foreign-present";
869
1303
  if (!cur) return "needs-install";
870
- // Refresh: the launcher when it is on disk, else the plugin's own script,
871
- // so an entry always names a renderer that exists. A missing launcher is
872
- // install's to create (stamped), never this path's.
1304
+ // Install alone decides the entry's shape; this hook never demotes a
1305
+ // launcher. An entry naming a launcher of ours that is on disk is left
1306
+ // byte-identical, whatever root runs the hook: the host loads an npm
1307
+ // registration in place from its marketplace directory, which is not the
1308
+ // cache path the launcher was planned against, and re-pointing the entry
1309
+ // at that root's script undid every install (the install spec, contract 0
1310
+ // as amended 2026-10-10). Recognised by path and existsSync only — the
1311
+ // launcher is not read here.
1312
+ const named = statusLineScriptPath(curCmd);
1313
+ const namesLauncher = ourLauncherPath(named, projectDir);
1314
+ if (namesLauncher && existsSync(named)) return "unchanged";
1315
+ // Refresh: a launcher of ours on disk when this root is a cache install or
1316
+ // the entry named a launcher, else the plugin's own script, so an entry
1317
+ // always names a renderer that exists. A missing launcher is install's to
1318
+ // create (stamped), never this path's.
873
1319
  const { launcher } = desiredStatusLineCommand(projectDir, root, home);
874
1320
  // A launcher on disk at either path — the new one, or the legacy one an
875
1321
  // earlier release wrote (the layout ADR) — is kept; moving it is install's.
876
- const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) => existsSync(f));
877
- const desired = launcher && onDisk
878
- ? `node "${onDisk}"`
879
- : `node "${join(root, "scripts", "statusline.mjs")}"`;
1322
+ const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) =>
1323
+ existsSync(f),
1324
+ );
1325
+ const desired =
1326
+ (launcher || namesLauncher) && onDisk ? `node "${onDisk}"` : `node "${join(root, "scripts", "statusline.mjs")}"`;
880
1327
  if (curCmd !== desired) {
881
1328
  // Keep any sibling keys the platform supports on this object
882
1329
  // (refreshInterval and friends) — we own the command, not the entry.
@@ -953,19 +1400,40 @@ export function findAgentsBlock(text) {
953
1400
  const loose = new RegExp(AGENTS_BLOCK_OPEN_LOOSE_SRC).exec(text);
954
1401
  if (!loose) return null;
955
1402
  const line = text.slice(0, loose.index).split("\n").length;
956
- return { present: true, v: Number(loose[1]), start: loose.index, end: null, unclosed: true, wrapped: true, line, count, block: null };
1403
+ return {
1404
+ present: true,
1405
+ v: Number(loose[1]),
1406
+ start: loose.index,
1407
+ end: null,
1408
+ unclosed: true,
1409
+ wrapped: true,
1410
+ line,
1411
+ count,
1412
+ block: null,
1413
+ };
957
1414
  }
958
1415
  const closeAt = text.indexOf(AGENTS_BLOCK_CLOSE, m.index + m[0].length);
959
- if (closeAt === -1) return { present: true, v: Number(m[1]), start: m.index, end: null, unclosed: true, count, block: null };
1416
+ if (closeAt === -1)
1417
+ return { present: true, v: Number(m[1]), start: m.index, end: null, unclosed: true, count, block: null };
960
1418
  const end = closeAt + AGENTS_BLOCK_CLOSE.length;
961
- return { present: true, v: Number(m[1]), start: m.index, end, unclosed: false, count, block: text.slice(m.index, end) };
1419
+ return {
1420
+ present: true,
1421
+ v: Number(m[1]),
1422
+ start: m.index,
1423
+ end,
1424
+ unclosed: false,
1425
+ count,
1426
+ block: text.slice(m.index, end),
1427
+ };
962
1428
  }
963
1429
 
964
1430
  // Whether `text` holds `line` as a line of its own, trimmed and exact — the
965
1431
  // installer's match for the `@<file>` import, so `@./AGENTS.md` reads as
966
1432
  // absent to install and doctor alike.
967
1433
  export function importsLine(text, line) {
968
- return String(text ?? "").split("\n").some((l) => l.trim() === line);
1434
+ return String(text ?? "")
1435
+ .split("\n")
1436
+ .some((l) => l.trim() === line);
969
1437
  }
970
1438
 
971
1439
  // Whether a harness sees the one agents block where it stands — the predicate
@@ -1025,14 +1493,24 @@ export function renderAgentsBlock(tmpl, roster = null) {
1025
1493
  const AGENT_LINE = /^- [^:]*?:\s+(?:run|consult) (?:the )?`projectstore:([a-z]+)`/;
1026
1494
  const flush = () => {
1027
1495
  if (!bullet) return;
1028
- const m = AGENT_LINE.exec(bullet.map((l) => l.trim()).join(" ").replace(/^- /, "- "));
1496
+ const m = AGENT_LINE.exec(
1497
+ bullet
1498
+ .map((l) => l.trim())
1499
+ .join(" ")
1500
+ .replace(/^- /, "- "),
1501
+ );
1029
1502
  if (!names || !m || names.has(m[1])) out.push(...bullet);
1030
1503
  bullet = null;
1031
1504
  };
1032
1505
  for (const l of lines) {
1033
- if (/^- /.test(l)) { flush(); bullet = [l]; }
1034
- else if (bullet && /^\s+\S/.test(l)) bullet.push(l);
1035
- else { flush(); out.push(l); }
1506
+ if (/^- /.test(l)) {
1507
+ flush();
1508
+ bullet = [l];
1509
+ } else if (bullet && /^\s+\S/.test(l)) bullet.push(l);
1510
+ else {
1511
+ flush();
1512
+ out.push(l);
1513
+ }
1036
1514
  }
1037
1515
  flush();
1038
1516
  return out.join("\n");
@@ -1056,7 +1534,9 @@ export function layoutRoster(cfg, root = pluginRoot()) {
1056
1534
  try {
1057
1535
  const r = loadLayout(cfg.layout, root).agents;
1058
1536
  return Array.isArray(r) && r.length ? r : null;
1059
- } catch { return null; }
1537
+ } catch {
1538
+ return null;
1539
+ }
1060
1540
  }
1061
1541
 
1062
1542
  export function folderByKind(layout, kind) {
@@ -1065,14 +1545,33 @@ export function folderByKind(layout, kind) {
1065
1545
 
1066
1546
  // ─── Templates ─────────────────────────────────────────────────────────
1067
1547
 
1068
- export function loadTemplate(lang, name) {
1069
- const p = join(pluginRoot(), "templates", lang, `${name}.md.tmpl`);
1548
+ // `root` is the plugin root to read from — the bin passes its own package's,
1549
+ // as it does for layouts, so a scaffold plans against the copy that runs.
1550
+ export function loadTemplate(lang, name, root = pluginRoot()) {
1551
+ const p = join(root, "templates", lang, `${name}.md.tmpl`);
1070
1552
  if (!existsSync(p)) {
1071
1553
  throw new Error(`Template not found: templates/${lang}/${name}.md.tmpl`);
1072
1554
  }
1073
1555
  return readFileSync(p, "utf8");
1074
1556
  }
1075
1557
 
1558
+ // templates/<lang>/strings.json, parsed — or null when the language has none
1559
+ // or it does not parse. No English fallback, unlike the status line's own
1560
+ // reader: the folder names and descriptions are written into a vault's
1561
+ // READMEs, and an English description in a Russian vault would read as the
1562
+ // vault's own prose forever after (the skeleton's Purpose column takes it
1563
+ // from there). A missing string is the caller's refusal to name.
1564
+ export function loadStrings(lang, root = pluginRoot()) {
1565
+ const p = join(root, "templates", String(lang), "strings.json");
1566
+ if (!existsSync(p)) return null;
1567
+ try {
1568
+ const s = JSON.parse(readFileSync(p, "utf8"));
1569
+ return s && typeof s === "object" && !Array.isArray(s) ? s : null;
1570
+ } catch {
1571
+ return null;
1572
+ }
1573
+ }
1574
+
1076
1575
  // {{x}} substitutes raw; {{x_json}} substitutes JSON.stringify(String(x)) — a
1077
1576
  // valid YAML double-quoted scalar. Frontmatter lines in templates use the
1078
1577
  // _json form so titles containing `"` or `:` cannot corrupt the YAML.
@@ -1100,7 +1599,9 @@ export function renderTemplate(template, vars) {
1100
1599
  // language is the canonical form used when WRITING. Matching always accepts
1101
1600
  // every registered form of every language — a ru-headed file in an en-bound
1102
1601
  // vault must still lint. This is deliberately separate from
1103
- // templates/<lang>/strings.json, which is a render-only map for the statusline.
1602
+ // templates/<lang>/strings.json, a render-only map: the status line's strings
1603
+ // and the folder names and descriptions scaffold writes into READMEs, which
1604
+ // nothing ever matches against.
1104
1605
 
1105
1606
  let _headingsCache = null;
1106
1607
 
@@ -1110,7 +1611,7 @@ export function loadHeadingsRegistry() {
1110
1611
  try {
1111
1612
  _headingsCache = JSON.parse(readFileSync(p, "utf8"));
1112
1613
  } catch (e) {
1113
- throw new Error(`heading registry missing or unreadable (${p}): ${e.message}`);
1614
+ throw new Error(`heading registry missing or unreadable (${p}): ${e.message}`, { cause: e });
1114
1615
  }
1115
1616
  return _headingsCache;
1116
1617
  }
@@ -1192,10 +1693,10 @@ export function footerDateRe() {
1192
1693
  // so the loss had no detector at all. Anchored, both halves behave as
1193
1694
  // documented: reconcile reports the index unusable and doctor warns.
1194
1695
  export function indexHeaderRe() {
1195
- const cols = ["file", "title", "status", "date"].map((c) =>
1196
- allForms("index_columns", c).map(escapeRe).join("|"));
1696
+ const cols = ["file", "title", "status", "date"].map((c) => allForms("index_columns", c).map(escapeRe).join("|"));
1197
1697
  return new RegExp(
1198
- `^\\|\\s*(?:${cols[0]})\\s*\\|\\s*(?:${cols[1]})\\s*\\|\\s*(?:${cols[2]})\\s*\\|\\s*(?:${cols[3]})\\s*\\|\\s*$`);
1698
+ `^\\|\\s*(?:${cols[0]})\\s*\\|\\s*(?:${cols[1]})\\s*\\|\\s*(?:${cols[2]})\\s*\\|\\s*(?:${cols[3]})\\s*\\|\\s*$`,
1699
+ );
1199
1700
  }
1200
1701
 
1201
1702
  // ─── Frontmatter list fields (code_refs / specs / stories / adr) ───────
@@ -1261,10 +1762,39 @@ export function isLegacyStory(fm, since) {
1261
1762
  // Cyrillic → Latin so ru titles produce portable ASCII filenames; every other
1262
1763
  // Unicode letter/digit survives via \p{L}\p{N}. Never returns an empty slug.
1263
1764
  const CYRILLIC = {
1264
- а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z",
1265
- и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r",
1266
- с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh",
1267
- щ: "shch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya",
1765
+ а: "a",
1766
+ б: "b",
1767
+ в: "v",
1768
+ г: "g",
1769
+ д: "d",
1770
+ е: "e",
1771
+ ё: "e",
1772
+ ж: "zh",
1773
+ з: "z",
1774
+ и: "i",
1775
+ й: "y",
1776
+ к: "k",
1777
+ л: "l",
1778
+ м: "m",
1779
+ н: "n",
1780
+ о: "o",
1781
+ п: "p",
1782
+ р: "r",
1783
+ с: "s",
1784
+ т: "t",
1785
+ у: "u",
1786
+ ф: "f",
1787
+ х: "h",
1788
+ ц: "ts",
1789
+ ч: "ch",
1790
+ ш: "sh",
1791
+ щ: "shch",
1792
+ ъ: "",
1793
+ ы: "y",
1794
+ ь: "",
1795
+ э: "e",
1796
+ ю: "yu",
1797
+ я: "ya",
1268
1798
  };
1269
1799
 
1270
1800
  export function slugify(s) {
@@ -1460,7 +1990,11 @@ export function listStoryFiles(storiesDir) {
1460
1990
  for (const entry of readdirSync(storiesDir).sort()) {
1461
1991
  const full = join(storiesDir, entry);
1462
1992
  let st;
1463
- try { st = statSync(full); } catch { continue; }
1993
+ try {
1994
+ st = statSync(full);
1995
+ } catch {
1996
+ continue;
1997
+ }
1464
1998
  if (st.isFile() && entry.endsWith(".md")) {
1465
1999
  out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
1466
2000
  continue;
@@ -1486,14 +2020,22 @@ export function listStoryFiles(storiesDir) {
1486
2020
  export function listEpicStories(epicDir) {
1487
2021
  const out = [];
1488
2022
  // epics/ holds loose files too (README, notes) — only folders are epics.
1489
- try { if (!statSync(epicDir).isDirectory()) return out; } catch { return out; }
2023
+ try {
2024
+ if (!statSync(epicDir).isDirectory()) return out;
2025
+ } catch {
2026
+ return out;
2027
+ }
1490
2028
  for (const s of listStoryFiles(join(epicDir, "stories"))) {
1491
2029
  out.push({ abs: s.abs, rel: `stories/${s.rel}`, slug: s.slug });
1492
2030
  }
1493
2031
  for (const entry of readdirSync(epicDir).sort()) {
1494
2032
  if (!entry.startsWith("story-") || !entry.endsWith(".md")) continue;
1495
2033
  const full = join(epicDir, entry);
1496
- try { if (!statSync(full).isFile()) continue; } catch { continue; }
2034
+ try {
2035
+ if (!statSync(full).isFile()) continue;
2036
+ } catch {
2037
+ continue;
2038
+ }
1497
2039
  out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
1498
2040
  }
1499
2041
  return out;
@@ -1574,14 +2116,16 @@ export function buildNodeIndex(cfg, layout) {
1574
2116
  const nodes = [];
1575
2117
  const push = (abs, rel, type, { prefix = null, story = false } = {}) => {
1576
2118
  let md;
1577
- try { md = readFileSync(abs, "utf8"); } catch { return; }
2119
+ try {
2120
+ md = readFileSync(abs, "utf8");
2121
+ } catch {
2122
+ return;
2123
+ }
1578
2124
  const fm = parseFrontmatter(md).data;
1579
2125
  const name = basename(rel);
1580
2126
  // A folder-shape story is identified by its folder name, like doctor's
1581
2127
  // storyStemOf; every other node by its filename stem.
1582
- const stem = name === "README.md"
1583
- ? basename(dirname(rel))
1584
- : name.replace(/\.md$/i, "");
2128
+ const stem = name === "README.md" ? basename(dirname(rel)) : name.replace(/\.md$/i, "");
1585
2129
  const idField = fm.id ?? fm.slug; // research/concept/runbook/meeting templates carry slug:, not id:
1586
2130
  nodes.push({
1587
2131
  path: rel,
@@ -1706,10 +2250,13 @@ export function resolveLinkTarget(rawTarget, linkType, ctx) {
1706
2250
  const tiers = [
1707
2251
  () => (index.byIdentity.get(key) || []).filter(eligible),
1708
2252
  () => (index.byStem.get(key) || []).filter(eligible),
1709
- () => index.nodes.filter((n) =>
1710
- eligible(n)
1711
- && isLegacyNumberedId(target, n.story ? { story: true } : { prefix: n.prefix })
1712
- && n.stem.toLowerCase().startsWith(`${key}-`)),
2253
+ () =>
2254
+ index.nodes.filter(
2255
+ (n) =>
2256
+ eligible(n) &&
2257
+ isLegacyNumberedId(target, n.story ? { story: true } : { prefix: n.prefix }) &&
2258
+ n.stem.toLowerCase().startsWith(`${key}-`),
2259
+ ),
1713
2260
  ];
1714
2261
  for (const tier of tiers) {
1715
2262
  const hits = [...new Set(tier())];
@@ -1734,11 +2281,11 @@ export function resolveLinkTarget(rawTarget, linkType, ctx) {
1734
2281
  // split is what makes the bounds unit-testable without a slow filesystem, and
1735
2282
  // what stops a second convenience being paid for out of the same budget later.
1736
2283
 
1737
- export const PURPOSE_CELL = 160; // contract 1
1738
- export const TITLE_CELL = 80; // contract 1
1739
- export const PATH_CELL = 200; // contracts 1, 19 — measured: longest path here is 123
1740
- export const INFLIGHT_CAP = 5; // contracts 1, 19
1741
- export const ERROR_CELL = 500; // contract 3
2284
+ export const PURPOSE_CELL = 160; // contract 1
2285
+ export const TITLE_CELL = 80; // contract 1
2286
+ export const PATH_CELL = 200; // contracts 1, 19 — measured: longest path here is 123
2287
+ export const INFLIGHT_CAP = 5; // contracts 1, 19
2288
+ export const ERROR_CELL = 500; // contract 3
1742
2289
 
1743
2290
  // A truncation marks itself, and the mark counts toward the budget (contract 1).
1744
2291
  export function truncEnd(s, max) {
@@ -2077,8 +2624,10 @@ export function renderVaultSkeleton(facts) {
2077
2624
  if (more > 0) L.push(`- …and ${more} more; see \`${commandForm("status")}\``);
2078
2625
  if (cont.artifact) {
2079
2626
  L.push("");
2080
- L.push(`**In flight**: ${pathCell(cont.artifact)} was the newest structured write before` +
2081
- " compaction. If we were drafting it, continue from there.");
2627
+ L.push(
2628
+ `**In flight**: ${pathCell(cont.artifact)} was the newest structured write before` +
2629
+ " compaction. If we were drafting it, continue from there.",
2630
+ );
2082
2631
  }
2083
2632
  L.push("");
2084
2633
  }
@@ -2142,7 +2691,11 @@ export function ensureSessionsDir(vault) {
2142
2691
  // vault lives. The .gitignore inside <vault>/.projectstore/ is merged by
2143
2692
  // line (2026-09-06: a vault that is also a project shares the directory
2144
2693
  // with the project layout, whose committed harness/ a "*" would hide).
2145
- ensureGitignoreLines(join(vault, LAYOUT.root, ".gitignore"), [`${LAYOUT.vaultSessions}/`], "projectstore — runtime data, do not commit");
2694
+ ensureGitignoreLines(
2695
+ join(vault, LAYOUT.root, ".gitignore"),
2696
+ [`${LAYOUT.vaultSessions}/`],
2697
+ "projectstore — runtime data, do not commit",
2698
+ );
2146
2699
  return dir;
2147
2700
  }
2148
2701
 
@@ -2154,7 +2707,9 @@ export function writeSession(vault, sessionId, projectRoot) {
2154
2707
  const path = sessionFilePath(vault, sessionId);
2155
2708
  let existing = null;
2156
2709
  if (existsSync(path)) {
2157
- try { existing = JSON.parse(readFileSync(path, "utf8")); } catch {}
2710
+ try {
2711
+ existing = JSON.parse(readFileSync(path, "utf8"));
2712
+ } catch {}
2158
2713
  }
2159
2714
  const data = {
2160
2715
  id: sessionId,
@@ -2189,10 +2744,18 @@ export function readActiveSessions(vault, currentSessionId, maxAgeMinutes = 30)
2189
2744
  if (!name.endsWith(".json")) continue;
2190
2745
  const path = join(dir, name);
2191
2746
  let stat;
2192
- try { stat = statSync(path); } catch { continue; }
2747
+ try {
2748
+ stat = statSync(path);
2749
+ } catch {
2750
+ continue;
2751
+ }
2193
2752
  if (stat.mtimeMs < cutoff) continue;
2194
2753
  let data;
2195
- try { data = JSON.parse(readFileSync(path, "utf8")); } catch { continue; }
2754
+ try {
2755
+ data = JSON.parse(readFileSync(path, "utf8"));
2756
+ } catch {
2757
+ continue;
2758
+ }
2196
2759
  if (data.id === currentSessionId) continue;
2197
2760
  out.push({ ...data, last_active: stat.mtime });
2198
2761
  }
@@ -2236,7 +2799,6 @@ export function cleanupStaleSessions(vault, maxAgeHours = 24, currentSessionId =
2236
2799
  // behind by v0.3 – v0.5 (file-based per-project session id). Safe to call
2237
2800
  // on every session start; no-op if the file is absent. Kept until v0.7.
2238
2801
 
2239
-
2240
2802
  // ─── Session activity log ──────────────────────────────────────────────
2241
2803
  //
2242
2804
  // Each session file may carry a `recent_activity` array, populated by
@@ -2348,7 +2910,7 @@ export function isInsideVault(filePath, vaultPath) {
2348
2910
  // so per-session ids/titles never reach the user's git history.
2349
2911
 
2350
2912
  // The WRITER's view: <project>/.projectstore/state/sessions. Readers fall back
2351
- // to the legacy .claude/.projectstore/state through 0.29 (readSessionState).
2913
+ // to the legacy .claude/.projectstore/state through 0.30 (readSessionState).
2352
2914
  export function stateDir(projectDir) {
2353
2915
  return layoutPaths(projectDir).sessions;
2354
2916
  }
@@ -2365,7 +2927,11 @@ export function sessionStatePath(projectDir, sessionId) {
2365
2927
  export function ensureRuntimeDir(projectDir) {
2366
2928
  const p = layoutPaths(projectDir);
2367
2929
  mkdirSync(p.root, { recursive: true });
2368
- ensureGitignoreLines(p.gitignore, [...LAYOUT.gitignore], "projectstore — the binding and the state are machine-local; harness/ is committed");
2930
+ ensureGitignoreLines(
2931
+ p.gitignore,
2932
+ [...LAYOUT.gitignore],
2933
+ "projectstore — the binding and the state are machine-local; harness/ is committed",
2934
+ );
2369
2935
  return p.root;
2370
2936
  }
2371
2937
 
@@ -2373,16 +2939,32 @@ export function ensureRuntimeDir(projectDir) {
2373
2939
  // harness inside; its own ignore file so nothing in here can reach git,
2374
2940
  // whichever writer gets there first.
2375
2941
  export function ensureStateDir(projectDir) {
2376
- ensureRuntimeDir(projectDir);
2942
+ ensureStateRoot(projectDir);
2377
2943
  const p = layoutPaths(projectDir);
2378
2944
  mkdirSync(p.sessions, { recursive: true });
2379
- if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
2380
2945
  return p.sessions;
2381
2946
  }
2382
2947
 
2948
+ // state/ and its .gitignore, header included, without sessions/: what the
2949
+ // welcome marker's writer needs in a project that is not bound, where no
2950
+ // session state may appear. The header is how a project uninstall
2951
+ // recognises the marker as ours (install spec contract 13, as amended by the
2952
+ // shell-fetch story).
2953
+ export function ensureStateRoot(projectDir) {
2954
+ ensureRuntimeDir(projectDir);
2955
+ const p = layoutPaths(projectDir);
2956
+ mkdirSync(p.state, { recursive: true });
2957
+ if (!existsSync(p.stateGitignore))
2958
+ writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
2959
+ return p.state;
2960
+ }
2961
+
2383
2962
  export function readSessionState(projectDir, sessionId) {
2384
2963
  try {
2385
- const p = pickExisting(sessionStatePath(projectDir, sessionId), join(legacyStateDir(projectDir), `${sessionId}.json`));
2964
+ const p = pickExisting(
2965
+ sessionStatePath(projectDir, sessionId),
2966
+ join(legacyStateDir(projectDir), `${sessionId}.json`),
2967
+ );
2386
2968
  return JSON.parse(readFileSync(p, "utf8"));
2387
2969
  } catch {
2388
2970
  return null;
@@ -2401,13 +2983,137 @@ export function writeSessionState(projectDir, sessionId, patch) {
2401
2983
  // readSessionState answers null, so the next write keeps only its own patch
2402
2984
  // and the rest of the pointer is gone for good. The rename also hides the
2403
2985
  // empty instant after the truncate. The
2404
- // default sweep stays on: this writer runs only on vault-file tool calls, so
2986
+ // default sweep stays on: this writer runs only on vault-file tool calls,
2987
+ // story-close refreshes and once at SessionStart, so
2405
2988
  // it can afford the readdir that reaps orphan temps here (the status line's
2406
2989
  // breadcrumb, written beside it, opts out because it renders every refresh).
2407
2990
  writeFileAtomic(sessionStatePath(projectDir, sessionId), JSON.stringify(next, null, 2));
2408
2991
  return next;
2409
2992
  }
2410
2993
 
2994
+ // ─── The pointer's epic and story (ADR-006 decision 1, amended 2026-10-12) ──
2995
+ // What touching one vault file says about this session's work: the epic it
2996
+ // sits under and, for a story, the story's key, title and status as the file
2997
+ // says them NOW — callers run after a write, never before it. The merge in
2998
+ // writeSessionState cannot delete a key, so a story field with no value is
2999
+ // written as null (every reader treats it as absent): a touch of another
3000
+ // epic clears the previous epic's story, and an unreadable story names no
3001
+ // stale title. Null when the file is not under an epic.
3002
+ export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFileSync(p, "utf8")) {
3003
+ const m = filePath.slice(vaultPath.length + 1).match(/^epics\/([^/]+)\/(.+)$/);
3004
+ if (!m) return null;
3005
+ const [, epicId, inEpic] = m;
3006
+ const patch = { active_epic: epicId };
3007
+ try {
3008
+ const { data } = parseFrontmatter(read(join(vaultPath, "epics", epicId, "epic.md")));
3009
+ if (data.title) patch.epic_title = String(data.title);
3010
+ } catch {}
3011
+ const key = storyKeyOf(inEpic);
3012
+ if (key) {
3013
+ let data = null;
3014
+ try {
3015
+ data = parseFrontmatter(read(filePath)).data;
3016
+ } catch {}
3017
+ patch.active_story = key;
3018
+ patch.story_title = data && data.title ? String(data.title) : null;
3019
+ patch.story_status = data && data.status ? String(data.status) : null;
3020
+ } else if (!cur || cur.active_epic !== epicId) {
3021
+ Object.assign(patch, { active_story: null, story_title: null, story_status: null });
3022
+ }
3023
+ return patch;
3024
+ }
3025
+
3026
+ // The file a pointer's story key names, by the three shapes the board lists
3027
+ // (storyKeyOf); null when none exists. existsSync is a stat, which does not
3028
+ // download an evicted iCloud file.
3029
+ export function storyFileFor(vaultPath, epicId, key) {
3030
+ if (!epicId || !key || /[/\\\0]/.test(epicId) || /[/\\\0]/.test(key)) return null;
3031
+ const epicDir = join(vaultPath, "epics", epicId);
3032
+ const candidates = [join(epicDir, "stories", `${key}.md`), join(epicDir, "stories", key, "README.md")];
3033
+ if (key.startsWith("story-")) candidates.push(join(epicDir, `${key}.md`));
3034
+ return candidates.find((p) => existsSync(p)) || null;
3035
+ }
3036
+
3037
+ // `projectstore session refresh <path>` — ADR-006 decision 1's "projectstore
3038
+ // commands on approved epic/story writes". The close ceremony copies the
3039
+ // story through Bash, which no hook sees, so the story command calls this
3040
+ // after the copy: this session's pointer is patched from the file as it is
3041
+ // now, as a touch of that file would be. The session is the host's
3042
+ // (session_id_env); a harness that names none gets nothing written and no
3043
+ // failure, so a close there is never broken by it. `path` is vault-relative
3044
+ // or absolute inside the vault.
3045
+ export function refreshSessionPointer(projectDir, cfg, path, { env = process.env } = {}) {
3046
+ const vault = cfg.vault_path;
3047
+ const abs = isAbsolute(path) ? resolve(path) : join(vault, path);
3048
+ const rel = abs.startsWith(`${vault}/`) ? abs.slice(vault.length + 1) : null;
3049
+ if (!rel) return { refusal: { code: "NOT_IN_VAULT", message: `${path} is not inside the bound vault (${vault})` } };
3050
+ if (!existsSync(abs)) return { refusal: { code: "NOT_FOUND", message: `${rel} does not exist in the vault` } };
3051
+ const sessionId = hostSessionId(env);
3052
+ const cur = sessionId ? readSessionState(projectDir, sessionId) : null;
3053
+ const patch = pointerPatchFor(vault, abs, cur);
3054
+ if (!patch || !patch.active_story)
3055
+ return { refusal: { code: "NOT_A_STORY", message: `${rel} is not a story file under an epic` } };
3056
+ if (!sessionId) return { session: null, refreshed: false, reason: "no-session", path: rel };
3057
+ const next = writeSessionState(projectDir, sessionId, patch);
3058
+ return {
3059
+ session: sessionId,
3060
+ refreshed: true,
3061
+ path: rel,
3062
+ pointer: {
3063
+ active_epic: next.active_epic ?? null,
3064
+ active_story: next.active_story ?? null,
3065
+ story_title: next.story_title ?? null,
3066
+ story_status: next.story_status ?? null,
3067
+ },
3068
+ };
3069
+ }
3070
+
3071
+ // SessionStart's refresh of this session's story fields from the story file:
3072
+ // a pointer written before a close (0.29.3 patched it before the write, and
3073
+ // never on the close ceremony's copy), or a story closed in another session
3074
+ // or by hand since. The read races a budget, as resolveOpenStory's does — an
3075
+ // evicted file blocks — and a lost race writes nothing. A story the epic no
3076
+ // longer has clears the fields; a vault or epic folder that is not there (an
3077
+ // unmounted drive) proves nothing and writes nothing.
3078
+ // "refreshed" | "unchanged" | "unknown".
3079
+ export async function refreshStoryPointer(projectDir, vaultPath, sessionId, opts = {}) {
3080
+ const cur = readSessionState(projectDir, sessionId);
3081
+ if (!cur || !cur.active_epic || !cur.active_story) return "unchanged";
3082
+ const file = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
3083
+ if (!file && !existsSync(join(vaultPath, "epics", cur.active_epic, "epic.md"))) return "unknown";
3084
+ let title = null;
3085
+ let status = null;
3086
+ if (file) {
3087
+ const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
3088
+ let timer = null;
3089
+ const deadline = new Promise((resolve) => {
3090
+ timer = setTimeout(() => resolve(null), opts.budgetMs ?? 200);
3091
+ });
3092
+ let text;
3093
+ try {
3094
+ text = await Promise.race([
3095
+ Promise.resolve()
3096
+ .then(() => readFile(file))
3097
+ .then(String, () => null),
3098
+ deadline,
3099
+ ]);
3100
+ } finally {
3101
+ if (timer) clearTimeout(timer);
3102
+ }
3103
+ if (text === null) return "unknown";
3104
+ const { data } = parseFrontmatter(text);
3105
+ title = data.title ? String(data.title) : null;
3106
+ status = data.status ? String(data.status) : null;
3107
+ }
3108
+ const patch = file
3109
+ ? { story_title: title, story_status: status }
3110
+ : { active_story: null, story_title: null, story_status: null };
3111
+ const same = Object.entries(patch).every(([k, v]) => (cur[k] ?? null) === v);
3112
+ if (same) return "unchanged";
3113
+ writeSessionState(projectDir, sessionId, patch);
3114
+ return "refreshed";
3115
+ }
3116
+
2411
3117
  export function cleanupStaleSessionState(projectDir, maxAgeHours = 24) {
2412
3118
  const dir = stateDir(projectDir);
2413
3119
  if (!existsSync(dir)) return 0;
@@ -2455,23 +3161,36 @@ export function cleanupStaleSessionState(projectDir, maxAgeHours = 24) {
2455
3161
  // consumer. Lifted out of diff-refs.mjs so the hook, doctor and diff-refs
2456
3162
  // cannot drift apart (contract 1).
2457
3163
  export const SOURCE_IGNORE = [
2458
- /(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/, /(^|\/)pnpm-lock\.yaml$/,
2459
- /(^|\/)Cargo\.lock$/, /(^|\/)node_modules\//, /(^|\/)dist\//, /(^|\/)build\//,
2460
- /(^|\/)\.claude\//, /\.min\.(js|css)$/,
3164
+ /(^|\/)package-lock\.json$/,
3165
+ /(^|\/)yarn\.lock$/,
3166
+ /(^|\/)pnpm-lock\.yaml$/,
3167
+ /(^|\/)Cargo\.lock$/,
3168
+ /(^|\/)node_modules\//,
3169
+ /(^|\/)dist\//,
3170
+ /(^|\/)build\//,
3171
+ /(^|\/)\.claude\//,
3172
+ /\.min\.(js|css)$/,
2461
3173
  ];
2462
3174
 
2463
3175
  // The entry-rule counter ignores strictly more than the base set, and the
2464
3176
  // difference is deliberate rather than an oversight of one or the other.
2465
- // /projectstore:bind writes these three itself, in a session that by
3177
+ // /projectstore:bind writes these itself, in a session that by
2466
3178
  // construction has no story open — counting them makes the plugin nag about its
2467
3179
  // own setup. They must NOT join SOURCE_IGNORE: an edit to AGENTS.md is a real
2468
3180
  // code reference (the PS-AGENTS epic already lists it), and folding these into
2469
3181
  // the shared set would silently drop it from every proposed code_refs.
2470
3182
  // Root-anchored, unlike the patterns above: a monorepo's nested AGENTS.md is
2471
- // ordinary source and must still count.
3183
+ // ordinary source and must still count. The fourth is the binding's own
3184
+ // ignore file, which `bind` and `init` write through ensureRuntimeDir: the
3185
+ // layout move added it and this list missed it, so a fresh `init` on a clean
3186
+ // project read as one uncommitted source file (measured 2026-10-08 on 0.29.2).
3187
+ // Built from LAYOUT.root, which the resolver alone spells (layout contract 0).
2472
3188
  export const ENTRY_IGNORE = [
2473
3189
  ...SOURCE_IGNORE,
2474
- /^AGENTS\.md$/, /^CLAUDE\.md$/, /^\.gitignore$/,
3190
+ /^AGENTS\.md$/,
3191
+ /^CLAUDE\.md$/,
3192
+ /^\.gitignore$/,
3193
+ new RegExp(`^${LAYOUT.root.replace(".", "\\.")}\\/\\.gitignore$`),
2475
3194
  ];
2476
3195
 
2477
3196
  export function isSourcePath(absPath, projectDir, vaultPath) {
@@ -2545,7 +3264,11 @@ export function listVaultStoryFiles(vaultPath) {
2545
3264
  const out = [];
2546
3265
  const epicsDir = join(vaultPath, "epics");
2547
3266
  let entries;
2548
- try { entries = readdirSync(epicsDir).sort(); } catch { return out; }
3267
+ try {
3268
+ entries = readdirSync(epicsDir).sort();
3269
+ } catch {
3270
+ return out;
3271
+ }
2549
3272
  for (const e of entries) {
2550
3273
  for (const s of listEpicStories(join(epicsDir, e))) out.push(s.abs);
2551
3274
  }
@@ -2575,7 +3298,11 @@ export async function resolveOpenStory(vaultPath, opts = {}) {
2575
3298
  const scan = (async () => {
2576
3299
  for (const f of files) {
2577
3300
  let text;
2578
- try { text = await readFile(f); } catch { continue; }
3301
+ try {
3302
+ text = await readFile(f);
3303
+ } catch {
3304
+ continue;
3305
+ }
2579
3306
  // String(): the injected reader is a seam, and a caller that forgets an
2580
3307
  // encoding hands back a Buffer. Coercing here keeps that a non-event.
2581
3308
  if (openStoryFrom([parseFrontmatter(String(text)).data])) return true;
@@ -2598,7 +3325,11 @@ export function lastVaultActivityMs(vaultPath) {
2598
3325
  let newest = 0;
2599
3326
  const walk = (dir) => {
2600
3327
  let entries;
2601
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
3328
+ try {
3329
+ entries = readdirSync(dir, { withFileTypes: true });
3330
+ } catch {
3331
+ return;
3332
+ }
2602
3333
  for (const e of entries) {
2603
3334
  if (e.name.startsWith(".")) continue; // .projectstore/, .obsidian/, .git/
2604
3335
  const p = join(dir, e.name);
@@ -2657,8 +3388,7 @@ export function writeOpenStoryCache(projectDir, sessionId, value) {
2657
3388
 
2658
3389
  export function firedCount(projectDir, sessionId) {
2659
3390
  try {
2660
- return readdirSync(markerDir(projectDir, sessionId))
2661
- .filter((n) => /^fired-\d+$/.test(n)).length;
3391
+ return readdirSync(markerDir(projectDir, sessionId)).filter((n) => /^fired-\d+$/.test(n)).length;
2662
3392
  } catch {
2663
3393
  return 0;
2664
3394
  }
@@ -2773,7 +3503,11 @@ export function electEmitter(projectDir, sessionId) {
2773
3503
  if (n === 0) target = "fired-1";
2774
3504
  else if (n === 1 && isArmed(projectDir, sessionId)) target = "fired-2";
2775
3505
  if (!target) return false;
2776
- try { mkdirSync(dir, { recursive: true }); } catch { return false; }
3506
+ try {
3507
+ mkdirSync(dir, { recursive: true });
3508
+ } catch {
3509
+ return false;
3510
+ }
2777
3511
 
2778
3512
  // In the ARMED state the arming is the scarce thing, so consuming it must be
2779
3513
  // the atomic act. Creating fired-2 first and unlinking `armed` afterwards
@@ -2783,7 +3517,11 @@ export function electEmitter(projectDir, sessionId) {
2783
3517
  // both win). unlink() succeeding is what elects here, exactly as create()
2784
3518
  // does in the unarmed state.
2785
3519
  if (target === "fired-2") {
2786
- try { unlinkSync(join(dir, "armed")); } catch { return false; }
3520
+ try {
3521
+ unlinkSync(join(dir, "armed"));
3522
+ } catch {
3523
+ return false;
3524
+ }
2787
3525
  }
2788
3526
  try {
2789
3527
  writeFileSync(join(dir, target), "", { flag: "wx" });
@@ -2821,17 +3559,16 @@ export function electEmitter(projectDir, sessionId) {
2821
3559
  // did; without the second, parallel subagents (which share one session id)
2822
3560
  // vote on a name none of them can accept.
2823
3561
 
2824
- export const ANCHOR_SETTLE = 5; // tally at which a key becomes offerable
2825
- export const ANCHOR_MARGIN = 10; // lead a challenger needs to take over
2826
- const ANCHOR_NAME_CELL = 48; // a name is typed by hand; keep it typeable
3562
+ export const ANCHOR_SETTLE = 5; // tally at which a key becomes offerable
3563
+ export const ANCHOR_MARGIN = 10; // lead a challenger needs to take over
3564
+ const ANCHOR_NAME_CELL = 48; // a name is typed by hand; keep it typeable
2827
3565
 
2828
3566
  // Which cluster an artifact belongs to: its epic, or the document itself.
2829
3567
  // Folders come from the layout, never from a hard-coded set — a vault that
2830
3568
  // renames `research/` must not silently stop being nameable.
2831
3569
  export function anchorKeyOf(rel, layout) {
2832
3570
  if (typeof rel !== "string" || !rel) return null;
2833
- const folders = (layout && Array.isArray(layout.folders) ? layout.folders : [])
2834
- .filter((f) => f && f.path);
3571
+ const folders = (layout && Array.isArray(layout.folders) ? layout.folders : []).filter((f) => f && f.path);
2835
3572
  const epic = folders.find((f) => f.kind === "epic");
2836
3573
  if (epic && rel.startsWith(epic.path + "/")) {
2837
3574
  // String ops, never an interpolated RegExp: a layout path is user data, and
@@ -2845,7 +3582,7 @@ export function anchorKeyOf(rel, layout) {
2845
3582
  if (f.kind === "epic") continue;
2846
3583
  if (rel.startsWith(f.path + "/") && rel.endsWith(".md")) {
2847
3584
  const base = rel.slice(f.path.length + 1);
2848
- if (base.includes("/")) continue; // only files directly in the folder
3585
+ if (base.includes("/")) continue; // only files directly in the folder
2849
3586
  // A folder's generated index is not a document: `adr/README.md` would
2850
3587
  // otherwise settle an anchor and compose the name "readme".
2851
3588
  if (f.readme && base.toLowerCase() === "readme.md") continue;
@@ -2883,9 +3620,7 @@ export function foldAnchor(state, hit) {
2883
3620
  let offer = null;
2884
3621
  const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
2885
3622
  if (top && top[1] >= ANCHOR_SETTLE) {
2886
- const take = incumbent === null
2887
- ? true
2888
- : top[0] !== incumbent && top[1] >= (counts[incumbent] || 0) + ANCHOR_MARGIN;
3623
+ const take = incumbent === null ? true : top[0] !== incumbent && top[1] >= (counts[incumbent] || 0) + ANCHOR_MARGIN;
2889
3624
  if (take && top[0] !== incumbent) {
2890
3625
  incumbent = top[0];
2891
3626
  // A moved anchor is not automatically a new NAME. Two documents can share
@@ -2960,7 +3695,10 @@ export function anchorDir(projectDir, sessionId) {
2960
3695
  }
2961
3696
 
2962
3697
  function anchorSlot(key, leaf) {
2963
- const h = createHash("sha1").update(leaf ? `${key}\u0000${leaf}` : key).digest("hex").slice(0, 16);
3698
+ const h = createHash("sha1")
3699
+ .update(leaf ? `${key}\u0000${leaf}` : key)
3700
+ .digest("hex")
3701
+ .slice(0, 16);
2964
3702
  return leaf ? `l${h}` : `k${h}`;
2965
3703
  }
2966
3704
 
@@ -3007,14 +3745,17 @@ export function readAnchorState(projectDir, sessionId) {
3007
3745
  try {
3008
3746
  raw = readFileSync(join(dir, f), "utf8");
3009
3747
  n = statSync(join(dir, slot)).size;
3010
- } catch { continue; }
3748
+ } catch {
3749
+ continue;
3750
+ }
3011
3751
  if (!raw || !n) continue;
3012
3752
  if (slot.startsWith("k")) {
3013
3753
  st.counts[raw] = n;
3014
3754
  } else {
3015
3755
  const nl = raw.indexOf("\n");
3016
3756
  if (nl < 0) continue;
3017
- const key = raw.slice(0, nl), leaf = raw.slice(nl + 1);
3757
+ const key = raw.slice(0, nl),
3758
+ leaf = raw.slice(nl + 1);
3018
3759
  st.leaves[key] = { ...(st.leaves[key] || {}), [leaf]: n };
3019
3760
  }
3020
3761
  }
@@ -3074,7 +3815,11 @@ export function liveSessionNames(selfSessionId, dir = null) {
3074
3815
  const root = dir || join(claudeHome(), "sessions");
3075
3816
  const out = [];
3076
3817
  let files;
3077
- try { files = readdirSync(root); } catch { return out; }
3818
+ try {
3819
+ files = readdirSync(root);
3820
+ } catch {
3821
+ return out;
3822
+ }
3078
3823
  for (const f of files) {
3079
3824
  if (!f.endsWith(".json")) continue;
3080
3825
  try {
@@ -3093,7 +3838,11 @@ export function liveSessionNames(selfSessionId, dir = null) {
3093
3838
  export function ownSessionName(selfSessionId, dir = null) {
3094
3839
  const root = dir || join(claudeHome(), "sessions");
3095
3840
  let files;
3096
- try { files = readdirSync(root); } catch { return null; }
3841
+ try {
3842
+ files = readdirSync(root);
3843
+ } catch {
3844
+ return null;
3845
+ }
3097
3846
  for (const f of files) {
3098
3847
  if (!f.endsWith(".json")) continue;
3099
3848
  try {
@@ -3111,7 +3860,7 @@ export function sessionNameOffer(name, { peers = [], current = null, declined =
3111
3860
  // Never talk over a name the person chose. `declined` carries names we have
3112
3861
  // seen the session wear that we did not compose.
3113
3862
  if (current && current !== name && declined.includes(current)) return null;
3114
- if (current === name) return null; // already wearing it
3863
+ if (current === name) return null; // already wearing it
3115
3864
  const taken = new Set(peers.map((p) => p && p.name).filter(Boolean));
3116
3865
  if (!taken.has(name)) return { name, qualified: false };
3117
3866
  // Taken. Qualify rather than skip: the anchor is still the honest answer, and
@@ -3138,21 +3887,6 @@ export function sessionNameOfferText(offer, { env = process.env } = {}) {
3138
3887
 
3139
3888
  // ─── Frontmatter parsing (minimal) ─────────────────────────────────────
3140
3889
 
3141
- export function parseFrontmatter(md) {
3142
- const m = md.match(/^---\n([\s\S]*?)\n---/);
3143
- if (!m) return { data: {}, body: md };
3144
- const data = {};
3145
- for (const line of m[1].split("\n")) {
3146
- const kv = line.match(/^(\w+):\s*(.*)$/);
3147
- if (!kv) continue;
3148
- let v = kv[2].trim();
3149
- if (v === "null") v = null;
3150
- else if (v.startsWith('"') && v.endsWith('"')) {
3151
- // JSON.parse round-trips escaped scalars from renderTemplate's _json
3152
- // form (titles containing quotes); fall back to the bare strip.
3153
- try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
3154
- }
3155
- data[kv[1]] = v;
3156
- }
3157
- return { data, body: md.slice(m[0].length) };
3158
- }
3890
+ // Moved to stages.mjs (Node-free) so the band module parses frontmatter by
3891
+ // the same rule; re-exported here for every existing caller.
3892
+ export { parseFrontmatter };