projectstore-claude 0.29.3 → 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 (53) hide show
  1. package/bin/projectstore-claude.mjs +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  4. package/node_modules/projectstore/README.md +17 -0
  5. package/node_modules/projectstore/commands/bind.md +6 -17
  6. package/node_modules/projectstore/commands/doctor.md +12 -7
  7. package/node_modules/projectstore/commands/statusline.md +1 -1
  8. package/node_modules/projectstore/commands/story.md +15 -2
  9. package/node_modules/projectstore/docs/how-it-works.md +5 -3
  10. package/node_modules/projectstore/harnesses/claude-code.json +5 -5
  11. package/node_modules/projectstore/harnesses/codex.json +4 -5
  12. package/node_modules/projectstore/hooks/band.tsx +48 -20
  13. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  14. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  15. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  16. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  17. package/node_modules/projectstore/package.json +11 -2
  18. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  19. package/node_modules/projectstore/scripts/band.mjs +72 -21
  20. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  21. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  22. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  23. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  24. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  25. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  26. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  27. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  28. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  29. package/node_modules/projectstore/scripts/fetch-shell.mjs +106 -20
  30. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  31. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  32. package/node_modules/projectstore/scripts/install-harness.mjs +3511 -616
  33. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  34. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  35. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  37. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  38. package/node_modules/projectstore/scripts/query.mjs +378 -80
  39. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  40. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  41. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  42. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  43. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  44. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  45. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  46. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  47. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  48. package/node_modules/projectstore/scripts/term.mjs +382 -97
  49. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  50. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  51. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  52. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  53. package/package.json +2 -2
@@ -17,15 +17,7 @@
17
17
  // Without --json the findings are drawn by doctor-report.mjs, the
18
18
  // renderer the bin uses, in its plain layout.
19
19
 
20
- import {
21
- existsSync,
22
- readFileSync,
23
- readdirSync,
24
- statSync,
25
- accessSync,
26
- constants,
27
- realpathSync,
28
- } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, realpathSync } from "node:fs";
29
21
  import { join, basename, resolve, dirname, relative } from "node:path";
30
22
  import { homedir } from "node:os";
31
23
  import { spawnSync } from "node:child_process";
