projectstore-claude 0.28.2 → 0.29.1

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 (32) hide show
  1. package/README.md +3 -1
  2. package/bin/projectstore-claude.mjs +14 -9
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +2 -2
  6. package/node_modules/projectstore/commands/review.md +1 -1
  7. package/node_modules/projectstore/harnesses/claude-code.json +20 -2
  8. package/node_modules/projectstore/harnesses/codex.json +24 -4
  9. package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
  10. package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
  11. package/node_modules/projectstore/hooks/session-start.mjs +20 -18
  12. package/node_modules/projectstore/package.json +2 -2
  13. package/node_modules/projectstore/scripts/binding.mjs +3 -3
  14. package/node_modules/projectstore/scripts/build-adapters.mjs +51 -18
  15. package/node_modules/projectstore/scripts/cli.mjs +176 -29
  16. package/node_modules/projectstore/scripts/codemap.mjs +2 -2
  17. package/node_modules/projectstore/scripts/doctor.mjs +110 -58
  18. package/node_modules/projectstore/scripts/draft.mjs +4 -3
  19. package/node_modules/projectstore/scripts/graph.mjs +3 -2
  20. package/node_modules/projectstore/scripts/harness.mjs +89 -0
  21. package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
  22. package/node_modules/projectstore/scripts/kanban.mjs +3 -2
  23. package/node_modules/projectstore/scripts/lib.mjs +69 -8
  24. package/node_modules/projectstore/scripts/mcp.mjs +2 -2
  25. package/node_modules/projectstore/scripts/query.mjs +2 -1
  26. package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
  27. package/node_modules/projectstore/scripts/story-section.mjs +2 -1
  28. package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
  29. package/node_modules/projectstore/scripts/term.mjs +149 -0
  30. package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
  31. package/node_modules/projectstore/scripts/worktree.mjs +12 -7
  32. package/package.json +2 -2
@@ -8,7 +8,8 @@
8
8
  // the command's --fix flow (install side) and reconcile (vault side).
9
9
  //
10
10
  // Finding: { group: "install"|"vault", level: "issue"|"warn"|"info",
11
- // check: "<id>", message: "...", file?: "<path>" }
11
+ // check: "<id>", message: "...", file?: "<path>",
12
+ // about?: "<harness id>" } (a fact about another harness: aboutHarness)
12
13
  // The SessionStart line counts level==="issue" only.
13
14
  //
14
15
  // CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
@@ -79,8 +80,13 @@ import {
79
80
  cmpPrecedence,
80
81
  cmpVersion,
81
82
  blockVisibleTo,
83
+ commandForm,
84
+ sharedRoleForm,
85
+ speakingHarness,
86
+ inheritForm,
87
+ bindInherits,
82
88
  } from "./lib.mjs";
83
- import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
89
+ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand, invocation } from "./harness.mjs";
84
90
 
85
91
  // A remedy used to interpolate the surface's harness variable here. It cannot:
86
92
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -142,6 +148,18 @@ function finding(group, level, check, message, file) {
142
148
  return f;
143
149
  }
144
150
 
151
+ // A finding whose subject is ANOTHER harness in the same project — its
152
+ // registration, its surfaces, its legacy layout, its registry's versions — is
153
+ // that harness's: it opens with that harness's name, carries `about`, and says
154
+ // its steps in that harness's forms and words, because they happen there
155
+ // (generation spec, contract 18). Under that harness itself, or with no
156
+ // harness to name, the finding is unchanged.
157
+ export function aboutHarness(f, h, speaker = speakingHarness()) {
158
+ return speaker && h?.id && h.id !== speaker.id && h.display_name
159
+ ? { ...f, message: `${h.display_name}: ${f.message}`, about: h.id }
160
+ : f;
161
+ }
162
+
145
163
  function pluginVersion(root = pluginRoot()) {
146
164
  try {
147
165
  return JSON.parse(
@@ -176,7 +194,7 @@ export function checkConfig(cfg, proj = projectRoot()) {
176
194
  try { b = resolveBinding(proj); } catch {}
177
195
  if (b && b.state === "inheritable") {
178
196
  return [finding("install", "issue", "worktree-unbound",
179
- `This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. Run /projectstore:bind --inherit to adopt that binding.`)];
197
+ `This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. Run ${inheritForm(b.vaultPath, { layout: b.layout, language: b.language })} ${bindInherits() ? "to adopt that binding" : "to bind it to the same vault, layout and language"}.`)];
180
198
  }
181
199
  const p = layoutPaths(proj);
182
200
  const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
@@ -185,7 +203,7 @@ export function checkConfig(cfg, proj = projectRoot()) {
185
203
  `${relative(proj, present)} exists but is not valid JSON — the project reads as unbound until it is fixed; bind refuses to overwrite it.`, relative(proj, present))];
186
204
  }
