projectstore-claude 0.29.2 → 0.29.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -0,0 +1,185 @@
1
+ // projectstore — doctor-report.mjs: doctor's text report, drawn from its
2
+ // findings (the presentation spec, *Terminal presentation of the projectstore
3
+ // CLI: layout, glyphs, colour roles, questions and live lines*, contract 11).
4
+ //
5
+ // A renderer, not a second doctor: it imports no check and is pure — it
6
+ // returns text and writes nothing. That is why doctor.mjs itself stays
7
+ // spawned, never imported (the MCP ADR's decision 2 table), while this module
8
+ // is imported by both readers of the findings: the bin, which renders the
9
+ // findings of its one `--json` spawn in its own process (so a terminal gets
10
+ // colour), and doctor.mjs's own text mode, through a dynamic import. The
11
+ // dynamic import keeps the SessionStart hook's static graph — which reaches
12
+ // doctor.mjs — as it was; this module imports only term.mjs and lib.mjs.
13
+ //
14
+ // report(findings, groups, { caps, verbose, version, project, env }) → text
15
+ // CHECKS → the check table
16
+ //
17
+ // The layout: a badge with the version and the project's folder name; per
18
+ // section (INSTALL, VAULT) a heading with its counts and a rule; issues, then
19
+ // warnings, then notes; the Summary line last, verbatim — the write ceremony
20
+ // reads it (commands/story.md step 5a, agents/clerk.md). That line is
21
+ // lib.mjs's doctorSummaryLine, so a caller's fallback ends with it too.
22
+ //
23
+ // Two readers, one layout:
24
+ // - plain (a pipe, an agent's Bash tool) prints every finding, its message
25
+ // whole on one line with its path, and every note. Agents read it, and the
26
+ // vocabulary suite's about-filter cuts a finding out by its exact message;
27
+ // - a terminal gets the same report compacted: a cause with many instances
28
+ // lists each by its capture and path, at most eight, and the notes of the
29
+ // folding rows fold into one line. `--verbose` turns the compaction off.
30
+ // Every entry keeps its `[check]` id: the `--fix` walk of commands/doctor.md
31
+ // keys on it.
32
+
33
+ import { PLAIN, painter, icon, badge, heading, rule, rowLines, wrap, commandBlock } from "./term.mjs";
34
+ import { commandForm, doctorSummaryLine } from "./lib.mjs";
35
+
36
+ // ─── The check table ─────────────────────────────────────────────────────
37
+ //
38
+ // One row per check id that is grouped, fixed or folded; a check with no row
39
+ // prints each finding as it is. A row is { instance, title, fix, folds }:
40
+ // - instance: a pattern over the message, with zero or one capture. Two or
41
+ // more findings of one check and one level that match it form one entry:
42
+ // the title once, then each finding's capture and path. A finding that
43
+ // does not match — and a finding about another harness (`about`), whose
44
+ // exact message the vocabulary suite cuts out — prints on its own.
45
+ // - fix: what to do, listed once under the check's entries at each level,
46
+ // only for a check whose messages carry no command (contract 11): a
47
+ // sentence, or { say, command } where the command is composed through
48
+ // commandForm and stands on its own line (contract 6). Each is worded to
49
+ // be true for every message its check can carry.
50
+ // - folds: notes of this check fold on a terminal — true for all of them,
51
+ // or a pattern for the ones it names. The offer checks (doctor.mjs's
52
+ // OFFER_CHECKS: upgrade, layout-legacy) never fold, so they have no row.
53
+ const row = ({ instance = null, title = null, fix = null, folds = false }) => Object.freeze({ instance, title, fix, folds });
54
+
55
+ export const CHECKS = Object.freeze({
56
+ // Fixes: the checks whose messages name no command.
57
+ "code-refs": row({
58
+ instance: /^code_refs path "([^"]+)" does not resolve/,
59
+ title: "code_refs path does not resolve inside the project",
60
+ fix: "make each code_refs path exist in the project, and keep a story's paths inside its epic's",
61
+ }),
62
+ "wikilink": row({
63
+ instance: /^Dead wiki-link \[\[(.+)\]\]\.$/,
64
+ title: "Dead wiki-link",
65
+ fix: "make each [[link]] name exactly one existing artifact (correct the name, or qualify it with its folder); if the check was skipped, fix the error it names",
66
+ }),
67
+ "rel-link": row({
68
+ instance: /^Dead relative link \((.+)\)\.$/,
69
+ title: "Dead relative link",
70
+ fix: "point each relative link at a file that exists, or remove it",
71
+ }),
72
+ "acceptance": row({ fix: "tick the criteria the work met, or move the story out of done" }),
73
+ "review-status": row({ fix: "set reviewed_at to the review's date, or clear review_status" }),
74
+ "epic-status": row({ fix: "close the open stories, or move the epic out of done" }),
75
+ "spec-links": row({ fix: "make each spec's stories and each story's specs name existing artifacts that list each other back, written inline as specs: [\"id\"]" }),
76
+ "index": row({
77
+ instance: / is not listed in .+\/README\.md's index\.$/,
78
+ title: "Artifact is not listed in its folder's README index",
79
+ fix: { say: "rebuild the folder indexes from frontmatter", command: (env) => commandForm("reconcile", { env }) },
80
+ }),
81
+ "index-header": row({ fix: "rewrite the index header in a registered form, so reconcile can rebuild the index" }),
82
+ // Notes that fold on a terminal while their section has something to fix.
83
+ "surface": row({ folds: /— current, last written by / }),
84
+ "mcp": row({ folds: true }),
85
+ "statusline": row({ folds: /^Base HUD present/ }),
86
+ "harness": row({ folds: true }),
87
+ "plugin-registration": row({ folds: / registered from the npm package / }),
88
+ "spec-policy": row({ folds: true }),
89
+ "identity": row({ folds: true }),
90
+ "artifact-name": row({ folds: true }),
91
+ });
92
+
93
+ // The instance list on a terminal: at most this many, then `+N more`.
94
+ export const INSTANCE_CAP = 8;
95
+
96
+ const NONE = row({});
97
+ const LEVELS = Object.freeze([
98
+ // [level, glyph name, role, count word]
99
+ ["issue", "issue", "error", "issue"],
100
+ ["warn", "warning", "attention", "warning"],
101
+ // A note is not a warning: its own glyph, so the two differ without colour.
102
+ ["info", "info", "explanation", "note"],
103
+ ]);
104
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
105
+ // Joins the dash to the path after it, so wrapping never leaves a bare dash
106
+ // at a line's end with the path alone below; a space again once wrapped.
107
+ const GLUE = "";
108
+
109
+ const folderName = (p) => (p ? String(p).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || null : null);
110
+ const folds = (r, f) => f.level === "info" && Boolean(r.folds) && (r.folds === true || r.folds.test(f.message));
111
+ const capture = (r, f) => { const m = r.instance.exec(f.message); return m && m[1] !== undefined ? m[1] : null; };
112
+
113
+ export function report(findings, groups, { caps: c = PLAIN, verbose = false, version = null, project = null, env = process.env } = {}) {
114
+ const paint = painter(c);
115
+ // The terminal's compaction (contract 11, "on a TTY only"): the cap, the
116
+ // folds and the short instance lines. Plain output prints everything, and
117
+ // --verbose asks a terminal for the same.
118
+ const compact = c.live && !verbose;
119
+ const width = c.live ? c.width : 0;
120
+ const dash = icon(c, "dash");
121
+ // Prose at an indent: wrapped on a live terminal, every line after the
122
+ // first indented two further; one line anywhere else.
123
+ const prose = (n, text) => {
124
+ const pad = " ".repeat(n);
125
+ return wrap(text, width, pad + " ").split("\n").map((l, k) => (k ? l : pad + l).split(GLUE).join(" "));
126
+ };
127
+ const withPath = (text, f) => (f.file ? `${text} ${paint("explanation", dash)}${GLUE}${paint("explanation", f.file)}` : text);
128
+
129
+ function entries(level, check, r, these) {
130
+ const [, glyph, role] = LEVELS.find(([l]) => l === level);
131
+ const lead = `${paint(role, icon(c, glyph))} [${check}]`;
132
+ const matched = r.instance ? these.filter((f) => !f.about && r.instance.test(f.message)) : [];
133
+ const grouped = matched.length >= 2 ? matched : [];
134
+ const out = [];
135
+ for (const f of these) {
136
+ if (!grouped.includes(f)) { out.push(...prose(2, withPath(`${lead} ${f.message}`, f))); continue; }
137
+ if (f !== grouped[0]) continue;
138
+ out.push(...prose(2, `${lead} ${paint("emphasis", r.title)} (${grouped.length})`));
139
+ if (!compact) { for (const g of grouped) out.push(...prose(6, withPath(g.message, g))); continue; }
140
+ // Each instance a row with its own glyph: its capture, then its path.
141
+ // A path moved beneath its capture (contract 5) reads `— <path>`, as
142
+ // plain's `message — file` does, so neither colour nor the indent is
143
+ // all that tells it from the next capture.
144
+ const shown = grouped.slice(0, INSTANCE_CAP);
145
+ const laid = rowLines(c, shown.map((g) => { const cap = capture(r, g); return { glyph: "dot", role: "explanation", ...(cap === null ? { target: g.file || g.message } : { target: cap, note: g.file || "" }) }; }), { indent: 6 });
146
+ for (const [first, ...moved] of laid) out.push(first, ...moved.map((l, k) => (k ? l : l.replace(/^ */, (pad) => `${pad}${paint("explanation", dash)} `))));
147
+ if (grouped.length > shown.length) out.push(` ${paint("explanation", `+${grouped.length - shown.length} more`)}`);
148
+ }
149
+ if (r.fix) {
150
+ const fix = typeof r.fix === "string" ? { say: r.fix, command: null } : r.fix;
151
+ out.push(...prose(4, `${paint("action", "fix:")} ${fix.say}${fix.command ? ":" : "."}`));
152
+ if (fix.command) out.push(...commandBlock(c, [fix.command(env)], { indent: 6 }), "");
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function section(group) {
158
+ const fs = findings.filter((f) => f.group === group);
159
+ const n = Object.fromEntries(LEVELS.map(([l]) => [l, fs.filter((f) => f.level === l).length]));
160
+ // Each count in its role; a zero issue or warning count is the done role.
161
+ const counts = LEVELS.map(([l, , role, word]) => [plural(n[l], word), l === "info" ? role : n[l] ? role : "done"]);
162
+ const out = [heading(c, group.toUpperCase(), counts), rule(c)];
163
+ if (!fs.length) return [...out, ` ${paint("done", icon(c, "ok"))} clean`];
164
+ // A note folds only while its section has something to fix.
165
+ const folding = compact && n.issue + n.warn > 0;
166
+ let hidden = 0;
167
+ for (const [level] of LEVELS) {
168
+ const atLevel = fs.filter((f) => f.level === level);
169
+ for (const check of [...new Set(atLevel.map((f) => f.check))]) {
170
+ const r = Object.hasOwn(CHECKS, check) ? CHECKS[check] : NONE;
171
+ let these = atLevel.filter((f) => f.check === check);
172
+ if (folding) { const kept = these.filter((f) => !folds(r, f)); hidden += these.length - kept.length; these = kept; }
173
+ if (these.length) out.push(...entries(level, check, r, these));
174
+ }
175
+ }
176
+ if (hidden) out.push(` ${paint("explanation", `${plural(hidden, "note")} hidden ${icon(c, "dot")} --verbose shows them`)}`);
177
+ while (out.at(-1) === "") out.pop();
178
+ return out;
179
+ }
180
+
181
+ const lines = [badge(c, "projectstore doctor", [version, folderName(project)])];
182
+ for (const g of groups) lines.push("", ...section(g));
183
+ lines.push("", doctorSummaryLine(findings, { env }));
184
+ return lines.join("\n") + "\n";
185
+ }
@@ -14,6 +14,8 @@
14
14
  //
15
15
  // CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
16
16
  // default = --install --vault. Exit code is always 0 (reporting tool).
17
+ // Without --json the findings are drawn by doctor-report.mjs, the
18
+ // renderer the bin uses, in its plain layout.
17
19
 
18
20
  import {
19
21
  existsSync,
@@ -66,7 +68,8 @@ import {
66
68
  findAgentsBlock,
67
69
  statusLineLauncherPath,
68
70
  LAUNCHER_HEADER,
69
- installedPluginEntries, isMain,
71
+ installedPluginEntries, selectCopy, takesNoRegister, copyUpdateAdvice, isMain,
72
+ channelMark,
70
73
  pickExisting,
71
74
  legacyStatusLineLauncherPath,
72
75
  stateDir,
@@ -85,6 +88,7 @@ import {
85
88
  speakingHarness,
86
89
  inheritForm,
87
90
  bindInherits,
91
+ doctorFallbackText,
88
92
  } from "./lib.mjs";
89
93
  import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand, invocation } from "./harness.mjs";
90
94
 
@@ -101,6 +105,7 @@ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness,
101
105
  // brand, and --harness still carries the target.
102
106
  import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
103
107
  import { resolveBinding } from "./worktree.mjs";
108
+ import { missingScaffoldRequirements } from "./scaffold.mjs";
104
109
 
105
110
  const AGENT_BLOCK_MARKER = new RegExp(AGENTS_BLOCK_OPEN_SRC, "g");
106
111
  // The provenance grammar's prefix, duplicated here on purpose: the startup
@@ -257,6 +262,15 @@ export function checkLayoutTemplates(cfg) {
257
262
  out.push(finding("install", "issue", "templates", `Missing template for language "${lang}": ${k}.md.tmpl`));
258
263
  }
259
264
  }
265
+ // What `scaffold` (and `init`) refuse on, through the same check the plan
266
+ // makes: the vault README's template, and a name and a description for every
267
+ // folder kind the layout declares — `diagram` included. A warn, not an issue:
268
+ // a vault already scaffolded is not broken by a hand-added kind; only its
269
+ // next scaffold is refused. The folder README is the issue above.
270
+ for (const m of missingScaffoldRequirements(layout, lang, pluginRoot())) {
271
+ if (m.what === "template" && m.name === "folder-readme") continue;
272
+ out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
273
+ }
260
274
  if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
261
275
  out.push(finding("install", "issue", "templates",
262
276
  "scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?"));
@@ -492,14 +506,17 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
492
506
  // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
493
507
  // every harness the project uses. rc.1 and rc.2 left a block in an
494
508
  // AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
495
- // nothing said so. Used means detected by directory, or identified from the
496
- // environment — never the source harness a terminal run falls back to, so a
497
- // Codex-only project hears nothing about CLAUDE.md (contract 16). An issue
509
+ // nothing said so. Used means detected by directory or by its state
510
+ // directory, or identified from the environment — never the source harness
511
+ // a terminal run falls back to, so a Codex-only project hears nothing about
512
+ // CLAUDE.md (contract 16). The state directory is what a bare upgrade and a
513
+ // bare uninstall select by, so doctor warns about exactly the block they
514
+ // move (the story "One run plans the agents block once…", rule 5). An issue
498
515
  // for the harness that identified itself, a warning for one only detected.
499
516
  const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
500
517
  if (blockFile) {
501
518
  const identified = identifiedHarnessId(env);
502
- const used = new Set([...detectHarnesses(proj).map((d) => d.id), ...(identified ? [identified] : [])]);
519
+ const used = new Set([...detectHarnesses(proj, { state: true }).map((d) => d.id), ...(identified ? [identified] : [])]);
503
520
  for (const id of used) {
504
521
  const m = loadHarness(id);
505
522
  if (!m || blockVisibleTo(m, blockFile, texts)) continue;
@@ -616,9 +633,12 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
616
633
  push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run ${invocation(sh, "agents", { args: "register" })}.`, where));
617
634
  }
618
635
  }
619
- // The statusline entry's states are checkStatusline's, under its own id —
620
- // its ours-stale (a command this installation would not write) is
621
- // self-healing: syncStatusLine rewrites it on the next SessionStart.
636
+ // The statusline entry's states are checkStatusline's, under its own id.
637
+ // Its ours-stale (a command this installation would not write) is not
638
+ // always self-healing: syncStatusLine re-points a direct entry at the
639
+ // running root's script and never touches an entry naming a launcher on
640
+ // disk, so install is what re-points an entry at the launcher
641
+ // (contract 0 as amended 2026-10-10).
622
642
  }
623
643
  return out;
624
644
  }
@@ -655,8 +675,9 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
655
675
  // Only when another harness in this project HAS an overlay. A project with
656
676
  // none at all is the ordinary fresh state and needs no advice; the asymmetry
657
677
  // is what is actionable, because it is almost always the second harness that
658
- // was forgotten rather than the first that was deliberate.
659
- const used = detectHarnesses(proj).map((d) => d.id);
678
+ // was forgotten rather than the first that was deliberate. Used as a bare
679
+ // upgrade or uninstall selects: by directory or by state directory.
680
+ const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
660
681
  if (used.length > 1) {
661
682
  const overlays = used.map((id) => ({ id, o: readOverlayAt(proj, loadHarness(id)?.runtime?.overlay || id) }));
662
683
  const configured = overlays.filter(({ o }) => o.present);
@@ -691,8 +712,13 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
691
712
  // itself); a competing copy enabled beside ours → an issue (two enabled copies
692
713
  // of one plugin); a competitor alone → an info naming the npm path; foreign →
693
714
  // never repairable; the host CLI missing → an info.
694
- export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness() } = {}) {
715
+ export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness(), cfg = null } = {}) {
695
716
  const out = [];
717
+ // A checkout whose binding records a channel switch (the converge story's
718
+ // 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
720
+ // reads who made the mark.
721
+ const marked = Boolean(channelMark(cfg));
696
722
  for (const s of states.filter((x) => x.kind === "registration")) {
697
723
  // The shell form when the manifest names a shell (contract 12): the
698
724
  // command a user can paste, built in one place.
@@ -719,6 +745,7 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
719
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));
720
746
  continue;
721
747
  }
748
+ if (marked) continue;
722
749
  // The release line, not the build: a 0.28 release candidate reads a
723
750
  // moved project; 0.27.x reads it as unbound. No version reads as old.
724
751
  const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
@@ -737,8 +764,16 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
737
764
  } else if (s.state === "stale") {
738
765
  push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
739
766
  } else if (s.state === "current") {
767
+ // The host loads a host plugin registration in place, from the
768
+ // plugin's root inside our marketplace directory (the payload — what
769
+ // its plugin-root variable names); the install path is the registry's
770
+ // copy, the one the launcher renders (measured on host 2.1.293 and
771
+ // 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}`;
740
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));
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}.`));
776
+ else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (${loaded}); refresh with ${refresh}.`));
742
777
  }