@@ -45,7 +37,6 @@ import {
45
37
  statusLineIsOurWiring,
46
38
  isLegacyStory,
47
39
  sectionOf,
48
- headingLineRe,
49
40
  indexHeaderRe,
50
41
  evidenceSuffixRe,
51
42
  storiesAttributionRe,
@@ -68,17 +59,23 @@ import {
68
59
  findAgentsBlock,
69
60
  statusLineLauncherPath,
70
61
  LAUNCHER_HEADER,
71
- installedPluginEntries, selectCopy, takesNoRegister, copyUpdateAdvice, isMain,
62
+ installedPluginEntries,
63
+ selectCopy,
64
+ takesNoRegister,
65
+ copyUpdateAdvice,
66
+ isMain,
72
67
  channelMark,
73
68
  pickExisting,
74
69
  legacyStatusLineLauncherPath,
75
70
  stateDir,
76
71
  legacyStateDir,
77
- layoutPaths, sessionsDir,
72
+ layoutPaths,
73
+ sessionsDir,
78
74
  isLauncherPath,
79
75
  LAYOUT,
80
76
  hostSettingsPath,
81
- readOverlayAt, layoutRoster,
77
+ readOverlayAt,
78
+ layoutRoster,
82
79
  installChannel,
83
80
  cmpPrecedence,
84
81
  cmpVersion,
@@ -89,8 +86,19 @@ import {
89
86
  inheritForm,
90
87
  bindInherits,
91
88
  doctorFallbackText,
89
+ fromTerminalIn,
92
90
  } from "./lib.mjs";
93
- 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";
94
102
 
95
103
  // A remedy used to interpolate the surface's harness variable here. It cannot:
96
104
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -167,9 +175,7 @@ export function aboutHarness(f, h, speaker = speakingHarness()) {
167
175
 
168
176
  function pluginVersion(root = pluginRoot()) {
169
177
  try {
170
- return JSON.parse(
171
- readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"),
172
- ).version;
178
+ return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
173
179
  } catch {
174
180
  return null;
175
181
  }
@@ -196,19 +202,43 @@ export function checkConfig(cfg, proj = projectRoot()) {
196
202
  // new one. Assigned rather than appended: two instructions for one problem
197
203
  // is how a person ends up binding a second vault by hand.
198
204
  let b = null;
199
- 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).
200
211
  if (b && b.state === "inheritable") {
201
- return [finding("install", "issue", "worktree-unbound",
202
- `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
+ ];
203
220
  }
204
221
  const p = layoutPaths(proj);
205
222
  const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
206
223
  if (present) {
207
- return [finding("install", "issue", "config-unparseable",
208
- `${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
+ ];
209
233
  }
210
- return [finding("install", "issue", "config",
211
- `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
+ ];
212
242
  }
213
243
  const out = [];
214
244
  if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
@@ -272,8 +302,14 @@ export function checkLayoutTemplates(cfg) {
272
302
  out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
273
303
  }
274
304
  if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
275
- out.push(finding("install", "issue", "templates",
276
- "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
+ );
277
313
  }
278
314
  return out;
279
315
  }
@@ -281,16 +317,34 @@ export function checkLayoutTemplates(cfg) {
281
317
  export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
282
318
  const dir = sessionsDir(cfg.vault_path);
283
319
  if (!existsSync(dir)) {
284
- return [finding("install", "warn", "hooks",
285
- "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
+ ];
286
328
  }
287
329
  const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
288
330
  const fresh = readdirSync(dir).some((n) => {
289
331
  if (!n.endsWith(".json")) return false;
290
- 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
+ }
291
337
  });
292
- return fresh ? [] : [finding("install", "warn", "hooks",
293
- `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
+ ];
294
348
  }
295
349
 
296
350
  // Which plugin version a wired statusLine script belongs to. Null for our
@@ -299,9 +353,7 @@ export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
299
353
  export function statusLineScriptVersion(scriptPath) {
300
354
  try {
301
355
  const root = dirname(dirname(scriptPath)); // <root>/scripts/statusline.mjs
302
- return (
303
- JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null
304
- );
356
+ return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null;
305
357
  } catch {
306
358
  return null;
307
359
  }
@@ -331,20 +383,38 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
331
383
 
332
384
  if (st && st.enabled === true) {
333
385
  if (!curCmd) {
334
- out.push(finding("install", "issue", "statusline",
335
- `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
+ );
336
394
  } else if (!isOurs) {
337
- out.push(finding("install", "issue", "statusline",
338
- "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
+ );
339
403
  } else {
340
404
  const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
341
405
  const wiredRoot = m ? dirname(dirname(m[1])) : null;
342
406
  const isLauncher = m ? isLauncherPath(m[1]) : false;
343
407
  if (m && !existsSync(m[1])) {
344
- out.push(finding("install", "issue", "statusline",
345
- isLauncher
346
- ? `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.`
347
- : `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
+ );
348
418
  } else if (m && isPluginCacheRoot(wiredRoot, home)) {
349
419
  // Only a versioned cache path can go stale this way. The launcher
350
420
  // carries no version, and a dev checkout is wired deliberately and
@@ -352,23 +422,48 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
352
422
  const wired = statusLineScriptVersion(m[1]);
353
423
  const inst = installedPluginRoot(home, dirname(wiredRoot));
354
424
  if (wired && inst && inst.version && wired !== inst.version) {
355
- out.push(finding("install", "warn", "statusline",
356
- `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
+ );
357
433
  }
358
434
  }
359
435
  }
360
436
  } else if (st && st.enabled === false && isOurs) {
361
- out.push(finding("install", "warn", "statusline",
362
- "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
+ );
363
445
  } else if ((!st || typeof st.enabled !== "boolean") && isOurs) {
364
- out.push(finding("install", "info", "statusline",
365
- "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
+ );
366
454
  }
367
455
 
368
456
  try {
369
457
  const base = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"))?.statusLine?.command;
370
458
  if (base && !statusLineIsOurs(base)) {
371
- 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
+ );
372
467
  }
373
468
  } catch {}
374
469
  // session_id divergence (ADR-006): the renderer's breadcrumb names the id
@@ -383,8 +478,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
383
478
  .filter((n) => n.endsWith(".json") && !n.startsWith("."))
384
479
  .map((n) => n.replace(/\.json$/, ""));
385
480
  if (hookIds.length && !hookIds.includes(bc.session_id)) {
386
- out.push(finding("install", "warn", "statusline",
387
- `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
+ );
388
489
  }
389
490
  }
390
491
  // What the user is actually looking at: the renderer stamps the version
@@ -393,8 +494,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
393
494
  if (bc && bc.version && isPluginCacheRoot(bc.root, home)) {
394
495
  const inst = installedPluginRoot(home, dirname(bc.root));
395
496
  if (inst && inst.version && inst.version !== bc.version) {
396
- out.push(finding("install", "warn", "statusline",
397
- `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
+ );
398
505
  }
399
506
  }
400
507
  } catch {}
@@ -416,15 +523,27 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
416
523
  // session with a command that cannot clear it (the critic of the layout
417
524
  // spec's 2026-10-03 amendment, case S1).
418
525
  let wired = null;
419
- 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 {}
420
529
  if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
421
530
  const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
422
531
  let text;
423
- try { text = readFileSync(lp, "utf8"); } catch { return []; }
532
+ try {
533
+ text = readFileSync(lp, "utf8");
534
+ } catch {
535
+ return [];
536
+ }
424
537
  if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
425
- return [finding("install", "info", "upgrade",
426
- `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.`,
427
- 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
+ ];
428
547
  }
429
548
 
430
549
  // The two findings the layout move itself repairs carry what they say without
@@ -449,7 +568,11 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
449
568
  const p = join(proj, name);
450
569
  if (!existsSync(p)) continue;
451
570
  let text;
452
- try { text = readFileSync(p, "utf8"); } catch { continue; }
571
+ try {
572
+ text = readFileSync(p, "utf8");
573
+ } catch {
574
+ continue;
575
+ }
453
576
  texts[name] = text;
454
577
  const f = findAgentsBlock(text);
455
578
  if (!f) continue;
@@ -457,16 +580,30 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
457
580
  blocks += f.count;
458
581
  if (f.wrapped) {
459
582
  wrappedFiles++;
460
- out.push(finding("install", "issue", "agents-block",
461
- `${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
+ );
462
592
  continue;
463
593
  }
464
594
  if (f.unclosed) {
465
595
  // Named here as the wrapped marker is, so the startup count carries it:
466
596
  // the agents-block plan refuses it, and the layout move with it.
467
597
  unclosedFiles++;
468
- out.push(finding("install", "issue", "agents-block",
469
- `${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
+ );
470
607
  }
471
608
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
472
609
  const v = parseInt(m[1], 10);
@@ -474,8 +611,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
474
611
  }
475
612
  }
476
613
  if (blocks === 0) {
477
- out.push(finding("install", "info", "agents-block",
478
- `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
+ );
479
622
  }
480
623
  if (blocks > 1) {
481
624
  // One block in each file is a state install resolves (it keeps the
@@ -484,13 +627,26 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
484
627
  // advice is withheld while one is wrapped or never closes.
485
628
  const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
486
629
  if (twiceInOne) {
487
- out.push(finding("install", "issue", "agents-block",
488
- `${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
+ );
489
639
  } else if (wrappedFiles || unclosedFiles) {
490
640
  // already named above, file by file
491
641
  } else {
492
- out.push(finding("install", "warn", "agents-block",
493
- `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
+ );
494
650
  }
495
651
  }
496
652
  // A state the agents-block plan refuses — a wrapped marker, a block that
@@ -499,7 +655,13 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
499
655
  const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
500
656
  for (const s of staleVersions) {
501
657
  const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
502
- 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
+ );
503
665
  out.push(refuses ? f : moveRepairs(f, fact));
504
666
  }
505
667
  // Placement, held to the predicate install plans from (the install spec,
@@ -516,7 +678,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
516
678
  const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
517
679
  if (blockFile) {
518
680
  const identified = identifiedHarnessId(env);
519
- const used = new Set([...detectHarnesses(proj, { state: true }).map((d) => d.id), ...(identified ? [identified] : [])]);
681
+ const used = new Set([
682
+ ...detectHarnesses(proj, { state: true }).map((d) => d.id),
683
+ ...(identified ? [identified] : []),
684
+ ]);
520
685
  for (const id of used) {
521
686
  const m = loadHarness(id);
522
687
  if (!m || blockVisibleTo(m, blockFile, texts)) continue;
@@ -525,9 +690,16 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
525
690
  ? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
526
691
  : `it reads ${(ab.files || []).join(" and ")} only`;
527
692
  const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
528
- // The resolved-root form the surface remedy uses: the running copy's own bin.
529
- const remedy = `install the block for ${m.display_name}: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block --project "${proj}"`;
530
- 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
+ );
531
703
  // The layout's harness is the one the move's command installs for, so the
532
704
  // move plans this import; another harness's is not the move's to repair.
533
705
  out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
@@ -547,10 +719,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
547
719
  // finding leaves here carrying it.
548
720
  export function foldIntoMove(findings) {
549
721
  const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
550
- const step = layout && (layout.check === "layout-two-configs"
551
- ? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
552
- : "the layout move repairs this: run what the layout-legacy finding names");
553
- 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
+ );
554
730
  }
555
731
 
556
732
  // Both scopes are walked. The original reason ("project > user > plugin, so a
@@ -580,24 +756,44 @@ export function foldIntoMove(findings) {
580
756
  // surfaces.mjs needs — out of the SessionStart module graph. The startup
581
757
  // checks never call this.
582
758
  // Read the states once per doctor run; both checks below consume the result.
583
- 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
+ ) {
584
763
  const { surfaceStates, FOREIGN_TEXT } = await import("./surfaces.mjs");
585
764
  return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
586
765
  }
587
766
 
588
- 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
+ ) {
589
779
  const out = [];
590
780
  let r, FOREIGN_TEXT;
591
781
  try {
592
- ({ 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 })));
593
783
  } catch (e) {
594
784
  return [finding("install", "warn", "surface", `Installed-surface states could not be read: ${e && e.message}`)];
595
785
  }
596
786
  // Contract 16: a harness is reported only when the project uses it; when
597
787
  // none does and nothing of ours is installed, one line names what can be.
598
788
  if (!r.used.length) {
599
- out.push(finding("install", "info", "harness",
600
- `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
+ );
601
797
  return out;
602
798
  }
603
799
  for (const s of r.states) {
@@ -607,15 +803,35 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
607
803
  // steps in its forms, and no status-line hint where it has no status line.
608
804
  // A shared surface — the agents block, one file every harness owns — is
609
805
  // every harness's: never marked, its step in the listener's form.
610
- const sh = s.kind === "shared" ? speaker : (loadHarness(s.harness) || speaker);
806
+ const sh = s.kind === "shared" ? speaker : loadHarness(s.harness) || speaker;
611
807
  const push = (f) => out.push(aboutHarness(f, sh, speaker));
612
808
  if (s.kind === "exclusive") {
613
809
  if (s.state === "foreign") {
614
- push(finding("install", "issue", "surface-foreign",
615
- `${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
+ );
616
819
  } else if (s.state === "stale" && s.produced) {
617
- const status = sh?.surfaces?.statusline?.supported === false ? "" : ` (for the status line, ${invocation(sh, "statusline", { args: "on" })})`;
618
- 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
+ );
619
835
  } else if (s.state === "stale" && !s.produced) {
620
836
  push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
621
837
  } else if (s.state === "current" && s.writtenBy && !s.sameProject) {
@@ -630,7 +846,15 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
630
846
  if (s.state === "unparseable") {
631
847
  push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
632
848
  } else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
633
- 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
+ );
634
858
  }
635
859
  }
636
860
  // The statusline entry's states are checkStatusline's, under its own id.
@@ -651,8 +875,26 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
651
875
  const out = [];
652
876
  const o = readOverlayAt(proj);
653
877
  const where = relative(proj, o.path);
654
- 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));
655
- 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
+ );
656
898
  if (cfg && cfg.agents && typeof cfg.agents === "object") {
657
899
  // The binding that carries the block is the one the reader found — before
658
900
  // the migration that is the legacy path, and naming the new one would send
@@ -664,7 +906,19 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
664
906
  const remedy = layoutRemedy(proj, { root, home });
665
907
  // The block and its move are the source harness's — a pre-0.28 binding
666
908
  // and that harness's command — so its fact under any other listener.
667
- 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
+ );
668
922
  }
669
923
  // A project can be used from more than one harness, and a model name is
670
924
  // harness-specific (ADR-008) — so each one has its own overlay and they do
@@ -684,12 +938,18 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
684
938
  if (configured.length) {
685
939
  for (const { id, o } of overlays) {
686
940
  if (o.present) continue;
687
- out.push(finding("install", "info", "overlay-absent",
688
- `This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — `
689
- + `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} `
690
- + `${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). `
691
- + `Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
692
- 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
+ );
693
953
  }
694
954
  }
695
955
  }
@@ -699,7 +959,16 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
699
959
  const roster = layoutRoster(cfg);
700
960
  if (roster) {
701
961
  for (const n of Object.keys(o.agents.per_agent)) {
702
- 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
+ );
703
972
  }
704
973
  }
705
974
  return out;
@@ -708,25 +977,39 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
708
977
  // Contract 4′ (2026-09-05): the registration the package made through the
709
978
  // host's CLI — one row per registration surface the manifest declares. Current
710
979
  // → one info; stale → an issue naming the refresh in the form that can make
711
- // it (the package's own bin via npx: a cache install never re-registers
712
- // itself); a competing copy enabled beside ours → an issue (two enabled copies
713
- // of one plugin); a competitor alone → an info naming the npm path; foreign →
714
- // never repairable; the host CLI missing → an info.
715
- export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness(), cfg = null } = {}) {
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
+ ) {
716
999
  const out = [];
717
1000
  // A checkout whose binding records a channel switch (the converge story's
718
1001
  // rule 3) chose the npm install: the info that ends "If moving to npm was
719
- // meant, ignore this" is the one thing it no longer needs. Doctor never
1002
+ // meant, record it…" is the one thing it no longer needs. Doctor never
720
1003
  // reads who made the mark.
721
1004
  const marked = Boolean(channelMark(cfg));
722
1005
  for (const s of states.filter((x) => x.kind === "registration")) {
723
- // The shell form when the manifest names a shell (contract 12): the
724
- // 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.
725
1008
  const h = loadHarness(s.harness) || { id: s.harness };
726
1009
  // A registration of ANOTHER harness in the same project is that
727
1010
  // harness's fact: named, marked, its steps in its own forms (aboutHarness).
728
1011
  const push = (f) => out.push(aboutHarness(f, h, speaker));
729
- 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}` });
730
1013
  // A copy this registration silenced for the checkout and the checkout
731
1014
  // still holds off, one per key (the install spec, contract 13 as amended
732
1015
  // after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
@@ -736,44 +1019,117 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
736
1019
  if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
737
1020
  // The row this checkout loads: its own local-scope row first, then a
738
1021
  // user-scope one — never another checkout's (layoutRemedy's rule).
739
- 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
+ };
740
1029
  const here = real(proj);
741
- 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
+ );
742
1033
  for (const key of s.silenced) {
743
- 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];
744
1037
  if (!row) {
745
- 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
+ );
746
1047
  continue;
747
1048
  }
748
1049
  if (marked) continue;
749
1050
  // The release line, not the build: a 0.28 release candidate reads a
750
1051
  // moved project; 0.27.x reads it as unbound. No version reads as old.
751
1052
  const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
752
- push(finding("install", "info", "plugin-registration",
753
- `${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
+ );
754
1069
  }
755
1070
  }
756
1071
  const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
757
1072
  if (s.state === "foreign") {
758
- 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
+ );
759
1082
  } else if (s.state === "unavailable") {
760
- 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
+ );
761
1091
  } else if (s.state === "absent") {
762
1092
  // A git-marketplace install alone is not a finding: a permanent info
763
1093
  // advertising the npm path to every marketplace user is noise (2026-09-05).
764
1094
  } else if (s.state === "stale") {
765
- 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
+ );
766
1104
  } else if (s.state === "current") {
767
1105
  // The host loads a host plugin registration in place, from the
768
1106
  // plugin's root inside our marketplace directory (the payload — what
769
1107
  // its plugin-root variable names); the install path is the registry's
770
1108
  // copy, the one the launcher renders (measured on host 2.1.293 and
771
1109
  // 2.1.296). A portable registration's host loads the copy.
772
- const loaded = h.surfaces?.[s.surface]?.format === "host-plugin-registration" && s.payload
773
- ? `install path ${s.installPath}; loaded in place from ${s.payload}`
774
- : `loaded from ${s.installPath}`;
775
- 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));
776
- else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (${loaded}); refresh with ${refresh}.`));
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
+ );
777
1133
  }
778
1134
  }
779
1135
  return out;
@@ -794,10 +1150,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
794
1150
  const registryOf = sourceHarness()?.id || null;
795
1151
  for (const e of installedPluginEntries(home, proj)) {
796
1152
  // A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
797
- 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
+ });
798
1159
  }
799
1160
  for (const s of states) {
800
- 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 });
801
1163
  }