187
205
  return [finding("install", "issue", "config",
188
- "No projectstore config (.projectstore/projectstore.json). Run /projectstore:bind <vault-path>.")];
206
+ `No projectstore config (.projectstore/projectstore.json). Run ${commandForm("bind", { args: "<vault-path>" })}.`)];
189
207
  }
190
208
  const out = [];
191
209
  if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
@@ -277,8 +295,11 @@ export function statusLineScriptVersion(scriptPath) {
277
295
 
278
296
  // Read-only probe of the statusline wiring (never calls syncStatusLine, which
279
297
  // is a mutating self-heal that SessionStart already ran — ADR-005).
280
- export function checkStatusline(cfg, proj, home = homedir()) {
298
+ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingHarness()) {
281
299
  const out = [];
300
+ // A harness without a status line hears nothing about one, whatever the
301
+ // shared binding asks for (generation spec, contract 18).
302
+ if (harness?.surfaces?.statusline?.supported === false) return out;
282
303
  const local = hostSettingsPath(proj);
283
304
  let cur = null;
284
305
  if (existsSync(local)) {
@@ -297,7 +318,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
297
318
  if (st && st.enabled === true) {
298
319
  if (!curCmd) {
299
320
  out.push(finding("install", "issue", "statusline",
300
- "statusline.enabled=true but no statusLine wired in settings.local.json — run /projectstore:statusline on (it installs the entry and the launcher behind a preview); the SessionStart hook only refreshes an entry that already exists."));
321
+ `statusline.enabled=true but no statusLine wired in settings.local.json — run ${commandForm("statusline", { args: "on" })} (it installs the entry and the launcher behind a preview); the SessionStart hook only refreshes an entry that already exists.`));
301
322
  } else if (!isOurs) {
302
323
  out.push(finding("install", "issue", "statusline",
303
324
  "statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag."));
@@ -308,7 +329,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
308
329
  if (m && !existsSync(m[1])) {
309
330
  out.push(finding("install", "issue", "statusline",
310
331
  isLauncher
311
- ? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run /projectstore:statusline on to reinstall it; until then the next session start repoints the entry at the installed script.`
332
+ ? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run ${commandForm("statusline", { args: "on" })} to reinstall it; until then the next session start repoints the entry at the installed script.`
312
333
  : `statusLine points at a missing script (stale plugin path?): ${m[1]}`));
313
334
  } else if (m && isPluginCacheRoot(wiredRoot, home)) {
314
335
  // Only a versioned cache path can go stale this way. The launcher
@@ -318,7 +339,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
318
339
  const inst = installedPluginRoot(home, dirname(wiredRoot));
319
340
  if (wired && inst && inst.version && wired !== inst.version) {
320
341
  out.push(finding("install", "warn", "statusline",
321
- `statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run /projectstore:statusline on to install the version-agnostic launcher; the SessionStart hook only repoints the pinned path at the current install.`));
342
+ `statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run ${commandForm("statusline", { args: "on" })} to install the version-agnostic launcher; the SessionStart hook only repoints the pinned path at the current install.`));
322
343
  }
323
344
  }
324
345
  }
@@ -373,7 +394,8 @@ export function checkStatusline(cfg, proj, home = homedir()) {
373
394
  // load the provenance leaf). So the startup line names the step. Only for a
374
395
  // cache install: a dev checkout does not produce the launcher at all, and its
375
396
  // install would leave the file, not re-stamp it.
376
- export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot()) {
397
+ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(), harness = speakingHarness()) {
398
+ if (harness?.surfaces?.statusline?.supported === false) return [];
377
399
  if (!isPluginCacheRoot(root, home)) return [];
378
400
  // Only a launcher our entry runs. Under a foreign status line nothing reads
379
401
  // it, install leaves that slot alone, and the offer would repeat every
@@ -387,7 +409,7 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot())
387
409
  try { text = readFileSync(lp, "utf8"); } catch { return []; }