743
778
  }
744
779
  return out;
@@ -913,20 +948,13 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
913
948
  // the move never needs the registration, so a misread channel can cost a
914
949
  // launcher stamp but never a channel switch. A copy that predates the move
915
950
  // (no bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first.
916
- // Nor can one whose CLI predates --no-register (takesNoRegister).
951
+ // Nor can one whose CLI predates --no-register (lib's takesNoRegister).
917
952
  //
918
- // Whether a registry copy can run the command named for it. `--no-register`
919
- // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
920
- // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
921
- // amended after the rc.3 tag). Read from the copy's own parse table, never
922
- // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
923
- // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
924
- // file read, because this runs in the SessionStart subset: never an import()
925
- // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
926
- // released copies keep their parse there.
927
- export function takesNoRegister(copy) {
928
- try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
929
- }
953
+ // The selection, the capability read and the copy advice are lib's
954
+ // (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
955
+ // installer's keep (the converge story's rule 5). takesNoRegister is
956
+ // re-exported here, where its callers already import it.
957
+ export { takesNoRegister };
930
958
 
931
959
  export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
932
960
  // A session under a relocated host home hands it on: a terminal without it
@@ -939,29 +967,23 @@ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harn
939
967
  const channel = installChannel(root, { home, harness });
940
968
  if (channel === "registration") return shell(pluginVersion(root));
