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
@@ -14,16 +14,10 @@
14
14
  //
15
15
  // CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
16
16
  // default = --install --vault. Exit code is always 0 (reporting tool).
17
+ // Without --json the findings are drawn by doctor-report.mjs, the
18
+ // renderer the bin uses, in its plain layout.
17
19
 
18
- import {
19
- existsSync,
20
- readFileSync,
21
- readdirSync,
22
- statSync,
23
- accessSync,
24
- constants,
25
- realpathSync,
26
- } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, realpathSync } from "node:fs";
27
21
  import { join, basename, resolve, dirname, relative } from "node:path";
28
22
  import { homedir } from "node:os";
29
23
  import { spawnSync } from "node:child_process";
@@ -43,7 +37,6 @@ import {
43
37
  statusLineIsOurWiring,
44
38
  isLegacyStory,
45
39
  sectionOf,
46
- headingLineRe,
47
40
  indexHeaderRe,
48
41
  evidenceSuffixRe,
49
42
  storiesAttributionRe,
@@ -66,16 +59,23 @@ import {
66
59
  findAgentsBlock,
67
60
  statusLineLauncherPath,
68
61
  LAUNCHER_HEADER,
69
- installedPluginEntries, isMain,
62
+ installedPluginEntries,
63
+ selectCopy,
64
+ takesNoRegister,
65
+ copyUpdateAdvice,
66
+ isMain,
67
+ channelMark,
70
68
  pickExisting,
71
69
  legacyStatusLineLauncherPath,
72
70
  stateDir,
73
71
  legacyStateDir,
74
- layoutPaths, sessionsDir,
72
+ layoutPaths,
73
+ sessionsDir,
75
74
  isLauncherPath,
76
75
  LAYOUT,
77
76
  hostSettingsPath,
78
- readOverlayAt, layoutRoster,
77
+ readOverlayAt,
78
+ layoutRoster,
79
79
  installChannel,
80
80
  cmpPrecedence,
81
81
  cmpVersion,
@@ -85,8 +85,20 @@ import {
85
85
  speakingHarness,
86
86
  inheritForm,
87
87
  bindInherits,
88
+ doctorFallbackText,
89
+ fromTerminalIn,
88
90
  } from "./lib.mjs";
89
- import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand, invocation } from "./harness.mjs";
91
+ import {
92
+ agentOverrides,
93
+ childEnv,
94
+ sourceHarness,
95
+ loadHarness,
96
+ detectHarnesses,
97
+ identifiedHarnessId,
98
+ configPath as harnessConfigPath,
99
+ packageCommand,
100
+ invocation,
101
+ } from "./harness.mjs";
90
102
 
91
103
  // A remedy used to interpolate the surface's harness variable here. It cannot:
92
104
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -101,6 +113,7 @@ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness,
101
113
  // brand, and --harness still carries the target.
102
114
  import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
103
115
  import { resolveBinding } from "./worktree.mjs";
116
+ import { missingScaffoldRequirements } from "./scaffold.mjs";
104
117
 
105
118
  const AGENT_BLOCK_MARKER = new RegExp(AGENTS_BLOCK_OPEN_SRC, "g");
106
119
  // The provenance grammar's prefix, duplicated here on purpose: the startup
@@ -162,9 +175,7 @@ export function aboutHarness(f, h, speaker = speakingHarness()) {
162
175
 
163
176
  function pluginVersion(root = pluginRoot()) {
164
177
  try {
165
- return JSON.parse(
166
- readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"),
167
- ).version;
178
+ return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
168
179
  } catch {
169
180
  return null;
170
181
  }
@@ -191,19 +202,43 @@ export function checkConfig(cfg, proj = projectRoot()) {
191
202
  // new one. Assigned rather than appended: two instructions for one problem
192
203
  // is how a person ends up binding a second vault by hand.
193
204
  let b = null;
194
- try { b = resolveBinding(proj); } catch {}
205
+ try {
206
+ b = resolveBinding(proj);
207
+ } catch {}
208
+ // What binding does first, then the command alone on its line: an
209
+ // in-session form that can carry the parent's vault path (the story
210
+ // "User-facing text says where to run…", rule 4).
195
211
  if (b && b.state === "inheritable") {
196
- return [finding("install", "issue", "worktree-unbound",
197
- `This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. Run ${inheritForm(b.vaultPath, { layout: b.layout, language: b.language })} ${bindInherits() ? "to adopt that binding" : "to bind it to the same vault, layout and language"}.`)];
212
+ return [
213
+ finding(
214
+ "install",
215
+ "issue",
216
+ "worktree-unbound",
217
+ `This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. ${bindInherits() ? "Adopt that binding" : "Bind it to the same vault, layout and language"}, in the session:\n${inheritForm(b.vaultPath, { layout: b.layout, language: b.language })}`,
218
+ ),
219
+ ];
198
220
  }
199
221
  const p = layoutPaths(proj);
200
222
  const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
201
223
  if (present) {
202
- return [finding("install", "issue", "config-unparseable",
203
- `${relative(proj, present)} exists but is not valid JSON — the project reads as unbound until it is fixed; bind refuses to overwrite it.`, relative(proj, present))];
224
+ return [
225
+ finding(
226
+ "install",
227
+ "issue",
228
+ "config-unparseable",
229
+ `${relative(proj, present)} exists but is not valid JSON — the project reads as unbound until it is fixed; bind refuses to overwrite it.`,
230
+ relative(proj, present),
231
+ ),
232
+ ];
204
233
  }
205
- return [finding("install", "issue", "config",
206
- `No projectstore config (.projectstore/projectstore.json). Run ${commandForm("bind", { args: "<vault-path>" })}.`)];
234
+ return [
235
+ finding(
236
+ "install",
237
+ "issue",
238
+ "config",
239
+ `No projectstore config (.projectstore/projectstore.json). Run ${commandForm("bind", { args: "<vault-path>" })}.`,
240
+ ),
241
+ ];
207
242
  }
208
243
  const out = [];
209
244
  if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
@@ -257,9 +292,24 @@ export function checkLayoutTemplates(cfg) {
257
292
  out.push(finding("install", "issue", "templates", `Missing template for language "${lang}": ${k}.md.tmpl`));
258
293
  }
259
294
  }
295
+ // What `scaffold` (and `init`) refuse on, through the same check the plan
296
+ // makes: the vault README's template, and a name and a description for every
297
+ // folder kind the layout declares — `diagram` included. A warn, not an issue:
298
+ // a vault already scaffolded is not broken by a hand-added kind; only its
299
+ // next scaffold is refused. The folder README is the issue above.
300
+ for (const m of missingScaffoldRequirements(layout, lang, pluginRoot())) {
301
+ if (m.what === "template" && m.name === "folder-readme") continue;
302
+ out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
303
+ }
260
304
  if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
261
- out.push(finding("install", "issue", "templates",
262
- "scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?"));
305
+ out.push(
306
+ finding(
307
+ "install",
308
+ "issue",
309
+ "templates",
310
+ "scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?",
311
+ ),
312
+ );
263
313
  }
264
314
  return out;
265
315
  }
@@ -267,16 +317,34 @@ export function checkLayoutTemplates(cfg) {
267
317
  export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
268
318
  const dir = sessionsDir(cfg.vault_path);
269
319
  if (!existsSync(dir)) {
270
- return [finding("install", "warn", "hooks",
271
- "No session registry in the vault — SessionStart hook may not be firing (or no session started yet).")];
320
+ return [
321
+ finding(
322
+ "install",
323
+ "warn",
324
+ "hooks",
325
+ "No session registry in the vault — SessionStart hook may not be firing (or no session started yet).",
326
+ ),
327
+ ];
272
328
  }
273
329
  const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
274
330
  const fresh = readdirSync(dir).some((n) => {
275
331
  if (!n.endsWith(".json")) return false;
276
- try { return statSync(join(dir, n)).mtimeMs >= cutoff; } catch { return false; }
332
+ try {
333
+ return statSync(join(dir, n)).mtimeMs >= cutoff;
334
+ } catch {
335
+ return false;
336
+ }
277
337
  });
278
- return fresh ? [] : [finding("install", "warn", "hooks",
279
- `No session registration fresher than ${maxAgeMinutes} min — hooks may not be firing.`)];
338
+ return fresh
339
+ ? []
340
+ : [
341
+ finding(
342
+ "install",
343
+ "warn",
344
+ "hooks",
345
+ `No session registration fresher than ${maxAgeMinutes} min — hooks may not be firing.`,
346
+ ),
347
+ ];
280
348
  }
281
349
 
282
350
  // Which plugin version a wired statusLine script belongs to. Null for our
@@ -285,9 +353,7 @@ export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
285
353
  export function statusLineScriptVersion(scriptPath) {
286
354
  try {
287
355
  const root = dirname(dirname(scriptPath)); // <root>/scripts/statusline.mjs
288
- return (
289
- JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null
290
- );
356
+ return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null;
291
357
  } catch {
292
358
  return null;
293
359
  }
@@ -317,20 +383,38 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
317
383
 
318
384
  if (st && st.enabled === true) {
319
385
  if (!curCmd) {
320
- out.push(finding("install", "issue", "statusline",
321
- `statusline.enabled=true but no statusLine wired in settings.local.json — run ${commandForm("statusline", { args: "on" })} (it installs the entry and the launcher behind a preview); the SessionStart hook only refreshes an entry that already exists.`));
386
+ out.push(
387
+ finding(
388
+ "install",
389
+ "issue",
390
+ "statusline",
391
+ `statusline.enabled=true but no statusLine wired in settings.local.json — run ${commandForm("statusline", { args: "on" })} (it installs the entry and the launcher behind a preview); the SessionStart hook only refreshes an entry that already exists.`,
392
+ ),
393
+ );
322
394
  } else if (!isOurs) {
323
- out.push(finding("install", "issue", "statusline",
324
- "statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag."));
395
+ out.push(
396
+ finding(
397
+ "install",
398
+ "issue",
399
+ "statusline",
400
+ "statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag.",
401
+ ),
402
+ );
325
403
  } else {
326
404
  const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
327
405
  const wiredRoot = m ? dirname(dirname(m[1])) : null;
328
406
  const isLauncher = m ? isLauncherPath(m[1]) : false;
329
407
  if (m && !existsSync(m[1])) {
330
- out.push(finding("install", "issue", "statusline",
331
- isLauncher
332
- ? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run ${commandForm("statusline", { args: "on" })} to reinstall it; until then the next session start repoints the entry at the installed script.`
333
- : `statusLine points at a missing script (stale plugin path?): ${m[1]}`));
408
+ out.push(
409
+ finding(
410
+ "install",
411
+ "issue",
412
+ "statusline",
413
+ isLauncher
414
+ ? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run ${commandForm("statusline", { args: "on" })} to reinstall it; until then the next session start repoints the entry at the installed script.`
415
+ : `statusLine points at a missing script (stale plugin path?): ${m[1]}`,
416
+ ),
417
+ );
334
418
  } else if (m && isPluginCacheRoot(wiredRoot, home)) {
335
419
  // Only a versioned cache path can go stale this way. The launcher
336
420
  // carries no version, and a dev checkout is wired deliberately and
@@ -338,23 +422,48 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
338
422
  const wired = statusLineScriptVersion(m[1]);
339
423
  const inst = installedPluginRoot(home, dirname(wiredRoot));
340
424
  if (wired && inst && inst.version && wired !== inst.version) {
341
- out.push(finding("install", "warn", "statusline",
342
- `statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run ${commandForm("statusline", { args: "on" })} to install the version-agnostic launcher; the SessionStart hook only repoints the pinned path at the current install.`));
425
+ out.push(
426
+ finding(
427
+ "install",
428
+ "warn",
429
+ "statusline",
430
+ `statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run ${commandForm("statusline", { args: "on" })} to install the version-agnostic launcher; the SessionStart hook only repoints the pinned path at the current install.`,
431
+ ),
432
+ );
343
433
  }
344
434
  }
345
435
  }
346
436
  } else if (st && st.enabled === false && isOurs) {
347
- out.push(finding("install", "warn", "statusline",
348
- "statusline.enabled=false but our statusLine entry is still wired — the hook removes it on next session start."));
437
+ out.push(
438
+ finding(
439
+ "install",
440
+ "warn",
441
+ "statusline",
442
+ "statusline.enabled=false but our statusLine entry is still wired — the hook removes it on next session start.",
443
+ ),
444
+ );
349
445
  } else if ((!st || typeof st.enabled !== "boolean") && isOurs) {
350
- out.push(finding("install", "info", "statusline",
351
- "statusLine wired manually (no statusline flag in projectstore.json) — the hook will leave it alone."));
446
+ out.push(
447
+ finding(
448
+ "install",
449
+ "info",
450
+ "statusline",
451
+ "statusLine wired manually (no statusline flag in projectstore.json) — the hook will leave it alone.",
452
+ ),
453
+ );
352
454
  }
353
455
 
354
456
  try {
355
457
  const base = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"))?.statusLine?.command;
356
458
  if (base && !statusLineIsOurs(base)) {
357
- out.push(finding("install", "info", "statusline", "Base HUD present in your user settings.json — projectstore composes above it."));
459
+ out.push(
460
+ finding(
461
+ "install",
462
+ "info",
463
+ "statusline",
464
+ "Base HUD present in your user settings.json — projectstore composes above it.",
465
+ ),
466
+ );
358
467
  }
359
468
  } catch {}
360
469
  // session_id divergence (ADR-006): the renderer's breadcrumb names the id
@@ -369,8 +478,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
369
478
  .filter((n) => n.endsWith(".json") && !n.startsWith("."))
370
479
  .map((n) => n.replace(/\.json$/, ""));
371
480
  if (hookIds.length && !hookIds.includes(bc.session_id)) {
372
- out.push(finding("install", "warn", "statusline",
373
- `statusLine renderer last saw session_id ${String(bc.session_id).slice(0, 8)}… with no hook-side state file — possible session_id divergence (renderer shows the cold-start line while hooks log activity).`));
481
+ out.push(
482
+ finding(
483
+ "install",
484
+ "warn",
485
+ "statusline",
486
+ `statusLine renderer last saw session_id ${String(bc.session_id).slice(0, 8)}… with no hook-side state file — possible session_id divergence (renderer shows the cold-start line while hooks log activity).`,
487
+ ),
488
+ );
374
489
  }
375
490
  }
376
491
  // What the user is actually looking at: the renderer stamps the version
@@ -379,8 +494,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
379
494
  if (bc && bc.version && isPluginCacheRoot(bc.root, home)) {
380
495
  const inst = installedPluginRoot(home, dirname(bc.root));
381
496
  if (inst && inst.version && inst.version !== bc.version) {
382
- out.push(finding("install", "warn", "statusline",
383
- `The last status line rendered in this project came from projectstore ${bc.version} while ${inst.version} is installed — a session that resolved its statusLine command before the update (the breadcrumb is per project, so it may belong to a sibling session). Restart that session; after that the launcher picks up the installed version at every render.`));
497
+ out.push(
498
+ finding(
499
+ "install",
500
+ "warn",
501
+ "statusline",
502
+ `The last status line rendered in this project came from projectstore ${bc.version} while ${inst.version} is installed — a session that resolved its statusLine command before the update (the breadcrumb is per project, so it may belong to a sibling session). Restart that session; after that the launcher picks up the installed version at every render.`,
503
+ ),
504
+ );
384
505
  }
385
506
  }
386
507
  } catch {}
@@ -402,15 +523,27 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
402
523
  // session with a command that cannot clear it (the critic of the layout
403
524
  // spec's 2026-10-03 amendment, case S1).
404
525
  let wired = null;
405
- try { wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null; } catch {}
526
+ try {
527
+ wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
528
+ } catch {}
406
529
  if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
407
530
  const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
408
531
  let text;
409
- try { text = readFileSync(lp, "utf8"); } catch { return []; }
532
+ try {
533
+ text = readFileSync(lp, "utf8");
534
+ } catch {
535
+ return [];
536
+ }
410
537
  if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
411
- return [finding("install", "info", "upgrade",
412
- `The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering; run ${commandForm("doctor", { args: "--fix" })} once to re-stamp it.`,
413
- relative(proj, lp))];
538
+ return [
539
+ finding(
540
+ "install",
541
+ "info",
542
+ "upgrade",
543
+ `The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering. Re-stamp it once, in the session:\n${commandForm("doctor", { args: "--fix" })}`,
544
+ relative(proj, lp),
545
+ ),
546
+ ];
414
547
  }
415
548
 