388
410
  if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
389
411
  return [finding("install", "info", "upgrade",
390
- "The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering; run /projectstore:doctor --fix once to re-stamp it.",
412
+ `The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering; run ${commandForm("doctor", { args: "--fix" })} once to re-stamp it.`,
391
413
  relative(proj, lp))];
392
414
  }
393
415
 
@@ -422,7 +444,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
422
444
  if (f.wrapped) {
423
445
  wrappedFiles++;
424
446
  out.push(finding("install", "issue", "agents-block",
425
- `${name}:${f.line}: the projectstore:agents open marker does not close on its own line — put \`-->\` back on the marker's line, then run /projectstore:agents register (install and uninstall refuse until it does).`, name));
447
+ `${name}:${f.line}: the projectstore:agents open marker does not close on its own line — put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until it does).`, name));
426
448
  continue;
427
449
  }
428
450
  if (f.unclosed) {
@@ -430,7 +452,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
430
452
  // the agents-block plan refuses it, and the layout move with it.
431
453
  unclosedFiles++;
432
454
  out.push(finding("install", "issue", "agents-block",
433
- `${name}: the projectstore:agents block opens and never closes — close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run /projectstore:agents register (install and uninstall refuse until then).`, name));
455
+ `${name}: the projectstore:agents block opens and never closes — close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until then).`, name));
434
456
  }
435
457
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
436
458
  const v = parseInt(m[1], 10);
@@ -439,7 +461,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
439
461
  }
440
462
  if (blocks === 0) {
441
463
  out.push(finding("install", "info", "agents-block",
442
- "Agent routing block not registered — optional; ships with /projectstore:agents (v0.13)."));
464
+ `Agent routing block not registered — optional; ships with ${commandForm("agents")} (v0.13).`));
443
465
  }
444
466
  if (blocks > 1) {
445
467
  // One block in each file is a state install resolves (it keeps the
@@ -454,7 +476,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
454
476
  // already named above, file by file
455
477
  } else {
456
478
  out.push(finding("install", "warn", "agents-block",
457
- `The projectstore:agents block is in both CLAUDE.md and AGENTS.md — run /projectstore:agents register: install keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other.`));
479
+ `The projectstore:agents block is in both CLAUDE.md and AGENTS.md — run ${commandForm("agents", { args: "register" })}: install keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other.`));
458
480
  }
459
481
  }
460
482
  // A state the agents-block plan refuses — a wrapped marker, a block that
@@ -463,7 +485,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
463
485
  const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
464
486
  for (const s of staleVersions) {
465
487
  const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
466
- const f = finding("install", "issue", "agents-block", `${fact} — re-run /projectstore:agents register.`, s.file);
488
+ const f = finding("install", "issue", "agents-block", `${fact} — re-run ${commandForm("agents", { args: "register" })}.`, s.file);
467
489
  out.push(refuses ? f : moveRepairs(f, fact));
468
490
  }
469
491
  // Placement, held to the predicate install plans from (the install spec,