941
969
  if (channel === "marketplace") return own(root);
942
- // Real paths: the host records the project's cwd as one, and /var against
943
- // /private/var would otherwise drop the project's own row.
944
- const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
945
- const here = real(proj);
946
- const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
947
- const copy = installedPluginEntries(home, proj)
948
- .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
949
- .sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
970
+ // The selection is lib's selectCopy: this checkout's row first, then
971
+ // user-scope rows by recency, never another checkout's.
972
+ const copy = selectCopy(home, proj, { harness });
950
973
  if (copy) {
951
974
  const v = copy.version ? ` (${copy.version})` : "";
952
- if (!existsSync(join(copy.path, "bin", "projectstore.mjs"))) {
953
- return { advice: `This project's projectstore plugin${v} predates the move and cannot run it: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
954
- }
975
+ 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 }) };
955
977
  // Chosen first, gated after: the copy is what the project runs, so an
956
978
  // incapable one gets advice even beside a capable user-scope copy.
957
- const viaRegistration = installChannel(copy.path, { home, harness }) === "registration";
958
- if (!takesNoRegister(copy.path)) {
979
+ const viaRegistration = copy.channel === "registration";
980
+ if (copy.missing === "no-register") {
959
981
  return { advice: viaRegistration
960
982
  // Its channel's ordinary refresh, at the running version: the plain
961
983
  // upgrade re-registers from npm — not a switch for an npm project — and
962
984
  // moves the project in the same run.
963
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}"` })}`
964
- : `This project's projectstore plugin${v} predates --no-register and cannot run the move as named: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
986
+ : copyUpdateAdvice(copy, { cannot: "predates --no-register and cannot run the move as named", then }) };
965
987
  }