802
1164
  const versions = new Set(seen.map((x) => x.version));
803
1165
  if (versions.size < 2) return [];
@@ -807,9 +1169,22 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
807
1169
  // for copies of several harnesses, the listener — has one.
808
1170
  const owners = [...new Set(seen.map((x) => x.harness))];
809
1171
  const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
810
- const launcher = (subject || speaker)?.surfaces?.statusline?.supported === false ? "" : "; the launcher renders whichever is registered";
811
- return [aboutHarness(finding("install", "warn", "version-drift",
812
- `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
+ ];
813
1188
  }
814
1189
 
815
1190
  export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
@@ -826,7 +1201,11 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
826
1201
  for (const { dir, label, scope } of scopes) {
827
1202
  for (const f of listMd(dir)) {
828
1203
  let text;
829
- try { text = readFileSync(join(dir, f), "utf8"); } catch { continue; }
1204
+ try {
1205
+ text = readFileSync(join(dir, f), "utf8");
1206
+ } catch {
1207
+ continue;
1208
+ }
830
1209
  const m = text.match(/#\s*source:\s*projectstore\s+v(\S+)/);
831
1210
  const { data } = parseFrontmatter(text);
832
1211
  // A provenance-marked copy with no `name:` still deserves a usable
@@ -845,14 +1224,27 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
845
1224
  // without a provenance marker we cannot prove lineage — hedge FIRST,
846
1225
  // not 40 words in, or the false premise leads for user-authored agents
847
1226
  const lead = m ? `${f}` : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it`;
848
- out.push(finding("install", m ? "warn" : "info", "override-copies",
849
- `${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}`,
850
- 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
+ );
851
1236
  continue; // a stale-version note on top would just be noise
852
1237
  }
853
1238
  if (!CURRENT_AGENT_NAMES.includes(name)) {
854
- out.push(finding("install", "warn", "override-copies",
855
- `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
+ );
856
1248
  continue;
857
1249
  }
858
1250
  // ADR-008. A current-name copy overrides nothing either: the bundled agent
@@ -862,9 +1254,7 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
862
1254
  // refreshing such a copy fixes nothing; that is why it replaces the old
863
1255
  // "frozen at vX — re-run configure" advice instead of sitting beside it.
864
1256
  // Staleness is demoted to a parenthetical: still true, no longer the point.
865
- const stale = m && ver && m[1] !== ver
866
- ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)`
867
- : "";
1257
+ const stale = m && ver && m[1] !== ver ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)` : "";
868
1258
  // Without a marker we cannot prove lineage — hedge FIRST, and never issue
869
1259
  // a delete imperative against a file the user may well have written.
870
1260
  const lead = m
@@ -873,15 +1263,22 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
873
1263
  // `configure` only ever touched PROJECT scope, so pointing a user-scope
874
1264
  // copy at it would name a command that will not act — the scope split
875
1265
  // fca8def introduced for staleness applies here for the same reason.
876
- const remove = scope === "user"
877
- ? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
878
- : `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.`;
879
1270
  const advice = m
880
1271
  ? remove
881
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.`;
882
- out.push(finding("install", m ? "warn" : "info", "override-copies",
883
- `${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
884
- 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
+ );
885
1282
  }
886
1283
  }
887
1284
  return out;
@@ -895,60 +1292,130 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
895
1292
  // The variable names come from the manifest (runtime.agent_overrides); the
896
1293
  // wording is projectstore's, because the claim — ADR-008's quality floor — is.
897
1294
  export function checkEnvEffort() {
898
- return agentOverrides().filter((o) => o.kind === "effort").map((o) => finding("install", "warn", "env-effort",
899
- `${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
+ );
900
1305
  }
901
1306
 
902
1307
  export function checkEnvModel() {
903
- return agentOverrides().filter((o) => o.kind === "model").map((o) => finding("install", "warn", "env-model",
904
- `${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
+ );
905
1318
  }
906
1319
 
907
1320
  // The project-level layout (the layout ADR): a legacy .projectstore/projectstore.json
908
1321
  // or .projectstore/state/ is one warn naming the upgrade; two bindings is an
909
1322
  // issue. Cheap — a handful of existsSync — so the startup line carries the
910
1323
  // warn as an offer (OFFER_CHECKS).
911
- 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
+ ) {
912
1332
  const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
913
- const legacyBinding = existsSync(p.legacy.binding), legacyRuntime = existsSync(p.legacy.runtime);
1333
+ const legacyBinding = existsSync(p.legacy.binding),
1334
+ legacyRuntime = existsSync(p.legacy.runtime);
914
1335
  let resumable = false;
915
1336
  if (legacyBinding && existsSync(p.binding)) {
916
- 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 {}
917
1341
  }
918
1342
  if (legacyBinding && existsSync(p.binding) && !resumable) {
919
- 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
+ ];
920
1352
  }
921
1353
  if (!legacyBinding && !legacyRuntime && !existsSync(p.legacy.welcomed) && !existsSync(p.legacy.sessionId)) return [];
922
1354
  // The command in the channel of the copy the project runs (the layout spec,
923
1355
  // contract 12 as amended 2026-10-03; maintainer decision the same day). The
924
- // package's shell registers the plugin through its own channel: run for a
925
- // git-marketplace user it added projectstore@projectstore-npm at local scope
926
- // and turned the git copy off for the checkout (reproduced with the suite's
927
- // fake host), and without the host CLI on PATH it stopped part-way with
928
- // exit 1. Any other copy runs its own bin with --no-register, which leaves
929
- // the registration out of the plan — no host command by construction, not by
930
- // 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).
931
1366
  const remedy = layoutRemedy(proj, { root, home, harness });
932
- 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(", ");
933
1375
  // The files sit in that harness's legacy directory and the move is its
934
1376
  // command: its fact under any other listener (aboutHarness).
935
- return [aboutHarness(finding("install", level, "layout-legacy",
936
- `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).`,
937
- 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
+ ];
938
1398
  }
939
1399
 
940
1400
  // The one command that moves this project's files (the layout spec, contract
941
1401
  // 12 as amended 2026-10-03), in the channel of the copy the project runs. A
942
1402
  // session runs that copy, so its root answers: the package's own registration
943
- // takes the shell, any other host copy its own bin. A run from anywhere else —
944
- // a terminal `npx projectstore doctor`, a checkout — asks the host's registry,
945
- // counting only rows this project loads: its own local-scope row first, then
946
- // user-scope rows, never another checkout's. With none enabled, a package root
947
- // takes the shell and a checkout its own bin. BOTH forms carry --no-register:
948
- // the move never needs the registration, so a misread channel can cost a
949
- // launcher stamp but never a channel switch. A copy that predates the move
950
- // (no bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first.
951
- // Nor can one whose CLI predates --no-register (lib's 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).
1415
+ //
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).
952
1419
  //
953
1420
  // The selection, the capability read and the copy advice are lib's
954
1421
  // (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
@@ -956,16 +1423,34 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
956
1423
  // re-exported here, where its callers already import it.
957
1424
  export { takesNoRegister };
958
1425
 
959
- export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
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
+ ) {
960
1441
  // A session under a relocated host home hands it on: a terminal without it
961
1442
  // would classify the copy as a checkout, render no launcher and never clear
962
1443
  // the offer (the second review of the 2026-10-03 fixes, S2).
963
1444
  const homeVar = harness?.runtime?.home_env;
964
1445
  const prefix = homeVar && env[homeVar] ? `${homeVar}="${env[homeVar]}" ` : "";
965
- const shell = (version) => ({ command: prefix + packageCommand(harness, "upgrade", { version: version || "latest", args: `--no-register --project "${proj}"` }) });
966
- 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
+ });
967
1452
  const channel = installChannel(root, { home, harness });
968
- if (channel === "registration") return shell(pluginVersion(root));
1453
+ if (channel === "registration") return npm(pluginVersion(root));
969
1454
  if (channel === "marketplace") return own(root);
970
1455
  // The selection is lib's selectCopy: this checkout's row first, then
971
1456
  // user-scope rows by recency, never another checkout's.
@@ -973,21 +1458,27 @@ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harn
973
1458
  if (copy) {
974
1459
  const v = copy.version ? ` (${copy.version})` : "";
975
1460
  const then = "and the startup line names the command";
976
- if (copy.missing === "bin") return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
1461
+ if (copy.missing === "bin")
1462
+ return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
977
1463
  // Chosen first, gated after: the copy is what the project runs, so an
978
1464
  // incapable one gets advice even beside a capable user-scope copy.
979
1465
  const viaRegistration = copy.channel === "registration";
980
1466
  if (copy.missing === "no-register") {
981
- return { advice: viaRegistration
982
- // Its channel's ordinary refresh, at the running version: the plain
983
- // upgrade re-registers from npm — not a switch for an npm project — and
984
- // moves the project in the same run.
985
- ? `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}"` })}`
986
- : copyUpdateAdvice(copy, { cannot: "predates --no-register and cannot run the move as named", then }) };
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
+ };
987
1478
  }
988
- return viaRegistration ? shell(copy.version) : own(copy.path);
1479
+ return viaRegistration ? npm(copy.version) : own(copy.path);
989
1480
  }
990
- return channel === "package" ? shell(pluginVersion(root)) : own(root);
1481
+ return channel === "package" ? npm(pluginVersion(root)) : own(root);
991
1482
  }
992
1483
 
993
1484
  export function checkGitignore(proj) {
@@ -998,31 +1489,61 @@ export function checkGitignore(proj) {
998
1489
  const p = layoutPaths(proj);
999
1490
  if (existsSync(p.root)) {
1000
1491
  let lines = null;
1001
- 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 {}
1002
1497
  if (lines === null) {
1003
1498
  // Contract 5's ignore file is what keeps a machine-local binding — with
1004
1499
  // an absolute vault path — out of a commit. Until 2026-09-06 bind never
1005
1500
  // wrote it, so a project bound and committed before its first session
1006
1501
  // carried one; doctor reasoned from the self-ignoring without ever
1007
1502
  // checking it.
1008
- out.push(finding("install", "warn", "gitignore",
1009
- `.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
+ );
1010
1512
  } else {
1011
- 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
+ );
1012
1523
  else if (!lines.includes("*")) {
1013
1524
  // A lone "*" is the pre-2026-09-06 vault-that-is-a-project shape: it
1014
1525
  // ignores the binding and state/ already, and only hides harness/ when
1015
1526
  // that directory exists — which the branch above is for.
1016
1527
  const short = [...LAYOUT.gitignore].filter((l) => !lines.includes(l));
1017
- if (short.length) out.push(finding("install", "warn", "gitignore",
1018
- `.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
+ );
1019
1538
  }
1020
1539
  }
1021
1540
  }
1022
1541
  if (!existsSync(join(proj, ".git"))) return out;
1023
1542
  let lines = [];
1024
1543
  try {
1025
- 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());
1026
1547
  } catch {}
1027
1548
  const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
1028
1549
  if (coveredAll) return out;
@@ -1030,8 +1551,14 @@ export function checkGitignore(proj) {
1030
1551
  const wanted = [relative(proj, hostSettingsPath(proj))];
1031
1552
  const missing = wanted.filter((w) => !lines.includes(w));
1032
1553
  if (!missing.length) return out;
1033
- out.push(finding("install", "warn", "gitignore",
1034
- `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
+ );
1035
1562
  return out;
1036
1563
  }
1037
1564
 
@@ -1045,16 +1572,32 @@ export function checkTrackedRuntime(proj) {
1045
1572
  const want = [relative(proj, p.binding), relative(proj, p.state)];
1046
1573
  const r = spawnSync("git", ["ls-files", "--", ...want], { cwd: proj, encoding: "utf8", timeout: 5000 });
1047
1574
  if (r.status !== 0 || !r.stdout) return [];
1048
- 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);
1049
1579
  if (!tracked.length) return [];
1050
- return [finding("install", "warn", "gitignore-tracked",
1051
- `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
+ ];
1052
1589
  }
1053
1590
 
1054
1591
  export function checkVaultGit(cfg) {
1055
1592
  if (existsSync(join(cfg.vault_path, ".git"))) return [];
1056
- return [finding("install", "warn", "vault-git",
1057
- "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
+ ];
1058
1601
  }
1059
1602
 
1060
1603
  // Marketplace auto-update (maintainer request 2026-07-03): third-party
@@ -1080,12 +1623,20 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1080
1623
  // session. So fall back to the registered install when our own path says
1081
1624
  // nothing, and never claim --plugin-dir as a conclusion.
1082
1625
  const marketplaceOf = (p) =>
1083
- (String(p || "").replace(/\\/g, "/").match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
1626
+ (String(p || "")
1627
+ .replace(/\\/g, "/")
1628
+ .match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
1084
1629
  const inst = installedPluginRoot(home);
1085
1630
  const marketplace = marketplaceOf(pluginRoot()) || marketplaceOf(inst && inst.path);
1086
1631
  if (!marketplace) {
1087
- out.push(finding("install", "info", "auto-update",
1088
- "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
+ );
1089
1640
  return out;
1090
1641
  }
1091
1642
 
@@ -1099,8 +1650,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1099
1650
  // verb, reported by checkPluginRegistration (2026-09-05).
1100
1651
  if (entry && entry.source && entry.source.source === "directory") return out;
1101
1652
  if (!entry) {
1102
- out.push(finding("install", "warn", "auto-update",
1103
- `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
+ );
1104
1661
  return out;
1105
1662
  }
1106
1663
 
@@ -1112,11 +1669,17 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1112
1669
  } catch {}
1113
1670
  }
1114
1671
  if (!enabled) {
1115
- out.push(finding("install", "warn", "auto-update",
1116
- `Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
1117
- `Correct values: "autoUpdate": true on the "${marketplace}" entry in ~/.claude/plugins/known_marketplaces.json ` +
1118
- `(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
1119
- `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
+ );
1120
1683
  }
1121
1684
 
1122
1685
  // Bonus: the marketplace checkout's catalog knows the latest released
@@ -1132,12 +1695,18 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
1132
1695
  const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
1133
1696
  const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
1134
1697
  const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
1135
- const running = (named && named.version)
1136
- || 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;
1137
1700
  // Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
1138
1701
  if (latest && running && cmpPrecedence(latest, running) > 0) {
1139
- out.push(finding("install", "warn", "auto-update",
1140
- `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
+ );
1141
1710
  }
1142
1711
  } catch {}
1143
1712
  return out;
@@ -1155,18 +1724,46 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
1155
1724
  const mcp = harness && harness.surfaces && harness.surfaces.mcp;
1156
1725
  if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
1157
1726
  const p = join(root, mcp.file || ".mcp.json");
1158
- 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
+ ];
1159
1736
  let reg;
1160
- 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
+ }
1161
1744
  const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
1162
1745
  const args = server && Array.isArray(server.args) ? server.args : [];
1163
1746
  if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
1164
- 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")];
1165
- }
1166
- 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
+ ];
1167
1765
  }
1168
1766
 
1169
-
1170
1767
  // ─── Vault checks ──────────────────────────────────────────────────────
1171
1768
 
1172
1769
  // Collect every structured artifact with parsed frontmatter.
@@ -1175,7 +1772,11 @@ export function scanArtifacts(cfg, layout) {
1175
1772
  const artifacts = [];
1176
1773
  const push = (abs, rel, kind) => {
1177
1774
  let md;
1178
- try { md = readFileSync(abs, "utf8"); } catch { return; }
1775
+ try {
1776
+ md = readFileSync(abs, "utf8");
1777
+ } catch {
1778
+ return;
1779
+ }
1179
1780
  artifacts.push({ abs, rel, kind, fm: parseFrontmatter(md).data, body: md });
1180
1781
  };
1181
1782
  for (const folder of layout.folders) {
@@ -1217,13 +1818,27 @@ export function checkKanbanSync(cfg) {
1217
1818
  return [finding("vault", "warn", "kanban", `kanban generator failed: ${(r.stderr || "").trim()}`)];
1218
1819
  }
1219
1820
  let expected;
1220
- try { expected = JSON.parse(r.stdout).content; } catch {
1821
+ try {
1822
+ expected = JSON.parse(r.stdout).content;
1823
+ } catch {
1221
1824
  return [finding("vault", "warn", "kanban", "kanban generator returned unparseable output.")];
1222
1825
  }
1223
- 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();
1224
1832
  if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
1225
- return [finding("vault", "issue", "kanban",
1226
- `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
+ ];
1227
1842
  }
1228
1843
  return [];
1229
1844
  }
@@ -1247,28 +1862,51 @@ export function checkIndexes(cfg, layout, artifacts) {
1247
1862
  indexed.add(rel);
1248
1863
  const art = artifacts.find((a) => a.rel === rel);
1249
1864
  if (!art) {
1250
- out.push(finding("vault", "issue", "index",
1251
- `${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
+ );
1252
1874
  continue;
1253
1875
  }
1254
1876
  const fmStatus = (art.fm.status || "").trim();
1255
1877
  if (fmStatus && row.status && fmStatus !== row.status) {
1256
- out.push(finding("vault", "issue", "index",
1257
- `${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
+ );
1258
1887
  }
1259
1888
  const fmTitle = (art.fm.title || "").trim();
1260
1889
  if (fmTitle && row.title && fmTitle !== row.title) {
1261
- out.push(finding("vault", "warn", "index",
1262
- `${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
+ );
1263
1899
  }
1264
1900
  }
1265
1901
  for (const a of artifacts) {
1266
- const inFolder = folder.kind === "epic"
1267
- ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
1268
- : 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)}`;
1269
1906
  if (inFolder && !indexed.has(a.rel)) {
1270
- out.push(finding("vault", "warn", "index",
1271
- `${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
+ );
1272
1910
  }
1273
1911
  }
1274
1912
  }
@@ -1285,7 +1923,10 @@ function linesOutsideFences(s) {
1285
1923
  const out = [];
1286
1924
  let fenced = false;
1287
1925
  for (const line of s.split("\n")) {
1288
- if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
1926
+ if (/^\s*```/.test(line)) {
1927
+ fenced = !fenced;
1928
+ continue;
1929
+ }
1289
1930
  if (!fenced) out.push(line);
1290
1931
  }
1291
1932
  return out;
@@ -1305,23 +1946,39 @@ export function checkStoriesAndEpics(artifacts) {
1305
1946
  const sec = sectionOf(a.body, "acceptance") || "";
1306
1947
  const unchecked = (stripCodeSpans(sec).match(/- \[ \]/g) || []).length;
1307
1948
  if (unchecked > 0) {
1308
- out.push(finding("vault", "warn", "acceptance",
1309
- `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
+ );
1310
1958
  }
1311
1959
  }
1312
1960
  if ((a.fm.review_status || "") === "reviewed" && (!a.fm.reviewed_at || a.fm.reviewed_at === "null")) {
1313
- out.push(finding("vault", "issue", "review-status",
1314
- `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
+ );
1315
1964
  }
1316
1965
  }
1317
1966
  for (const epic of artifacts.filter((a) => a.kind === "epic")) {
1318
1967
  if ((epic.fm.status || "").toLowerCase() !== "done") continue;
1319
1968
  const dir = epic.rel.replace(/\/epic\.md$/, "");
1320
- const open = artifacts.filter((s) =>
1321
- 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
+ );
1322
1972
  if (open.length) {
1323
- out.push(finding("vault", "issue", "epic-status",
1324
- `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
+ );
1325
1982
  }
1326
1983
  }
1327
1984
  return out;
@@ -1339,13 +1996,18 @@ export function checkIndexHeaders(cfg, layout) {
1339
1996
  const readme = join(cfg.vault_path, folder.path, "README.md");
1340
1997
  if (!existsSync(readme)) continue;
1341
1998
  const lines = readFileSync(readme, "utf8").split("\n");
1342
- const headIdx = lines.findIndex((l, i) =>
1343
- /^\|.*\|\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] || ""));
1344
2000
  if (headIdx === -1) continue; // no table at all — nothing to lint
1345
2001
  if (!headerRe.test(lines[headIdx])) {
1346
- out.push(finding("vault", "warn", "index-header",
1347
- `${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).`,
1348
- `${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
+ );
1349
2011
  }
1350
2012
  }
1351
2013
  return out;
@@ -1383,14 +2045,20 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
1383
2045
  if (a.kind !== "story" || epicIdOf(a.rel, epicFolderPath) !== epicId) continue;
1384
2046
  const tier = storyMatchesEntry(storyId, { id: a.fm.id, stem: storyStemOf(a.rel) });
1385
2047
  if (!tier) continue;
1386
- if (best === 0 || tier < best) { best = tier; hits = [a]; }
1387
- 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);
1388
2052
  }
1389
2053
  if (hits.length === 1) return { story: hits[0] };
1390
2054
  if (hits.length > 1) {
1391
- 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
+ };
1392
2058
  }
1393
- 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
+ };
1394
2062
  }
1395
2063
 
1396
2064
  // The ONE spec resolver (SPEC-002 contract 5) — shared by checkSpecLinks,
@@ -1402,7 +2070,7 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
1402
2070
  // grandfathered SPEC-NNN-<slug>.md files. Cross-spec identity clashes are
1403
2071
  // the identity check's finding, so first-wins here stays deterministic.
1404
2072
  function buildSpecResolver(artifacts, layout = null) {
1405
- const prefix = layout ? folderByKind(layout, "spec")?.prefix ?? null : null;
2073
+ const prefix = layout ? (folderByKind(layout, "spec")?.prefix ?? null) : null;
1406
2074
  const byId = new Map();
1407
2075
  const byStem = new Map();
1408
2076
  for (const s of artifacts.filter((a) => a.kind === "spec")) {
@@ -1432,7 +2100,10 @@ export function parseSpecAcceptance(spec) {
1432
2100
  // story, not silently fall through to "applies to every covered story".
1433
2101
  const attr = text.match(attrRe);
1434
2102
  const stories = attr
1435
- ? attr[1].split(",").map((s) => s.trim()).filter(Boolean)
2103
+ ? attr[1]
2104
+ .split(",")
2105
+ .map((s) => s.trim())
2106
+ .filter(Boolean)
1436
2107
  : null;
1437
2108
  items.push({ checked, text, stories });
1438
2109
  }
@@ -1450,14 +2121,27 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1450
2121
  for (const entry of listOf(spec.fm, "stories")) {
1451
2122
  const r = resolveSpecStory(entry, artifacts, epicFolder.path);
1452
2123
  if (r.error) {
1453
- out.push(finding("vault", "issue", "spec-links",
1454
- `Spec "${spec.fm.id}" stories entry "${entry}" does not resolve: ${r.error}.`, spec.rel));
1455
- } else if (spec.fm.id &&
1456
- !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)) {
1457
2134
  // Membership through the SAME resolver — a slug-form back-reference
1458
2135
  // to a grandfathered SPEC-NNN file is a valid link, not a gap.
1459
- out.push(finding("vault", "warn", "spec-links",
1460
- `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
+ );
1461
2145
  }
1462
2146
  }
1463
2147
  }
@@ -1465,19 +2149,32 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1465
2149
  for (const id of listOf(story.fm, "specs")) {
1466
2150
  const spec = resolveSpec(id);
1467
2151
  if (!spec) {
1468
- out.push(finding("vault", "issue", "spec-links",
1469
- `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
+ );
1470
2161
  } else {
1471
2162
  const epicId = epicIdOf(story.rel, epicFolder.path);
1472
2163
  const stem = storyStemOf(story.rel);
1473
2164
  const covered = listOf(spec.fm, "stories").some((e) => {
1474
2165
  const m = String(e).match(/^([^/]+)\/(.+)$/);
1475
- return m && m[1] === epicId &&
1476
- storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
2166
+ return m && m[1] === epicId && storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
1477
2167
  });
1478
2168
  if (!covered) {
1479
- out.push(finding("vault", "warn", "spec-links",
1480
- `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
+ );
1481
2178
  }
1482
2179
  }
1483
2180
  }
@@ -1486,8 +2183,15 @@ export function checkSpecLinks(cfg, layout, artifacts) {
1486
2183
  // the list is invisible to every deterministic check.
1487
2184
  const fmBlock = story.body.match(/^---\n[\s\S]*?\n---/);
1488
2185
  if (story.fm.specs === "" && fmBlock && /\nspecs:\s*\n\s+-\s/.test(fmBlock[0])) {
1489
- out.push(finding("vault", "issue", "spec-links",
1490
- "`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
+ );
1491
2195
  }
1492
2196
  }
1493
2197
  return out;
@@ -1511,8 +2215,15 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
1511
2215
  if (isLegacyStory(story.fm, since)) continue;
1512
2216
  const ids = listOf(story.fm, "specs");
1513
2217
  if (!ids.length) {
1514
- out.push(finding("vault", "issue", "spec-coverage",
1515
- `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
+ );
1516
2227
  continue;
1517
2228
  }
1518
2229
  for (const id of ids) {
@@ -1521,12 +2232,26 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
1521
2232
  const st = specStatusOf(spec);
1522
2233
  if (status === "done") {
1523
2234
  if (!["active", "superseded"].includes(st)) {
1524
- out.push(finding("vault", "issue", "spec-status",
1525
- `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
+ );
1526
2244
  }
1527
2245
  } else if (st === "draft") {
1528
- out.push(finding("vault", "warn", "spec-status",
1529
- `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
+ );
1530
2255
  }
1531
2256
  }
1532
2257
  }
@@ -1557,8 +2282,15 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
1557
2282
  if (!spec) continue;
1558
2283
  const items = parseSpecAcceptance(spec);
1559
2284
  if (items === null) {
1560
- out.push(finding("vault", "warn", "spec-acceptance",
1561
- `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
+ );
1562
2294
  continue;
1563
2295
  }
1564
2296
  const coveredEntries = listOf(spec.fm, "stories");
@@ -1571,24 +2303,40 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
1571
2303
  applies = item.stories.some((bare) =>
1572
2304
  coveredEntries.some((e) => {
1573
2305
  const m = String(e).match(/^([^/]+)\/(.+)$/);
1574
- return m && m[1] === epicId && bare === m[2] &&
1575
- storyMatchesEntry(bare, { id: story.fm.id, stem }) > 0;
1576
- }));
1577
- const ambiguous = item.stories.some((bare) =>
1578
- coveredEntries.filter((e) => {
1579
- const m = String(e).match(/^([^/]+)\/(.+)$/);
1580
- return m && bare === m[2];
1581
- }).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
+ );
1582
2316
  const ambKey = `${spec.rel}|${item.text}`;
1583
2317
  if (ambiguous && !ambiguousReported.has(ambKey)) {
1584
2318
  ambiguousReported.add(ambKey);
1585
- out.push(finding("vault", "warn", "ambiguous-attribution",
1586
- `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
+ );
1587
2328
  }
1588
2329
  }
1589
2330
  if (applies && !item.checked) {
1590
- out.push(finding("vault", "issue", "spec-acceptance",
1591
- `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
+ );
1592
2340
  }
1593
2341
  }
1594
2342
  }
@@ -1615,25 +2363,53 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
1615
2363
  for (const line of linesOutsideFences(acc)) {
1616
2364
  const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
1617
2365
  if (m && !evidenceRe.test(m[2])) {
1618
- out.push(finding("vault", "warn", "evidence",
1619
- `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
+ );
1620
2375
  }
1621
2376
  }
1622
2377
  }
1623
2378
 
1624
2379
  const plan = sectionOf(story.body, "implementation_plan");
1625
2380
  if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
1626
- out.push(finding("vault", "warn", "plan-gate",
1627
- `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
+ );
1628
2390
  }
1629
2391
  const summary = sectionOf(story.body, "final_summary");
1630
2392
  if (summary === null) {
1631
- out.push(finding("vault", "warn", "final-summary",
1632
- "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
+ );
1633
2402
  }
1634
2403
  if (plan === null) {
1635
- out.push(finding("vault", "warn", "plan-gate",
1636
- "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
+ );
1637
2413
  }
1638
2414
  }
1639
2415
  return out;
@@ -1644,12 +2420,24 @@ export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
1644
2420
  const out = [];
1645
2421
  const hasSpecs = artifacts.some((a) => a.kind === "spec");
1646
2422
  if (hasSpecs && !vaultCfg.spec_policy) {
1647
- out.push(finding("vault", "info", "spec-policy",
1648
- "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
+ );
1649
2431
  }
1650
2432
  if (vaultCfg.spec_policy === "required" && !vaultCfg.spec_policy_since) {
1651
- out.push(finding("vault", "warn", "spec-policy",
1652
- "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
+ );
1653
2441
  }
1654
2442
  return out;
1655
2443
  }
@@ -1665,7 +2453,11 @@ export function walkVaultFiles(vault) {
1665
2453
  if (n.startsWith(".")) continue;
1666
2454
  const p = join(dir, n);
1667
2455
  let st;
1668
- try { st = statSync(p); } catch { continue; }
2456
+ try {
2457
+ st = statSync(p);
2458
+ } catch {
2459
+ continue;
2460
+ }
1669
2461
  if (st.isDirectory()) walk(p, relDir ? `${relDir}/${n}` : n);
1670
2462
  else if (n.endsWith(".md")) files.push({ rel: relDir ? `${relDir}/${n}` : n, name: n });
1671
2463
  }
@@ -1691,7 +2483,9 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
1691
2483
  const files = vaultFiles ?? walkVaultFiles(cfg.vault_path);
1692
2484
  let index = nodeIndex;
1693
2485
  if (!index) {
1694
- try { index = buildNodeIndex(cfg, loadLayout(cfg.layout)); } catch (e) {
2486
+ try {
2487
+ index = buildNodeIndex(cfg, loadLayout(cfg.layout));
2488
+ } catch (e) {
1695
2489
  // A silent no-op check would read as "links are fine" — say why not.
1696
2490
  return [finding("vault", "warn", "wikilink", `Link check skipped — node index failed: ${e.message}`)];
1697
2491
  }
@@ -1706,12 +2500,21 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
1706
2500
  for (const link of extractLinks(a.body)) {
1707
2501
  const r = resolveLinkTarget(link.target, link.type, ctx);
1708
2502
  if (r.outcome === "dead") {
1709
- out.push(link.type === "wikilink"
1710
- ? finding("vault", "issue", "wikilink", `Dead wiki-link [[${link.target}]].`, a.rel)
1711
- : 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
+ );
1712
2508
  } else if (r.outcome === "ambiguous") {
1713
- out.push(finding("vault", "warn", "wikilink",
1714
- `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
+ );
1715
2518
  }
1716
2519
  }
1717
2520
  }
@@ -1773,7 +2576,8 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
1773
2576
  const parts = x.rel.split("/");
1774
2577
  let entry = null; // stories/<f>.md | stories/<dir>/README.md | standalone story-*.md
1775
2578
  if (parts.length === 4 && parts[2] === "stories") entry = { name: parts[3], rel: x.rel };
1776
- 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 };
1777
2581
  else if (parts.length === 3 && parts[2].startsWith("story-")) entry = { name: parts[2], rel: x.rel };
1778
2582
  if (!entry || INFRA_NAMES.has(entry.name)) continue;
1779
2583
  if (!byEpic.has(parts[1])) byEpic.set(parts[1], []);
@@ -1803,31 +2607,53 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
1803
2607
  for (const [ident, hits] of groups) {
1804
2608
  const uniq = [...new Map(hits.map((h) => [h.entry.rel, h])).values()];
1805
2609
  if (uniq.length < 2) continue;
1806
- const key = uniq.map((h) => h.entry.rel).sort().join("|");
2610
+ const key = uniq
2611
+ .map((h) => h.entry.rel)
2612
+ .sort()
2613
+ .join("|");
1807
2614
  if (reported.has(key)) continue;
1808
2615
  reported.add(key);
1809
2616
  const selfOnly = uniq.every((h) => h.via === "self");
1810
2617
  const classed = uniq.map((h) => ({ h, era: eraOf(h.entry, opts) }));
1811
- const level = selfOnly ? "issue"
1812
- // Overlap reached via the legacy reading of a PROVEN slug-era file
1813
- // (its machine id is the full stem) is spurious — the file's real
1814
- // identity is the unstripped slug.
1815
- : classed.some(({ h, era }) => h.via !== "self" && era === "new") ? "warn"
1816
- : classed.some(({ era }) => era === "uncertain") ? "warn"
1817
- : classed.every(({ era }) => era === "legacy") ? "info"
1818
- : "issue"; // a new-era name colliding with a certain legacy one
1819
- const note = level === "warn" ? "; the overlap depends on a legacy reading that frontmatter does not confirm"
1820
- : level === "info" ? "; all carry legacy numbers — legal in the numbered era, duplicate topic is hygiene"
1821
- : "";
1822
- out.push(finding("vault", level, "identity",
1823
- `Same normalized identity "${ident}" in ${label}: ${uniq.map((h) => h.entry.name).join(", ")} — two artifacts claim one topic (ADR-010${note}).`,
1824
- 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
+ );
1825
2645
  }
1826
2646
  for (const [num, ents] of numbers) {
1827
2647
  if (new Set(ents.map((e) => e.rel)).size < 2) continue;
1828
- out.push(finding("vault", "info", "identity",
1829
- `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.`,
1830
- 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
+ );
1831
2657
  }
1832
2658
  }
1833
2659
  return out;
@@ -1843,8 +2669,15 @@ export function checkExternalRefsForm(artifacts) {
1843
2669
  if (a.fm.external_refs !== "") continue;
1844
2670
  const fmBlock = a.body.match(/^---\n[\s\S]*?\n---/);
1845
2671
  if (fmBlock && /\nexternal_refs:\s*\n\s+\S/.test(fmBlock[0])) {
1846
- out.push(finding("vault", "issue", "external-refs",
1847
- "`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
+ );
1848
2681
  }
1849
2682
  }
1850
2683
  return out;
@@ -1860,8 +2693,15 @@ export function checkArtifactNames(vaultFiles) {
1860
2693
  for (const x of vaultFiles) {
1861
2694
  const bad = legalArtifactName(x.name);
1862
2695
  if (bad) {
1863
- out.push(finding("vault", "warn", "artifact-name",
1864
- `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
+ );
1865
2705
  }
1866
2706
  if (INFRA_NAMES.has(x.name)) continue;
1867
2707
  if (!byName.has(x.name)) byName.set(x.name, []);
@@ -1869,9 +2709,15 @@ export function checkArtifactNames(vaultFiles) {
1869
2709
  }
1870
2710
  for (const [name, rels] of byName) {
1871
2711
  if (rels.length < 2) continue;
1872
- out.push(finding("vault", "info", "artifact-name",
1873
- `Basename "${name}" appears in ${rels.length} folders (${rels.map((r) => dirname(r)).join(", ")}) — short wiki-links [[${name.replace(/\.md$/, "")}]] are ambiguous.`,
1874
- 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
+ );
1875
2721
  }
1876
2722
  return out;
1877
2723
  }
@@ -1899,12 +2745,22 @@ export function checkWorkWithoutStory(cfg, proj) {
1899
2745
  const fms = [];
1900
2746
  let unreadable = 0;
1901
2747
  for (const f of files) {
1902
- 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
+ }
1903
2753
  }
1904
2754
  if (unreadable > 0 && !openStoryFrom(fms)) {
1905
2755
  // A diagnostic that goes quiet because it could not read is a false clean.
1906
- out.push(finding("vault", "warn", "work-without-story",
1907
- `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
+ );
1908
2764
  return out;
1909
2765
  }
1910
2766
  if (openStoryFrom(fms)) return out;
@@ -1927,14 +2783,21 @@ export function checkWorkWithoutStory(cfg, proj) {
1927
2783
  // kind: null — entry reminders only. The log is shared with the session-name
1928
2784
  // offer, whose breadcrumbs would otherwise be reported as reminders delivered.
1929
2785
  const fired = readEntryLog(proj, { withinDays: 30, kind: null }).length;
1930
- const firedNote = fired > 0
1931
- ? ` 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.`
1932
- : " 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).";
1933
2790
  const what = [];
1934
2791
  if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
1935
2792
  if (committedSince) what.push("commits newer than the vault's last activity");
1936
- out.push(finding("vault", "warn", "work-without-story",
1937
- `${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
+ );
1938
2801
  return out;
1939
2802
  }
1940
2803
 
@@ -1952,8 +2815,15 @@ export function checkCodeRefs(artifacts, proj) {
1952
2815
  for (const ref of refs) {
1953
2816
  if (ref.includes("*")) continue;
1954
2817
  if (!existsSync(join(proj, ref))) {
1955
- out.push(finding("vault", "issue", "code-refs",
1956
- `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
+ );
1957
2827
  }
1958
2828
  }
1959
2829
  }
@@ -1961,14 +2831,28 @@ export function checkCodeRefs(artifacts, proj) {
1961
2831
  const dir = a.rel.replace(/\/stories\/[^/]+$/, "");
1962
2832
  const parent = epicRefs.get(dir) || [];
1963
2833
  if (!parent.length) {
1964
- out.push(finding("vault", "warn", "code-refs",
1965
- "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
+ );
1966
2843
  } else {
1967
2844
  const norm = (r) => r.replace(/\/+$/, "");
1968
2845
  for (const ref of refs) {
1969
2846
  if (!parent.some((p) => norm(ref).startsWith(norm(p)))) {
1970
- out.push(finding("vault", "warn", "code-refs",
1971
- `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
+ );
1972
2856
  }
1973
2857
  }
1974
2858
  }
@@ -1989,13 +2873,27 @@ export function checkCodeMap(cfg) {
1989
2873
  });
1990
2874
  if (r.status !== 0) return [finding("vault", "warn", "code-map", "codemap generator failed.")];
1991
2875
  let expected;
1992
- try { expected = JSON.parse(r.stdout).content; } catch {
2876
+ try {
2877
+ expected = JSON.parse(r.stdout).content;
2878
+ } catch {
1993
2879
  return [finding("vault", "warn", "code-map", "codemap generator returned unparseable output.")];
1994
2880
  }
1995
- 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();
1996
2887
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1997
- return [finding("vault", "issue", "code-map",
1998
- `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
+ ];
1999
2897
  }
2000
2898
  return [];
2001
2899
  }
@@ -2008,7 +2906,9 @@ export function checkCodeMap(cfg) {
2008
2906
  export function checkGraph(cfg) {
2009
2907
  const p = join(cfg.vault_path, "graph.md");
2010
2908
  if (!existsSync(p)) {
2011
- 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
+ ];
2012
2912
  }
2013
2913
  const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
2014
2914
  encoding: "utf8",
@@ -2017,13 +2917,27 @@ export function checkGraph(cfg) {
2017
2917
  });
2018
2918
  if (r.status !== 0) return [finding("vault", "warn", "graph", `graph generator failed: ${(r.stderr || "").trim()}`)];
2019
2919
  let expected;
2020
- try { expected = JSON.parse(r.stdout).content; } catch {
2920
+ try {
2921
+ expected = JSON.parse(r.stdout).content;
2922
+ } catch {
2021
2923
  return [finding("vault", "warn", "graph", "graph generator returned unparseable output.")];
2022
2924
  }
2023
- 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();
2024
2931
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
2025
- return [finding("vault", "issue", "graph",
2026
- `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
+ ];
2027
2941
  }
2028
2942
  return [];
2029
2943
  }
@@ -2052,8 +2966,10 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2052
2966
  ...checkOverlays(cfg, proj, opts),
2053
2967
  );
2054
2968
  let read = null;
2055
- try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
2056
- out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
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 })));
2057
2973
  if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
2058
2974
  if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
2059
2975
  return foldIntoMove(out);
@@ -2061,7 +2977,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2061
2977
 
2062
2978
  export function runVaultChecks(cfg) {
2063
2979
  let layout;
2064
- try { layout = loadLayout(cfg.layout); } catch (e) {
2980
+ try {
2981
+ layout = loadLayout(cfg.layout);
2982
+ } catch (e) {
2065
2983
  return [finding("vault", "issue", "layout", `Layout not loadable: ${e.message}`)];
2066
2984
  }
2067
2985
  const artifacts = scanArtifacts(cfg, layout);
@@ -2080,7 +2998,9 @@ export function runVaultChecks(cfg) {
2080
2998
  () => checkLifecycleGates(artifacts, vaultCfg),
2081
2999
  ];
2082
3000
  for (const step of guarded) {
2083
- try { findings.push(...step()); } catch (e) {
3001
+ try {
3002
+ findings.push(...step());
3003
+ } catch (e) {
2084
3004
  findings.push(finding("vault", "issue", "registry", e.message));
2085
3005
  break;
2086
3006
  }
@@ -2104,6 +3024,7 @@ export function runVaultChecks(cfg) {
2104
3024
  // Decision 4). Aborts past the budget rather than reporting a false "clean".
2105
3025
  export function runStartupChecks(cfg, proj, budgetMs = 150) {
2106
3026
  const started = Date.now();
3027
+ const findings = [];
2107
3028
  const steps = [
2108
3029
  () => checkConfig(cfg, proj),
2109
3030
  () => (cfg && cfg.vault_path ? checkVaultPath(cfg) : []),
@@ -2112,15 +3033,22 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2112
3033
  () => checkAgentsBlock(proj),
2113
3034
  () => checkGitignore(proj),
2114
3035
  // The layout move is offered from the startup line (info here, warn in the
2115
- // report) — a user who never runs doctor still learns to migrate.
2116
- () => 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
+ }),
2117
3044
  () => checkEnvModel(),
2118
3045
  () => checkEnvEffort(),
2119
3046
  ];
2120
- const findings = [];
2121
3047
  for (const step of steps) {
2122
3048
  if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
2123
- try { findings.push(...step()); } catch {}
3049
+ try {
3050
+ findings.push(...step());
3051
+ } catch {}
2124
3052
  }
2125
3053
  // The findings the move repairs are not counted beside it (foldIntoMove).
2126
3054
  const settled = foldIntoMove(findings);
@@ -2128,12 +3056,13 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2128
3056
  // re-stamps the launcher at its new path (the layout spec, contract 7 as
2129
3057
  // amended 2026-10-03), and an in-session `doctor --fix` would write that
2130
3058
  // launcher and then stop at the deferred move with exit 1. So the line names
2131
- // 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.
2132
3062
  const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
2133
- const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
2134
3063
  const offers = settled
2135
3064
  .filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
2136
- .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);
2137
3066
  return {
2138
3067
  skipped: false,
2139
3068
  count: settled.filter((f) => f.level === "issue").length,
@@ -2150,7 +3079,10 @@ async function main() {
2150
3079
  const startup = args.includes("--startup");
2151
3080
  let install = args.includes("--install");
2152
3081
  let vault = args.includes("--vault");
2153
- if (!install && !vault && !startup) { install = true; vault = true; }
3082
+ if (!install && !vault && !startup) {
3083
+ install = true;
3084
+ vault = true;
3085
+ }
2154
3086
 
2155
3087
  const cfg = readConfig();
2156
3088
  const proj = projectRoot();
@@ -2163,7 +3095,10 @@ async function main() {
2163
3095
 
2164
3096
  const findings = [];
2165
3097
  const groups = [];
2166
- 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
+ }
2167
3102
  if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
2168
3103
  groups.push("vault");
2169
3104
  findings.push(...runVaultChecks(cfg));
@@ -2172,7 +3107,10 @@ async function main() {
2172
3107
  findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
2173
3108
  }
2174
3109
 
2175
- if (wantJson) { process.stdout.write(JSON.stringify(findings, null, 2) + "\n"); return; }
3110
+ if (wantJson) {
3111
+ process.stdout.write(JSON.stringify(findings, null, 2) + "\n");
3112
+ return;
3113
+ }
2176
3114
  process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
2177
3115
  }
2178
3116
 
@@ -2182,12 +3120,20 @@ async function main() {
2182
3120
  // statically, and the renderer's term.mjs is not on its path. A renderer
2183
3121
  // that cannot load, or throws, costs the reader nothing: the findings one
2184
3122
  // per line and the Summary line, still last (lib.mjs). `load` is a test's.
2185
- export async function textReport(findings, groups, { version = null, project = null, env = process.env, stderr = process.stderr } = {}, { load = () => import("./doctor-report.mjs") } = {}) {
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
+ ) {
2186
3129
  try {
2187
3130
  const { report } = await load();
2188
3131
  return report(findings, groups, { version, project, env });
2189
3132
  } catch (e) {
2190
- if (stderr) stderr.write(`doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`);
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
+ );
2191
3137
  return doctorFallbackText(findings, { env });
2192
3138
  }
2193
3139
  }