416
549
  // The two findings the layout move itself repairs carry what they say without
@@ -435,7 +568,11 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
435
568
  const p = join(proj, name);
436
569
  if (!existsSync(p)) continue;
437
570
  let text;
438
- try { text = readFileSync(p, "utf8"); } catch { continue; }
571
+ try {
572
+ text = readFileSync(p, "utf8");
573
+ } catch {
574
+ continue;
575
+ }
439
576
  texts[name] = text;
440
577
  const f = findAgentsBlock(text);
441
578
  if (!f) continue;
@@ -443,16 +580,30 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
443
580
  blocks += f.count;
444
581
  if (f.wrapped) {
445
582
  wrappedFiles++;
446
- out.push(finding("install", "issue", "agents-block",
447
- `${name}:${f.line}: the projectstore:agents open marker does not close on its own line — put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until it does).`, name));
583
+ out.push(
584
+ finding(
585
+ "install",
586
+ "issue",
587
+ "agents-block",
588
+ `${name}:${f.line}: the projectstore:agents open marker does not close on its own line, and install and uninstall refuse until it does. Put \`-->\` back on the marker's line, then in the session:\n${commandForm("agents", { args: "register" })}`,
589
+ name,
590
+ ),
591
+ );
448
592
  continue;
449
593
  }
450
594
  if (f.unclosed) {
451
595
  // Named here as the wrapped marker is, so the startup count carries it:
452
596
  // the agents-block plan refuses it, and the layout move with it.
453
597
  unclosedFiles++;
454
- out.push(finding("install", "issue", "agents-block",
455
- `${name}: the projectstore:agents block opens and never closes — close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until then).`, name));
598
+ out.push(
599
+ finding(
600
+ "install",
601
+ "issue",
602
+ "agents-block",
603
+ `${name}: the projectstore:agents block opens and never closes, and install and uninstall refuse until then. Close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then in the session:\n${commandForm("agents", { args: "register" })}`,
604
+ name,
605
+ ),
606
+ );
456
607
  }
457
608
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
458
609
  const v = parseInt(m[1], 10);
@@ -460,8 +611,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
460
611
  }
461
612
  }
462
613
  if (blocks === 0) {
463
- out.push(finding("install", "info", "agents-block",
464
- `Agent routing block not registered — optional; ships with ${commandForm("agents")} (v0.13).`));
614
+ out.push(
615
+ finding(
616
+ "install",
617
+ "info",
618
+ "agents-block",
619
+ `Agent routing block not registered — optional (v0.13). It comes with the agents command, in the session:\n${commandForm("agents")}`,
620
+ ),
621
+ );
465
622
  }
466
623
  if (blocks > 1) {
467
624
  // One block in each file is a state install resolves (it keeps the
@@ -470,13 +627,26 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
470
627
  // advice is withheld while one is wrapped or never closes.
471
628
  const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
472
629
  if (twiceInOne) {
473
- out.push(finding("install", "issue", "agents-block",
474
- `${twiceInOne[0]} carries the projectstore:agents block ${twiceInOne[1]} times — keep exactly one; install refuses until it does.`, twiceInOne[0]));
630
+ out.push(
631
+ finding(
632
+ "install",
633
+ "issue",
634
+ "agents-block",
635
+ `${twiceInOne[0]} carries the projectstore:agents block ${twiceInOne[1]} times — keep exactly one; install refuses until it does.`,
636
+ twiceInOne[0],
637
+ ),
638
+ );
475
639
  } else if (wrappedFiles || unclosedFiles) {
476
640
  // already named above, file by file
477
641
  } else {
478
- out.push(finding("install", "warn", "agents-block",
479
- `The projectstore:agents block is in both CLAUDE.md and AGENTS.md — run ${commandForm("agents", { args: "register" })}: install keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other.`));
642
+ out.push(
643
+ finding(
644
+ "install",
645
+ "warn",
646
+ "agents-block",
647
+ `The projectstore:agents block is in both CLAUDE.md and AGENTS.md. Registering it again keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other, in the session:\n${commandForm("agents", { args: "register" })}`,
648
+ ),
649
+ );
480
650
  }
481
651
  }
482
652
  // A state the agents-block plan refuses — a wrapped marker, a block that
@@ -485,21 +655,33 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
485
655
  const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
486
656
  for (const s of staleVersions) {
487
657
  const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
488
- const f = finding("install", "issue", "agents-block", `${fact} — re-run ${commandForm("agents", { args: "register" })}.`, s.file);
658
+ const f = finding(
659
+ "install",
660
+ "issue",
661
+ "agents-block",
662
+ `${fact}. Register it again, in the session:\n${commandForm("agents", { args: "register" })}`,
663
+ s.file,
664
+ );
489
665
  out.push(refuses ? f : moveRepairs(f, fact));
490
666
  }
491
667
  // Placement, held to the predicate install plans from (the install spec,
492
668
  // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
493
669
  // every harness the project uses. rc.1 and rc.2 left a block in an
494
670
  // AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
495
- // nothing said so. Used means detected by directory, or identified from the
496
- // environment — never the source harness a terminal run falls back to, so a
497
- // Codex-only project hears nothing about CLAUDE.md (contract 16). An issue
671
+ // nothing said so. Used means detected by directory or by its state
672
+ // directory, or identified from the environment — never the source harness
673
+ // a terminal run falls back to, so a Codex-only project hears nothing about
674
+ // CLAUDE.md (contract 16). The state directory is what a bare upgrade and a
675
+ // bare uninstall select by, so doctor warns about exactly the block they
676
+ // move (the story "One run plans the agents block once…", rule 5). An issue
498
677
  // for the harness that identified itself, a warning for one only detected.
499
678
  const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