@@ -546,7 +568,7 @@ export async function readSurfaceStates(proj, { home = homedir(), root = pluginR
546
568
  return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
547
569
  }
548
570
 
549
- export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env } = {}) {
571
+ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env, speaker = speakingHarness() } = {}) {
550
572
  const out = [];
551
573
  let r, FOREIGN_TEXT;
552
574
  try {
@@ -564,16 +586,23 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
564
586
  for (const s of r.states) {
565
587
  const where = relative(proj, s.path) || s.path;
566
588
  if (s.kind === "registration") continue; // checkPluginRegistration's
589
+ // Another harness's surface is that harness's fact (aboutHarness): its
590
+ // steps in its forms, and no status-line hint where it has no status line.
591
+ // A shared surface — the agents block, one file every harness owns — is
592
+ // every harness's: never marked, its step in the listener's form.
593
+ const sh = s.kind === "shared" ? speaker : (loadHarness(s.harness) || speaker);
594
+ const push = (f) => out.push(aboutHarness(f, sh, speaker));
567
595
  if (s.kind === "exclusive") {
568
596
  if (s.state === "foreign") {
569
- out.push(finding("install", "issue", "surface-foreign",
597
+ push(finding("install", "issue", "surface-foreign",
570
598
  `${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`, where));
571
599
  } else if (s.state === "stale" && s.produced) {
572
- out.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}" (for the status line, /projectstore:statusline on).`, where));
600
+ const status = sh?.surfaces?.statusline?.supported === false ? "" : ` (for the status line, ${invocation(sh, "statusline", { args: "on" })})`;
601
+ push(finding("install", "issue", "surface", `${where} — stale: ${s.reason}. Reinstall it: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${s.harness} --surface ${s.surface} --project "${proj}"${status}.`, where));
573
602
  } else if (s.state === "stale" && !s.produced) {
574
- out.push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
603
+ push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
575
604
  } else if (s.state === "current" && s.writtenBy && !s.sameProject) {
576
- out.push(finding("install", "info", "surface", `${where} — current, last written by ${s.writtenBy}.`, where));
605
+ push(finding("install", "info", "surface", `${where} — current, last written by ${s.writtenBy}.`, where));
577
606
  }
578
607
  } else if (s.surface === "agents_block") {
579
608
  // Version drift and duplicates are checkAgentsBlock's; what only the
@@ -582,9 +611,9 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
582
611
  // startup line, and the state names it with the file's own reason, as a
583
612
  // wrapped marker already was.
584
613
  if (s.state === "unparseable") {
585
- out.push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
614
+ push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
586
615
  } else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
587
- out.push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run /projectstore:agents register.`, where));
616
+ push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run ${invocation(sh, "agents", { args: "register" })}.`, where));
588
617
  }
589
618
  }
590
619
  // The statusline entry's states are checkStatusline's, under its own id —
@@ -598,7 +627,7 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
598
627
  // rejects is an issue naming key and file; a binding still carrying an
599
628
  // agents block is a pre-0.28 leftover the migration moves; an overlay that
600
629
  // does not parse is an issue.
601
- export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir() } = {}) {
630
+ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir(), speaker = speakingHarness() } = {}) {
602
631
  const out = [];
603
632
  const o = readOverlayAt(proj);
604
633
  const where = relative(proj, o.path);
@@ -613,7 +642,9 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
613
642
  // installer in no particular channel (the critic of the layout spec's
614
643
  // 2026-10-03 amendment, finding 7).
615
644
  const remedy = layoutRemedy(proj, { root, home });
616
- out.push(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));
645
+ // The block and its move are the source harness's — a pre-0.28 binding
646
+ // and that harness's command — so its fact under any other listener.
647
+ out.push(aboutHarness(finding("install", "warn", "agents-in-binding", `${b} still carries an agents block — since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedy.command ? `Move it from a terminal outside the session: ${remedy.command}` : remedy.advice}.`, b), sourceHarness(), speaker));
617
648
  }
618
649
  // A project can be used from more than one harness, and a model name is
619
650
  // harness-specific (ADR-008) — so each one has its own overlay and they do
@@ -636,7 +667,7 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
636
667
  `This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — `
637
668
  + `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} `
638
669
  + `${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). `
639
- + `Configure it: /projectstore:agents configure --harness ${id}.`,
670
+ + `Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
640
671
  relative(proj, o.path)));
641
672
  }
642
673
  }
@@ -660,12 +691,15 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
660
691
  // itself); a competing copy enabled beside ours → an issue (two enabled copies
661
692
  // of one plugin); a competitor alone → an info naming the npm path; foreign →
662
693
  // never repairable; the host CLI missing → an info.
663
- export function checkPluginRegistration(proj, states = [], { home = homedir() } = {}) {
694
+ export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness() } = {}) {
664
695
  const out = [];
665
696
  for (const s of states.filter((x) => x.kind === "registration")) {
666
697
  // The shell form when the manifest names a shell (contract 12): the
667
698
  // command a user can paste, built in one place.
668
699
  const h = loadHarness(s.harness) || { id: s.harness };
700
+ // A registration of ANOTHER harness in the same project is that
701
+ // harness's fact: named, marked, its steps in its own forms (aboutHarness).
702
+ const push = (f) => out.push(aboutHarness(f, h, speaker));
669
703
  const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` });
670
704
  // A copy this registration silenced for the checkout and the checkout
671
705
  // still holds off, one per key (the install spec, contract 13 as amended