966
988
  return viaRegistration ? shell(copy.version) : own(copy.path);
967
989
  }
@@ -2032,7 +2054,7 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2032
2054
  let read = null;
2033
2055
  try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
2034
2056
  out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
2035
- if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home }));
2057
+ if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
2036
2058
  if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
2037
2059
  return foldIntoMove(out);
2038
2060
  }
@@ -2122,27 +2144,6 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2122
2144
 
2123
2145
  // ─── CLI ───────────────────────────────────────────────────────────────
2124
2146
 
2125
- function icon(level) {
2126
- return level === "issue" ? "✖" : level === "warn" ? "⚠" : "ℹ";
2127
- }
2128
-
2129
- function report(findings, groups) {
2130
- const ver = pluginVersion();
2131
- const lines = [`projectstore doctor — plugin v${ver || "?"}, ${new Date().toISOString().slice(0, 10)}`];
2132
- for (const g of groups) {
2133
- const fs = findings.filter((f) => f.group === g);
2134
- lines.push("", `## ${g} (${fs.filter((f) => f.level === "issue").length} issue(s), ${fs.filter((f) => f.level === "warn").length} warning(s))`);
2135
- if (!fs.length) lines.push(" ✓ clean");
2136
- for (const f of fs) {
2137
- lines.push(` ${icon(f.level)} [${f.check}] ${f.message}${f.file ? ` — ${f.file}` : ""}`);
2138
- }
2139
- }
2140
- const issues = findings.filter((f) => f.level === "issue").length;
2141
- const warns = findings.filter((f) => f.level === "warn").length;
2142
- lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
2143
- return lines.join("\n");
2144
- }
2145
-
2146
2147
  async function main() {
2147
2148
  const args = process.argv.slice(2);
2148
2149
  const wantJson = args.includes("--json");
@@ -2171,7 +2172,24 @@ async function main() {
2171
2172
  findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
2172
2173
  }
2173
2174
 
2174
- process.stdout.write((wantJson ? JSON.stringify(findings, null, 2) : report(findings, groups)) + "\n");
2175
+ if (wantJson) { process.stdout.write(JSON.stringify(findings, null, 2) + "\n"); return; }
2176
+ process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
2177
+ }
2178
+
2179
+ // The text is the bin's renderer's, in its plain layout (the presentation
2180
+ // spec, contract 11) — one report, whoever prints it. Imported at run time,
2181
+ // and nowhere else in this file: the SessionStart hook imports this module
2182
+ // statically, and the renderer's term.mjs is not on its path. A renderer
2183
+ // that cannot load, or throws, costs the reader nothing: the findings one
2184
+ // 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") } = {}) {
2186
+ try {
2187
+ const { report } = await load();
2188
+ return report(findings, groups, { version, project, env });
2189
+ } 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`);
2191
+ return doctorFallbackText(findings, { env });
2192
+ }
2175
2193
  }
2176
2194
 
2177
2195
  if (isMain(import.meta.url)) {