500
679
  if (blockFile) {
501
680
  const identified = identifiedHarnessId(env);
502
- const used = new Set([...detectHarnesses(proj).map((d) => d.id), ...(identified ? [identified] : [])]);
681
+ const used = new Set([
682
+ ...detectHarnesses(proj, { state: true }).map((d) => d.id),
683
+ ...(identified ? [identified] : []),
684
+ ]);
503
685
  for (const id of used) {
504
686
  const m = loadHarness(id);
505
687
  if (!m || blockVisibleTo(m, blockFile, texts)) continue;
@@ -508,9 +690,16 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
508
690
  ? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
509
691
  : `it reads ${(ab.files || []).join(" and ")} only`;
510
692
  const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
511
- // The resolved-root form the surface remedy uses: the running copy's own bin.
512
- const remedy = `install the block for ${m.display_name}: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block --project "${proj}"`;
513
- const f = finding("install", id === identified ? "issue" : "warn", "agents-block", `${fact} — ${remedy}.`, blockFile);
693
+ // The resolved-root form the surface remedy uses: the running copy's own
694
+ // bin, the directory in prose and the command alone on the last line.
695
+ const remedy = `Install the block for ${m.display_name} ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block`;
696
+ const f = finding(
697
+ "install",
698
+ id === identified ? "issue" : "warn",
699
+ "agents-block",
700
+ `${fact}. ${remedy}`,
701
+ blockFile,
702
+ );
514
703
  // The layout's harness is the one the move's command installs for, so the
515
704
  // move plans this import; another harness's is not the move's to repair.
516
705
  out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
@@ -530,10 +719,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
530
719
  // finding leaves here carrying it.
531
720
  export function foldIntoMove(findings) {
532
721
  const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
533
- const step = layout && (layout.check === "layout-two-configs"
534
- ? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
535
- : "the layout move repairs this: run what the layout-legacy finding names");
536
- return findings.map(({ byMove, ...f }) => (byMove && layout ? { ...f, level: "info", message: `${byMove} — ${step}.` } : f));
722
+ const step =
723
+ layout &&
724
+ (layout.check === "layout-two-configs"
725
+ ? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
726
+ : "the layout move repairs this: run what the layout-legacy finding names");
727
+ return findings.map(({ byMove, ...f }) =>
728
+ byMove && layout ? { ...f, level: "info", message: `${byMove} — ${step}.` } : f,
729
+ );
537
730
  }
538
731
 
539
732
  // Both scopes are walked. The original reason ("project > user > plugin, so a
@@ -563,24 +756,44 @@ export function foldIntoMove(findings) {
563
756
  // surfaces.mjs needs — out of the SessionStart module graph. The startup
564
757
  // checks never call this.
565
758
  // Read the states once per doctor run; both checks below consume the result.
566
- export async function readSurfaceStates(proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, env = process.env } = {}) {
759
+ export async function readSurfaceStates(
760
+ proj,
761
+ { home = homedir(), root = pluginRoot(), manifestDir = undefined, env = process.env } = {},
762
+ ) {
567
763
  const { surfaceStates, FOREIGN_TEXT } = await import("./surfaces.mjs");
568
764
  return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
569
765
  }
570
766
 
571
- export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env, speaker = speakingHarness() } = {}) {
767
+ export async function checkHarnessSurfaces(
768
+ _cfg,
769
+ proj,
770
+ {
771
+ home = homedir(),
772
+ root = pluginRoot(),
773
+ manifestDir = undefined,
774
+ read = null,
775
+ env = process.env,
776
+ speaker = speakingHarness(),
777
+ } = {},
778
+ ) {
572
779
  const out = [];
573
780
  let r, FOREIGN_TEXT;
574
781
  try {
575
- ({ result: r, FOREIGN_TEXT } = read || await readSurfaceStates(proj, { home, root, manifestDir, env }));
782
+ ({ result: r, FOREIGN_TEXT } = read || (await readSurfaceStates(proj, { home, root, manifestDir, env })));
576
783
  } catch (e) {
577
784
  return [finding("install", "warn", "surface", `Installed-surface states could not be read: ${e && e.message}`)];
578
785
  }
579
786
  // Contract 16: a harness is reported only when the project uses it; when
580
787
  // none does and nothing of ours is installed, one line names what can be.
581
788
  if (!r.used.length) {
582
- out.push(finding("install", "info", "harness",
583
- `No harness detected in this project and nothing of ours installed — install can target: ${r.installable.join(", ")}.`));
789
+ out.push(
790
+ finding(
791
+ "install",
792
+ "info",
793
+ "harness",
794
+ `No harness detected in this project and nothing of ours installed — install can target: ${r.installable.join(", ")}.`,
795
+ ),
796
+ );
584
797
  return out;
585
798
  }
586
799
  for (const s of r.states) {
@@ -590,15 +803,35 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
590
803
  // steps in its forms, and no status-line hint where it has no status line.
591
804
  // A shared surface — the agents block, one file every harness owns — is
592
805
  // every harness's: never marked, its step in the listener's form.
593
- const sh = s.kind === "shared" ? speaker : (loadHarness(s.harness) || speaker);
806
+ const sh = s.kind === "shared" ? speaker : loadHarness(s.harness) || speaker;
594
807
  const push = (f) => out.push(aboutHarness(f, sh, speaker));
595
808
  if (s.kind === "exclusive") {
596
809
  if (s.state === "foreign") {
597
- push(finding("install", "issue", "surface-foreign",
598
- `${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`, where));
810
+ push(
811
+ finding(
812
+ "install",
813
+ "issue",
814
+ "surface-foreign",
815
+ `${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`,
816
+ where,
817
+ ),
818
+ );
599
819
  } else if (s.state === "stale" && s.produced) {
600
- const status = sh?.surfaces?.statusline?.supported === false ? "" : ` (for the status line, ${invocation(sh, "statusline", { args: "on" })})`;
601
- push(finding("install", "issue", "surface", `${where} — stale: ${s.reason}. Reinstall it: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${s.harness} --surface ${s.surface} --project "${proj}"${status}.`, where));
820
+ // Two commands, two paragraphs, each ending with its command alone on
821
+ // its line (the story "User-facing text says where to run…", rule 4).
822
+ const status =
823
+ sh?.surfaces?.statusline?.supported === false
824
+ ? ""
825
+ : `\nFor the status line, in the session:\n${invocation(sh, "statusline", { args: "on" })}`;
826
+ push(
827
+ finding(
828
+ "install",
829
+ "issue",
830
+ "surface",
831
+ `${where} — stale: ${s.reason}. Reinstall it ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${s.harness} --surface ${s.surface}${status}`,
832
+ where,
833
+ ),
834
+ );
602
835
  } else if (s.state === "stale" && !s.produced) {
603
836
  push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
604
837
  } else if (s.state === "current" && s.writtenBy && !s.sameProject) {
@@ -613,12 +846,23 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
613
846
  if (s.state === "unparseable") {
614
847
  push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
615
848
  } else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
616
- push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run ${invocation(sh, "agents", { args: "register" })}.`, where));
849
+ push(
850
+ finding(
851
+ "install",
852
+ "warn",
853
+ "surface",
854
+ `${where} [projectstore:agents] — ${s.reason}. Register it again, in the session:\n${invocation(sh, "agents", { args: "register" })}`,
855
+ where,
856
+ ),
857
+ );
617
858
  }
618
859
  }
619
- // The statusline entry's states are checkStatusline's, under its own id —
620
- // its ours-stale (a command this installation would not write) is
621
- // self-healing: syncStatusLine rewrites it on the next SessionStart.
860
+ // The statusline entry's states are checkStatusline's, under its own id.
861
+ // Its ours-stale (a command this installation would not write) is not
862
+ // always self-healing: syncStatusLine re-points a direct entry at the
863
+ // running root's script and never touches an entry naming a launcher on
864
+ // disk, so install is what re-points an entry at the launcher
865
+ // (contract 0 as amended 2026-10-10).
622
866
  }
623
867
  return out;
624
868
  }
@@ -631,8 +875,26 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
631
875
  const out = [];
632
876
  const o = readOverlayAt(proj);
633
877
  const where = relative(proj, o.path);
634
- if (o.unparseable) out.push(finding("install", "issue", "overlay-unparseable", `${where} is not valid JSON — the agents run on their frontmatter models until it is fixed.`, where));
635
- for (const k of o.rejected) out.push(finding("install", "issue", "overlay-forbidden-key", `${where} carries \`${k}\`, which an overlay may not: only agents.default.model and agents.per_agent.<name>.model are read; the key is ignored.`, where));
878
+ if (o.unparseable)
879
+ out.push(
880
+ finding(
881
+ "install",
882
+ "issue",
883
+ "overlay-unparseable",
884
+ `${where} is not valid JSON — the agents run on their frontmatter models until it is fixed.`,
885
+ where,
886
+ ),
887
+ );
888
+ for (const k of o.rejected)
889
+ out.push(
890
+ finding(
891
+ "install",
892
+ "issue",
893
+ "overlay-forbidden-key",
894
+ `${where} carries \`${k}\`, which an overlay may not: only agents.default.model and agents.per_agent.<name>.model are read; the key is ignored.`,
895
+ where,
896
+ ),
897
+ );
636
898
  if (cfg && cfg.agents && typeof cfg.agents === "object") {
637
899
  // The binding that carries the block is the one the reader found — before
638
900
  // the migration that is the legacy path, and naming the new one would send
@@ -644,7 +906,19 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
644
906
  const remedy = layoutRemedy(proj, { root, home });
645
907
  // The block and its move are the source harness's — a pre-0.28 binding
646
908
  // and that harness's command — so its fact under any other listener.
647
- out.push(aboutHarness(finding("install", "warn", "agents-in-binding", `${b} still carries an agents block — since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedy.command ? `Move it from a terminal outside the session: ${remedy.command}` : remedy.advice}.`, b), sourceHarness(), speaker));
909
+ out.push(
910
+ aboutHarness(
911
+ finding(
912
+ "install",
913
+ "warn",
914
+ "agents-in-binding",
915
+ `${b} still carries an agents block — since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
916
+ b,
917
+ ),
918
+ sourceHarness(),
919
+ speaker,
920
+ ),
921
+ );
648
922
  }
649
923
  // A project can be used from more than one harness, and a model name is
650
924
  // harness-specific (ADR-008) — so each one has its own overlay and they do
@@ -655,20 +929,27 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
655
929
  // Only when another harness in this project HAS an overlay. A project with
656
930
  // none at all is the ordinary fresh state and needs no advice; the asymmetry
657
931
  // is what is actionable, because it is almost always the second harness that
658
- // was forgotten rather than the first that was deliberate.
659
- const used = detectHarnesses(proj).map((d) => d.id);
932
+ // was forgotten rather than the first that was deliberate. Used as a bare
933
+ // upgrade or uninstall selects: by directory or by state directory.
934
+ const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
660
935
  if (used.length > 1) {
661
936
  const overlays = used.map((id) => ({ id, o: readOverlayAt(proj, loadHarness(id)?.runtime?.overlay || id) }));
662
937
  const configured = overlays.filter(({ o }) => o.present);
663
938
  if (configured.length) {
664
939
  for (const { id, o } of overlays) {
665
940
  if (o.present) continue;
666
- out.push(finding("install", "info", "overlay-absent",
667
- `This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — `
668
- + `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} `
669
- + `${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). `
670
- + `Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
671
- relative(proj, o.path)));
941
+ out.push(
942
+ finding(
943
+ "install",
944
+ "info",
945
+ "overlay-absent",
946
+ `This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — ` +
947
+ `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} ` +
948
+ `${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). ` +
949
+ `Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
950
+ relative(proj, o.path),
951
+ ),
952
+ );
672
953
  }
673
954
  }
674
955
  }
@@ -678,7 +959,16 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
678
959
  const roster = layoutRoster(cfg);
679
960
  if (roster) {
680
961
  for (const n of Object.keys(o.agents.per_agent)) {
681
- if (!roster.includes(n)) out.push(finding("install", "warn", "overlay-unknown-agent", `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so the model never applies. A typo, or an agent this package does not ship yet.`, where));
962
+ if (!roster.includes(n))
963
+ out.push(
964
+ finding(
965
+ "install",
966
+ "warn",
967
+ "overlay-unknown-agent",
968
+ `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so the model never applies. A typo, or an agent this package does not ship yet.`,
969
+ where,
970
+ ),
971
+ );
682
972
  }
683
973
  }
684
974
  return out;
@@ -687,20 +977,39 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
687
977
  // Contract 4′ (2026-09-05): the registration the package made through the
688
978
  // host's CLI — one row per registration surface the manifest declares. Current
689
979
  // → one info; stale → an issue naming the refresh in the form that can make
690
- // it (the package's own bin via npx: a cache install never re-registers
691
- // itself); a competing copy enabled beside ours → an issue (two enabled copies
692
- // of one plugin); a competitor alone → an info naming the npm path; foreign →
693
- // never repairable; the host CLI missing → an info.
694
- export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness() } = {}) {
980
+ // it (the npm package's core via npx, `--harness <id>`: a cache install never
981
+ // re-registers itself); a competing copy enabled beside ours → an issue (two
982
+ // enabled copies of one plugin); a competitor alone → an info naming the npm
983
+ // path; foreign → never repairable; the host CLI missing → an info.
984
+ //
985
+ // Every command here ends its message, alone on its line after a lead ending
986
+ // with `:`, and carries no --project: the lead names the directory (the story
987
+ // "User-facing text says where to run and names no project path and no
988
+ // shell", rules 1, 4 and 5). The report draws each lead as prose and each
989
+ // command alone after a blank line (doctor-report.mjs); --json carries the
990
+ // message one part per line. The silenced copy's info ends with how to record
991
+ // the switch (the setup story, rule 17): setup's `--channel npm`, in the core
992
+ // form, the last of its three paragraphs.
993
+ const SETUP_NPM = "npx projectstore setup --channel npm";
994
+ export function checkPluginRegistration(
995
+ proj,
996
+ states = [],
997
+ { home = homedir(), speaker = speakingHarness(), cfg = null } = {},
998
+ ) {
695
999
  const out = [];
1000
+ // A checkout whose binding records a channel switch (the converge story's
1001
+ // rule 3) chose the npm install: the info that ends "If moving to npm was
1002
+ // meant, record it…" is the one thing it no longer needs. Doctor never
1003
+ // reads who made the mark.
1004
+ const marked = Boolean(channelMark(cfg));
696
1005
  for (const s of states.filter((x) => x.kind === "registration")) {
697
- // The shell form when the manifest names a shell (contract 12): the
698
- // command a user can paste, built in one place.
1006
+ // The core's form with this registration's --harness (packageCommand):
1007
+ // the command a person can paste, built in one place.
699
1008
  const h = loadHarness(s.harness) || { id: s.harness };
700
1009
  // A registration of ANOTHER harness in the same project is that
701
1010
  // harness's fact: named, marked, its steps in its own forms (aboutHarness).
702
1011
  const push = (f) => out.push(aboutHarness(f, h, speaker));
703
- const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` });
1012
+ const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface}` });
704
1013
  // A copy this registration silenced for the checkout and the checkout
705
1014
  // still holds off, one per key (the install spec, contract 13 as amended
706
1015
  // after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
@@ -710,35 +1019,117 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
710
1019
  if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
711
1020
  // The row this checkout loads: its own local-scope row first, then a
712
1021
  // user-scope one — never another checkout's (layoutRemedy's rule).
713
- const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
1022
+ const real = (x) => {
1023
+ try {
1024
+ return realpathSync.native(x);
1025
+ } catch {
1026
+ return resolve(x);
1027
+ }
1028
+ };
714
1029
  const here = real(proj);
715
- const rows = installedPluginEntries(home, proj).filter((e) => e.present && (!e.projectPath || real(e.projectPath) === here));
1030
+ const rows = installedPluginEntries(home, proj).filter(
1031
+ (e) => e.present && (!e.projectPath || real(e.projectPath) === here),
1032
+ );
716
1033
  for (const key of s.silenced) {
717
- const row = rows.filter((e) => e.key === key).sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
1034
+ const row = rows
1035
+ .filter((e) => e.key === key)
1036
+ .sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
718
1037
  if (!row) {
719
- push(finding("install", "info", "plugin-registration", `${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`, s.path));
1038
+ push(
1039
+ finding(
1040
+ "install",
1041
+ "info",
1042
+ "plugin-registration",
1043
+ `${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`,
1044
+ s.path,
1045
+ ),
1046
+ );
720
1047
  continue;
721
1048
  }
1049
+ if (marked) continue;
722
1050
  // The release line, not the build: a 0.28 release candidate reads a
723
1051
  // moved project; 0.27.x reads it as unbound. No version reads as old.
724
1052
  const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
725
- push(finding("install", "info", "plugin-registration",
726
- `${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it: from a terminal outside the session, ${packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` })}, restart, then ${invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" })}. If moving to npm was meant, ignore this.`, s.path));
1053
+ push(
1054
+ finding(
1055
+ "install",
1056
+ "info",
1057
+ "plugin-registration",
1058
+ [
1059
+ `${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it, ${fromTerminalIn(proj)}, outside the session:`,
1060
+ packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface}` }),
1061
+ "Restart, then in the session:",
1062
+ invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" }),
1063
+ "If moving to npm was meant, record it from a terminal in this project:",
1064
+ SETUP_NPM,
1065
+ ].join("\n"),
1066
+ s.path,
1067
+ ),
1068
+ );
727
1069
  }
728
1070
  }
729
1071
  const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
730
1072
  if (s.state === "foreign") {
731
- push(finding("install", "issue", "plugin-registration-foreign", `${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`, s.path));
1073
+ push(
1074
+ finding(
1075
+ "install",
1076
+ "issue",
1077
+ "plugin-registration-foreign",
1078
+ `${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`,
1079
+ s.path,
1080
+ ),
1081
+ );
732
1082
  } else if (s.state === "unavailable") {
733
- push(finding("install", "info", "plugin-registration", `No npm registration of projectstore for this project, and ${s.reason}.`));
1083
+ push(
1084
+ finding(
1085
+ "install",
1086
+ "info",
1087
+ "plugin-registration",
1088
+ `No npm registration of projectstore for this project, and ${s.reason}.`,
1089
+ ),
1090
+ );
734
1091
  } else if (s.state === "absent") {
735
1092
  // A git-marketplace install alone is not a finding: a permanent info
736
1093
  // advertising the npm path to every marketplace user is noise (2026-09-05).
737
1094
  } else if (s.state === "stale") {
738
- push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
1095
+ push(
1096
+ finding(
1097
+ "install",
1098
+ "issue",
1099
+ "plugin-registration",
1100
+ `${s.entry} — stale: ${s.reason}. Refresh it ${fromTerminalIn(proj)}:\n${refresh}`,
1101
+ s.path,
1102
+ ),
1103
+ );
739
1104
  } else if (s.state === "current") {
740
- if (others) push(finding("install", "issue", "plugin-registration", `${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. install silences the other for this project: ${packageCommand(h, "install", { args: `--surface ${s.surface} --project "${proj}"` })} (or the host's own disable at the scope the manifest names — never the committed project scope).`, s.path));
741
- else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (loaded from ${s.installPath}); refresh with ${refresh}.`));
1105
+ // The host loads a host plugin registration in place, from the
1106
+ // plugin's root inside our marketplace directory (the payload — what
1107
+ // its plugin-root variable names); the install path is the registry's
1108
+ // copy, the one the launcher renders (measured on host 2.1.293 and
1109
+ // 2.1.296). A portable registration's host loads the copy.
1110
+ const loaded =
1111
+ h.surfaces?.[s.surface]?.format === "host-plugin-registration" && s.payload
1112
+ ? `install path ${s.installPath}; loaded in place from ${s.payload}`
1113
+ : `loaded from ${s.installPath}`;
1114
+ if (others)
1115
+ push(
1116
+ finding(
1117
+ "install",
1118
+ "issue",
1119
+ "plugin-registration",
1120
+ `${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. The host's own disable turns the other off, at the scope the manifest names — never the committed project scope. Or install silences it for this project, ${fromTerminalIn(proj)}:\n${packageCommand(h, "install", { args: `--surface ${s.surface}` })}`,
1121
+ s.path,
1122
+ ),
1123
+ );
1124
+ else
1125
+ push(
1126
+ finding(
1127
+ "install",
1128
+ "info",
1129
+ "plugin-registration",
1130
+ `${s.entry} ${s.installedVersion} registered from the npm package for this project (${loaded}). Refresh it ${fromTerminalIn(proj)}:\n${refresh}`,
1131
+ ),
1132
+ );
742
1133
  }
743
1134
  }
744
1135
  return out;
@@ -759,10 +1150,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
759
1150
  const registryOf = sourceHarness()?.id || null;
760
1151
  for (const e of installedPluginEntries(home, proj)) {
761
1152
  // A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
762
- if (e.version && e.present && e.enabled !== false) seen.push({ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`, version: e.version, harness: registryOf });
1153
+ if (e.version && e.present && e.enabled !== false)
1154
+ seen.push({
1155
+ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`,
1156
+ version: e.version,
1157
+ harness: registryOf,
1158
+ });
763
1159
  }
764
1160
  for (const s of states) {
765
- if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
1161
+ if (s.installedPkg)
1162
+ seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
766
1163
  }
767
1164
  const versions = new Set(seen.map((x) => x.version));
768
1165
  if (versions.size < 2) return [];
@@ -772,9 +1169,22 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
772
1169
  // for copies of several harnesses, the listener — has one.
773
1170
  const owners = [...new Set(seen.map((x) => x.harness))];
774
1171
  const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
775
- const launcher = (subject || speaker)?.surfaces?.statusline?.supported === false ? "" : "; the launcher renders whichever is registered";
776
- return [aboutHarness(finding("install", "warn", "version-drift",
777
- `projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one${launcher}.`), subject, speaker)];
1172
+ const launcher =
1173
+ (subject || speaker)?.surfaces?.statusline?.supported === false
1174
+ ? ""
1175
+ : "; the launcher renders whichever is registered";
1176
+ return [
1177
+ aboutHarness(
1178
+ finding(
1179
+ "install",
1180
+ "warn",
1181
+ "version-drift",
1182
+ `projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one${launcher}.`,
1183
+ ),
1184
+ subject,
1185
+ speaker,
1186
+ ),
1187
+ ];
778
1188
  }
779
1189
 
780
1190
  export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
@@ -791,7 +1201,11 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
791
1201
  for (const { dir, label, scope } of scopes) {
792
1202
  for (const f of listMd(dir)) {
793
1203
  let text;
794
- try { text = readFileSync(join(dir, f), "utf8"); } catch { continue; }
1204
+ try {
1205
+ text = readFileSync(join(dir, f), "utf8");
1206
+ } catch {
1207
+ continue;
1208
+ }
795
1209
  const m = text.match(/#\s*source:\s*projectstore\s+v(\S+)/);
796
1210
  const { data } = parseFrontmatter(text);
797
1211
  // A provenance-marked copy with no `name:` still deserves a usable
@@ -810,14 +1224,27 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
810
1224
  // without a provenance marker we cannot prove lineage — hedge FIRST,
811
1225
  // not 40 words in, or the false premise leads for user-authored agents
812
1226
  const lead = m ? `${f}` : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it`;
813
- out.push(finding("install", m ? "warn" : "info", "override-copies",
814
- `${lead} carries the pre-v0.13 name "${name}", which no longer matches a bundled agent, so it overrides nothing and stands alongside "${now}" in the roster.${everywhere} ${advice}`,
815
- where));
1227
+ out.push(
1228
+ finding(
1229
+ "install",
1230
+ m ? "warn" : "info",
1231
+ "override-copies",
1232
+ `${lead} carries the pre-v0.13 name "${name}", which no longer matches a bundled agent, so it overrides nothing and stands alongside "${now}" in the roster.${everywhere} ${advice}`,
1233
+ where,
1234
+ ),
1235
+ );
816
1236
  continue; // a stale-version note on top would just be noise
817
1237
  }
818
1238
  if (!CURRENT_AGENT_NAMES.includes(name)) {
819
- out.push(finding("install", "warn", "override-copies",
820
- `Override copy ${f} has name "${name}" which matches no bundled agent — it duplicates instead of overriding.${everywhere}`, where));
1239
+ out.push(
1240
+ finding(
1241
+ "install",
1242
+ "warn",
1243
+ "override-copies",
1244
+ `Override copy ${f} has name "${name}" which matches no bundled agent — it duplicates instead of overriding.${everywhere}`,
1245
+ where,
1246
+ ),
1247
+ );
821
1248
  continue;
822
1249
  }
823
1250
  // ADR-008. A current-name copy overrides nothing either: the bundled agent
@@ -827,9 +1254,7 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
827
1254
  // refreshing such a copy fixes nothing; that is why it replaces the old
828
1255
  // "frozen at vX — re-run configure" advice instead of sitting beside it.
829
1256
  // Staleness is demoted to a parenthetical: still true, no longer the point.
830
- const stale = m && ver && m[1] !== ver
831
- ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)`
832
- : "";
1257
+ const stale = m && ver && m[1] !== ver ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)` : "";
833
1258
  // Without a marker we cannot prove lineage — hedge FIRST, and never issue
834
1259
  // a delete imperative against a file the user may well have written.
835
1260
  const lead = m
@@ -838,15 +1263,22 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
838
1263
  // `configure` only ever touched PROJECT scope, so pointing a user-scope
839
1264
  // copy at it would name a command that will not act — the scope split
840
1265
  // fca8def introduced for staleness applies here for the same reason.
841
- const remove = scope === "user"
842
- ? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
843
- : `Delete it via ${commandForm("agents", { args: "configure" })}, which now records the model in .projectstore/harness/<harness>.json (the active harness's overlay) and passes it per invocation.`;
1266
+ const remove =
1267
+ scope === "user"
1268
+ ? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
1269
+ : `Delete it via ${commandForm("agents", { args: "configure" })}, which now records the model in .projectstore/harness/<harness>.json (the active harness's overlay) and passes it per invocation.`;
844
1270
  const advice = m
845
1271
  ? remove
846
1272
  : `If you wrote it yourself, nothing is broken; if you meant to change the bundled agent's model, that is ${commandForm("agents", { args: "configure" })}, not a copy.`;
847
- out.push(finding("install", m ? "warn" : "info", "override-copies",
848
- `${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
849
- where));
1273
+ out.push(
1274
+ finding(
1275
+ "install",
1276
+ m ? "warn" : "info",
1277
+ "override-copies",
1278
+ `${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
1279
+ where,
1280
+ ),
1281
+ );
850
1282
  }
851
1283
  }
852
1284
  return out;
@@ -860,112 +1292,193 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
860
1292
  // The variable names come from the manifest (runtime.agent_overrides); the
861
1293
  // wording is projectstore's, because the claim — ADR-008's quality floor — is.
862
1294
  export function checkEnvEffort() {
863
- return agentOverrides().filter((o) => o.kind === "effort").map((o) => finding("install", "warn", "env-effort",
864
- `${o.env}=${o.value} is set — it overrides the bundled agents' "effort: max" frontmatter, so every projectstore agent runs at "${o.value}". Effort is not configurable per project (ADR-008); unset the variable to restore the quality floor.`));
1295
+ return agentOverrides()
1296
+ .filter((o) => o.kind === "effort")
1297
+ .map((o) =>
1298
+ finding(
1299
+ "install",
1300
+ "warn",
1301
+ "env-effort",
1302
+ `${o.env}=${o.value} is set — it overrides the bundled agents' "effort: max" frontmatter, so every projectstore agent runs at "${o.value}". Effort is not configurable per project (ADR-008); unset the variable to restore the quality floor.`,
1303
+ ),
1304
+ );
865
1305
  }
866
1306
 
867
1307
  export function checkEnvModel() {
868
- return agentOverrides().filter((o) => o.kind === "model").map((o) => finding("install", "warn", "env-model",
869
- `${o.env}=${o.value} is set — it overrides ALL projectstore agent model configuration, per-invocation parameter included.`));
1308
+ return agentOverrides()
1309
+ .filter((o) => o.kind === "model")
1310
+ .map((o) =>
1311
+ finding(
1312
+ "install",
1313
+ "warn",
1314
+ "env-model",
1315
+ `${o.env}=${o.value} is set — it overrides ALL projectstore agent model configuration, per-invocation parameter included.`,
1316
+ ),
1317
+ );
870
1318
  }
871
1319
 
872
1320
  // The project-level layout (the layout ADR): a legacy .projectstore/projectstore.json
873
1321
  // or .projectstore/state/ is one warn naming the upgrade; two bindings is an
874
1322
  // issue. Cheap — a handful of existsSync — so the startup line carries the
875
1323
  // warn as an offer (OFFER_CHECKS).
876
- export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir(), speaker = speakingHarness() } = {}) {
1324
+ // `restamp`: the startup line's pending re-stamp, which the move covers — its
1325
+ // sentence goes before the move's command, never after it (the story
1326
+ // "User-facing text says where to run…", rule 4).
1327
+ export function checkLayout(
1328
+ proj,
1329
+ harness = sourceHarness(),
1330
+ { level = "warn", root = pluginRoot(), home = homedir(), speaker = speakingHarness(), restamp = false } = {},
1331
+ ) {
877
1332
  const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
878
- const legacyBinding = existsSync(p.legacy.binding), legacyRuntime = existsSync(p.legacy.runtime);
1333
+ const legacyBinding = existsSync(p.legacy.binding),
1334
+ legacyRuntime = existsSync(p.legacy.runtime);
879
1335
  let resumable = false;
880
1336
  if (legacyBinding && existsSync(p.binding)) {
881
- try { const { agents, ...rest } = JSON.parse(readFileSync(p.legacy.binding, "utf8")); resumable = JSON.stringify(rest) === JSON.stringify(JSON.parse(readFileSync(p.binding, "utf8"))); } catch {}
1337
+ try {
1338
+ const { agents: _agents, ...rest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
1339
+ resumable = JSON.stringify(rest) === JSON.stringify(JSON.parse(readFileSync(p.binding, "utf8")));
1340
+ } catch {}
882
1341
  }
883
1342
  if (legacyBinding && existsSync(p.binding) && !resumable) {
884
- return [finding("install", "issue", "layout-two-configs", `Two bindings: ${relative(proj, p.legacy.binding)} (legacy) and ${relative(proj, p.binding)} — keep one and delete the other; install and upgrade refuse while both exist. Usually the legacy one goes: it is the copy an interrupted migration or a 0.27.x re-bind left behind (when both name the same vault, upgrade removes it itself).`, relative(proj, p.legacy.binding))];
1343
+ return [
1344
+ finding(
1345
+ "install",
1346
+ "issue",
1347
+ "layout-two-configs",
1348
+ `Two bindings: ${relative(proj, p.legacy.binding)} (legacy) and ${relative(proj, p.binding)} — keep one and delete the other; install and upgrade refuse while both exist. Usually the legacy one goes: it is the copy an interrupted migration or a 0.27.x re-bind left behind (when both name the same vault, upgrade removes it itself).`,
1349
+ relative(proj, p.legacy.binding),
1350
+ ),
1351
+ ];
885
1352
  }
886
1353
  if (!legacyBinding && !legacyRuntime && !existsSync(p.legacy.welcomed) && !existsSync(p.legacy.sessionId)) return [];
887
1354
  // The command in the channel of the copy the project runs (the layout spec,
888
1355
  // contract 12 as amended 2026-10-03; maintainer decision the same day). The
889
- // package's shell registers the plugin through its own channel: run for a
890
- // git-marketplace user it added projectstore@projectstore-npm at local scope
891
- // and turned the git copy off for the checkout (reproduced with the suite's
892
- // fake host), and without the host CLI on PATH it stopped part-way with
893
- // exit 1. Any other copy runs its own bin with --no-register, which leaves
894
- // the registration out of the plan — no host command by construction, not by
895
- // recognising the root (a checkout, a symlinked or relocated home).
1356
+ // npm package's plain upgrade registers the plugin through the npm channel:
1357
+ // run for a git-marketplace user it added projectstore@projectstore-npm at
1358
+ // local scope and turned the git copy off for the checkout (reproduced with
1359
+ // the suite's fake host), and without the host CLI on PATH it stopped
1360
+ // part-way with exit 1. So the npm form is named only for the npm
1361
+ // registration's copy; any other copy runs its own bin, and both carry
1362
+ // --no-register, which leaves the registration out of the plan — no host
1363
+ // command by construction, not by recognising the root (a checkout, a
1364
+ // symlinked or relocated home). Neither carries --project: the message names
1365
+ // the directory in prose, the command alone on its last line (remedyWords).
896
1366
  const remedy = layoutRemedy(proj, { root, home, harness });
897
- const held = [legacyBinding && relative(proj, p.legacy.binding), legacyRuntime && relative(proj, p.legacy.runtime) + "/", existsSync(p.legacy.welcomed) && relative(proj, p.legacy.welcomed), existsSync(p.legacy.sessionId) && relative(proj, p.legacy.sessionId)].filter(Boolean).join(", ");
1367
+ const held = [
1368
+ legacyBinding && relative(proj, p.legacy.binding),
1369
+ legacyRuntime && relative(proj, p.legacy.runtime) + "/",
1370
+ existsSync(p.legacy.welcomed) && relative(proj, p.legacy.welcomed),
1371
+ existsSync(p.legacy.sessionId) && relative(proj, p.legacy.sessionId),
1372
+ ]
1373
+ .filter(Boolean)
1374
+ .join(", ");
898
1375
  // The files sit in that harness's legacy directory and the move is its
899
1376
  // command: its fact under any other listener (aboutHarness).
900
- return [aboutHarness(finding("install", level, "layout-legacy",
901
- `The project layout moved to .projectstore/ (the layout ADR, 0.28); this project still holds ${held}. ${remedy.command ? `Migrate it from a terminal outside the session: ${remedy.command}` : remedy.advice} (readers fall back to the old paths through 0.29).`,
902
- relative(proj, [legacyBinding && p.legacy.binding, legacyRuntime && p.legacy.runtime, existsSync(p.legacy.welcomed) && p.legacy.welcomed, p.legacy.sessionId].find(Boolean))), harness, speaker)];
1377
+ return [
1378
+ aboutHarness(
1379
+ finding(
1380
+ "install",
1381
+ level,
1382
+ "layout-legacy",
1383
+ `The project layout moved to .projectstore/ (the layout ADR, 0.28); this project still holds ${held}. Readers fall back to the old paths through 0.30.${restamp ? " The same run re-stamps the status line launcher." : ""} ${remedyWords(remedy, proj, "Migrate it")}`,
1384
+ relative(
1385
+ proj,
1386
+ [
1387
+ legacyBinding && p.legacy.binding,
1388
+ legacyRuntime && p.legacy.runtime,
1389
+ existsSync(p.legacy.welcomed) && p.legacy.welcomed,
1390
+ p.legacy.sessionId,
1391
+ ].find(Boolean),
1392
+ ),
1393
+ ),
1394
+ harness,
1395
+ speaker,
1396
+ ),
1397
+ ];
903
1398
  }
904
1399
 
905
1400
  // The one command that moves this project's files (the layout spec, contract
906
1401
  // 12 as amended 2026-10-03), in the channel of the copy the project runs. A
907
1402
  // session runs that copy, so its root answers: the package's own registration
908
- // takes the shell, any other host copy its own bin. A run from anywhere else —
909
- // a terminal `npx projectstore doctor`, a checkout — asks the host's registry,
910
- // counting only rows this project loads: its own local-scope row first, then
911
- // user-scope rows, never another checkout's. With none enabled, a package root
912
- // takes the shell and a checkout its own bin. BOTH forms carry --no-register:
913
- // the move never needs the registration, so a misread channel can cost a
914
- // launcher stamp but never a channel switch. A copy that predates the move
915
- // (no bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first.
916
- // Nor can one whose CLI predates --no-register (takesNoRegister).
1403
+ // takes the core's npm form, `npx projectstore@<v> upgrade --harness <id>
1404
+ // --no-register` (the front-door ADR, decision 5), any other host copy its own
1405
+ // bin. A run from anywhere else — a terminal `npx projectstore doctor`, a
1406
+ // checkout — asks the host's registry, counting only rows this project loads:
1407
+ // its own local-scope row first, then user-scope rows, never another
1408
+ // checkout's. With none enabled, a package root takes the npm form and a
1409
+ // checkout its own bin. BOTH forms carry --no-register: the move never needs
1410
+ // the registration, so a misread channel can cost a launcher stamp but never a
1411
+ // channel switch. Neither carries --project: the finding names the directory
1412
+ // in prose (remedyWords). A copy that predates the move (no
1413
+ // bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first. Nor
1414
+ // can one whose CLI predates --no-register (lib's takesNoRegister).
917
1415
  //
918
- // Whether a registry copy can run the command named for it. `--no-register`
919
- // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
920
- // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
921
- // amended after the rc.3 tag). Read from the copy's own parse table, never
922
- // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
923
- // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
924
- // file read, because this runs in the SessionStart subset: never an import()
925
- // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
926
- // released copies keep their parse there.
927
- export function takesNoRegister(copy) {
928
- try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
929
- }
930
-
931
- export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
1416
+ // It answers { command } (a form above), { advice } (words with no command of
1417
+ // ours), or { advice, refresh } (the npm registration's incapable copy: words,
1418
+ // then the refresh that moves the project too).
1419
+ //
1420
+ // The selection, the capability read and the copy advice are lib's
1421
+ // (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
1422
+ // installer's keep (the converge story's rule 5). takesNoRegister is
1423
+ // re-exported here, where its callers already import it.
1424
+ export { takesNoRegister };
1425
+
1426
+ // The layout remedy's closing words (the story "User-facing text says where to
1427
+ // run and names no project path and no shell", rules 1, 4 and 5): the
1428
+ // directory in prose, then the command alone on the message's last line.
1429
+ // Advice that names no command of ours ends the message with its own period.
1430
+ export function remedyWords(remedy, proj, verb) {
1431
+ const where = `${fromTerminalIn(proj)}, outside the session:`;
1432
+ if (remedy.command) return `${verb} ${where}\n${remedy.command}`;
1433
+ if (remedy.refresh) return `${remedy.advice}, ${where}\n${remedy.refresh}`;
1434
+ return `${remedy.advice}.`;
1435
+ }
1436
+
1437
+ export function layoutRemedy(
1438
+ proj,
1439
+ { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {},
1440
+ ) {
932
1441
  // A session under a relocated host home hands it on: a terminal without it
933
1442
  // would classify the copy as a checkout, render no launcher and never clear
934
1443
  // the offer (the second review of the 2026-10-03 fixes, S2).
935
1444
  const homeVar = harness?.runtime?.home_env;
936
1445
  const prefix = homeVar && env[homeVar] ? `${homeVar}="${env[homeVar]}" ` : "";
937
- const shell = (version) => ({ command: prefix + packageCommand(harness, "upgrade", { version: version || "latest", args: `--no-register --project "${proj}"` }) });
938
- const own = (copy) => ({ command: `${prefix}node "${join(copy, "bin", "projectstore.mjs")}" upgrade --harness ${harness?.id || "<id>"} --no-register --project "${proj}"` });
1446
+ const npm = (version) => ({
1447
+ command: prefix + packageCommand(harness, "upgrade", { version: version || "latest", args: "--no-register" }),
1448
+ });
1449
+ const own = (copy) => ({
1450
+ command: `${prefix}node "${join(copy, "bin", "projectstore.mjs")}" upgrade --harness ${harness?.id || "<id>"} --no-register`,
1451
+ });
939
1452
  const channel = installChannel(root, { home, harness });
940
- if (channel === "registration") return shell(pluginVersion(root));
1453
+ if (channel === "registration") return npm(pluginVersion(root));
941
1454
  if (channel === "marketplace") return own(root);
942
- // Real paths: the host records the project's cwd as one, and /var against
943
- // /private/var would otherwise drop the project's own row.
944
- const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
945
- const here = real(proj);
946
- const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
947
- const copy = installedPluginEntries(home, proj)
948
- .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
949
- .sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
1455
+ // The selection is lib's selectCopy: this checkout's row first, then
1456
+ // user-scope rows by recency, never another checkout's.
1457
+ const copy = selectCopy(home, proj, { harness });
950
1458
  if (copy) {
951
1459
  const v = copy.version ? ` (${copy.version})` : "";
952
- if (!existsSync(join(copy.path, "bin", "projectstore.mjs"))) {
953
- return { advice: `This project's projectstore plugin${v} predates the move and cannot run it: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
954
- }
1460
+ const then = "and the startup line names the command";
1461
+ if (copy.missing === "bin")
1462
+ return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
955
1463
  // Chosen first, gated after: the copy is what the project runs, so an
956
1464
  // incapable one gets advice even beside a capable user-scope copy.
957
- const viaRegistration = installChannel(copy.path, { home, harness }) === "registration";
958
- if (!takesNoRegister(copy.path)) {
959
- return { advice: viaRegistration
960
- // Its channel's ordinary refresh, at the running version: the plain
961
- // upgrade re-registers from npm — not a switch for an npm project — and
962
- // moves the project in the same run.
963
- ? `This project's projectstore plugin${v} is the npm registration's copy and predates --no-register, so it cannot run the move as named: refresh that registration, which moves the project too, from a terminal outside the session: ${prefix}${packageCommand(harness, "upgrade", { version: pluginVersion(root) || "latest", args: `--project "${proj}"` })}`
964
- : `This project's projectstore plugin${v} predates --no-register and cannot run the move as named: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
1465
+ const viaRegistration = copy.channel === "registration";
1466
+ if (copy.missing === "no-register") {
1467
+ return viaRegistration
1468
+ ? // Its channel's ordinary refresh, at the running version: the plain
1469
+ // upgrade re-registers from npm — not a switch for an npm project — and
1470
+ // moves the project in the same run.
1471
+ {
1472
+ advice: `This project's projectstore plugin${v} is the npm registration's copy and predates --no-register, so it cannot run the move as named. Refresh that registration, which moves the project too`,
1473
+ refresh: prefix + packageCommand(harness, "upgrade", { version: pluginVersion(root) || "latest" }),
1474
+ }
1475
+ : {
1476
+ advice: copyUpdateAdvice(copy, { cannot: "predates --no-register and cannot run the move as named", then }),
1477
+ };
965
1478
  }
966
- return viaRegistration ? shell(copy.version) : own(copy.path);
1479
+ return viaRegistration ? npm(copy.version) : own(copy.path);
967
1480
  }
968
- return channel === "package" ? shell(pluginVersion(root)) : own(root);
1481
+ return channel === "package" ? npm(pluginVersion(root)) : own(root);
969
1482
  }
970
1483
 
971
1484
  export function checkGitignore(proj) {
@@ -976,31 +1489,61 @@ export function checkGitignore(proj) {
976
1489
  const p = layoutPaths(proj);
977
1490
  if (existsSync(p.root)) {
978
1491
  let lines = null;
979
- try { lines = readFileSync(p.gitignore, "utf8").split("\n").map((l) => l.trim()); } catch {}
1492
+ try {
1493
+ lines = readFileSync(p.gitignore, "utf8")
1494
+ .split("\n")
1495
+ .map((l) => l.trim());
1496
+ } catch {}
980
1497
  if (lines === null) {
981
1498
  // Contract 5's ignore file is what keeps a machine-local binding — with
982
1499
  // an absolute vault path — out of a commit. Until 2026-09-06 bind never
983
1500
  // wrote it, so a project bound and committed before its first session
984
1501
  // carried one; doctor reasoned from the self-ignoring without ever
985
1502
  // checking it.
986
- out.push(finding("install", "warn", "gitignore",
987
- `.projectstore/ exists without its .gitignore — the binding and state/ are machine-local and would be committed. Write the lines ${[...LAYOUT.gitignore].map((l) => JSON.stringify(l)).join(", ")}.`, ".projectstore/.gitignore"));
1503
+ out.push(
1504
+ finding(
1505
+ "install",
1506
+ "warn",
1507
+ "gitignore",
1508
+ `.projectstore/ exists without its .gitignore — the binding and state/ are machine-local and would be committed. Write the lines ${[...LAYOUT.gitignore].map((l) => JSON.stringify(l)).join(", ")}.`,
1509
+ ".projectstore/.gitignore",
1510
+ ),
1511
+ );
988
1512
  } else {
989
- if (lines.includes("*") && existsSync(p.overlayDir)) out.push(finding("install", "warn", "gitignore", `.projectstore/.gitignore carries "*", which hides harness/ (the committed overlays) from git — replace it with the lines ${[...LAYOUT.gitignore, LAYOUT.vaultSessions + "/"].map((l) => JSON.stringify(l)).join(", ")}.`, ".projectstore/.gitignore"));
1513
+ if (lines.includes("*") && existsSync(p.overlayDir))
1514
+ out.push(
1515
+ finding(
1516
+ "install",
1517
+ "warn",
1518
+ "gitignore",
1519
+ `.projectstore/.gitignore carries "*", which hides harness/ (the committed overlays) from git — replace it with the lines ${[...LAYOUT.gitignore, LAYOUT.vaultSessions + "/"].map((l) => JSON.stringify(l)).join(", ")}.`,
1520
+ ".projectstore/.gitignore",
1521
+ ),
1522
+ );
990
1523
  else if (!lines.includes("*")) {
991
1524
  // A lone "*" is the pre-2026-09-06 vault-that-is-a-project shape: it
992
1525
  // ignores the binding and state/ already, and only hides harness/ when
993
1526
  // that directory exists — which the branch above is for.
994
1527
  const short = [...LAYOUT.gitignore].filter((l) => !lines.includes(l));
995
- if (short.length) out.push(finding("install", "warn", "gitignore",
996
- `.projectstore/.gitignore is missing ${short.map((l) => JSON.stringify(l)).join(", ")} — the file is line-merged, so add the line rather than rewriting it.`, ".projectstore/.gitignore"));
1528
+ if (short.length)
1529
+ out.push(
1530
+ finding(
1531
+ "install",
1532
+ "warn",
1533
+ "gitignore",
1534
+ `.projectstore/.gitignore is missing ${short.map((l) => JSON.stringify(l)).join(", ")} — the file is line-merged, so add the line rather than rewriting it.`,
1535
+ ".projectstore/.gitignore",
1536
+ ),
1537
+ );
997
1538
  }
998
1539
  }
999
1540
  }
1000
1541
  if (!existsSync(join(proj, ".git"))) return out;
1001
1542
  let lines = [];
1002
1543
  try {
1003
- lines = readFileSync(join(proj, ".gitignore"), "utf8").split("\n").map((l) => l.trim());
1544
+ lines = readFileSync(join(proj, ".gitignore"), "utf8")
1545
+ .split("\n")
1546
+ .map((l) => l.trim());
1004
1547
  } catch {}
1005
1548
  const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
1006
1549
  if (coveredAll) return out;
@@ -1008,8 +1551,14 @@ export function checkGitignore(proj) {
1008
1551
  const wanted = [relative(proj, hostSettingsPath(proj))];
1009
1552
  const missing = wanted.filter((w) => !lines.includes(w));
1010
1553
  if (!missing.length) return out;
1011
- out.push(finding("install", "warn", "gitignore",
1012
- `Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`));
1554
+ out.push(
1555
+ finding(
1556
+ "install",
1557
+ "warn",
1558
+ "gitignore",
1559
+ `Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`,
1560
+ ),
1561
+ );
1013
1562
  return out;
1014
1563
  }
1015
1564
 
@@ -1023,16 +1572,32 @@ export function checkTrackedRuntime(proj) {
1023
1572
  const want = [relative(proj, p.binding), relative(proj, p.state)];
1024
1573
  const r = spawnSync("git", ["ls-files", "--", ...want], { cwd: proj, encoding: "utf8", timeout: 5000 });
1025
1574
  if (r.status !== 0 || !r.stdout) return [];
1026
- const tracked = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
1575
+ const tracked = r.stdout
1576
+ .split("\n")
1577
+ .map((l) => l.trim())
1578
+ .filter(Boolean);
1027
1579
  if (!tracked.length) return [];
1028
- return [finding("install", "warn", "gitignore-tracked",
1029
- `git already tracks ${tracked.join(", ")} — machine-local files with an absolute vault path, committed before the ignore lines existed. An ignore line does not untrack them: run \`git rm --cached ${tracked.join(" ")}\` and commit.`, tracked[0])];
1580
+ return [
1581
+ finding(
1582
+ "install",
1583
+ "warn",
1584
+ "gitignore-tracked",
1585
+ `git already tracks ${tracked.join(", ")} — machine-local files with an absolute vault path, committed before the ignore lines existed. An ignore line does not untrack them: run \`git rm --cached ${tracked.join(" ")}\` and commit.`,
1586
+ tracked[0],
1587
+ ),
1588
+ ];
1030
1589
  }
1031
1590
 
1032
1591
  export function checkVaultGit(cfg) {
1033
1592
  if (existsSync(join(cfg.vault_path, ".git"))) return [];
1034
- return [finding("install", "warn", "vault-git",
1035
- "Vault is not a git repository — the knowledge has no history/blame/review. Consider `git init` (doctor --fix offers it).")];
1593
+ return [
1594
+ finding(
1595
+ "install",
1596
+ "warn",
1597
+ "vault-git",
1598
+ "Vault is not a git repository — the knowledge has no history/blame/review. Consider `git init` (doctor --fix offers it).",
1599
+ ),
1600
+ ];
1036
1601
  }
1037
1602
 
1038
1603
  // Marketplace auto-update (maintainer request 2026-07-03): third-party
@@ -1058,12 +1623,20 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1058
1623
  // session. So fall back to the registered install when our own path says
1059
1624
  // nothing, and never claim --plugin-dir as a conclusion.
1060
1625
  const marketplaceOf = (p) =>
1061
- (String(p || "").replace(/\\/g, "/").match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
1626
+ (String(p || "")
1627
+ .replace(/\\/g, "/")
1628
+ .match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
1062
1629
  const inst = installedPluginRoot(home);
1063
1630
  const marketplace = marketplaceOf(pluginRoot()) || marketplaceOf(inst && inst.path);
1064
1631
  if (!marketplace) {
1065
- out.push(finding("install", "info", "auto-update",
1066
- "No marketplace install of projectstore found (--plugin-dir, or a checkout with none registered) — marketplace auto-update not applicable."));
1632
+ out.push(
1633
+ finding(
1634
+ "install",
1635
+ "info",
1636
+ "auto-update",
1637
+ "No marketplace install of projectstore found (--plugin-dir, or a checkout with none registered) — marketplace auto-update not applicable.",
1638
+ ),
1639
+ );
1067
1640
  return out;
1068
1641
  }
1069
1642
 
@@ -1077,8 +1650,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1077
1650
  // verb, reported by checkPluginRegistration (2026-09-05).
1078
1651
  if (entry && entry.source && entry.source.source === "directory") return out;
1079
1652
  if (!entry) {
1080
- out.push(finding("install", "warn", "auto-update",
1081
- `Marketplace "${marketplace}" is missing from ~/.claude/plugins/known_marketplaces.json — updates cannot be tracked. Re-add it: /plugin marketplace add <owner/repo>.`));
1653
+ out.push(
1654
+ finding(
1655
+ "install",
1656
+ "warn",
1657
+ "auto-update",
1658
+ `Marketplace "${marketplace}" is missing from ~/.claude/plugins/known_marketplaces.json — updates cannot be tracked. Re-add it: /plugin marketplace add <owner/repo>.`,
1659
+ ),
1660
+ );
1082
1661
  return out;
1083
1662
  }
1084
1663
 
@@ -1090,11 +1669,17 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1090
1669
  } catch {}
1091
1670
  }
1092
1671
  if (!enabled) {
1093
- out.push(finding("install", "warn", "auto-update",
1094
- `Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
1095
- `Correct values: "autoUpdate": true on the "${marketplace}" entry in ~/.claude/plugins/known_marketplaces.json ` +
1096
- `(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
1097
- `extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path: /plugin marketplace update ${marketplace}, then /reload-plugins.`));
1672
+ out.push(
1673
+ finding(
1674
+ "install",
1675
+ "warn",
1676
+ "auto-update",
1677
+ `Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
1678
+ `Correct values: "autoUpdate": true on the "${marketplace}" entry in ~/.claude/plugins/known_marketplaces.json ` +
1679
+ `(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
1680
+ `extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path: /plugin marketplace update ${marketplace}, then /reload-plugins.`,
1681
+ ),
1682
+ );
1098
1683
  }
1099
1684
 
1100
1685
  // Bonus: the marketplace checkout's catalog knows the latest released
@@ -1110,12 +1695,18 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1110
1695
  const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
1111
1696
  const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
1112
1697
  const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
1113
- const running = (named && named.version)
1114
- || JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
1698
+ const running =
1699
+ (named && named.version) || JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
1115
1700
  // Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
1116
1701
  if (latest && running && cmpPrecedence(latest, running) > 0) {
1117
- out.push(finding("install", "warn", "auto-update",
1118
- `A newer ${name} is available: v${latest} (running v${running}) — run /plugin marketplace update ${marketplace}, then /reload-plugins.`));
1702
+ out.push(
1703
+ finding(
1704
+ "install",
1705
+ "warn",
1706
+ "auto-update",
1707
+ `A newer ${name} is available: v${latest} (running v${running}) — run /plugin marketplace update ${marketplace}, then /reload-plugins.`,
1708
+ ),
1709
+ );
1119
1710
  }
1120
1711
  } catch {}
1121
1712
  return out;
@@ -1133,18 +1724,46 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
1133
1724
  const mcp = harness && harness.surfaces && harness.surfaces.mcp;
1134
1725
  if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
1135
1726
  const p = join(root, mcp.file || ".mcp.json");
1136
- if (!existsSync(p)) return [finding("install", "warn", "mcp", "No .mcp.json at the plugin root — the MCP read tools are not registered; the package ships one, so this install is incomplete or predates the MCP surface (0.28).")];
1727
+ if (!existsSync(p))
1728
+ return [
1729
+ finding(
1730
+ "install",
1731
+ "warn",
1732
+ "mcp",
1733
+ "No .mcp.json at the plugin root — the MCP read tools are not registered; the package ships one, so this install is incomplete or predates the MCP surface (0.28).",
1734
+ ),
1735
+ ];
1137
1736
  let reg;
1138
- try { reg = JSON.parse(readFileSync(p, "utf8")); } catch (e) { return [finding("install", "issue", "mcp", `.mcp.json at the plugin root is not valid JSON: ${e.message}`, ".mcp.json")]; }
1737
+ try {
1738
+ reg = JSON.parse(readFileSync(p, "utf8"));
1739
+ } catch (e) {
1740
+ return [
1741
+ finding("install", "issue", "mcp", `.mcp.json at the plugin root is not valid JSON: ${e.message}`, ".mcp.json"),
1742
+ ];
1743
+ }
1139
1744
  const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
1140
1745
  const args = server && Array.isArray(server.args) ? server.args : [];
1141
1746
  if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
1142
- return [finding("install", "issue", "mcp", ".mcp.json at the plugin root does not launch bin/projectstore.mjs mcp — the MCP read tools will not answer.", ".mcp.json")];
1143
- }
1144
- return [finding("install", "info", "mcp", "MCP read tools registered by the plugin's .mcp.json (one server per project, bound through the host's project directory).")];
1747
+ return [
1748
+ finding(
1749
+ "install",
1750
+ "issue",
1751
+ "mcp",
1752
+ ".mcp.json at the plugin root does not launch bin/projectstore.mjs mcp — the MCP read tools will not answer.",
1753
+ ".mcp.json",
1754
+ ),
1755
+ ];
1756
+ }
1757
+ return [
1758
+ finding(
1759
+ "install",
1760
+ "info",
1761
+ "mcp",
1762
+ "MCP read tools registered by the plugin's .mcp.json (one server per project, bound through the host's project directory).",
1763
+ ),
1764
+ ];
1145
1765
  }
1146
1766
 
1147
-
1148
1767
  // ─── Vault checks ──────────────────────────────────────────────────────
1149
1768
 
1150
1769
  // Collect every structured artifact with parsed frontmatter.
@@ -1153,7 +1772,11 @@ export function scanArtifacts(cfg, layout) {
1153
1772
  const artifacts = [];
1154
1773
  const push = (abs, rel, kind) => {
1155
1774
  let md;
1156
- try { md = readFileSync(abs, "utf8"); } catch { return; }
1775
+ try {
1776
+ md = readFileSync(abs, "utf8");
1777
+ } catch {
1778
+ return;
1779
+ }
1157
1780
  artifacts.push({ abs, rel, kind, fm: parseFrontmatter(md).data, body: md });
1158
1781
  };
1159
1782
  for (const folder of layout.folders) {
@@ -1195,13 +1818,27 @@ export function checkKanbanSync(cfg) {
1195
1818
  return [finding("vault", "warn", "kanban", `kanban generator failed: ${(r.stderr || "").trim()}`)];
1196
1819
  }
1197
1820
  let expected;
1198
- try { expected = JSON.parse(r.stdout).content; } catch {
1821
+ try {
1822
+ expected = JSON.parse(r.stdout).content;
1823
+ } catch {
1199
1824
  return [finding("vault", "warn", "kanban", "kanban generator returned unparseable output.")];
1200
1825
  }
1201
- const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1826
+ const norm = (s) =>
1827
+ s
1828
+ .split("\n")
1829
+ .filter((l) => !l.startsWith("generated_at:"))
1830
+ .join("\n")
1831
+ .trimEnd();
1202
1832
  if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
1203
- return [finding("vault", "issue", "kanban",
1204
- `kanban.md is out of sync with story frontmatter — run ${commandForm("kanban")} (or reconcile).`, "kanban.md")];
1833
+ return [
1834
+ finding(
1835
+ "vault",
1836
+ "issue",
1837
+ "kanban",
1838
+ `kanban.md is out of sync with story frontmatter — run ${commandForm("kanban")} (or reconcile).`,
1839
+ "kanban.md",
1840
+ ),
1841
+ ];
1205
1842
  }
1206
1843
  return [];
1207
1844
  }
@@ -1225,28 +1862,51 @@ export function checkIndexes(cfg, layout, artifacts) {
1225
1862
  indexed.add(rel);
1226
1863
  const art = artifacts.find((a) => a.rel === rel);
1227
1864
  if (!art) {
1228
- out.push(finding("vault", "issue", "index",
1229
- `${folder.path}/README.md row "${row.label}" points at a missing file: ${row.target}`, `${folder.path}/README.md`));
1865
+ out.push(
1866
+ finding(
1867
+ "vault",
1868
+ "issue",
1869
+ "index",
1870
+ `${folder.path}/README.md row "${row.label}" points at a missing file: ${row.target}`,
1871
+ `${folder.path}/README.md`,
1872
+ ),
1873
+ );
1230
1874
  continue;
1231
1875
  }
1232
1876
  const fmStatus = (art.fm.status || "").trim();
1233
1877
  if (fmStatus && row.status && fmStatus !== row.status) {
1234
- out.push(finding("vault", "issue", "index",
1235
- `${folder.path}/README.md lists "${row.label}" as "${row.status}" but its frontmatter says "${fmStatus}".`, art.rel));
1878
+ out.push(
1879
+ finding(
1880
+ "vault",
1881
+ "issue",
1882
+ "index",
1883
+ `${folder.path}/README.md lists "${row.label}" as "${row.status}" but its frontmatter says "${fmStatus}".`,
1884
+ art.rel,
1885
+ ),
1886
+ );
1236
1887
  }
1237
1888
  const fmTitle = (art.fm.title || "").trim();
1238
1889
  if (fmTitle && row.title && fmTitle !== row.title) {
1239
- out.push(finding("vault", "warn", "index",
1240
- `${folder.path}/README.md title for "${row.label}" differs from frontmatter title.`, art.rel));
1890
+ out.push(
1891
+ finding(
1892
+ "vault",
1893
+ "warn",
1894
+ "index",
1895
+ `${folder.path}/README.md title for "${row.label}" differs from frontmatter title.`,
1896
+ art.rel,
1897
+ ),
1898
+ );
1241
1899
  }
1242
1900
  }
1243
1901
  for (const a of artifacts) {
1244
- const inFolder = folder.kind === "epic"
1245
- ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
1246
- : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`;
1902
+ const inFolder =
1903
+ folder.kind === "epic"
1904
+ ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
1905
+ : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`;
1247
1906
  if (inFolder && !indexed.has(a.rel)) {
1248
- out.push(finding("vault", "warn", "index",
1249
- `${a.rel} is not listed in ${folder.path}/README.md's index.`, a.rel));
1907
+ out.push(
1908
+ finding("vault", "warn", "index", `${a.rel} is not listed in ${folder.path}/README.md's index.`, a.rel),
1909
+ );
1250
1910
  }
1251
1911
  }
1252
1912
  }
@@ -1263,7 +1923,10 @@ function linesOutsideFences(s) {
1263
1923
  const out = [];
1264
1924
  let fenced = false;
1265
1925
  for (const line of s.split("\n")) {
1266
- if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
1926
+ if (/^\s*```/.test(line)) {
1927
+ fenced = !fenced;
1928
+ continue;
1929
+ }
1267
1930
  if (!fenced) out.push(line);
1268
1931
  }
1269
1932
  return out;
@@ -1283,23 +1946,39 @@ export function checkStoriesAndEpics(artifacts) {
1283
1946
  const sec = sectionOf(a.body, "acceptance") || "";
1284
1947
  const unchecked = (stripCodeSpans(sec).match(/- \[ \]/g) || []).length;
1285
1948
  if (unchecked > 0) {
1286
- out.push(finding("vault", "warn", "acceptance",
1287
- `Story is "done" with ${unchecked} unchecked acceptance criteria.`, a.rel));
1949
+ out.push(
1950
+ finding(
1951
+ "vault",
1952
+ "warn",
1953
+ "acceptance",
1954
+ `Story is "done" with ${unchecked} unchecked acceptance criteria.`,
1955
+ a.rel,
1956
+ ),
1957
+ );
1288
1958
  }
1289
1959
  }
1290
1960
  if ((a.fm.review_status || "") === "reviewed" && (!a.fm.reviewed_at || a.fm.reviewed_at === "null")) {
1291
- out.push(finding("vault", "issue", "review-status",
1292
- `review_status is "reviewed" but reviewed_at is empty.`, a.rel));
1961
+ out.push(
1962
+ finding("vault", "issue", "review-status", `review_status is "reviewed" but reviewed_at is empty.`, a.rel),
1963
+ );
1293
1964
  }
1294
1965
  }
1295
1966
  for (const epic of artifacts.filter((a) => a.kind === "epic")) {
1296
1967
  if ((epic.fm.status || "").toLowerCase() !== "done") continue;
1297
1968
  const dir = epic.rel.replace(/\/epic\.md$/, "");
1298
- const open = artifacts.filter((s) =>
1299
- s.kind === "story" && s.rel.startsWith(dir + "/") && (s.fm.status || "").toLowerCase() !== "done");
1969
+ const open = artifacts.filter(
1970
+ (s) => s.kind === "story" && s.rel.startsWith(dir + "/") && (s.fm.status || "").toLowerCase() !== "done",
1971
+ );
1300
1972
  if (open.length) {
1301
- out.push(finding("vault", "issue", "epic-status",
1302
- `Epic is "done" while ${open.length} child stor${open.length === 1 ? "y is" : "ies are"} not.`, epic.rel));
1973
+ out.push(
1974
+ finding(
1975
+ "vault",
1976
+ "issue",
1977
+ "epic-status",
1978
+ `Epic is "done" while ${open.length} child stor${open.length === 1 ? "y is" : "ies are"} not.`,
1979
+ epic.rel,
1980
+ ),
1981
+ );
1303
1982
  }
1304
1983
  }
1305
1984
  return out;
@@ -1317,13 +1996,18 @@ export function checkIndexHeaders(cfg, layout) {
1317
1996
  const readme = join(cfg.vault_path, folder.path, "README.md");
1318
1997
  if (!existsSync(readme)) continue;
1319
1998
  const lines = readFileSync(readme, "utf8").split("\n");
1320
- const headIdx = lines.findIndex((l, i) =>
1321
- /^\|.*\|\s*$/.test(l) && /^\|[-\s|]+\|\s*$/.test(lines[i + 1] || ""));
1999
+ const headIdx = lines.findIndex((l, i) => /^\|.*\|\s*$/.test(l) && /^\|[-\s|]+\|\s*$/.test(lines[i + 1] || ""));
1322
2000
  if (headIdx === -1) continue; // no table at all — nothing to lint
1323
2001
  if (!headerRe.test(lines[headIdx])) {
1324
- out.push(finding("vault", "warn", "index-header",
1325
- `${folder.path}/README.md index header "${lines[headIdx].trim()}" matches no registered form — reconcile cannot rebuild this index (standard form: | File | Title | Status | Date |, localized forms in scaffold/headings.json).`,
1326
- `${folder.path}/README.md`));
2002
+ out.push(
2003
+ finding(
2004
+ "vault",
2005
+ "warn",
2006
+ "index-header",
2007
+ `${folder.path}/README.md index header "${lines[headIdx].trim()}" matches no registered form — reconcile cannot rebuild this index (standard form: | File | Title | Status | Date |, localized forms in scaffold/headings.json).`,
2008
+ `${folder.path}/README.md`,
2009
+ ),
2010
+ );
1327
2011
  }
1328
2012
  }
1329
2013
  return out;
@@ -1361,14 +2045,20 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
1361
2045
  if (a.kind !== "story" || epicIdOf(a.rel, epicFolderPath) !== epicId) continue;
1362
2046
  const tier = storyMatchesEntry(storyId, { id: a.fm.id, stem: storyStemOf(a.rel) });
1363
2047
  if (!tier) continue;
1364
- if (best === 0 || tier < best) { best = tier; hits = [a]; }
1365
- else if (tier === best) hits.push(a);
2048
+ if (best === 0 || tier < best) {
2049
+ best = tier;
2050
+ hits = [a];
2051
+ } else if (tier === best) hits.push(a);
1366
2052
  }
1367
2053
  if (hits.length === 1) return { story: hits[0] };
1368
2054
  if (hits.length > 1) {
1369
- return { error: `ambiguous — "${storyId}" matches ${hits.map((a) => storyStemOf(a.rel)).join(", ")}; qualify the reference` };
2055
+ return {
2056
+ error: `ambiguous — "${storyId}" matches ${hits.map((a) => storyStemOf(a.rel)).join(", ")}; qualify the reference`,
2057
+ };
1370
2058
  }
1371
- return { error: `no story in ${epicFolderPath}/${epicId} matches "${storyId}" (by exact id:, exact filename stem, or legacy story-NNN prefix)` };
2059
+ return {
2060
+ error: `no story in ${epicFolderPath}/${epicId} matches "${storyId}" (by exact id:, exact filename stem, or legacy story-NNN prefix)`,
2061
+ };
1372
2062
  }
1373
2063
 
1374
2064
  // The ONE spec resolver (SPEC-002 contract 5) — shared by checkSpecLinks,
@@ -1380,7 +2070,7 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
1380
2070
  // grandfathered SPEC-NNN-<slug>.md files. Cross-spec identity clashes are
1381
2071
  // the identity check's finding, so first-wins here stays deterministic.
1382
2072
  function buildSpecResolver(artifacts, layout = null) {
1383
- const prefix = layout ? folderByKind(layout, "spec")?.prefix ?? null : null;
2073
+ const prefix = layout ? (folderByKind(layout, "spec")?.prefix ?? null) : null;
1384
2074
  const byId = new Map();
1385
2075
  const byStem = new Map();
1386
2076
  for (const s of artifacts.filter((a) => a.kind === "spec")) {
@@ -1410,7 +2100,10 @@ export function parseSpecAcceptance(spec) {
1410
2100
  // story, not silently fall through to "applies to every covered story".
1411
2101
  const attr = text.match(attrRe);
1412
2102
  const stories = attr
1413
- ? attr[1].split(",").map((s) => s.trim()).filter(Boolean)
2103
+ ? attr[1]
2104
+ .split(",")
2105
+ .map((s) => s.trim())
2106
+ .filter(Boolean)
1414
2107
  : null;
1415
2108
  items.push({ checked, text, stories });
1416
2109
  }
@@ -1428,14 +2121,27 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1428
2121
  for (const entry of listOf(spec.fm, "stories")) {
1429
2122
  const r = resolveSpecStory(entry, artifacts, epicFolder.path);
1430
2123
  if (r.error) {
1431
- out.push(finding("vault", "issue", "spec-links",
1432
- `Spec "${spec.fm.id}" stories entry "${entry}" does not resolve: ${r.error}.`, spec.rel));
1433
- } else if (spec.fm.id &&
1434
- !listOf(r.story.fm, "specs").some((ref) => resolveSpec(ref) === spec)) {
2124
+ out.push(
2125
+ finding(
2126
+ "vault",
2127
+ "issue",
2128
+ "spec-links",
2129
+ `Spec "${spec.fm.id}" stories entry "${entry}" does not resolve: ${r.error}.`,
2130
+ spec.rel,
2131
+ ),
2132
+ );
2133
+ } else if (spec.fm.id && !listOf(r.story.fm, "specs").some((ref) => resolveSpec(ref) === spec)) {
1435
2134
  // Membership through the SAME resolver — a slug-form back-reference
1436
2135
  // to a grandfathered SPEC-NNN file is a valid link, not a gap.
1437
- out.push(finding("vault", "warn", "spec-links",
1438
- `Spec "${spec.fm.id}" covers ${entry} but the story's \`specs:\` list lacks "${spec.fm.id}" (bidirectional link).`, r.story.rel));
2136
+ out.push(
2137
+ finding(
2138
+ "vault",
2139
+ "warn",
2140
+ "spec-links",
2141
+ `Spec "${spec.fm.id}" covers ${entry} but the story's \`specs:\` list lacks "${spec.fm.id}" (bidirectional link).`,
2142
+ r.story.rel,
2143
+ ),
2144
+ );
1439
2145
  }
1440
2146
  }
1441
2147
  }
@@ -1443,19 +2149,32 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1443
2149
  for (const id of listOf(story.fm, "specs")) {
1444
2150
  const spec = resolveSpec(id);
1445
2151
  if (!spec) {
1446
- out.push(finding("vault", "issue", "spec-links",
1447
- `Story lists spec "${id}" which does not exist in specs/.`, story.rel));
2152
+ out.push(
2153
+ finding(
2154
+ "vault",
2155
+ "issue",
2156
+ "spec-links",
2157
+ `Story lists spec "${id}" which does not exist in specs/.`,
2158
+ story.rel,
2159
+ ),
2160
+ );
1448
2161
  } else {
1449
2162
  const epicId = epicIdOf(story.rel, epicFolder.path);
1450
2163
  const stem = storyStemOf(story.rel);
1451
2164
  const covered = listOf(spec.fm, "stories").some((e) => {
1452
2165
  const m = String(e).match(/^([^/]+)\/(.+)$/);
1453
- return m && m[1] === epicId &&
1454
- storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
2166
+ return m && m[1] === epicId && storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
1455
2167
  });
1456
2168
  if (!covered) {
1457
- out.push(finding("vault", "warn", "spec-links",
1458
- `Story lists spec "${id}" but that spec's \`stories:\` does not list it back.`, story.rel));
2169
+ out.push(
2170
+ finding(
2171
+ "vault",
2172
+ "warn",
2173
+ "spec-links",
2174
+ `Story lists spec "${id}" but that spec's \`stories:\` does not list it back.`,
2175
+ story.rel,
2176
+ ),
2177
+ );
1459
2178
  }
1460
2179
  }
1461
2180
  }
@@ -1464,8 +2183,15 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1464
2183
  // the list is invisible to every deterministic check.
1465
2184
  const fmBlock = story.body.match(/^---\n[\s\S]*?\n---/);
1466
2185
  if (story.fm.specs === "" && fmBlock && /\nspecs:\s*\n\s+-\s/.test(fmBlock[0])) {
1467
- out.push(finding("vault", "issue", "spec-links",
1468
- "`specs:` uses block-sequence YAML which projectstore cannot parse — use inline flow: specs: [\"SPEC-001\"].", story.rel));
2186
+ out.push(
2187
+ finding(
2188
+ "vault",
2189
+ "issue",
2190
+ "spec-links",
2191
+ '`specs:` uses block-sequence YAML which projectstore cannot parse — use inline flow: specs: ["SPEC-001"].',
2192
+ story.rel,
2193
+ ),
2194
+ );
1469
2195
  }
1470
2196
  }
1471
2197
  return out;
@@ -1489,8 +2215,15 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
1489
2215
  if (isLegacyStory(story.fm, since)) continue;
1490
2216
  const ids = listOf(story.fm, "specs");
1491
2217
  if (!ids.length) {
1492
- out.push(finding("vault", "issue", "spec-coverage",
1493
- `Story is ${status} with no covering spec (spec_policy: required — every story needs a spec; ADR-007).`, story.rel));
2218
+ out.push(
2219
+ finding(
2220
+ "vault",
2221
+ "issue",
2222
+ "spec-coverage",
2223
+ `Story is ${status} with no covering spec (spec_policy: required — every story needs a spec; ADR-007).`,
2224
+ story.rel,
2225
+ ),
2226
+ );
1494
2227
  continue;
1495
2228
  }
1496
2229
  for (const id of ids) {
@@ -1499,12 +2232,26 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
1499
2232
  const st = specStatusOf(spec);
1500
2233
  if (status === "done") {
1501
2234
  if (!["active", "superseded"].includes(st)) {
1502
- out.push(finding("vault", "issue", "spec-status",
1503
- `Story is done while covering spec "${id}" is ${st} — a story may close only against an active spec.`, story.rel));
2235
+ out.push(
2236
+ finding(
2237
+ "vault",
2238
+ "issue",
2239
+ "spec-status",
2240
+ `Story is done while covering spec "${id}" is ${st} — a story may close only against an active spec.`,
2241
+ story.rel,
2242
+ ),
2243
+ );
1504
2244
  }
1505
2245
  } else if (st === "draft") {
1506
- out.push(finding("vault", "warn", "spec-status",
1507
- `Story is ${status} while covering spec "${id}" is still draft — the spec must go active before implementation.`, story.rel));
2246
+ out.push(
2247
+ finding(
2248
+ "vault",
2249
+ "warn",
2250
+ "spec-status",
2251
+ `Story is ${status} while covering spec "${id}" is still draft — the spec must go active before implementation.`,
2252
+ story.rel,
2253
+ ),
2254
+ );
1508
2255
  }
1509
2256
  }
1510
2257
  }
@@ -1535,8 +2282,15 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
1535
2282
  if (!spec) continue;
1536
2283
  const items = parseSpecAcceptance(spec);
1537
2284
  if (items === null) {
1538
- out.push(finding("vault", "warn", "spec-acceptance",
1539
- `Covering spec "${id}" has no Acceptance section — its criteria cannot gate this story.`, spec.rel));
2285
+ out.push(
2286
+ finding(
2287
+ "vault",
2288
+ "warn",
2289
+ "spec-acceptance",
2290
+ `Covering spec "${id}" has no Acceptance section — its criteria cannot gate this story.`,
2291
+ spec.rel,
2292
+ ),
2293
+ );
1540
2294
  continue;
1541
2295
  }
1542
2296
  const coveredEntries = listOf(spec.fm, "stories");
@@ -1549,24 +2303,40 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
1549
2303
  applies = item.stories.some((bare) =>
1550
2304
  coveredEntries.some((e) => {
1551
2305
  const m = String(e).match(/^([^/]+)\/(.+)$/);
1552
- return m && m[1] === epicId && bare === m[2] &&
1553
- storyMatchesEntry(bare, { id: story.fm.id, stem }) > 0;
1554
- }));
1555
- const ambiguous = item.stories.some((bare) =>
1556
- coveredEntries.filter((e) => {
1557
- const m = String(e).match(/^([^/]+)\/(.+)$/);
1558
- return m && bare === m[2];
1559
- }).length > 1);
2306
+ return m && m[1] === epicId && bare === m[2] && storyMatchesEntry(bare, { id: story.fm.id, stem }) > 0;
2307
+ }),
2308
+ );
2309
+ const ambiguous = item.stories.some(
2310
+ (bare) =>
2311
+ coveredEntries.filter((e) => {
2312
+ const m = String(e).match(/^([^/]+)\/(.+)$/);
2313
+ return m && bare === m[2];
2314
+ }).length > 1,
2315
+ );
1560
2316
  const ambKey = `${spec.rel}|${item.text}`;
1561
2317
  if (ambiguous && !ambiguousReported.has(ambKey)) {
1562
2318
  ambiguousReported.add(ambKey);
1563
- out.push(finding("vault", "warn", "ambiguous-attribution",
1564
- `Spec "${id}" acceptance item attributes bare id(s) "${item.stories.join(", ")}" that match more than one covered epic — qualify as <epic-id>/<story-id>.`, spec.rel));
2319
+ out.push(
2320
+ finding(
2321
+ "vault",
2322
+ "warn",
2323
+ "ambiguous-attribution",
2324
+ `Spec "${id}" acceptance item attributes bare id(s) "${item.stories.join(", ")}" that match more than one covered epic — qualify as <epic-id>/<story-id>.`,
2325
+ spec.rel,
2326
+ ),
2327
+ );
1565
2328
  }
1566
2329
  }
1567
2330
  if (applies && !item.checked) {
1568
- out.push(finding("vault", "issue", "spec-acceptance",
1569
- `Story is done but spec "${id}" acceptance item is unchecked: "${item.text.slice(0, 80)}".`, story.rel));
2331
+ out.push(
2332
+ finding(
2333
+ "vault",
2334
+ "issue",
2335
+ "spec-acceptance",
2336
+ `Story is done but spec "${id}" acceptance item is unchecked: "${item.text.slice(0, 80)}".`,
2337
+ story.rel,
2338
+ ),
2339
+ );
1570
2340
  }
1571
2341
  }
1572
2342
  }
@@ -1593,25 +2363,53 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
1593
2363
  for (const line of linesOutsideFences(acc)) {
1594
2364
  const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
1595
2365
  if (m && !evidenceRe.test(m[2])) {
1596
- out.push(finding("vault", "warn", "evidence",
1597
- `Checked acceptance criterion carries no evidence suffix ("— evidence: <test | command | file:line>"): "${m[2].slice(0, 70)}".`, story.rel));
2366
+ out.push(
2367
+ finding(
2368
+ "vault",
2369
+ "warn",
2370
+ "evidence",
2371
+ `Checked acceptance criterion carries no evidence suffix ("— evidence: <test | command | file:line>"): "${m[2].slice(0, 70)}".`,
2372
+ story.rel,
2373
+ ),
2374
+ );
1598
2375
  }
1599
2376
  }
1600
2377
  }
1601
2378
 
1602
2379
  const plan = sectionOf(story.body, "implementation_plan");
1603
2380
  if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
1604
- out.push(finding("vault", "warn", "plan-gate",
1605
- `Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the ${commandForm("story", { args: "plan" })} gate.`, story.rel));
2381
+ out.push(
2382
+ finding(
2383
+ "vault",
2384
+ "warn",
2385
+ "plan-gate",
2386
+ `Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the ${commandForm("story", { args: "plan" })} gate.`,
2387
+ story.rel,
2388
+ ),
2389
+ );
1606
2390
  }
1607
2391
  const summary = sectionOf(story.body, "final_summary");
1608
2392
  if (summary === null) {
1609
- out.push(finding("vault", "warn", "final-summary",
1610
- "Done story has no Final Summary section (lifecycle_gates: on requires a close-out record).", story.rel));
2393
+ out.push(
2394
+ finding(
2395
+ "vault",
2396
+ "warn",
2397
+ "final-summary",
2398
+ "Done story has no Final Summary section (lifecycle_gates: on requires a close-out record).",
2399
+ story.rel,
2400
+ ),
2401
+ );
1611
2402
  }
1612
2403
  if (plan === null) {
1613
- out.push(finding("vault", "warn", "plan-gate",
1614
- "Done story has no Implementation Plan section (lifecycle_gates: on requires the plan to live in the story).", story.rel));
2404
+ out.push(
2405
+ finding(
2406
+ "vault",
2407
+ "warn",
2408
+ "plan-gate",
2409
+ "Done story has no Implementation Plan section (lifecycle_gates: on requires the plan to live in the story).",
2410
+ story.rel,
2411
+ ),
2412
+ );
1615
2413
  }
1616
2414
  }
1617
2415
  return out;
@@ -1622,12 +2420,24 @@ export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
1622
2420
  const out = [];
1623
2421
  const hasSpecs = artifacts.some((a) => a.kind === "spec");
1624
2422
  if (hasSpecs && !vaultCfg.spec_policy) {
1625
- out.push(finding("vault", "info", "spec-policy",
1626
- "Vault contains specs but declares no spec_policy — consider enabling spec-first: add { \"spec_policy\": \"required\" } to <vault>/.projectstore.json (doctor gates activate; ADR-007)."));
2423
+ out.push(
2424
+ finding(
2425
+ "vault",
2426
+ "info",
2427
+ "spec-policy",
2428
+ 'Vault contains specs but declares no spec_policy — consider enabling spec-first: add { "spec_policy": "required" } to <vault>/.projectstore.json (doctor gates activate; ADR-007).',
2429
+ ),
2430
+ );
1627
2431
  }
1628
2432
  if (vaultCfg.spec_policy === "required" && !vaultCfg.spec_policy_since) {
1629
- out.push(finding("vault", "warn", "spec-policy",
1630
- "spec_policy is required but spec_policy_since is missing — the legacy exemption cannot be evaluated; stamp it with the enable date (ISO-8601)."));
2433
+ out.push(
2434
+ finding(
2435
+ "vault",
2436
+ "warn",
2437
+ "spec-policy",
2438
+ "spec_policy is required but spec_policy_since is missing — the legacy exemption cannot be evaluated; stamp it with the enable date (ISO-8601).",
2439
+ ),
2440
+ );
1631
2441
  }
1632
2442
  return out;
1633
2443
  }
@@ -1643,7 +2453,11 @@ export function walkVaultFiles(vault) {
1643
2453
  if (n.startsWith(".")) continue;
1644
2454
  const p = join(dir, n);
1645
2455
  let st;
1646
- try { st = statSync(p); } catch { continue; }
2456
+ try {
2457
+ st = statSync(p);
2458
+ } catch {
2459
+ continue;
2460
+ }
1647
2461
  if (st.isDirectory()) walk(p, relDir ? `${relDir}/${n}` : n);
1648
2462
  else if (n.endsWith(".md")) files.push({ rel: relDir ? `${relDir}/${n}` : n, name: n });
1649
2463
  }
@@ -1669,7 +2483,9 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
1669
2483
  const files = vaultFiles ?? walkVaultFiles(cfg.vault_path);
1670
2484
  let index = nodeIndex;
1671
2485
  if (!index) {
1672
- try { index = buildNodeIndex(cfg, loadLayout(cfg.layout)); } catch (e) {
2486
+ try {
2487
+ index = buildNodeIndex(cfg, loadLayout(cfg.layout));
2488
+ } catch (e) {
1673
2489
  // A silent no-op check would read as "links are fine" — say why not.
1674
2490
  return [finding("vault", "warn", "wikilink", `Link check skipped — node index failed: ${e.message}`)];
1675
2491
  }
@@ -1684,12 +2500,21 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
1684
2500
  for (const link of extractLinks(a.body)) {
1685
2501
  const r = resolveLinkTarget(link.target, link.type, ctx);
1686
2502
  if (r.outcome === "dead") {
1687
- out.push(link.type === "wikilink"
1688
- ? finding("vault", "issue", "wikilink", `Dead wiki-link [[${link.target}]].`, a.rel)
1689
- : finding("vault", "issue", "rel-link", `Dead relative link (${link.target}).`, a.rel));
2503
+ out.push(
2504
+ link.type === "wikilink"
2505
+ ? finding("vault", "issue", "wikilink", `Dead wiki-link [[${link.target}]].`, a.rel)
2506
+ : finding("vault", "issue", "rel-link", `Dead relative link (${link.target}).`, a.rel),
2507
+ );
1690
2508
  } else if (r.outcome === "ambiguous") {
1691
- out.push(finding("vault", "warn", "wikilink",
1692
- `Ambiguous wiki-link [[${link.target}]] — matches ${r.candidates.join(", ")}; qualify with a path.`, a.rel));
2509
+ out.push(
2510
+ finding(
2511
+ "vault",
2512
+ "warn",
2513
+ "wikilink",
2514
+ `Ambiguous wiki-link [[${link.target}]] — matches ${r.candidates.join(", ")}; qualify with a path.`,
2515
+ a.rel,
2516
+ ),
2517
+ );
1693
2518
  }
1694
2519
  }
1695
2520
  }
@@ -1751,7 +2576,8 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
1751
2576
  const parts = x.rel.split("/");
1752
2577
  let entry = null; // stories/<f>.md | stories/<dir>/README.md | standalone story-*.md
1753
2578
  if (parts.length === 4 && parts[2] === "stories") entry = { name: parts[3], rel: x.rel };
1754
- else if (parts.length === 5 && parts[2] === "stories" && parts[4] === "README.md") entry = { name: parts[3], rel: x.rel };
2579
+ else if (parts.length === 5 && parts[2] === "stories" && parts[4] === "README.md")
2580
+ entry = { name: parts[3], rel: x.rel };
1755
2581
  else if (parts.length === 3 && parts[2].startsWith("story-")) entry = { name: parts[2], rel: x.rel };
1756
2582
  if (!entry || INFRA_NAMES.has(entry.name)) continue;
1757
2583
  if (!byEpic.has(parts[1])) byEpic.set(parts[1], []);
@@ -1781,31 +2607,53 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
1781
2607
  for (const [ident, hits] of groups) {
1782
2608
  const uniq = [...new Map(hits.map((h) => [h.entry.rel, h])).values()];
1783
2609
  if (uniq.length < 2) continue;
1784
- const key = uniq.map((h) => h.entry.rel).sort().join("|");
2610
+ const key = uniq
2611
+ .map((h) => h.entry.rel)
2612
+ .sort()
2613
+ .join("|");
1785
2614
  if (reported.has(key)) continue;
1786
2615
  reported.add(key);
1787
2616
  const selfOnly = uniq.every((h) => h.via === "self");
1788
2617
  const classed = uniq.map((h) => ({ h, era: eraOf(h.entry, opts) }));
1789
- const level = selfOnly ? "issue"
1790
- // Overlap reached via the legacy reading of a PROVEN slug-era file
1791
- // (its machine id is the full stem) is spurious — the file's real
1792
- // identity is the unstripped slug.
1793
- : classed.some(({ h, era }) => h.via !== "self" && era === "new") ? "warn"
1794
- : classed.some(({ era }) => era === "uncertain") ? "warn"
1795
- : classed.every(({ era }) => era === "legacy") ? "info"
1796
- : "issue"; // a new-era name colliding with a certain legacy one
1797
- const note = level === "warn" ? "; the overlap depends on a legacy reading that frontmatter does not confirm"
1798
- : level === "info" ? "; all carry legacy numbers — legal in the numbered era, duplicate topic is hygiene"
1799
- : "";
1800
- out.push(finding("vault", level, "identity",
1801
- `Same normalized identity "${ident}" in ${label}: ${uniq.map((h) => h.entry.name).join(", ")} — two artifacts claim one topic (ADR-010${note}).`,
1802
- uniq[0].entry.rel));
2618
+ const level = selfOnly
2619
+ ? "issue"
2620
+ : // Overlap reached via the legacy reading of a PROVEN slug-era file
2621
+ // (its machine id is the full stem) is spurious — the file's real
2622
+ // identity is the unstripped slug.
2623
+ classed.some(({ h, era }) => h.via !== "self" && era === "new")
2624
+ ? "warn"
2625
+ : classed.some(({ era }) => era === "uncertain")
2626
+ ? "warn"
2627
+ : classed.every(({ era }) => era === "legacy")
2628
+ ? "info"
2629
+ : "issue"; // a new-era name colliding with a certain legacy one
2630
+ const note =
2631
+ level === "warn"
2632
+ ? "; the overlap depends on a legacy reading that frontmatter does not confirm"
2633
+ : level === "info"
2634
+ ? "; all carry legacy numbers — legal in the numbered era, duplicate topic is hygiene"
2635
+ : "";
2636
+ out.push(
2637
+ finding(
2638
+ "vault",
2639
+ level,
2640
+ "identity",
2641
+ `Same normalized identity "${ident}" in ${label}: ${uniq.map((h) => h.entry.name).join(", ")} — two artifacts claim one topic (ADR-010${note}).`,
2642
+ uniq[0].entry.rel,
2643
+ ),
2644
+ );
1803
2645
  }
1804
2646
  for (const [num, ents] of numbers) {
1805
2647
  if (new Set(ents.map((e) => e.rel)).size < 2) continue;
1806
- out.push(finding("vault", "info", "identity",
1807
- `Display number ${num} is carried by ${ents.length} artifacts in ${label}: ${ents.map((e) => e.name).join(", ")} — numbers are reference metadata (ADR-010), duplicates confuse humans.`,
1808
- ents[0].rel));
2648
+ out.push(
2649
+ finding(
2650
+ "vault",
2651
+ "info",
2652
+ "identity",
2653
+ `Display number ${num} is carried by ${ents.length} artifacts in ${label}: ${ents.map((e) => e.name).join(", ")} — numbers are reference metadata (ADR-010), duplicates confuse humans.`,
2654
+ ents[0].rel,
2655
+ ),
2656
+ );
1809
2657
  }
1810
2658
  }
1811
2659
  return out;
@@ -1821,8 +2669,15 @@ export function checkExternalRefsForm(artifacts) {
1821
2669
  if (a.fm.external_refs !== "") continue;
1822
2670
  const fmBlock = a.body.match(/^---\n[\s\S]*?\n---/);
1823
2671
  if (fmBlock && /\nexternal_refs:\s*\n\s+\S/.test(fmBlock[0])) {
1824
- out.push(finding("vault", "issue", "external-refs",
1825
- "`external_refs:` uses block-form YAML which projectstore cannot parse — use inline flow: external_refs: {jira: \"ABC-123\"}.", a.rel));
2672
+ out.push(
2673
+ finding(
2674
+ "vault",
2675
+ "issue",
2676
+ "external-refs",
2677
+ '`external_refs:` uses block-form YAML which projectstore cannot parse — use inline flow: external_refs: {jira: "ABC-123"}.',
2678
+ a.rel,
2679
+ ),
2680
+ );
1826
2681
  }
1827
2682
  }
1828
2683
  return out;
@@ -1838,8 +2693,15 @@ export function checkArtifactNames(vaultFiles) {
1838
2693
  for (const x of vaultFiles) {
1839
2694
  const bad = legalArtifactName(x.name);
1840
2695
  if (bad) {
1841
- out.push(finding("vault", "warn", "artifact-name",
1842
- `Sync-conflict filename shape — ${bad}. Merge or remove; sync engines leave these beside the original.`, x.rel));
2696
+ out.push(
2697
+ finding(
2698
+ "vault",
2699
+ "warn",
2700
+ "artifact-name",
2701
+ `Sync-conflict filename shape — ${bad}. Merge or remove; sync engines leave these beside the original.`,
2702
+ x.rel,
2703
+ ),
2704
+ );
1843
2705
  }
1844
2706
  if (INFRA_NAMES.has(x.name)) continue;
1845
2707
  if (!byName.has(x.name)) byName.set(x.name, []);
@@ -1847,9 +2709,15 @@ export function checkArtifactNames(vaultFiles) {
1847
2709
  }
1848
2710
  for (const [name, rels] of byName) {
1849
2711
  if (rels.length < 2) continue;
1850
- out.push(finding("vault", "info", "artifact-name",
1851
- `Basename "${name}" appears in ${rels.length} folders (${rels.map((r) => dirname(r)).join(", ")}) — short wiki-links [[${name.replace(/\.md$/, "")}]] are ambiguous.`,
1852
- rels[0]));
2712
+ out.push(
2713
+ finding(
2714
+ "vault",
2715
+ "info",
2716
+ "artifact-name",
2717
+ `Basename "${name}" appears in ${rels.length} folders (${rels.map((r) => dirname(r)).join(", ")}) — short wiki-links [[${name.replace(/\.md$/, "")}]] are ambiguous.`,
2718
+ rels[0],
2719
+ ),
2720
+ );
1853
2721
  }
1854
2722
  return out;
1855
2723
  }
@@ -1877,12 +2745,22 @@ export function checkWorkWithoutStory(cfg, proj) {
1877
2745
  const fms = [];
1878
2746
  let unreadable = 0;
1879
2747
  for (const f of files) {
1880
- try { fms.push(parseFrontmatter(readFileSync(f, "utf8")).data); } catch { unreadable++; }
2748
+ try {
2749
+ fms.push(parseFrontmatter(readFileSync(f, "utf8")).data);
2750
+ } catch {
2751
+ unreadable++;
2752
+ }
1881
2753
  }
1882
2754
  if (unreadable > 0 && !openStoryFrom(fms)) {
1883
2755
  // A diagnostic that goes quiet because it could not read is a false clean.
1884
- out.push(finding("vault", "warn", "work-without-story",
1885
- `Could not read ${unreadable} story file(s), so "is any story in progress" is unproven — this check is inconclusive rather than clean.`));
2756
+ out.push(
2757
+ finding(
2758
+ "vault",
2759
+ "warn",
2760
+ "work-without-story",
2761
+ `Could not read ${unreadable} story file(s), so "is any story in progress" is unproven — this check is inconclusive rather than clean.`,
2762
+ ),
2763
+ );
1886
2764
  return out;
1887
2765
  }
1888
2766
  if (openStoryFrom(fms)) return out;
@@ -1905,14 +2783,21 @@ export function checkWorkWithoutStory(cfg, proj) {
1905
2783
  // kind: null — entry reminders only. The log is shared with the session-name
1906
2784
  // offer, whose breadcrumbs would otherwise be reported as reminders delivered.
1907
2785
  const fired = readEntryLog(proj, { withinDays: 30, kind: null }).length;
1908
- const firedNote = fired > 0
1909
- ? ` An entry reminder fired ${fired} time(s) in the last 30 days on this machine, so the prompt was delivered and the work still went untracked.`
1910
- : " No entry reminder fired in the last 30 days on this machine (the log is machine-local — .claude/ is gitignored).";
2786
+ const firedNote =
2787
+ fired > 0
2788
+ ? ` An entry reminder fired ${fired} time(s) in the last 30 days on this machine, so the prompt was delivered and the work still went untracked.`
2789
+ : " No entry reminder fired in the last 30 days on this machine (the log is machine-local — .claude/ is gitignored).";
1911
2790
  const what = [];
1912
2791
  if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
1913
2792
  if (committedSince) what.push("commits newer than the vault's last activity");
1914
- out.push(finding("vault", "warn", "work-without-story",
1915
- `${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault: ${commandForm("story", { args: '<EPIC> "<title>"' })}.${firedNote}`));
2793
+ out.push(
2794
+ finding(
2795
+ "vault",
2796
+ "warn",
2797
+ "work-without-story",
2798
+ `${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault: ${commandForm("story", { args: '<EPIC> "<title>"' })}.${firedNote}`,
2799
+ ),
2800
+ );
1916
2801
  return out;
1917
2802
  }
1918
2803
 
@@ -1930,8 +2815,15 @@ export function checkCodeRefs(artifacts, proj) {
1930
2815
  for (const ref of refs) {
1931
2816
  if (ref.includes("*")) continue;
1932
2817
  if (!existsSync(join(proj, ref))) {
1933
- out.push(finding("vault", "issue", "code-refs",
1934
- `code_refs path "${ref}" does not resolve inside the project (status: ${status}).`, a.rel));
2818
+ out.push(
2819
+ finding(
2820
+ "vault",
2821
+ "issue",
2822
+ "code-refs",
2823
+ `code_refs path "${ref}" does not resolve inside the project (status: ${status}).`,
2824
+ a.rel,
2825
+ ),
2826
+ );
1935
2827
  }
1936
2828
  }
1937
2829
  }
@@ -1939,14 +2831,28 @@ export function checkCodeRefs(artifacts, proj) {
1939
2831
  const dir = a.rel.replace(/\/stories\/[^/]+$/, "");
1940
2832
  const parent = epicRefs.get(dir) || [];
1941
2833
  if (!parent.length) {
1942
- out.push(finding("vault", "warn", "code-refs",
1943
- "Story has code_refs but its epic has none — set the epic's footprint first.", a.rel));
2834
+ out.push(
2835
+ finding(
2836
+ "vault",
2837
+ "warn",
2838
+ "code-refs",
2839
+ "Story has code_refs but its epic has none — set the epic's footprint first.",
2840
+ a.rel,
2841
+ ),
2842
+ );
1944
2843
  } else {
1945
2844
  const norm = (r) => r.replace(/\/+$/, "");
1946
2845
  for (const ref of refs) {
1947
2846
  if (!parent.some((p) => norm(ref).startsWith(norm(p)))) {
1948
- out.push(finding("vault", "warn", "code-refs",
1949
- `Story code_ref "${ref}" falls outside the parent epic's code_refs.`, a.rel));
2847
+ out.push(
2848
+ finding(
2849
+ "vault",
2850
+ "warn",
2851
+ "code-refs",
2852
+ `Story code_ref "${ref}" falls outside the parent epic's code_refs.`,
2853
+ a.rel,
2854
+ ),
2855
+ );
1950
2856
  }
1951
2857
  }
1952
2858
  }
@@ -1967,13 +2873,27 @@ export function checkCodeMap(cfg) {
1967
2873
  });
1968
2874
  if (r.status !== 0) return [finding("vault", "warn", "code-map", "codemap generator failed.")];
1969
2875
  let expected;
1970
- try { expected = JSON.parse(r.stdout).content; } catch {
2876
+ try {
2877
+ expected = JSON.parse(r.stdout).content;
2878
+ } catch {
1971
2879
  return [finding("vault", "warn", "code-map", "codemap generator returned unparseable output.")];
1972
2880
  }
1973
- const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
2881
+ const norm = (s) =>
2882
+ s
2883
+ .split("\n")
2884
+ .filter((l) => !l.startsWith("generated_at:"))
2885
+ .join("\n")
2886
+ .trimEnd();
1974
2887
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1975
- return [finding("vault", "issue", "code-map",
1976
- `code-map.md is stale against frontmatter code_refs — run ${commandForm("codemap")} (or reconcile).`, "code-map.md")];
2888
+ return [
2889
+ finding(
2890
+ "vault",
2891
+ "issue",
2892
+ "code-map",
2893
+ `code-map.md is stale against frontmatter code_refs — run ${commandForm("codemap")} (or reconcile).`,
2894
+ "code-map.md",
2895
+ ),
2896
+ ];
1977
2897
  }
1978
2898
  return [];
1979
2899
  }
@@ -1986,7 +2906,9 @@ export function checkCodeMap(cfg) {
1986
2906
  export function checkGraph(cfg) {
1987
2907
  const p = join(cfg.vault_path, "graph.md");
1988
2908
  if (!existsSync(p)) {
1989
- return [finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`)];
2909
+ return [
2910
+ finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`),
2911
+ ];
1990
2912
  }
1991
2913
  const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
1992
2914
  encoding: "utf8",
@@ -1995,13 +2917,27 @@ export function checkGraph(cfg) {
1995
2917
  });
1996
2918
  if (r.status !== 0) return [finding("vault", "warn", "graph", `graph generator failed: ${(r.stderr || "").trim()}`)];
1997
2919
  let expected;
1998
- try { expected = JSON.parse(r.stdout).content; } catch {
2920
+ try {
2921
+ expected = JSON.parse(r.stdout).content;
2922
+ } catch {
1999
2923
  return [finding("vault", "warn", "graph", "graph generator returned unparseable output.")];
2000
2924
  }
2001
- const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
2925
+ const norm = (s) =>
2926
+ s
2927
+ .split("\n")
2928
+ .filter((l) => !l.startsWith("generated_at:"))
2929
+ .join("\n")
2930
+ .trimEnd();
2002
2931
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
2003
- return [finding("vault", "issue", "graph",
2004
- `graph.md is out of sync with vault links — run ${commandForm("graph")} (or reconcile).`, "graph.md")];
2932
+ return [
2933
+ finding(
2934
+ "vault",
2935
+ "issue",
2936
+ "graph",
2937
+ `graph.md is out of sync with vault links — run ${commandForm("graph")} (or reconcile).`,
2938
+ "graph.md",
2939
+ ),
2940
+ ];
2005
2941
  }
2006
2942
  return [];
2007
2943
  }
@@ -2030,16 +2966,20 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2030
2966
  ...checkOverlays(cfg, proj, opts),
2031
2967
  );