@@ -682,29 +716,29 @@ export function checkPluginRegistration(proj, states = [], { home = homedir() }
682
716
  for (const key of s.silenced) {
683
717
  const row = rows.filter((e) => e.key === key).sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
684
718
  if (!row) {
685
- out.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));
719
+ push(finding("install", "info", "plugin-registration", `${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`, s.path));
686
720
  continue;
687
721
  }
688
722
  // The release line, not the build: a 0.28 release candidate reads a
689
723
  // moved project; 0.27.x reads it as unbound. No version reads as old.
690
724
  const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
691
- out.push(finding("install", "info", "plugin-registration",
692
- `${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 /projectstore:doctor --fix. If moving to npm was meant, ignore this.`, s.path));
725
+ push(finding("install", "info", "plugin-registration",
726
+ `${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it: from a terminal outside the session, ${packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` })}, restart, then ${invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" })}. If moving to npm was meant, ignore this.`, s.path));
693
727
  }
694
728
  }
695
729
  const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
696
730
  if (s.state === "foreign") {
697
- out.push(finding("install", "issue", "plugin-registration-foreign", `${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`, s.path));
731
+ push(finding("install", "issue", "plugin-registration-foreign", `${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`, s.path));
698
732
  } else if (s.state === "unavailable") {
699
- out.push(finding("install", "info", "plugin-registration", `No npm registration of projectstore for this project, and ${s.reason}.`));
733
+ push(finding("install", "info", "plugin-registration", `No npm registration of projectstore for this project, and ${s.reason}.`));
700
734
  } else if (s.state === "absent") {
701
735
  // A git-marketplace install alone is not a finding: a permanent info
702
736
  // advertising the npm path to every marketplace user is noise (2026-09-05).
703
737
  } else if (s.state === "stale") {
704
- out.push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
738
+ push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
705
739
  } else if (s.state === "current") {
706
- if (others) out.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));
707
- else out.push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (loaded from ${s.installPath}); refresh with ${refresh}.`));
740
+ if (others) push(finding("install", "issue", "plugin-registration", `${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. install silences the other for this project: ${packageCommand(h, "install", { args: `--surface ${s.surface} --project "${proj}"` })} (or the host's own disable at the scope the manifest names — never the committed project scope).`, s.path));
741
+ else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (loaded from ${s.installPath}); refresh with ${refresh}.`));
708
742
  }
709
743
  }
710
744
  return out;
@@ -715,28 +749,40 @@ export function checkPluginRegistration(proj, states = [], { home = homedir() }
715
749
  // hosts install from different sources. The versions come from the harness
716
750
  // registry (one per marketplace key and scope) and from the pkg= field of a
717
751
  // file we stamped in this project; each is named with where it was read.
718
- export function checkVersionDrift(home = homedir(), states = [], proj = null) {
752
+ export function checkVersionDrift(home = homedir(), states = [], proj = null, { speaker = speakingHarness() } = {}) {
719
753
  // Pairs, not a map keyed by source: two registrations under one marketplace
720
754
  // key and one scope are the common shape (the registry keeps every install
721
755
  // it made), and they must both be seen. Only installs still on disk count —
722
756
  // a wiped entry is not a copy anyone runs.
723
757
  const seen = [];
758
+ // The registry read here is the source harness's own.
759
+ const registryOf = sourceHarness()?.id || null;
724
760
  for (const e of installedPluginEntries(home, proj)) {
725
761
  // A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
726
- if (e.version && e.present && e.enabled !== false) seen.push({ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`, version: e.version });
762
+ if (e.version && e.present && e.enabled !== false) seen.push({ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`, version: e.version, harness: registryOf });
727
763
  }
728
764
  for (const s of states) {
729
- if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg });
765
+ if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
730
766
  }
731
767
  const versions = new Set(seen.map((x) => x.version));
732
768
  if (versions.size < 2) return [];
733
769
  const list = seen.map(({ source, version }) => `${version} (${source})`).join(", ");
734
- return [finding("install", "warn", "version-drift",
735
- `projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one; the launcher renders whichever is registered.`)];
770
+ // Every copy one harness's: that harness's fact (aboutHarness). The launcher
771
+ // is the status line's, so its clause is said only where the subject — or,
772
+ // for copies of several harnesses, the listener — has one.
773
+ const owners = [...new Set(seen.map((x) => x.harness))];
774
+ const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
775
+ const launcher = (subject || speaker)?.surfaces?.statusline?.supported === false ? "" : "; the launcher renders whichever is registered";
776
+ return [aboutHarness(finding("install", "warn", "version-drift",
777
+ `projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one${launcher}.`), subject, speaker)];
736
778
  }
737
779
 
738
- export function checkOverrideCopies(proj, home = homedir()) {
780
+ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
739
781
  const out = [];
782
+ // Copies of our agents a host loads from its own agents directory: a fact
783
+ // only for a harness that loads agents that way (its agents surface); a
784
+ // harness without one hears nothing about them (generation spec, contract 18).
785
+ if (harness && harness.surfaces?.agents?.supported === false) return out;
740
786
  const ver = pluginVersion();
741
787
  const scopes = [
742
788
  { dir: join(proj, ".claude", "agents"), label: ".claude/agents", scope: "project" },
@@ -793,13 +839,13 @@ export function checkOverrideCopies(proj, home = homedir()) {
793
839
  // copy at it would name a command that will not act — the scope split
794
840
  // fca8def introduced for staleness applies here for the same reason.
795
841
  const remove = scope === "user"
796
- ? `Delete ${where} by hand (or via /projectstore:doctor --fix) — /projectstore:agents configure only cleans up project-scope copies.`
797
- : "Delete it via /projectstore:agents configure, which now records the model in .projectstore/harness/<harness>.json (the active harness's overlay) and passes it per invocation.";
842
+ ? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
843
+ : `Delete it via ${commandForm("agents", { args: "configure" })}, which now records the model in .projectstore/harness/<harness>.json (the active harness's overlay) and passes it per invocation.`;
798
844
  const advice = m
799
845
  ? remove
800
- : "If you wrote it yourself, nothing is broken; if you meant to change the bundled agent's model, that is /projectstore:agents configure, not a copy.";
846
+ : `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.`;
801
847
  out.push(finding("install", m ? "warn" : "info", "override-copies",
802
- `${lead} It registers as "${name}" while the bundled agent registers as "projectstore:${name}", so both exist side by side.${everywhere}${stale} ${advice}`,
848
+ `${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
803
849
  where));
804
850
  }
805
851
  }
@@ -827,7 +873,7 @@ export function checkEnvModel() {
827
873
  // or .projectstore/state/ is one warn naming the upgrade; two bindings is an
828
874
  // issue. Cheap — a handful of existsSync — so the startup line carries the
829
875
  // warn as an offer (OFFER_CHECKS).
830
- export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir() } = {}) {
876
+ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir(), speaker = speakingHarness() } = {}) {
831
877
  const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
832
878
  const legacyBinding = existsSync(p.legacy.binding), legacyRuntime = existsSync(p.legacy.runtime);
833
879
  let resumable = false;
@@ -849,9 +895,11 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
849
895
  // recognising the root (a checkout, a symlinked or relocated home).
850
896
  const remedy = layoutRemedy(proj, { root, home, harness });
851
897
  const held = [legacyBinding && relative(proj, p.legacy.binding), legacyRuntime && relative(proj, p.legacy.runtime) + "/", existsSync(p.legacy.welcomed) && relative(proj, p.legacy.welcomed), existsSync(p.legacy.sessionId) && relative(proj, p.legacy.sessionId)].filter(Boolean).join(", ");
852
- return [finding("install", level, "layout-legacy",
898
+ // The files sit in that harness's legacy directory and the move is its
899
+ // command: its fact under any other listener (aboutHarness).
900
+ return [aboutHarness(finding("install", level, "layout-legacy",
853
901
  `The project layout moved to .projectstore/ (the layout ADR, 0.28); this project still holds ${held}. ${remedy.command ? `Migrate it from a terminal outside the session: ${remedy.command}` : remedy.advice} (readers fall back to the old paths through 0.29).`,
854
- relative(proj, [legacyBinding && p.legacy.binding, legacyRuntime && p.legacy.runtime, existsSync(p.legacy.welcomed) && p.legacy.welcomed, p.legacy.sessionId].find(Boolean)))];
902
+ relative(proj, [legacyBinding && p.legacy.binding, legacyRuntime && p.legacy.runtime, existsSync(p.legacy.welcomed) && p.legacy.welcomed, p.legacy.sessionId].find(Boolean))), harness, speaker)];
855
903
  }
856
904
 
857
905
  // The one command that moves this project's files (the layout spec, contract
@@ -991,8 +1039,12 @@ export function checkVaultGit(cfg) {
991
1039
  // marketplaces do NOT auto-update by default, so a stale plugin looks like
992
1040
  // "the feature is broken". Read the real registries and, when the flag is
993
1041
  // off, tell the user the exact correct values.
994
- export function checkAutoUpdate(home = homedir()) {
1042
+ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
995
1043
  const out = [];
1044
+ // Auto-update is a toggle of the host's own plugin marketplace. A harness
1045
+ // whose registration is not the host's plugin system (Codex's is a portable
1046
+ // marketplace driven by our shell) has no such toggle to report on.
1047
+ if (harness?.surfaces?.plugin?.format !== "host-plugin-registration") return out;
996
1048
  // Two corrections over the first version of this check, both found by running
997
1049
  // doctor straight out of a checkout (2026-08-05):
998
1050
  //
@@ -1074,12 +1126,12 @@ export function checkAutoUpdate(home = homedir()) {
1074
1126
  // the placeholders per session, so the check is only that the shipped file is
1075
1127
  // there and launches this package's bin. A host surface — nothing to
1076
1128
  // install, nothing to derive — so this is not a surfaces.mjs state.
1077
- export function checkMcpRegistration(root = pluginRoot(), harness = sourceHarness()) {
1129
+ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarness()) {
1078
1130
  // Whether a plugin-root .mcp.json registers anything is the host's fact,
1079
1131
  // read from the manifest: a harness whose mcp surface is not host-loaded
1080
1132
  // has nothing to check here.
1081
1133
  const mcp = harness && harness.surfaces && harness.surfaces.mcp;
1082
- if (!mcp || mcp.kind !== "host") return [];
1134
+ if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
1083
1135
  const p = join(root, mcp.file || ".mcp.json");
1084
1136
  if (!existsSync(p)) return [finding("install", "warn", "mcp", "No .mcp.json at the plugin root — the MCP read tools are not registered; the package ships one, so this install is incomplete or predates the MCP surface (0.28).")];
1085
1137
  let reg;
@@ -1132,7 +1184,7 @@ export function checkKanbanSync(cfg) {
1132
1184
  const vault = cfg.vault_path;
1133
1185
  const onDisk = join(vault, "kanban.md");
1134
1186
  if (!existsSync(onDisk)) {
1135
- return [finding("vault", "info", "kanban", "No kanban.md yet — run /projectstore:kanban to create the board.")];
1187
+ return [finding("vault", "info", "kanban", `No kanban.md yet — run ${commandForm("kanban")} to create the board.`)];
1136
1188
  }
1137
1189
  const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "kanban.mjs")], {
1138
1190
  encoding: "utf8",
@@ -1149,7 +1201,7 @@ export function checkKanbanSync(cfg) {
1149
1201
  const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1150
1202
  if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
1151
1203
  return [finding("vault", "issue", "kanban",
1152
- "kanban.md is out of sync with story frontmatter — run /projectstore:kanban (or reconcile).", "kanban.md")];
1204
+ `kanban.md is out of sync with story frontmatter — run ${commandForm("kanban")} (or reconcile).`, "kanban.md")];
1153
1205
  }
1154
1206
  return [];
1155
1207
  }
@@ -1550,7 +1602,7 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
1550
1602
  const plan = sectionOf(story.body, "implementation_plan");
1551
1603
  if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
1552
1604
  out.push(finding("vault", "warn", "plan-gate",
1553
- "Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the /projectstore:story plan gate.", story.rel));
1605
+ `Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the ${commandForm("story", { args: "plan" })} gate.`, story.rel));
1554
1606
  }
1555
1607
  const summary = sectionOf(story.body, "final_summary");
1556
1608
  if (summary === null) {
@@ -1860,7 +1912,7 @@ export function checkWorkWithoutStory(cfg, proj) {
1860
1912
  if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
1861
1913
  if (committedSince) what.push("commits newer than the vault's last activity");
1862
1914
  out.push(finding("vault", "warn", "work-without-story",
1863
- `${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault: /projectstore:story <EPIC> "<title>".${firedNote}`));
1915
+ `${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault: ${commandForm("story", { args: '<EPIC> "<title>"' })}.${firedNote}`));
1864
1916
  return out;
1865
1917
  }
1866
1918
 
@@ -1921,7 +1973,7 @@ export function checkCodeMap(cfg) {
1921
1973
  const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1922
1974
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1923
1975
  return [finding("vault", "issue", "code-map",
1924
- "code-map.md is stale against frontmatter code_refs — run /projectstore:codemap (or reconcile).", "code-map.md")];
1976
+ `code-map.md is stale against frontmatter code_refs — run ${commandForm("codemap")} (or reconcile).`, "code-map.md")];
1925
1977
  }
1926
1978
  return [];
1927
1979
  }
@@ -1934,7 +1986,7 @@ export function checkCodeMap(cfg) {
1934
1986
  export function checkGraph(cfg) {
1935
1987
  const p = join(cfg.vault_path, "graph.md");
1936
1988
  if (!existsSync(p)) {
1937
- return [finding("vault", "info", "graph", "No graph.md yet — run /projectstore:graph to create the link graph.")];
1989
+ return [finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`)];
1938
1990
  }
1939
1991
  const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
1940
1992
  encoding: "utf8",
@@ -1949,7 +2001,7 @@ export function checkGraph(cfg) {
1949
2001
  const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1950
2002
  if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1951
2003
  return [finding("vault", "issue", "graph",
1952
- "graph.md is out of sync with vault links — run /projectstore:graph (or reconcile).", "graph.md")];
2004
+ `graph.md is out of sync with vault links — run ${commandForm("graph")} (or reconcile).`, "graph.md")];
1953
2005
  }
1954
2006
  return [];
1955
2007
  }
@@ -2087,7 +2139,7 @@ function report(findings, groups) {
2087
2139
  }
2088
2140
  const issues = findings.filter((f) => f.level === "issue").length;
2089
2141
  const warns = findings.filter((f) => f.level === "warn").length;
2090
- lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? "Repairs: /projectstore:doctor --fix (install), /projectstore:kanban / reconcile (vault)." : "Vault and wiring look healthy."}`);
2142
+ lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
2091
2143
  return lines.join("\n");
2092
2144
  }
2093
2145
 
@@ -54,7 +54,8 @@ import {
54
54
  slugify,
55
55
  findSlugCollision,
56
56
  displayNumberOf,
57
- today, isMain
57
+ today, isMain,
58
+ commandForm,
58
59
  } from "./lib.mjs";
59
60
 
60
61
  function die(msg, code = 1) {
@@ -157,7 +158,7 @@ function buildStory(cfg, layout, args) {
157
158
  const vault = cfg.vault_path;
158
159
  const storiesDir = join(vault, folder.path, epicId, "stories");
159
160
  if (!existsSync(join(vault, folder.path, epicId))) {
160
- die(`Epic folder not found: ${folder.path}/${epicId}. Create the epic first via /projectstore:epic.`);
161
+ die(`Epic folder not found: ${folder.path}/${epicId}. Create the epic first via ${commandForm("epic")}.`);
161
162
  }
162
163
  const storyPrefix = folder.story_prefix || "story-";
163
164
  const slug = slugify(title);
@@ -234,7 +235,7 @@ function main() {
234
235
  const rest = argv.slice(1);
235
236
 
236
237
  const cfg = readConfig();
237
- if (!cfg) die("No projectstore config. Run /projectstore:bind <vault-path> first.");
238
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind", { args: "<vault-path>" })} first.`);
238
239
  const layout = loadLayout(cfg.layout);
239
240
 
240
241
  let result;
@@ -23,7 +23,8 @@ import {
23
23
  buildNodeIndex,
24
24
  extractLinks,
25
25
  resolveLinkTarget,
26
- storyMatchesEntry, isMain
26
+ storyMatchesEntry, isMain,
27
+ commandForm,
27
28
  } from "./lib.mjs";
28
29
  import { walkVaultFiles } from "./doctor.mjs";
29
30
 
@@ -204,7 +205,7 @@ export function buildGraph(cfg, layout, { files = null } = {}) {
204
205
 
205
206
  function main() {
206
207
  const cfg = readConfig();
207
- if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
208
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
208
209
  const layout = loadLayout(cfg.layout);
209
210
  const g = buildGraph(cfg, layout);
210
211
  process.stdout.write(JSON.stringify({