2032
2968
  let read = null;
2033
- try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
2034
- out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
2035
- if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home }));
2969
+ try {
2970
+ read = await readSurfaceStates(proj, opts);
2971
+ } catch {} // reported as a warn by checkHarnessSurfaces
2972
+ out.push(...(await checkHarnessSurfaces(cfg, proj, { ...opts, read })));
2973
+ if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
2036
2974
  if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
2037
2975
  return foldIntoMove(out);
2038
2976
  }
2039
2977
 
2040
2978
  export function runVaultChecks(cfg) {
2041
2979
  let layout;
2042
- try { layout = loadLayout(cfg.layout); } catch (e) {
2980
+ try {
2981
+ layout = loadLayout(cfg.layout);
2982
+ } catch (e) {
2043
2983
  return [finding("vault", "issue", "layout", `Layout not loadable: ${e.message}`)];
2044
2984
  }
2045
2985
  const artifacts = scanArtifacts(cfg, layout);
@@ -2058,7 +2998,9 @@ export function runVaultChecks(cfg) {
2058
2998
  () => checkLifecycleGates(artifacts, vaultCfg),
2059
2999
  ];
2060
3000
  for (const step of guarded) {
2061
- try { findings.push(...step()); } catch (e) {
3001
+ try {
3002
+ findings.push(...step());
3003
+ } catch (e) {
2062
3004
  findings.push(finding("vault", "issue", "registry", e.message));
2063
3005
  break;
2064
3006
  }
@@ -2082,6 +3024,7 @@ export function runVaultChecks(cfg) {
2082
3024
  // Decision 4). Aborts past the budget rather than reporting a false "clean".
2083
3025
  export function runStartupChecks(cfg, proj, budgetMs = 150) {
2084
3026
  const started = Date.now();
3027
+ const findings = [];
2085
3028
  const steps = [
2086
3029
  () => checkConfig(cfg, proj),
2087
3030
  () => (cfg && cfg.vault_path ? checkVaultPath(cfg) : []),
@@ -2090,15 +3033,22 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2090
3033
  () => checkAgentsBlock(proj),
2091
3034
  () => checkGitignore(proj),
2092
3035
  // The layout move is offered from the startup line (info here, warn in the
2093
- // report) — a user who never runs doctor still learns to migrate.
2094
- () => checkLayout(proj, undefined, { level: "info" }),
3036
+ // report) — a user who never runs doctor still learns to migrate. A
3037
+ // pending re-stamp, found by the step above, is covered by the move: its
3038
+ // sentence goes before the move's command (below).
3039
+ () =>
3040
+ checkLayout(proj, undefined, {
3041
+ level: "info",
3042
+ restamp: findings.some((f) => f.level === "info" && f.check === "upgrade"),
3043
+ }),
2095
3044
  () => checkEnvModel(),
2096
3045
  () => checkEnvEffort(),
2097
3046
  ];
2098
- const findings = [];
2099
3047
  for (const step of steps) {
2100
3048
  if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
2101
- try { findings.push(...step()); } catch {}
3049
+ try {
3050
+ findings.push(...step());
3051
+ } catch {}
2102
3052
  }
2103
3053
  // The findings the move repairs are not counted beside it (foldIntoMove).
2104
3054
  const settled = foldIntoMove(findings);
@@ -2106,12 +3056,13 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2106
3056
  // re-stamps the launcher at its new path (the layout spec, contract 7 as
2107
3057
  // amended 2026-10-03), and an in-session `doctor --fix` would write that
2108
3058
  // launcher and then stop at the deferred move with exit 1. So the line names
2109
- // one step, and says what it covers when the dropped offer would have fired.
3059
+ // one step, and says what it covers when the dropped offer would have fired:
3060
+ // checkLayout's `restamp`, a sentence before the move's command, so the
3061
+ // command still ends the offer, alone on its line.
2110
3062
  const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
2111
- const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
2112
3063
  const offers = settled
2113
3064
  .filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
2114
- .map((f) => (movePending && restamp && f.check === "layout-legacy" ? `${f.message} The same run re-stamps the status line launcher.` : f.message));
3065
+ .map((f) => f.message);
2115
3066
  return {
2116
3067
  skipped: false,
2117
3068
  count: settled.filter((f) => f.level === "issue").length,
@@ -2122,34 +3073,16 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2122
3073
 
2123
3074
  // ─── CLI ───────────────────────────────────────────────────────────────
2124
3075
 
2125
- function icon(level) {
2126
- return level === "issue" ? "✖" : level === "warn" ? "⚠" : "ℹ";
2127
- }
2128
-
2129
- function report(findings, groups) {
2130
- const ver = pluginVersion();
2131
- const lines = [`projectstore doctor — plugin v${ver || "?"}, ${new Date().toISOString().slice(0, 10)}`];
2132
- for (const g of groups) {
2133
- const fs = findings.filter((f) => f.group === g);
2134
- lines.push("", `## ${g} (${fs.filter((f) => f.level === "issue").length} issue(s), ${fs.filter((f) => f.level === "warn").length} warning(s))`);
2135
- if (!fs.length) lines.push(" ✓ clean");
2136
- for (const f of fs) {
2137
- lines.push(` ${icon(f.level)} [${f.check}] ${f.message}${f.file ? ` — ${f.file}` : ""}`);
2138
- }
2139
- }
2140
- const issues = findings.filter((f) => f.level === "issue").length;
2141
- const warns = findings.filter((f) => f.level === "warn").length;
2142
- lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
2143
- return lines.join("\n");
2144
- }
2145
-
2146
3076
  async function main() {
2147
3077
  const args = process.argv.slice(2);
2148
3078
  const wantJson = args.includes("--json");
2149
3079
  const startup = args.includes("--startup");
2150
3080
  let install = args.includes("--install");
2151
3081
  let vault = args.includes("--vault");
2152
- if (!install && !vault && !startup) { install = true; vault = true; }
3082
+ if (!install && !vault && !startup) {
3083
+ install = true;
3084
+ vault = true;
3085
+ }
2153
3086
 
2154
3087
  const cfg = readConfig();
2155
3088
  const proj = projectRoot();
@@ -2162,7 +3095,10 @@ async function main() {
2162
3095
 
2163
3096
  const findings = [];
2164
3097
  const groups = [];
2165
- if (install) { groups.push("install"); findings.push(...await runInstallChecks(cfg, proj)); }
3098
+ if (install) {
3099
+ groups.push("install");
3100
+ findings.push(...(await runInstallChecks(cfg, proj)));
3101
+ }
2166
3102
  if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
2167
3103
  groups.push("vault");
2168
3104
  findings.push(...runVaultChecks(cfg));
@@ -2171,7 +3107,35 @@ async function main() {
2171
3107
  findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
2172
3108
  }
2173
3109
 
2174
- process.stdout.write((wantJson ? JSON.stringify(findings, null, 2) : report(findings, groups)) + "\n");
3110
+ if (wantJson) {
3111
+ process.stdout.write(JSON.stringify(findings, null, 2) + "\n");
3112
+ return;
3113
+ }
3114
+ process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
3115
+ }
3116
+
3117
+ // The text is the bin's renderer's, in its plain layout (the presentation
3118
+ // spec, contract 11) — one report, whoever prints it. Imported at run time,
3119
+ // and nowhere else in this file: the SessionStart hook imports this module
3120
+ // statically, and the renderer's term.mjs is not on its path. A renderer
3121
+ // that cannot load, or throws, costs the reader nothing: the findings one
3122
+ // per line and the Summary line, still last (lib.mjs). `load` is a test's.
3123
+ export async function textReport(
3124
+ findings,
3125
+ groups,
3126
+ { version = null, project = null, env = process.env, stderr = process.stderr } = {},
3127
+ { load = () => import("./doctor-report.mjs") } = {},
3128
+ ) {
3129
+ try {
3130
+ const { report } = await load();
3131
+ return report(findings, groups, { version, project, env });
3132
+ } catch (e) {
3133
+ if (stderr)
3134
+ stderr.write(
3135
+ `doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`,
3136
+ );
3137
+ return doctorFallbackText(findings, { env });
3138
+ }
2175
3139
  }
2176
3140
 
2177
3141
  if (isMain(import.meta.url)) {