projectstore-codex 0.0.1 → 0.28.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 (185) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +163 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +36 -14
  155. package/plugin.json +53 -0
  156. package/skills/projectstore-adr/SKILL.md +76 -0
  157. package/skills/projectstore-agents/SKILL.md +50 -0
  158. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  159. package/skills/projectstore-bind/SKILL.md +44 -0
  160. package/skills/projectstore-clerk/SKILL.md +126 -0
  161. package/skills/projectstore-codemap/SKILL.md +69 -0
  162. package/skills/projectstore-concept/SKILL.md +36 -0
  163. package/skills/projectstore-critic/SKILL.md +127 -0
  164. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  165. package/skills/projectstore-doctor/SKILL.md +33 -0
  166. package/skills/projectstore-epic/SKILL.md +59 -0
  167. package/skills/projectstore-graph/SKILL.md +75 -0
  168. package/skills/projectstore-kanban/SKILL.md +60 -0
  169. package/skills/projectstore-librarian/SKILL.md +114 -0
  170. package/skills/projectstore-meeting/SKILL.md +36 -0
  171. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  172. package/skills/projectstore-planner/SKILL.md +113 -0
  173. package/skills/projectstore-reconcile/SKILL.md +92 -0
  174. package/skills/projectstore-research/SKILL.md +36 -0
  175. package/skills/projectstore-review/SKILL.md +108 -0
  176. package/skills/projectstore-reviewer/SKILL.md +131 -0
  177. package/skills/projectstore-runbook/SKILL.md +36 -0
  178. package/skills/projectstore-scaffold/SKILL.md +42 -0
  179. package/skills/projectstore-search/SKILL.md +41 -0
  180. package/skills/projectstore-spec/SKILL.md +110 -0
  181. package/skills/projectstore-status/SKILL.md +47 -0
  182. package/skills/projectstore-statusline/SKILL.md +29 -0
  183. package/skills/projectstore-story/SKILL.md +132 -0
  184. package/skills/projectstore-story-completion/SKILL.md +69 -0
  185. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,2127 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — doctor.mjs
3
+ // Deterministic, no-LLM diagnostics engine (ADR-005). Exports individual check
4
+ // functions plus group runners; consumed by the /projectstore:doctor command,
5
+ // the SessionStart hook (cheap --startup subset) and, later, reconcile.
6
+ //
7
+ // Read-only by contract: detection never mutates anything. Repairs live behind
8
+ // the command's --fix flow (install side) and reconcile (vault side).
9
+ //
10
+ // Finding: { group: "install"|"vault", level: "issue"|"warn"|"info",
11
+ // check: "<id>", message: "...", file?: "<path>" }
12
+ // The SessionStart line counts level==="issue" only.
13
+ //
14
+ // CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
15
+ // default = --install --vault. Exit code is always 0 (reporting tool).
16
+
17
+ import {
18
+ existsSync,
19
+ readFileSync,
20
+ readdirSync,
21
+ statSync,
22
+ accessSync,
23
+ constants,
24
+ realpathSync,
25
+ } from "node:fs";
26
+ import { join, basename, resolve, dirname, relative } from "node:path";
27
+ import { homedir } from "node:os";
28
+ import { spawnSync } from "node:child_process";
29
+ import {
30
+ readConfig,
31
+ loadLayout,
32
+ folderByKind,
33
+ parseFrontmatter,
34
+ pluginRoot,
35
+ projectRoot,
36
+ listOf,
37
+ readVaultConfig,
38
+ claudeHome,
39
+ installedPluginRoot,
40
+ isPluginCacheRoot,
41
+ statusLineIsOurs,
42
+ statusLineIsOurWiring,
43
+ isLegacyStory,
44
+ sectionOf,
45
+ headingLineRe,
46
+ indexHeaderRe,
47
+ evidenceSuffixRe,
48
+ storiesAttributionRe,
49
+ slugIdentity,
50
+ isLegacyNumberedId,
51
+ storyMatchesEntry,
52
+ legalArtifactName,
53
+ stripCodeSpans,
54
+ extractLinks,
55
+ buildNodeIndex,
56
+ resolveLinkTarget,
57
+ listVaultStoryFiles,
58
+ openStoryFrom,
59
+ readEntryLog,
60
+ lastVaultActivityMs,
61
+ ENTRY_IGNORE,
62
+ AGENTS_BLOCK_OPEN_SRC,
63
+ AGENTS_BLOCK_CLOSE,
64
+ agentsBlockVersion,
65
+ findAgentsBlock,
66
+ statusLineLauncherPath,
67
+ LAUNCHER_HEADER,
68
+ installedPluginEntries, isMain,
69
+ pickExisting,
70
+ legacyStatusLineLauncherPath,
71
+ stateDir,
72
+ legacyStateDir,
73
+ layoutPaths, sessionsDir,
74
+ isLauncherPath,
75
+ LAYOUT,
76
+ hostSettingsPath,
77
+ readOverlayAt, layoutRoster,
78
+ installChannel,
79
+ cmpPrecedence,
80
+ cmpVersion,
81
+ blockVisibleTo,
82
+ } from "./lib.mjs";
83
+ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
84
+
85
+ // A remedy used to interpolate the surface's harness variable here. It cannot:
86
+ // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
87
+ // finding is runtime output, which nothing substitutes — braced or not, the
88
+ // reader would get the literal and the shell would expand it to nothing
89
+ // ("Cannot find module '/bin/projectstore.mjs'"). A remedy now names the
90
+ // resolved root, which this installation knows. The prose asks the host for
91
+ // the same path through ${CLAUDE_PLUGIN_ROOT}, which IS substituted in
92
+ // command, skill and agent content — different text, one resolution. See the
93
+ // story "The prompt surface asks the shell for a variable the host would have
94
+ // substituted"; harness neutrality is unaffected, an absolute path carries no
95
+ // brand, and --harness still carries the target.
96
+ import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
97
+ import { resolveBinding } from "./worktree.mjs";
98
+
99
+ const AGENT_BLOCK_MARKER = new RegExp(AGENTS_BLOCK_OPEN_SRC, "g");
100
+ // The provenance grammar's prefix, duplicated here on purpose: the startup
101
+ // path may not load the provenance leaf, and this is all it needs to tell a
102
+ // stamped launcher from one written before stamps existed. A test in
103
+ // tests/provenance.test.mjs keeps the literal equal to what the emitter writes.
104
+ export const STAMP_PREFIX = "projectstore: v";
105
+ // Startup findings that are offers, not issues: rendered by the SessionStart
106
+ // hook as their own line, so an info a user should act on once is not lost
107
+ // behind the issue count.
108
+ export const OFFER_CHECKS = new Set(["upgrade", "layout-legacy"]);
109
+ // checkAgentsBlock compares the marker version alone, so a bump in the
110
+ // template IS the propagation mechanism: without it no bound project ever
111
+ // learns the block changed. The cost is that every already-bound project
112
+ // reports an install issue until it re-runs /projectstore:agents register —
113
+ // intended, and disclosed in the release note. The version is read from the
114
+ // template (lib.mjs agentsBlockVersion), never from a constant here.
115
+ // The live roster. A copy carrying one of these names does NOT override the
116
+ // bundled agent (ADR-008, verified 2026-08-05): plugin agents register under a
117
+ // scoped id, project/user copies register bare, so the names never collide and
118
+ // the documented scope-priority rule never fires. Such a copy is a sibling, and
119
+ // /projectstore:agents configure no longer writes one.
120
+ const CURRENT_AGENT_NAMES = ["critic", "planner", "reviewer", "librarian", "archaeologist", "clerk"];
121
+ // Names bundled BEFORE v0.13 (ADR-001/004). Provenance checks need them to
122
+ // recognise a copy as ours.
123
+ //
124
+ // `renamed` and `replaced` still carry different advice, but NOT the advice this
125
+ // table originally held. It used to say a pure rename (projectstore-critic →
126
+ // critic) "restores the override" — under ADR-008 nothing restores an override,
127
+ // because a bare-named copy never overrode the scoped plugin agent to begin
128
+ // with. What survives is the role question: critic was the same role under a new
129
+ // name, whereas planner/reviewer were *transformed* into narrow vault-aware
130
+ // roles that explicitly are not general-purpose, so renaming a general-purpose
131
+ // copy onto them would silently swap its job.
132
+ const LEGACY_AGENTS = {
133
+ "projectstore-critic": { now: "critic", renamed: true },
134
+ "code-planner": { now: "planner", renamed: false },
135
+ "code-reviewer": { now: "reviewer", renamed: false },
136
+ };
137
+ const BUNDLED_AGENT_NAMES = [...CURRENT_AGENT_NAMES, ...Object.keys(LEGACY_AGENTS)];
138
+
139
+ function finding(group, level, check, message, file) {
140
+ const f = { group, level, check, message };
141
+ if (file) f.file = file;
142
+ return f;
143
+ }
144
+
145
+ function pluginVersion(root = pluginRoot()) {
146
+ try {
147
+ return JSON.parse(
148
+ readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"),
149
+ ).version;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function listMd(dir) {
156
+ try {
157
+ return readdirSync(dir).filter((n) => n.endsWith(".md"));
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ // ─── Install checks ────────────────────────────────────────────────────
164
+
165
+ // `proj` is a parameter rather than a projectRoot() call so the worktree probe
166
+ // is testable, and so a future refactor cannot make this spawn git for a project
167
+ // it was not asked about. The probe runs only on the unbound branch: a bound
168
+ // project never pays for it.
169
+ export function checkConfig(cfg, proj = projectRoot()) {
170
+ if (!cfg) {
171
+ // An unbound worktree of a bound checkout needs the opposite advice from a
172
+ // project that was never bound — adopt the parent's vault, do not choose a
173
+ // new one. Assigned rather than appended: two instructions for one problem
174
+ // is how a person ends up binding a second vault by hand.
175
+ let b = null;
176
+ try { b = resolveBinding(proj); } catch {}
177
+ if (b && b.state === "inheritable") {
178
+ 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.`)];
180
+ }
181
+ const p = layoutPaths(proj);
182
+ const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
183
+ if (present) {
184
+ return [finding("install", "issue", "config-unparseable",
185
+ `${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
+ }
187
+ return [finding("install", "issue", "config",
188
+ "No projectstore config (.projectstore/projectstore.json). Run /projectstore:bind <vault-path>.")];
189
+ }
190
+ const out = [];
191
+ if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
192
+ return out;
193
+ }
194
+
195
+ export function checkVaultPath(cfg) {
196
+ const out = [];
197
+ const vault = cfg.vault_path;
198
+ if (!existsSync(vault)) {
199
+ out.push(finding("install", "issue", "vault-path", `Vault path does not exist: ${vault}`));
200
+ return out;
201
+ }
202
+ try {
203
+ readdirSync(vault);
204
+ } catch {
205
+ out.push(finding("install", "issue", "vault-path", `Vault path is not readable/listable: ${vault}`));
206
+ return out;
207
+ }
208
+ try {
209
+ accessSync(vault, constants.W_OK);
210
+ } catch {
211
+ out.push(finding("install", "issue", "vault-path", `Vault path is not writable: ${vault}`));
212
+ }
213
+ return out;
214
+ }
215
+
216
+ export function checkLayoutTemplates(cfg) {
217
+ const out = [];
218
+ let layout;
219
+ try {
220
+ layout = loadLayout(cfg.layout);
221
+ } catch (e) {
222
+ out.push(finding("install", "issue", "layout", `Layout not loadable: ${e.message}`));
223
+ return out;
224
+ }
225
+ const lang = cfg.language || "en";
226
+ // Layout-driven (PS-SPEC story-001): a command needs a template iff it maps
227
+ // to a declared folder kind ("story" maps through the epic folder; "kanban"
228
+ // through the layout's kanban block). Folders WITHOUT a command (e.g.
229
+ // diagrams) require no template — no false findings for them.
230
+ const kinds = (layout.commands || []).filter((k) => {
231
+ if (k === "kanban") return Boolean(layout.kanban);
232
+ if (k === "story") return Boolean(folderByKind(layout, "epic"));
233
+ return Boolean(folderByKind(layout, k));
234
+ });
235
+ kinds.push("folder-readme");
236
+ for (const k of kinds) {
237
+ const p = join(pluginRoot(), "templates", lang, `${k}.md.tmpl`);
238
+ if (!existsSync(p)) {
239
+ out.push(finding("install", "issue", "templates", `Missing template for language "${lang}": ${k}.md.tmpl`));
240
+ }
241
+ }
242
+ if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
243
+ out.push(finding("install", "issue", "templates",
244
+ "scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?"));
245
+ }
246
+ return out;
247
+ }
248
+
249
+ export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
250
+ const dir = sessionsDir(cfg.vault_path);
251
+ if (!existsSync(dir)) {
252
+ return [finding("install", "warn", "hooks",
253
+ "No session registry in the vault — SessionStart hook may not be firing (or no session started yet).")];
254
+ }
255
+ const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
256
+ const fresh = readdirSync(dir).some((n) => {
257
+ if (!n.endsWith(".json")) return false;
258
+ try { return statSync(join(dir, n)).mtimeMs >= cutoff; } catch { return false; }
259
+ });
260
+ return fresh ? [] : [finding("install", "warn", "hooks",
261
+ `No session registration fresher than ${maxAgeMinutes} min — hooks may not be firing.`)];
262
+ }
263
+
264
+ // Which plugin version a wired statusLine script belongs to. Null for our
265
+ // launcher (<project>/.projectstore/state/claude-code/statusline.mjs), which has no
266
+ // version of its own — it resolves the installed one at render time.
267
+ export function statusLineScriptVersion(scriptPath) {
268
+ try {
269
+ const root = dirname(dirname(scriptPath)); // <root>/scripts/statusline.mjs
270
+ return (
271
+ JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null
272
+ );
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ // Read-only probe of the statusline wiring (never calls syncStatusLine, which
279
+ // is a mutating self-heal that SessionStart already ran — ADR-005).
280
+ export function checkStatusline(cfg, proj, home = homedir()) {
281
+ const out = [];
282
+ const local = hostSettingsPath(proj);
283
+ let cur = null;
284
+ if (existsSync(local)) {
285
+ try {
286
+ cur = JSON.parse(readFileSync(local, "utf8"))?.statusLine ?? null;
287
+ } catch {
288
+ out.push(finding("install", "warn", "statusline", `.claude/settings.local.json is not parseable JSON.`));
289
+ return out;
290
+ }
291
+ }
292
+ const curCmd = cur && typeof cur.command === "string" ? cur.command : null;
293
+ // Strict: only a wiring we could have written is ours to rewire or delete.
294
+ const isOurs = statusLineIsOurWiring(curCmd, proj, home);
295
+ const st = cfg.statusline;
296
+
297
+ if (st && st.enabled === true) {
298
+ if (!curCmd) {
299
+ 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."));
301
+ } else if (!isOurs) {
302
+ out.push(finding("install", "issue", "statusline",
303
+ "statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag."));
304
+ } else {
305
+ const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
306
+ const wiredRoot = m ? dirname(dirname(m[1])) : null;
307
+ const isLauncher = m ? isLauncherPath(m[1]) : false;
308
+ if (m && !existsSync(m[1])) {
309
+ out.push(finding("install", "issue", "statusline",
310
+ 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.`
312
+ : `statusLine points at a missing script (stale plugin path?): ${m[1]}`));
313
+ } else if (m && isPluginCacheRoot(wiredRoot, home)) {
314
+ // Only a versioned cache path can go stale this way. The launcher
315
+ // carries no version, and a dev checkout is wired deliberately and
316
+ // never rewired — warning about either would be a permanent lie.
317
+ const wired = statusLineScriptVersion(m[1]);
318
+ const inst = installedPluginRoot(home, dirname(wiredRoot));
319
+ if (wired && inst && inst.version && wired !== inst.version) {
320
+ 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.`));
322
+ }
323
+ }
324
+ }
325
+ } else if (st && st.enabled === false && isOurs) {
326
+ out.push(finding("install", "warn", "statusline",
327
+ "statusline.enabled=false but our statusLine entry is still wired — the hook removes it on next session start."));
328
+ } else if ((!st || typeof st.enabled !== "boolean") && isOurs) {
329
+ out.push(finding("install", "info", "statusline",
330
+ "statusLine wired manually (no statusline flag in projectstore.json) — the hook will leave it alone."));
331
+ }
332
+
333
+ try {
334
+ const base = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"))?.statusLine?.command;
335
+ if (base && !statusLineIsOurs(base)) {
336
+ out.push(finding("install", "info", "statusline", "Base HUD present in your user settings.json — projectstore composes above it."));
337
+ }
338
+ } catch {}
339
+ // session_id divergence (ADR-006): the renderer's breadcrumb names the id
340
+ // the statusLine process received; hook-side pointer files name the ids the
341
+ // hooks observed. A breadcrumb id with no pointer file while others exist
342
+ // means the two processes disagree — the issue note's second suspect.
343
+ try {
344
+ const sdir = pickExisting(stateDir(proj), legacyStateDir(proj));
345
+ const bc = JSON.parse(readFileSync(join(sdir, ".last-render.json"), "utf8"));
346
+ if (bc && bc.session_id) {
347
+ const hookIds = readdirSync(sdir)
348
+ .filter((n) => n.endsWith(".json") && !n.startsWith("."))
349
+ .map((n) => n.replace(/\.json$/, ""));
350
+ if (hookIds.length && !hookIds.includes(bc.session_id)) {
351
+ out.push(finding("install", "warn", "statusline",
352
+ `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).`));
353
+ }
354
+ }
355
+ // What the user is actually looking at: the renderer stamps the version
356
+ // that drew the line. Breadcrumbs without a root (pre-0.16.0) and dev
357
+ // checkouts are skipped — only a cache install can go stale this way.
358
+ if (bc && bc.version && isPluginCacheRoot(bc.root, home)) {
359
+ const inst = installedPluginRoot(home, dirname(bc.root));
360
+ if (inst && inst.version && inst.version !== bc.version) {
361
+ out.push(finding("install", "warn", "statusline",
362
+ `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.`));
363
+ }
364
+ }
365
+ } catch {}
366
+ return out;
367
+ }
368
+
369
+ // The one-time offer after a plugin update (the story "Seamless upgrade from
370
+ // 0.27.1 to 0.28"): a launcher we wrote before file stamps existed still
371
+ // renders, but its embedded fallback root is frozen at the old version until
372
+ // install re-stamps it — which the SessionStart hook may not do (it cannot
373
+ // load the provenance leaf). So the startup line names the step. Only for a
374
+ // cache install: a dev checkout does not produce the launcher at all, and its
375
+ // install would leave the file, not re-stamp it.
376
+ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot()) {
377
+ if (!isPluginCacheRoot(root, home)) return [];
378
+ // Only a launcher our entry runs. Under a foreign status line nothing reads
379
+ // it, install leaves that slot alone, and the offer would repeat every
380
+ // session with a command that cannot clear it (the critic of the layout
381
+ // spec's 2026-10-03 amendment, case S1).
382
+ let wired = null;
383
+ try { wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null; } catch {}
384
+ if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
385
+ const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
386
+ let text;
387
+ try { text = readFileSync(lp, "utf8"); } catch { return []; }
388
+ if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
389
+ 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.",
391
+ relative(proj, lp))];
392
+ }
393
+
394
+ // The two findings the layout move itself repairs carry what they say without
395
+ // their remedy, so a pending move can name itself instead (foldIntoMove).
396
+ const moveRepairs = (f, fact) => ({ ...f, byMove: fact });
397
+
398
+ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot() } = {}) {
399
+ const out = [];
400
+ const AGENT_BLOCK_VERSION = agentsBlockVersion();
401
+ const texts = {};
402
+ // One parser for every reader (findAgentsBlock): its count is the loose one,
403
+ // so a good block plus a re-wrapped marker in one file is "more than once"
404
+ // here exactly as install and uninstall see it (both refuse), never a quiet
405
+ // startup; a wrapped marker on its own is named with its line — "not
406
+ // registered" is the reading that makes install append a second block.
407
+ let blocks = 0;
408
+ let wrappedFiles = 0;
409
+ let unclosedFiles = 0;
410
+ const perFile = {};
411
+ const staleVersions = [];
412
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
413
+ const p = join(proj, name);
414
+ if (!existsSync(p)) continue;
415
+ let text;
416
+ try { text = readFileSync(p, "utf8"); } catch { continue; }
417
+ texts[name] = text;
418
+ const f = findAgentsBlock(text);
419
+ if (!f) continue;
420
+ perFile[name] = f.count;
421
+ blocks += f.count;
422
+ if (f.wrapped) {
423
+ wrappedFiles++;
424
+ 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));
426
+ continue;
427
+ }
428
+ if (f.unclosed) {
429
+ // Named here as the wrapped marker is, so the startup count carries it:
430
+ // the agents-block plan refuses it, and the layout move with it.
431
+ unclosedFiles++;
432
+ 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));
434
+ }
435
+ for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
436
+ const v = parseInt(m[1], 10);
437
+ if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
438
+ }
439
+ }
440
+ if (blocks === 0) {
441
+ out.push(finding("install", "info", "agents-block",
442
+ "Agent routing block not registered — optional; ships with /projectstore:agents (v0.13)."));
443
+ }
444
+ if (blocks > 1) {
445
+ // One block in each file is a state install resolves (it keeps the
446
+ // preferred file's); two in one file is not, and stays an issue — and a
447
+ // wrapped marker anywhere means install refuses, so the "both files"
448
+ // advice is withheld while one is wrapped or never closes.
449
+ const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
450
+ if (twiceInOne) {
451
+ out.push(finding("install", "issue", "agents-block",
452
+ `${twiceInOne[0]} carries the projectstore:agents block ${twiceInOne[1]} times — keep exactly one; install refuses until it does.`, twiceInOne[0]));
453
+ } else if (wrappedFiles || unclosedFiles) {
454
+ // already named above, file by file
455
+ } else {
456
+ 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.`));
458
+ }
459
+ }
460
+ // A state the agents-block plan refuses — a wrapped marker, a block that
461
+ // never closes, a block twice in one file — refuses the move with it, so
462
+ // nothing here is the move's to repair while one stands.
463
+ const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
464
+ for (const s of staleVersions) {
465
+ 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);
467
+ out.push(refuses ? f : moveRepairs(f, fact));
468
+ }
469
+ // Placement, held to the predicate install plans from (the install spec,
470
+ // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
471
+ // every harness the project uses. rc.1 and rc.2 left a block in an
472
+ // AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
473
+ // nothing said so. Used means detected by directory, or identified from the
474
+ // environment — never the source harness a terminal run falls back to, so a
475
+ // Codex-only project hears nothing about CLAUDE.md (contract 16). An issue
476
+ // for the harness that identified itself, a warning for one only detected.
477
+ const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
478
+ if (blockFile) {
479
+ const identified = identifiedHarnessId(env);
480
+ const used = new Set([...detectHarnesses(proj).map((d) => d.id), ...(identified ? [identified] : [])]);
481
+ for (const id of used) {
482
+ const m = loadHarness(id);
483
+ if (!m || blockVisibleTo(m, blockFile, texts)) continue;
484
+ const ab = m.surfaces.agents_block;
485
+ const why = (ab.files || []).includes(blockFile)
486
+ ? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
487
+ : `it reads ${(ab.files || []).join(" and ")} only`;
488
+ const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
489
+ // The resolved-root form the surface remedy uses: the running copy's own bin.
490
+ const remedy = `install the block for ${m.display_name}: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block --project "${proj}"`;
491
+ const f = finding("install", id === identified ? "issue" : "warn", "agents-block", `${fact} — ${remedy}.`, blockFile);
492
+ // The layout's harness is the one the move's command installs for, so the
493
+ // move plans this import; another harness's is not the move's to repair.
494
+ out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
495
+ }
496
+ }
497
+ return out;
498
+ }
499
+
500
+ // While the layout move is pending, the findings the move itself repairs fold
501
+ // into it instead of being counted beside it (the layout spec, contract 7 as
502
+ // amended after the rc.3 tag; the upgrade story's O1): the stale block, which
503
+ // the move re-registers, and the block's invisibility to the layout's
504
+ // harness, whose import the move plans. Nothing else folds — a block twice in
505
+ // one file, one that never closes or a wrapped marker makes the agents-block
506
+ // plan refuse, which blocks the move itself. The message points at the layout
507
+ // finding rather than computing its command again. The tag is internal: no
508
+ // finding leaves here carrying it.
509
+ export function foldIntoMove(findings) {
510
+ const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
511
+ const step = layout && (layout.check === "layout-two-configs"
512
+ ? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
513
+ : "the layout move repairs this: run what the layout-legacy finding names");
514
+ return findings.map(({ byMove, ...f }) => (byMove && layout ? { ...f, level: "info", message: `${byMove} — ${step}.` } : f));
515
+ }
516
+
517
+ // Both scopes are walked. The original reason ("project > user > plugin, so a
518
+ // copy in either scope shadows the bundle") turned out to be wrong — a copy
519
+ // shadows NOTHING, because plugin agents register under a scoped id and copies
520
+ // under a bare one (ADR-008). The conclusion survives its premise: a user-scope
521
+ // copy still stands beside the bundled agent in every project, so reporting only
522
+ // the project scope would leave that permanently invisible.
523
+ //
524
+ // Provenance is established by the `# source: projectstore vX` marker OR by a
525
+ // bundled name: copies taken before the marker existed have no other tell, and
526
+ // those are precisely the ones old enough to have gone stale. Where the marker
527
+ // is absent the finding drops to `info`, since a same-named agent the user
528
+ // wrote themselves is indistinguishable from ours.
529
+ // ─── Installed surfaces: the states the install spec defines ───────────
530
+ //
531
+ // Every surface the manifests name for a harness this project uses, read
532
+ // through surfaces.mjs — the same derivation the verbs use, so the report
533
+ // and install never disagree about a file. Reported BY EXCEPTION: a current
534
+ // surface says nothing, except contract 12's "last written by", which is the
535
+ // one `current` worth saying. Wiring facts the state model cannot express
536
+ // (an entry naming a missing script, a foreign slot, a lagging pinned path)
537
+ // stay with checkStatusline under its own id.
538
+ //
539
+ // Imported dynamically, and only here: hooks/session-start.mjs imports this
540
+ // module statically, and the install spec keeps the provenance leaf — which
541
+ // surfaces.mjs needs — out of the SessionStart module graph. The startup
542
+ // checks never call this.
543
+ // Read the states once per doctor run; both checks below consume the result.
544
+ export async function readSurfaceStates(proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, env = process.env } = {}) {
545
+ const { surfaceStates, FOREIGN_TEXT } = await import("./surfaces.mjs");
546
+ return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
547
+ }
548
+
549
+ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env } = {}) {
550
+ const out = [];
551
+ let r, FOREIGN_TEXT;
552
+ try {
553
+ ({ result: r, FOREIGN_TEXT } = read || await readSurfaceStates(proj, { home, root, manifestDir, env }));
554
+ } catch (e) {
555
+ return [finding("install", "warn", "surface", `Installed-surface states could not be read: ${e && e.message}`)];
556
+ }
557
+ // Contract 16: a harness is reported only when the project uses it; when
558
+ // none does and nothing of ours is installed, one line names what can be.
559
+ if (!r.used.length) {
560
+ out.push(finding("install", "info", "harness",
561
+ `No harness detected in this project and nothing of ours installed — install can target: ${r.installable.join(", ")}.`));
562
+ return out;
563
+ }
564
+ for (const s of r.states) {
565
+ const where = relative(proj, s.path) || s.path;
566
+ if (s.kind === "registration") continue; // checkPluginRegistration's
567
+ if (s.kind === "exclusive") {
568
+ if (s.state === "foreign") {
569
+ out.push(finding("install", "issue", "surface-foreign",
570
+ `${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`, where));
571
+ } 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));
573
+ } else if (s.state === "stale" && !s.produced) {
574
+ out.push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
575
+ } else if (s.state === "current" && s.writtenBy && !s.sameProject) {
576
+ out.push(finding("install", "info", "surface", `${where} — current, last written by ${s.writtenBy}.`, where));
577
+ }
578
+ } else if (s.surface === "agents_block") {
579
+ // Version drift and duplicates are checkAgentsBlock's; what only the
580
+ // state knows is content that differs at the same version. A block that
581
+ // never closes is named by both: checkAgentsBlock carries it to the
582
+ // startup line, and the state names it with the file's own reason, as a
583
+ // wrapped marker already was.
584
+ if (s.state === "unparseable") {
585
+ out.push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
586
+ } 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));
588
+ }
589
+ }
590
+ // The statusline entry's states are checkStatusline's, under its own id —
591
+ // its ours-stale (a command this installation would not write) is
592
+ // self-healing: syncStatusLine rewrites it on the next SessionStart.
593
+ }
594
+ return out;
595
+ }
596
+
597
+ // The harness overlay (layout spec, contracts 2–4): a key the allowlist
598
+ // rejects is an issue naming key and file; a binding still carrying an
599
+ // agents block is a pre-0.28 leftover the migration moves; an overlay that
600
+ // does not parse is an issue.
601
+ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir() } = {}) {
602
+ const out = [];
603
+ const o = readOverlayAt(proj);
604
+ const where = relative(proj, o.path);
605
+ 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));
606
+ 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));
607
+ if (cfg && cfg.agents && typeof cfg.agents === "object") {
608
+ // The binding that carries the block is the one the reader found — before
609
+ // the migration that is the legacy path, and naming the new one would send
610
+ // the user to a file that does not exist yet.
611
+ const b = relative(proj, harnessConfigPath(proj, process.env));
612
+ // The same command as the layout move: a bare "run upgrade" re-runs the
613
+ // installer in no particular channel (the critic of the layout spec's
614
+ // 2026-10-03 amendment, finding 7).
615
+ 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));
617
+ }
618
+ // A project can be used from more than one harness, and a model name is
619
+ // harness-specific (ADR-008) — so each one has its own overlay and they do
620
+ // not inherit from each other. Silence about a missing one would read as
621
+ // "configured"; it means the agents there run on their frontmatter models,
622
+ // and in particular the clerk is NOT pinned cheap.
623
+ //
624
+ // Only when another harness in this project HAS an overlay. A project with
625
+ // none at all is the ordinary fresh state and needs no advice; the asymmetry
626
+ // is what is actionable, because it is almost always the second harness that
627
+ // was forgotten rather than the first that was deliberate.
628
+ const used = detectHarnesses(proj).map((d) => d.id);
629
+ if (used.length > 1) {
630
+ const overlays = used.map((id) => ({ id, o: readOverlayAt(proj, loadHarness(id)?.runtime?.overlay || id) }));
631
+ const configured = overlays.filter(({ o }) => o.present);
632
+ if (configured.length) {
633
+ for (const { id, o } of overlays) {
634
+ if (o.present) continue;
635
+ out.push(finding("install", "info", "overlay-absent",
636
+ `This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — `
637
+ + `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} `
638
+ + `${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}.`,
640
+ relative(proj, o.path)));
641
+ }
642
+ }
643
+ }
644
+ // A configured name no roster agent carries runs nothing: the model never
645
+ // applies. A warn, not an issue — a newer package's agent is a legitimate
646
+ // reason for a committed overlay to name one this copy does not ship.
647
+ const roster = layoutRoster(cfg);
648
+ if (roster) {
649
+ for (const n of Object.keys(o.agents.per_agent)) {
650
+ 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));
651
+ }
652
+ }
653
+ return out;
654
+ }
655
+
656
+ // Contract 4′ (2026-09-05): the registration the package made through the
657
+ // host's CLI — one row per registration surface the manifest declares. Current
658
+ // → one info; stale → an issue naming the refresh in the form that can make
659
+ // it (the package's own bin via npx: a cache install never re-registers
660
+ // itself); a competing copy enabled beside ours → an issue (two enabled copies
661
+ // of one plugin); a competitor alone → an info naming the npm path; foreign →
662
+ // never repairable; the host CLI missing → an info.
663
+ export function checkPluginRegistration(proj, states = [], { home = homedir() } = {}) {
664
+ const out = [];
665
+ for (const s of states.filter((x) => x.kind === "registration")) {
666
+ // The shell form when the manifest names a shell (contract 12): the
667
+ // command a user can paste, built in one place.
668
+ const h = loadHarness(s.harness) || { id: s.harness };
669
+ const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` });
670
+ // A copy this registration silenced for the checkout and the checkout
671
+ // still holds off, one per key (the install spec, contract 13 as amended
672
+ // after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
673
+ // git-marketplace projects, and nothing said so. Only for a registration
674
+ // this checkout holds; the record lives in our directory, so a directory
675
+ // removed by hand takes it along and leaves nothing to name.
676
+ if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
677
+ // The row this checkout loads: its own local-scope row first, then a
678
+ // user-scope one — never another checkout's (layoutRemedy's rule).
679
+ const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
680
+ const here = real(proj);
681
+ const rows = installedPluginEntries(home, proj).filter((e) => e.present && (!e.projectPath || real(e.projectPath) === here));
682
+ for (const key of s.silenced) {
683
+ const row = rows.filter((e) => e.key === key).sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
684
+ 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));
686
+ continue;
687
+ }
688
+ // The release line, not the build: a 0.28 release candidate reads a
689
+ // moved project; 0.27.x reads it as unbound. No version reads as old.
690
+ 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));
693
+ }
694
+ }
695
+ const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
696
+ 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));
698
+ } else if (s.state === "unavailable") {
699
+ out.push(finding("install", "info", "plugin-registration", `No npm registration of projectstore for this project, and ${s.reason}.`));
700
+ } else if (s.state === "absent") {
701
+ // A git-marketplace install alone is not a finding: a permanent info
702
+ // advertising the npm path to every marketplace user is noise (2026-09-05).
703
+ } else if (s.state === "stale") {
704
+ out.push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
705
+ } 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}.`));
708
+ }
709
+ }
710
+ return out;
711
+ }
712
+
713
+ // Contract 17: two registrations of projectstore at different versions on one
714
+ // machine are a finding, not a failure — install cannot prevent it, since
715
+ // hosts install from different sources. The versions come from the harness
716
+ // registry (one per marketplace key and scope) and from the pkg= field of a
717
+ // file we stamped in this project; each is named with where it was read.
718
+ export function checkVersionDrift(home = homedir(), states = [], proj = null) {
719
+ // Pairs, not a map keyed by source: two registrations under one marketplace
720
+ // key and one scope are the common shape (the registry keeps every install
721
+ // it made), and they must both be seen. Only installs still on disk count —
722
+ // a wiped entry is not a copy anyone runs.
723
+ const seen = [];
724
+ for (const e of installedPluginEntries(home, proj)) {
725
+ // 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 });
727
+ }
728
+ for (const s of states) {
729
+ if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg });
730
+ }
731
+ const versions = new Set(seen.map((x) => x.version));
732
+ if (versions.size < 2) return [];
733
+ 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.`)];
736
+ }
737
+
738
+ export function checkOverrideCopies(proj, home = homedir()) {
739
+ const out = [];
740
+ const ver = pluginVersion();
741
+ const scopes = [
742
+ { dir: join(proj, ".claude", "agents"), label: ".claude/agents", scope: "project" },
743
+ { dir: join(home, ".claude", "agents"), label: "~/.claude/agents", scope: "user" },
744
+ ];
745
+ for (const { dir, label, scope } of scopes) {
746
+ for (const f of listMd(dir)) {
747
+ let text;
748
+ try { text = readFileSync(join(dir, f), "utf8"); } catch { continue; }
749
+ const m = text.match(/#\s*source:\s*projectstore\s+v(\S+)/);
750
+ const { data } = parseFrontmatter(text);
751
+ // A provenance-marked copy with no `name:` still deserves a usable
752
+ // message: falling back to the filename beats reporting `name ""`.
753
+ const name = data.name || (m ? basename(f, ".md") : "");
754
+ if (!m && !BUNDLED_AGENT_NAMES.includes(name)) continue; // user-authored agent — never ours to judge
755
+
756
+ const where = join(label, f);
757
+ const everywhere = scope === "user" ? " User-scoped, so it applies in every project." : "";
758
+
759
+ if (Object.hasOwn(LEGACY_AGENTS, name)) {
760
+ const { now, renamed } = LEGACY_AGENTS[name];
761
+ const advice = renamed
762
+ ? `v0.13 renamed the role to "${now}". Renaming this file would NOT make it override the bundled agent — nothing does (ADR-008) — so delete it, or keep it as an agent of your own under a name you invoke deliberately.`
763
+ : `v0.13 replaced it with a narrower vault-aware "${now}", so renaming would swap its role — keep this copy if you use it outside projectstore, but re-check its pinned model.`;
764
+ // without a provenance marker we cannot prove lineage — hedge FIRST,
765
+ // not 40 words in, or the false premise leads for user-authored agents
766
+ const lead = m ? `${f}` : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it`;
767
+ out.push(finding("install", m ? "warn" : "info", "override-copies",
768
+ `${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}`,
769
+ where));
770
+ continue; // a stale-version note on top would just be noise
771
+ }
772
+ if (!CURRENT_AGENT_NAMES.includes(name)) {
773
+ out.push(finding("install", "warn", "override-copies",
774
+ `Override copy ${f} has name "${name}" which matches no bundled agent — it duplicates instead of overriding.${everywhere}`, where));
775
+ continue;
776
+ }
777
+ // ADR-008. A current-name copy overrides nothing either: the bundled agent
778
+ // registers as "projectstore:<name>" and this one as "<name>", so both are
779
+ // live and the registration block keeps invoking the bundled one — the
780
+ // model pinned here never runs. This fires at ANY version, because
781
+ // refreshing such a copy fixes nothing; that is why it replaces the old
782
+ // "frozen at vX — re-run configure" advice instead of sitting beside it.
783
+ // Staleness is demoted to a parenthetical: still true, no longer the point.
784
+ const stale = m && ver && m[1] !== ver
785
+ ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)`
786
+ : "";
787
+ // Without a marker we cannot prove lineage — hedge FIRST, and never issue
788
+ // a delete imperative against a file the user may well have written.
789
+ const lead = m
790
+ ? `Override copy ${f} overrides nothing.`
791
+ : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it overrides nothing.`;
792
+ // `configure` only ever touched PROJECT scope, so pointing a user-scope
793
+ // copy at it would name a command that will not act — the scope split
794
+ // fca8def introduced for staleness applies here for the same reason.
795
+ 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.";
798
+ const advice = m
799
+ ? 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.";
801
+ 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}`,
803
+ where));
804
+ }
805
+ }
806
+ return out;
807
+ }
808
+
809
+ // ADR-008 made `effort` unconfigurable per project, which promotes this env var
810
+ // from a curiosity to the ONLY thing that can move our agents off `effort: max`.
811
+ // It beats frontmatter, so a value set for cost or latency silently drops all
812
+ // five agents below the quality floor the plugin advertises — exactly the class
813
+ // of silent downgrade doctor exists to name.
814
+ // The variable names come from the manifest (runtime.agent_overrides); the
815
+ // wording is projectstore's, because the claim — ADR-008's quality floor — is.
816
+ export function checkEnvEffort() {
817
+ return agentOverrides().filter((o) => o.kind === "effort").map((o) => finding("install", "warn", "env-effort",
818
+ `${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.`));
819
+ }
820
+
821
+ export function checkEnvModel() {
822
+ return agentOverrides().filter((o) => o.kind === "model").map((o) => finding("install", "warn", "env-model",
823
+ `${o.env}=${o.value} is set — it overrides ALL projectstore agent model configuration, per-invocation parameter included.`));
824
+ }
825
+
826
+ // The project-level layout (the layout ADR): a legacy .projectstore/projectstore.json
827
+ // or .projectstore/state/ is one warn naming the upgrade; two bindings is an
828
+ // issue. Cheap — a handful of existsSync — so the startup line carries the
829
+ // warn as an offer (OFFER_CHECKS).
830
+ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir() } = {}) {
831
+ const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
832
+ const legacyBinding = existsSync(p.legacy.binding), legacyRuntime = existsSync(p.legacy.runtime);
833
+ let resumable = false;
834
+ if (legacyBinding && existsSync(p.binding)) {
835
+ try { const { agents, ...rest } = JSON.parse(readFileSync(p.legacy.binding, "utf8")); resumable = JSON.stringify(rest) === JSON.stringify(JSON.parse(readFileSync(p.binding, "utf8"))); } catch {}
836
+ }
837
+ if (legacyBinding && existsSync(p.binding) && !resumable) {
838
+ 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))];
839
+ }
840
+ if (!legacyBinding && !legacyRuntime && !existsSync(p.legacy.welcomed) && !existsSync(p.legacy.sessionId)) return [];
841
+ // The command in the channel of the copy the project runs (the layout spec,
842
+ // contract 12 as amended 2026-10-03; maintainer decision the same day). The
843
+ // package's shell registers the plugin through its own channel: run for a
844
+ // git-marketplace user it added projectstore@projectstore-npm at local scope
845
+ // and turned the git copy off for the checkout (reproduced with the suite's
846
+ // fake host), and without the host CLI on PATH it stopped part-way with
847
+ // exit 1. Any other copy runs its own bin with --no-register, which leaves
848
+ // the registration out of the plan — no host command by construction, not by
849
+ // recognising the root (a checkout, a symlinked or relocated home).
850
+ const remedy = layoutRemedy(proj, { root, home, harness });
851
+ 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",
853
+ `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)))];
855
+ }
856
+
857
+ // The one command that moves this project's files (the layout spec, contract
858
+ // 12 as amended 2026-10-03), in the channel of the copy the project runs. A
859
+ // session runs that copy, so its root answers: the package's own registration
860
+ // takes the shell, any other host copy its own bin. A run from anywhere else —
861
+ // a terminal `npx projectstore doctor`, a checkout — asks the host's registry,
862
+ // counting only rows this project loads: its own local-scope row first, then
863
+ // user-scope rows, never another checkout's. With none enabled, a package root
864
+ // takes the shell and a checkout its own bin. BOTH forms carry --no-register:
865
+ // the move never needs the registration, so a misread channel can cost a
866
+ // launcher stamp but never a channel switch. A copy that predates the move
867
+ // (no bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first.
868
+ // Nor can one whose CLI predates --no-register (takesNoRegister).
869
+ //
870
+ // Whether a registry copy can run the command named for it. `--no-register`
871
+ // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
872
+ // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
873
+ // amended after the rc.3 tag). Read from the copy's own parse table, never
874
+ // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
875
+ // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
876
+ // file read, because this runs in the SessionStart subset: never an import()
877
+ // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
878
+ // released copies keep their parse there.
879
+ export function takesNoRegister(copy) {
880
+ try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
881
+ }
882
+
883
+ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
884
+ // A session under a relocated host home hands it on: a terminal without it
885
+ // would classify the copy as a checkout, render no launcher and never clear
886
+ // the offer (the second review of the 2026-10-03 fixes, S2).
887
+ const homeVar = harness?.runtime?.home_env;
888
+ const prefix = homeVar && env[homeVar] ? `${homeVar}="${env[homeVar]}" ` : "";
889
+ const shell = (version) => ({ command: prefix + packageCommand(harness, "upgrade", { version: version || "latest", args: `--no-register --project "${proj}"` }) });
890
+ const own = (copy) => ({ command: `${prefix}node "${join(copy, "bin", "projectstore.mjs")}" upgrade --harness ${harness?.id || "<id>"} --no-register --project "${proj}"` });
891
+ const channel = installChannel(root, { home, harness });
892
+ if (channel === "registration") return shell(pluginVersion(root));
893
+ if (channel === "marketplace") return own(root);
894
+ // Real paths: the host records the project's cwd as one, and /var against
895
+ // /private/var would otherwise drop the project's own row.
896
+ const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
897
+ const here = real(proj);
898
+ const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
899
+ const copy = installedPluginEntries(home, proj)
900
+ .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
901
+ .sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
902
+ if (copy) {
903
+ const v = copy.version ? ` (${copy.version})` : "";
904
+ if (!existsSync(join(copy.path, "bin", "projectstore.mjs"))) {
905
+ 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` };
906
+ }
907
+ // Chosen first, gated after: the copy is what the project runs, so an
908
+ // incapable one gets advice even beside a capable user-scope copy.
909
+ const viaRegistration = installChannel(copy.path, { home, harness }) === "registration";
910
+ if (!takesNoRegister(copy.path)) {
911
+ return { advice: viaRegistration
912
+ // Its channel's ordinary refresh, at the running version: the plain
913
+ // upgrade re-registers from npm — not a switch for an npm project — and
914
+ // moves the project in the same run.
915
+ ? `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}"` })}`
916
+ : `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` };
917
+ }
918
+ return viaRegistration ? shell(copy.version) : own(copy.path);
919
+ }
920
+ return channel === "package" ? shell(pluginVersion(root)) : own(root);
921
+ }
922
+
923
+ export function checkGitignore(proj) {
924
+ const out = [];
925
+ // .projectstore/.gitignore is line-merged with the vault's own writer; a "*"
926
+ // (a vault that is also a project, written before 2026-09-06) hides the
927
+ // committed harness/ overlays.
928
+ const p = layoutPaths(proj);
929
+ if (existsSync(p.root)) {
930
+ let lines = null;
931
+ try { lines = readFileSync(p.gitignore, "utf8").split("\n").map((l) => l.trim()); } catch {}
932
+ if (lines === null) {
933
+ // Contract 5's ignore file is what keeps a machine-local binding — with
934
+ // an absolute vault path — out of a commit. Until 2026-09-06 bind never
935
+ // wrote it, so a project bound and committed before its first session
936
+ // carried one; doctor reasoned from the self-ignoring without ever
937
+ // checking it.
938
+ out.push(finding("install", "warn", "gitignore",
939
+ `.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"));
940
+ } else {
941
+ 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"));
942
+ else if (!lines.includes("*")) {
943
+ // A lone "*" is the pre-2026-09-06 vault-that-is-a-project shape: it
944
+ // ignores the binding and state/ already, and only hides harness/ when
945
+ // that directory exists — which the branch above is for.
946
+ const short = [...LAYOUT.gitignore].filter((l) => !lines.includes(l));
947
+ if (short.length) out.push(finding("install", "warn", "gitignore",
948
+ `.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"));
949
+ }
950
+ }
951
+ }
952
+ if (!existsSync(join(proj, ".git"))) return out;
953
+ let lines = [];
954
+ try {
955
+ lines = readFileSync(join(proj, ".gitignore"), "utf8").split("\n").map((l) => l.trim());
956
+ } catch {}
957
+ const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
958
+ if (coveredAll) return out;
959
+ // Our own files are self-ignored inside .projectstore/; what is left is the host's.
960
+ const wanted = [relative(proj, hostSettingsPath(proj))];
961
+ const missing = wanted.filter((w) => !lines.includes(w));
962
+ if (!missing.length) return out;
963
+ out.push(finding("install", "warn", "gitignore",
964
+ `Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`));
965
+ return out;
966
+ }
967
+
968
+ // An ignore line never untracks a file already in the index, so the project
969
+ // that committed its binding before 2026-09-06 keeps committing it. Only git
970
+ // can answer this, so it lives outside checkGitignore, which the SessionStart
971
+ // budget forbids a subprocess.
972
+ export function checkTrackedRuntime(proj) {
973
+ if (!existsSync(join(proj, ".git"))) return [];
974
+ const p = layoutPaths(proj);
975
+ const want = [relative(proj, p.binding), relative(proj, p.state)];
976
+ const r = spawnSync("git", ["ls-files", "--", ...want], { cwd: proj, encoding: "utf8", timeout: 5000 });
977
+ if (r.status !== 0 || !r.stdout) return [];
978
+ const tracked = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
979
+ if (!tracked.length) return [];
980
+ return [finding("install", "warn", "gitignore-tracked",
981
+ `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])];
982
+ }
983
+
984
+ export function checkVaultGit(cfg) {
985
+ if (existsSync(join(cfg.vault_path, ".git"))) return [];
986
+ return [finding("install", "warn", "vault-git",
987
+ "Vault is not a git repository — the knowledge has no history/blame/review. Consider `git init` (doctor --fix offers it).")];
988
+ }
989
+
990
+ // Marketplace auto-update (maintainer request 2026-07-03): third-party
991
+ // marketplaces do NOT auto-update by default, so a stale plugin looks like
992
+ // "the feature is broken". Read the real registries and, when the flag is
993
+ // off, tell the user the exact correct values.
994
+ export function checkAutoUpdate(home = homedir()) {
995
+ const out = [];
996
+ // Two corrections over the first version of this check, both found by running
997
+ // doctor straight out of a checkout (2026-08-05):
998
+ //
999
+ // 1. The path pattern required a segment AFTER the marketplace name, so a
1000
+ // marketplace clone root (.../plugins/marketplaces/<name>) never matched
1001
+ // even though the name is right there. Hence the trailing (?:/|$).
1002
+ // 2. pluginRoot() is the SCRIPT's own location. That equals the session's
1003
+ // plugin only when the harness launched us with its plugin-root variable; run
1004
+ // from a checkout it reported "local dev install" about what was in fact
1005
+ // an ordinary marketplace install — the check described itself, not the
1006
+ // session. So fall back to the registered install when our own path says
1007
+ // nothing, and never claim --plugin-dir as a conclusion.
1008
+ const marketplaceOf = (p) =>
1009
+ (String(p || "").replace(/\\/g, "/").match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
1010
+ const inst = installedPluginRoot(home);
1011
+ const marketplace = marketplaceOf(pluginRoot()) || marketplaceOf(inst && inst.path);
1012
+ if (!marketplace) {
1013
+ out.push(finding("install", "info", "auto-update",
1014
+ "No marketplace install of projectstore found (--plugin-dir, or a checkout with none registered) — marketplace auto-update not applicable."));
1015
+ return out;
1016
+ }
1017
+
1018
+ let registry = null;
1019
+ try {
1020
+ registry = JSON.parse(readFileSync(join(claudeHome(home), "plugins", "known_marketplaces.json"), "utf8"));
1021
+ } catch {}
1022
+ const entry = registry ? registry[marketplace] : null;
1023
+ // A directory marketplace — the package's own registration — has no remote
1024
+ // to poll and nothing to toggle; its update path is the package's upgrade
1025
+ // verb, reported by checkPluginRegistration (2026-09-05).
1026
+ if (entry && entry.source && entry.source.source === "directory") return out;
1027
+ if (!entry) {
1028
+ out.push(finding("install", "warn", "auto-update",
1029
+ `Marketplace "${marketplace}" is missing from ~/.claude/plugins/known_marketplaces.json — updates cannot be tracked. Re-add it: /plugin marketplace add <owner/repo>.`));
1030
+ return out;
1031
+ }
1032
+
1033
+ let enabled = entry.autoUpdate === true;
1034
+ if (!enabled) {
1035
+ try {
1036
+ const s = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"));
1037
+ if (s?.extraKnownMarketplaces?.[marketplace]?.autoUpdate === true) enabled = true;
1038
+ } catch {}
1039
+ }
1040
+ if (!enabled) {
1041
+ out.push(finding("install", "warn", "auto-update",
1042
+ `Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
1043
+ `Correct values: "autoUpdate": true on the "${marketplace}" entry in ~/.claude/plugins/known_marketplaces.json ` +
1044
+ `(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
1045
+ `extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path: /plugin marketplace update ${marketplace}, then /reload-plugins.`));
1046
+ }
1047
+
1048
+ // Bonus: the marketplace checkout's catalog knows the latest released
1049
+ // version — flag when it is newer than the one actually running.
1050
+ //
1051
+ // "Running" must be read from the SAME root that named the marketplace above.
1052
+ // On the fallback path our own script is a checkout, so pluginRoot() and
1053
+ // pluginVersion() describe the checkout, not the install — reporting that as
1054
+ // the running version is the very confusion this check was just fixed for.
1055
+ const named = marketplaceOf(pluginRoot()) ? { path: pluginRoot(), version: null } : inst;
1056
+ try {
1057
+ const root = (named && named.path) || pluginRoot();
1058
+ const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
1059
+ const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
1060
+ const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
1061
+ const running = (named && named.version)
1062
+ || JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
1063
+ // Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
1064
+ if (latest && running && cmpPrecedence(latest, running) > 0) {
1065
+ out.push(finding("install", "warn", "auto-update",
1066
+ `A newer ${name} is available: v${latest} (running v${running}) — run /plugin marketplace update ${marketplace}, then /reload-plugins.`));
1067
+ }
1068
+ } catch {}
1069
+ return out;
1070
+ }
1071
+
1072
+ // The plugin-bundled MCP registration (MCP ADR decision 6, amended
1073
+ // 2026-09-05): Claude Code loads .mcp.json from the plugin root and expands
1074
+ // the placeholders per session, so the check is only that the shipped file is
1075
+ // there and launches this package's bin. A host surface — nothing to
1076
+ // install, nothing to derive — so this is not a surfaces.mjs state.
1077
+ export function checkMcpRegistration(root = pluginRoot(), harness = sourceHarness()) {
1078
+ // Whether a plugin-root .mcp.json registers anything is the host's fact,
1079
+ // read from the manifest: a harness whose mcp surface is not host-loaded
1080
+ // has nothing to check here.
1081
+ const mcp = harness && harness.surfaces && harness.surfaces.mcp;
1082
+ if (!mcp || mcp.kind !== "host") return [];
1083
+ const p = join(root, mcp.file || ".mcp.json");
1084
+ 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
+ let reg;
1086
+ 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")]; }
1087
+ const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
1088
+ const args = server && Array.isArray(server.args) ? server.args : [];
1089
+ if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
1090
+ 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")];
1091
+ }
1092
+ 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).")];
1093
+ }
1094
+
1095
+
1096
+ // ─── Vault checks ──────────────────────────────────────────────────────
1097
+
1098
+ // Collect every structured artifact with parsed frontmatter.
1099
+ export function scanArtifacts(cfg, layout) {
1100
+ const vault = cfg.vault_path;
1101
+ const artifacts = [];
1102
+ const push = (abs, rel, kind) => {
1103
+ let md;
1104
+ try { md = readFileSync(abs, "utf8"); } catch { return; }
1105
+ artifacts.push({ abs, rel, kind, fm: parseFrontmatter(md).data, body: md });
1106
+ };
1107
+ for (const folder of layout.folders) {
1108
+ const dir = join(vault, folder.path);
1109
+ if (!existsSync(dir)) continue;
1110
+ if (folder.kind === "epic") {
1111
+ for (const id of readdirSync(dir)) {
1112
+ const epicMd = join(dir, id, "epic.md");
1113
+ if (existsSync(epicMd)) push(epicMd, `${folder.path}/${id}/epic.md`, "epic");
1114
+ const storiesDir = join(dir, id, "stories");
1115
+ for (const f of listMd(storiesDir)) {
1116
+ push(join(storiesDir, f), `${folder.path}/${id}/stories/${f}`, "story");
1117
+ }
1118
+ }
1119
+ } else {
1120
+ for (const f of listMd(dir)) {
1121
+ if (f === "README.md") continue;
1122
+ push(join(dir, f), `${folder.path}/${f}`, folder.kind);
1123
+ }
1124
+ }
1125
+ }
1126
+ return artifacts;
1127
+ }
1128
+
1129
+ // status ↔ kanban: generate the expected board with the real generator and
1130
+ // text-diff it against disk, ignoring the generated_at stamp (ADR-005).
1131
+ export function checkKanbanSync(cfg) {
1132
+ const vault = cfg.vault_path;
1133
+ const onDisk = join(vault, "kanban.md");
1134
+ if (!existsSync(onDisk)) {
1135
+ return [finding("vault", "info", "kanban", "No kanban.md yet — run /projectstore:kanban to create the board.")];
1136
+ }
1137
+ const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "kanban.mjs")], {
1138
+ encoding: "utf8",
1139
+ timeout: 10000,
1140
+ env: childEnv(process.env, { projectRoot: projectRoot() }),
1141
+ });
1142
+ if (r.status !== 0) {
1143
+ return [finding("vault", "warn", "kanban", `kanban generator failed: ${(r.stderr || "").trim()}`)];
1144
+ }
1145
+ let expected;
1146
+ try { expected = JSON.parse(r.stdout).content; } catch {
1147
+ return [finding("vault", "warn", "kanban", "kanban generator returned unparseable output.")];
1148
+ }
1149
+ const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1150
+ if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
1151
+ return [finding("vault", "issue", "kanban",
1152
+ "kanban.md is out of sync with story frontmatter — run /projectstore:kanban (or reconcile).", "kanban.md")];
1153
+ }
1154
+ return [];
1155
+ }
1156
+
1157
+ // Folder README index rows ↔ artifact frontmatter.
1158
+ export function checkIndexes(cfg, layout, artifacts) {
1159
+ const out = [];
1160
+ const vault = cfg.vault_path;
1161
+ const rowRx = /^\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|([^|]+)\|([^|]+)\|([^|]+)\|/;
1162
+ for (const folder of layout.folders) {
1163
+ const readme = join(vault, folder.path, "README.md");
1164
+ if (!existsSync(readme)) continue;
1165
+ let rows = [];
1166
+ for (const line of readFileSync(readme, "utf8").split("\n")) {
1167
+ const m = line.match(rowRx);
1168
+ if (m) rows.push({ label: m[1], target: m[2].replace(/^\.\//, ""), title: m[3].trim(), status: m[4].trim() });
1169
+ }
1170
+ const indexed = new Set();
1171
+ for (const row of rows) {
1172
+ const rel = `${folder.path}/${row.target}`;
1173
+ indexed.add(rel);
1174
+ const art = artifacts.find((a) => a.rel === rel);
1175
+ if (!art) {
1176
+ out.push(finding("vault", "issue", "index",
1177
+ `${folder.path}/README.md row "${row.label}" points at a missing file: ${row.target}`, `${folder.path}/README.md`));
1178
+ continue;
1179
+ }
1180
+ const fmStatus = (art.fm.status || "").trim();
1181
+ if (fmStatus && row.status && fmStatus !== row.status) {
1182
+ out.push(finding("vault", "issue", "index",
1183
+ `${folder.path}/README.md lists "${row.label}" as "${row.status}" but its frontmatter says "${fmStatus}".`, art.rel));
1184
+ }
1185
+ const fmTitle = (art.fm.title || "").trim();
1186
+ if (fmTitle && row.title && fmTitle !== row.title) {
1187
+ out.push(finding("vault", "warn", "index",
1188
+ `${folder.path}/README.md title for "${row.label}" differs from frontmatter title.`, art.rel));
1189
+ }
1190
+ }
1191
+ for (const a of artifacts) {
1192
+ const inFolder = folder.kind === "epic"
1193
+ ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
1194
+ : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`;
1195
+ if (inFolder && !indexed.has(a.rel)) {
1196
+ out.push(finding("vault", "warn", "index",
1197
+ `${a.rel} is not listed in ${folder.path}/README.md's index.`, a.rel));
1198
+ }
1199
+ }
1200
+ }
1201
+ return out;
1202
+ }
1203
+
1204
+ // Fence/inline-code stripping lives in lib.mjs (stripCodeSpans) — one
1205
+ // definition shared with the link-graph extractor, so "not a link/checkbox
1206
+ // when inside code" means the same thing everywhere.
1207
+
1208
+ // RAW lines outside fenced blocks — for checks that must both MATCH (fence-
1209
+ // immune) and REPORT the line verbatim (backticks intact in the message).
1210
+ function linesOutsideFences(s) {
1211
+ const out = [];
1212
+ let fenced = false;
1213
+ for (const line of s.split("\n")) {
1214
+ if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
1215
+ if (!fenced) out.push(line);
1216
+ }
1217
+ return out;
1218
+ }
1219
+
1220
+ // Epic id of a story artifact, derived from the layout's epic folder path —
1221
+ // never from a hardcoded segment index (custom layouts may nest the folder).
1222
+ function epicIdOf(storyRel, epicFolderPath) {
1223
+ if (!storyRel.startsWith(epicFolderPath + "/")) return null;
1224
+ return storyRel.slice(epicFolderPath.length + 1).split("/")[0];
1225
+ }
1226
+
1227
+ export function checkStoriesAndEpics(artifacts) {
1228
+ const out = [];
1229
+ for (const a of artifacts) {
1230
+ if (a.kind === "story" && (a.fm.status || "").toLowerCase() === "done") {
1231
+ const sec = sectionOf(a.body, "acceptance") || "";
1232
+ const unchecked = (stripCodeSpans(sec).match(/- \[ \]/g) || []).length;
1233
+ if (unchecked > 0) {
1234
+ out.push(finding("vault", "warn", "acceptance",
1235
+ `Story is "done" with ${unchecked} unchecked acceptance criteria.`, a.rel));
1236
+ }
1237
+ }
1238
+ if ((a.fm.review_status || "") === "reviewed" && (!a.fm.reviewed_at || a.fm.reviewed_at === "null")) {
1239
+ out.push(finding("vault", "issue", "review-status",
1240
+ `review_status is "reviewed" but reviewed_at is empty.`, a.rel));
1241
+ }
1242
+ }
1243
+ for (const epic of artifacts.filter((a) => a.kind === "epic")) {
1244
+ if ((epic.fm.status || "").toLowerCase() !== "done") continue;
1245
+ const dir = epic.rel.replace(/\/epic\.md$/, "");
1246
+ const open = artifacts.filter((s) =>
1247
+ s.kind === "story" && s.rel.startsWith(dir + "/") && (s.fm.status || "").toLowerCase() !== "done");
1248
+ if (open.length) {
1249
+ out.push(finding("vault", "issue", "epic-status",
1250
+ `Epic is "done" while ${open.length} child stor${open.length === 1 ? "y is" : "ies are"} not.`, epic.rel));
1251
+ }
1252
+ }
1253
+ return out;
1254
+ }
1255
+
1256
+ // Folder README whose index table header matches no registered form — the
1257
+ // silent-rebuildIndex-null class of failure (ru indexes never reconciled for
1258
+ // the whole life of the feature). Standard form is 4 columns; extra hand-kept
1259
+ // columns (e.g. a 5-column specs index) are flagged for migration, since
1260
+ // reconcile would drop them.
1261
+ export function checkIndexHeaders(cfg, layout) {
1262
+ const out = [];
1263
+ const headerRe = indexHeaderRe();
1264
+ for (const folder of layout.folders) {
1265
+ const readme = join(cfg.vault_path, folder.path, "README.md");
1266
+ if (!existsSync(readme)) continue;
1267
+ const lines = readFileSync(readme, "utf8").split("\n");
1268
+ const headIdx = lines.findIndex((l, i) =>
1269
+ /^\|.*\|\s*$/.test(l) && /^\|[-\s|]+\|\s*$/.test(lines[i + 1] || ""));
1270
+ if (headIdx === -1) continue; // no table at all — nothing to lint
1271
+ if (!headerRe.test(lines[headIdx])) {
1272
+ out.push(finding("vault", "warn", "index-header",
1273
+ `${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).`,
1274
+ `${folder.path}/README.md`));
1275
+ }
1276
+ }
1277
+ return out;
1278
+ }
1279
+
1280
+ // ─── Spec checks (PS-SPEC story-006, ADR-007 Decisions 2/3/5/6) ────────
1281
+ //
1282
+ // All spec gates are no-ops unless the VAULT policy says spec_policy=required.
1283
+ // Link integrity (checkSpecLinks) runs whenever specs exist — dead links are
1284
+ // defects regardless of policy.
1285
+
1286
+ function specStatusOf(spec) {
1287
+ return String(spec.fm.status || "draft").toLowerCase();
1288
+ }
1289
+
1290
+ // Filename stem used for story identity matching; a folder-shape story
1291
+ // (stories/<name>/README.md) is identified by its folder name.
1292
+ function storyStemOf(storyRel) {
1293
+ const b = basename(storyRel);
1294
+ return b === "README.md" ? basename(dirname(storyRel)) : b.replace(/\.md$/, "");
1295
+ }
1296
+
1297
+ // Resolve a spec's `stories:` entry "<epic-id>/<story-id>" to a story
1298
+ // artifact. Tiered via the shared matcher (SPEC-002 contract 5): exact fm.id,
1299
+ // exact filename stem, legacy story-NNN prefix fallback — the strongest tier
1300
+ // wins across the epic's stories, and a tie within it is a reported
1301
+ // ambiguity, never a silent first match.
1302
+ function resolveSpecStory(entry, artifacts, epicFolderPath) {
1303
+ const m = String(entry).match(/^([^/]+)\/(.+)$/);
1304
+ if (!m) return { error: `not in <epic-id>/<story-id> form: "${entry}"` };
1305
+ const [, epicId, storyId] = m;
1306
+ let best = 0;
1307
+ let hits = [];
1308
+ for (const a of artifacts) {
1309
+ if (a.kind !== "story" || epicIdOf(a.rel, epicFolderPath) !== epicId) continue;
1310
+ const tier = storyMatchesEntry(storyId, { id: a.fm.id, stem: storyStemOf(a.rel) });
1311
+ if (!tier) continue;
1312
+ if (best === 0 || tier < best) { best = tier; hits = [a]; }
1313
+ else if (tier === best) hits.push(a);
1314
+ }
1315
+ if (hits.length === 1) return { story: hits[0] };
1316
+ if (hits.length > 1) {
1317
+ return { error: `ambiguous — "${storyId}" matches ${hits.map((a) => storyStemOf(a.rel)).join(", ")}; qualify the reference` };
1318
+ }
1319
+ return { error: `no story in ${epicFolderPath}/${epicId} matches "${storyId}" (by exact id:, exact filename stem, or legacy story-NNN prefix)` };
1320
+ }
1321
+
1322
+ // The ONE spec resolver (SPEC-002 contract 5) — shared by checkSpecLinks,
1323
+ // checkSpecCoverage and checkSpecAcceptance: dual-keying only some of them
1324
+ // would let the others silently skip a resolvable spec (their "dead link
1325
+ // already reported" premise breaks). Exact fm.id wins (grandfathered
1326
+ // SPEC-NNN entries hit here); normalized filename-stem candidates (legacy
1327
+ // prefix stripped, case-insensitive) resolve slug-form references to
1328
+ // grandfathered SPEC-NNN-<slug>.md files. Cross-spec identity clashes are
1329
+ // the identity check's finding, so first-wins here stays deterministic.
1330
+ function buildSpecResolver(artifacts, layout = null) {
1331
+ const prefix = layout ? folderByKind(layout, "spec")?.prefix ?? null : null;
1332
+ const byId = new Map();
1333
+ const byStem = new Map();
1334
+ for (const s of artifacts.filter((a) => a.kind === "spec")) {
1335
+ const id = String(s.fm.id || "");
1336
+ if (id && !byId.has(id)) byId.set(id, s);
1337
+ for (const c of slugIdentity(basename(s.rel), { prefix }).candidates) {
1338
+ if (!byStem.has(c.id)) byStem.set(c.id, s);
1339
+ }
1340
+ }
1341
+ return (ref) => byId.get(String(ref)) ?? byStem.get(String(ref).toLowerCase()) ?? null;
1342
+ }
1343
+
1344
+ // Parse a spec's Acceptance section into items:
1345
+ // { checked, text, stories: [bare story ids] | null (unattributed) }
1346
+ export function parseSpecAcceptance(spec) {
1347
+ const sec = sectionOf(spec.body, "spec_acceptance");
1348
+ if (sec === null) return null;
1349
+ const attrRe = storiesAttributionRe();
1350
+ const items = [];
1351
+ for (const line of linesOutsideFences(sec)) {
1352
+ const m = line.match(/^\s*-\s*\[( |x|X)\]\s*(.*)$/);
1353
+ if (!m) continue;
1354
+ const checked = m[1].toLowerCase() === "x";
1355
+ const text = m[2];
1356
+ // Full-width colon accepted for the same reason as the evidence suffix: a zh
1357
+ // spec writing `— stories:PS-X/story-foo` must attribute the item to that
1358
+ // story, not silently fall through to "applies to every covered story".
1359
+ const attr = text.match(attrRe);
1360
+ const stories = attr
1361
+ ? attr[1].split(",").map((s) => s.trim()).filter(Boolean)
1362
+ : null;
1363
+ items.push({ checked, text, stories });
1364
+ }
1365
+ return items;
1366
+ }
1367
+
1368
+ export function checkSpecLinks(cfg, layout, artifacts) {
1369
+ const out = [];
1370
+ const epicFolder = folderByKind(layout, "epic");
1371
+ if (!epicFolder) return out;
1372
+ const specs = artifacts.filter((a) => a.kind === "spec");
1373
+ const resolveSpec = buildSpecResolver(artifacts, layout);
1374
+
1375
+ for (const spec of specs) {
1376
+ for (const entry of listOf(spec.fm, "stories")) {
1377
+ const r = resolveSpecStory(entry, artifacts, epicFolder.path);
1378
+ if (r.error) {
1379
+ out.push(finding("vault", "issue", "spec-links",
1380
+ `Spec "${spec.fm.id}" stories entry "${entry}" does not resolve: ${r.error}.`, spec.rel));
1381
+ } else if (spec.fm.id &&
1382
+ !listOf(r.story.fm, "specs").some((ref) => resolveSpec(ref) === spec)) {
1383
+ // Membership through the SAME resolver — a slug-form back-reference
1384
+ // to a grandfathered SPEC-NNN file is a valid link, not a gap.
1385
+ out.push(finding("vault", "warn", "spec-links",
1386
+ `Spec "${spec.fm.id}" covers ${entry} but the story's \`specs:\` list lacks "${spec.fm.id}" (bidirectional link).`, r.story.rel));
1387
+ }
1388
+ }
1389
+ }
1390
+ for (const story of artifacts.filter((a) => a.kind === "story")) {
1391
+ for (const id of listOf(story.fm, "specs")) {
1392
+ const spec = resolveSpec(id);
1393
+ if (!spec) {
1394
+ out.push(finding("vault", "issue", "spec-links",
1395
+ `Story lists spec "${id}" which does not exist in specs/.`, story.rel));
1396
+ } else {
1397
+ const epicId = epicIdOf(story.rel, epicFolder.path);
1398
+ const stem = storyStemOf(story.rel);
1399
+ const covered = listOf(spec.fm, "stories").some((e) => {
1400
+ const m = String(e).match(/^([^/]+)\/(.+)$/);
1401
+ return m && m[1] === epicId &&
1402
+ storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
1403
+ });
1404
+ if (!covered) {
1405
+ out.push(finding("vault", "warn", "spec-links",
1406
+ `Story lists spec "${id}" but that spec's \`stories:\` does not list it back.`, story.rel));
1407
+ }
1408
+ }
1409
+ }
1410
+ // Block-sequence YAML trap: parseFrontmatter is line-based; `specs:` with
1411
+ // an empty parsed value while the raw FRONTMATTER shows a block list means
1412
+ // the list is invisible to every deterministic check.
1413
+ const fmBlock = story.body.match(/^---\n[\s\S]*?\n---/);
1414
+ if (story.fm.specs === "" && fmBlock && /\nspecs:\s*\n\s+-\s/.test(fmBlock[0])) {
1415
+ out.push(finding("vault", "issue", "spec-links",
1416
+ "`specs:` uses block-sequence YAML which projectstore cannot parse — use inline flow: specs: [\"SPEC-001\"].", story.rel));
1417
+ }
1418
+ }
1419
+ return out;
1420
+ }
1421
+
1422
+ // In-scope story = status beyond planned, not legacy-exempt.
1423
+ function specScopeStatus(fm) {
1424
+ const s = String(fm.status || "").toLowerCase();
1425
+ return ["in-progress", "in_progress", "review", "done"].includes(s) ? s : null;
1426
+ }
1427
+
1428
+ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
1429
+ if ((vaultCfg.spec_policy || "optional") !== "required") return [];
1430
+ const out = [];
1431
+ const since = vaultCfg.spec_policy_since || null;
1432
+ const resolveSpec = buildSpecResolver(artifacts, layout);
1433
+
1434
+ for (const story of artifacts.filter((a) => a.kind === "story")) {
1435
+ const status = specScopeStatus(story.fm);
1436
+ if (!status) continue;
1437
+ if (isLegacyStory(story.fm, since)) continue;
1438
+ const ids = listOf(story.fm, "specs");
1439
+ if (!ids.length) {
1440
+ out.push(finding("vault", "issue", "spec-coverage",
1441
+ `Story is ${status} with no covering spec (spec_policy: required — every story needs a spec; ADR-007).`, story.rel));
1442
+ continue;
1443
+ }
1444
+ for (const id of ids) {
1445
+ const spec = resolveSpec(id);
1446
+ if (!spec) continue; // dead link already reported by spec-links (same resolver)
1447
+ const st = specStatusOf(spec);
1448
+ if (status === "done") {
1449
+ if (!["active", "superseded"].includes(st)) {
1450
+ out.push(finding("vault", "issue", "spec-status",
1451
+ `Story is done while covering spec "${id}" is ${st} — a story may close only against an active spec.`, story.rel));
1452
+ }
1453
+ } else if (st === "draft") {
1454
+ out.push(finding("vault", "warn", "spec-status",
1455
+ `Story is ${status} while covering spec "${id}" is still draft — the spec must go active before implementation.`, story.rel));
1456
+ }
1457
+ }
1458
+ }
1459
+ return out;
1460
+ }
1461
+
1462
+ // Additive acceptance oracle (ADR-007 Decision 3): a done story requires every
1463
+ // spec acceptance item ATTRIBUTED to it (bare ids resolved against that spec's
1464
+ // own stories list) — plus every UNATTRIBUTED item — checked, in every
1465
+ // covering spec.
1466
+ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
1467
+ if ((vaultCfg.spec_policy || "optional") !== "required") return [];
1468
+ const out = [];
1469
+ const since = vaultCfg.spec_policy_since || null;
1470
+ const epicFolder = folderByKind(layout, "epic");
1471
+ if (!epicFolder) return out;
1472
+ const resolveSpec = buildSpecResolver(artifacts, layout);
1473
+ const ambiguousReported = new Set(); // spec-scoped: one finding per spec+item
1474
+
1475
+ for (const story of artifacts.filter((a) => a.kind === "story")) {
1476
+ if (String(story.fm.status || "").toLowerCase() !== "done") continue;
1477
+ if (isLegacyStory(story.fm, since)) continue;
1478
+ const epicId = epicIdOf(story.rel, epicFolder.path);
1479
+ const stem = storyStemOf(story.rel);
1480
+
1481
+ for (const id of listOf(story.fm, "specs")) {
1482
+ const spec = resolveSpec(id);
1483
+ if (!spec) continue;
1484
+ const items = parseSpecAcceptance(spec);
1485
+ if (items === null) {
1486
+ out.push(finding("vault", "warn", "spec-acceptance",
1487
+ `Covering spec "${id}" has no Acceptance section — its criteria cannot gate this story.`, spec.rel));
1488
+ continue;
1489
+ }
1490
+ const coveredEntries = listOf(spec.fm, "stories");
1491
+ for (const item of items) {
1492
+ let applies;
1493
+ if (item.stories === null) {
1494
+ applies = true; // unattributed → applies to all covered stories
1495
+ } else {
1496
+ // Bare ids resolve against THIS spec's own stories list.
1497
+ applies = item.stories.some((bare) =>
1498
+ coveredEntries.some((e) => {
1499
+ const m = String(e).match(/^([^/]+)\/(.+)$/);
1500
+ return m && m[1] === epicId && bare === m[2] &&
1501
+ storyMatchesEntry(bare, { id: story.fm.id, stem }) > 0;
1502
+ }));
1503
+ const ambiguous = item.stories.some((bare) =>
1504
+ coveredEntries.filter((e) => {
1505
+ const m = String(e).match(/^([^/]+)\/(.+)$/);
1506
+ return m && bare === m[2];
1507
+ }).length > 1);
1508
+ const ambKey = `${spec.rel}|${item.text}`;
1509
+ if (ambiguous && !ambiguousReported.has(ambKey)) {
1510
+ ambiguousReported.add(ambKey);
1511
+ out.push(finding("vault", "warn", "ambiguous-attribution",
1512
+ `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));
1513
+ }
1514
+ }
1515
+ if (applies && !item.checked) {
1516
+ out.push(finding("vault", "issue", "spec-acceptance",
1517
+ `Story is done but spec "${id}" acceptance item is unchecked: "${item.text.slice(0, 80)}".`, story.rel));
1518
+ }
1519
+ }
1520
+ }
1521
+ }
1522
+ return out;
1523
+ }
1524
+
1525
+ // ─── Lifecycle gates (PS-SPEC story-008) — behind lifecycle_gates=on ───
1526
+
1527
+ export function checkLifecycleGates(artifacts, vaultCfg) {
1528
+ const gates = String(vaultCfg.lifecycle_gates || "off").toLowerCase();
1529
+ if (!["on", "true"].includes(gates)) return [];
1530
+ const out = [];
1531
+ const since = vaultCfg.spec_policy_since || null;
1532
+ const evidenceRe = evidenceSuffixRe();
1533
+
1534
+ for (const story of artifacts.filter((a) => a.kind === "story")) {
1535
+ if (String(story.fm.status || "").toLowerCase() !== "done") continue;
1536
+ if (isLegacyStory(story.fm, since)) continue;
1537
+
1538
+ const acc = sectionOf(story.body, "acceptance");
1539
+ if (acc !== null) {
1540
+ // Raw lines (fence-immune matching, verbatim reporting — backticks kept).
1541
+ for (const line of linesOutsideFences(acc)) {
1542
+ const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
1543
+ if (m && !evidenceRe.test(m[2])) {
1544
+ out.push(finding("vault", "warn", "evidence",
1545
+ `Checked acceptance criterion carries no evidence suffix ("— evidence: <test | command | file:line>"): "${m[2].slice(0, 70)}".`, story.rel));
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ const plan = sectionOf(story.body, "implementation_plan");
1551
+ if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
1552
+ 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));
1554
+ }
1555
+ const summary = sectionOf(story.body, "final_summary");
1556
+ if (summary === null) {
1557
+ out.push(finding("vault", "warn", "final-summary",
1558
+ "Done story has no Final Summary section (lifecycle_gates: on requires a close-out record).", story.rel));
1559
+ }
1560
+ if (plan === null) {
1561
+ out.push(finding("vault", "warn", "plan-gate",
1562
+ "Done story has no Implementation Plan section (lifecycle_gates: on requires the plan to live in the story).", story.rel));
1563
+ }
1564
+ }
1565
+ return out;
1566
+ }
1567
+
1568
+ // Suggestion for existing binds: specs exist but no vault policy declared.
1569
+ export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
1570
+ const out = [];
1571
+ const hasSpecs = artifacts.some((a) => a.kind === "spec");
1572
+ if (hasSpecs && !vaultCfg.spec_policy) {
1573
+ out.push(finding("vault", "info", "spec-policy",
1574
+ "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)."));
1575
+ }
1576
+ if (vaultCfg.spec_policy === "required" && !vaultCfg.spec_policy_since) {
1577
+ out.push(finding("vault", "warn", "spec-policy",
1578
+ "spec_policy is required but spec_policy_since is missing — the legacy exemption cannot be evaluated; stamp it with the enable date (ISO-8601)."));
1579
+ }
1580
+ return out;
1581
+ }
1582
+
1583
+ // One shared recursive walk of the vault's markdown files (dotfiles
1584
+ // skipped). scanArtifacts only sees layout-declared folders — vault-wide
1585
+ // claims (wikilink targets, identity uniqueness, filename shapes) must use
1586
+ // this walk instead, or folder-shape story READMEs and loose notes go blind.
1587
+ export function walkVaultFiles(vault) {
1588
+ const files = []; // { rel, name } — rel is /-joined relative to the vault root
1589
+ const walk = (dir, relDir) => {
1590
+ for (const n of readdirSync(dir)) {
1591
+ if (n.startsWith(".")) continue;
1592
+ const p = join(dir, n);
1593
+ let st;
1594
+ try { st = statSync(p); } catch { continue; }
1595
+ if (st.isDirectory()) walk(p, relDir ? `${relDir}/${n}` : n);
1596
+ else if (n.endsWith(".md")) files.push({ rel: relDir ? `${relDir}/${n}` : n, name: n });
1597
+ }
1598
+ };
1599
+ walk(vault, "");
1600
+ return files;
1601
+ }
1602
+
1603
+ // Body links resolved through the ONE shared resolver (spec:
1604
+ // vault-link-graph-derived-view-and-shared-link-resolver): dead stays an
1605
+ // issue, ambiguous (multiple node candidates — invisible to the old
1606
+ // basename-set check) is a NEW warn, out-of-scope (the target exists but
1607
+ // is not an artifact: kanban, code-map, READMEs, attachments) is silent at
1608
+ // every level. A deliberate, documented behavior change in both
1609
+ // directions: tiered matching also accepts links the exact-basename check
1610
+ // rejected (number-stripped slug readings), and path-qualified links now
1611
+ // resolve as paths — a wrong relative depth is dead here even though
1612
+ // Obsidian's basename fallback happens to heal it. The graph generator
1613
+ // consumes the same resolver, so a dead edge in graph.md and a dead-link
1614
+ // finding here are the same fact reported twice.
1615
+ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = null) {
1616
+ const out = [];
1617
+ const files = vaultFiles ?? walkVaultFiles(cfg.vault_path);
1618
+ let index = nodeIndex;
1619
+ if (!index) {
1620
+ try { index = buildNodeIndex(cfg, loadLayout(cfg.layout)); } catch (e) {
1621
+ // A silent no-op check would read as "links are fine" — say why not.
1622
+ return [finding("vault", "warn", "wikilink", `Link check skipped — node index failed: ${e.message}`)];
1623
+ }
1624
+ }
1625
+ for (const a of artifacts) {
1626
+ const ctx = {
1627
+ sourceRel: a.rel,
1628
+ index,
1629
+ files,
1630
+ exists: (rel) => existsSync(join(cfg.vault_path, rel)),
1631
+ };
1632
+ for (const link of extractLinks(a.body)) {
1633
+ const r = resolveLinkTarget(link.target, link.type, ctx);
1634
+ if (r.outcome === "dead") {
1635
+ out.push(link.type === "wikilink"
1636
+ ? finding("vault", "issue", "wikilink", `Dead wiki-link [[${link.target}]].`, a.rel)
1637
+ : finding("vault", "issue", "rel-link", `Dead relative link (${link.target}).`, a.rel));
1638
+ } else if (r.outcome === "ambiguous") {
1639
+ out.push(finding("vault", "warn", "wikilink",
1640
+ `Ambiguous wiki-link [[${link.target}]] — matches ${r.candidates.join(", ")}; qualify with a path.`, a.rel));
1641
+ }
1642
+ }
1643
+ }
1644
+ return out;
1645
+ }
1646
+
1647
+ // ─── Artifact identity & filename shapes (ADR-010 / SPEC-002 4, 7) ─────
1648
+
1649
+ // Infrastructure names carry no topic identity: README.md is every folder's
1650
+ // index, epic.md every epic's root, kanban.md the board.
1651
+ const INFRA_NAMES = new Set(["README.md", "epic.md", "kanban.md"]);
1652
+
1653
+ // Normalized slug-identity uniqueness per identity scope (a kind folder; an
1654
+ // epic for stories), on candidate sets — so `ADR-003-foo.md` vs `foo.md`
1655
+ // and `story-006-foo.md` vs `story-foo.md` collide with no rename ever
1656
+ // having happened. Severity keys on each member's ERA, decided by
1657
+ // frontmatter where the filename alone is ambiguous (a digit-leading
1658
+ // story stem reads as either era; the exact machine id settles it):
1659
+ // - two as-written twins (flat story + folder-shape namesake) → issue;
1660
+ // - a new-era name overlapping a certain legacy one → issue (the exact
1661
+ // case the pre-write guard exists to prevent);
1662
+ // - any member of undecidable era (digit-leading, no fm evidence) → warn;
1663
+ // - all members certainly legacy (same slug, different numbers) → info —
1664
+ // in the numbered era the number WAS the identity, so this was legal
1665
+ // and grandfathering must not turn it into a defect (contract 6).
1666
+ // Duplicate display numbers are info: numbers are reference metadata
1667
+ // (ADR-010), duplicates confuse humans but identify nothing.
1668
+ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
1669
+ const out = [];
1670
+ const fmByRel = new Map(artifacts.map((a) => [a.rel, a.fm]));
1671
+ // Era of one directory entry: "new" | "legacy" | "uncertain".
1672
+ const eraOf = (entry, opts) => {
1673
+ const idn = slugIdentity(entry.name, opts);
1674
+ if (!idn.legacyNumber) return "new";
1675
+ const fm = fmByRel.get(entry.rel);
1676
+ const fmId = fm && fm.id != null ? String(fm.id) : "";
1677
+ if (fmId) {
1678
+ const machineId = opts.story ? `story-${idn.primary}` : idn.primary;
1679
+ if (fmId.toLowerCase() === machineId) return "new"; // exact machine id = slug era
1680
+ if (isLegacyNumberedId(fmId, opts)) return "legacy";
1681
+ }
1682
+ const num = fm && fm.number != null ? String(fm.number).trim() : "";
1683
+ if (num && num !== "null") return "legacy";
1684
+ return idn.digitLeading ? "uncertain" : "legacy"; // prefix-anchored names are confident
1685
+ };
1686
+ const scopes = [];
1687
+ const epicFolder = folderByKind(layout, "epic");
1688
+ for (const f of layout.folders) {
1689
+ if (f.kind === "epic") continue;
1690
+ const entries = vaultFiles
1691
+ .filter((x) => dirname(x.rel) === f.path && !INFRA_NAMES.has(x.name))
1692
+ .map((x) => ({ name: x.name, rel: x.rel }));
1693
+ scopes.push({ label: f.path, entries, opts: { prefix: f.prefix || null } });
1694
+ }
1695
+ if (epicFolder) {
1696
+ const byEpic = new Map();
1697
+ for (const x of vaultFiles) {
1698
+ if (!x.rel.startsWith(epicFolder.path + "/")) continue;
1699
+ const parts = x.rel.split("/");
1700
+ let entry = null; // stories/<f>.md | stories/<dir>/README.md | standalone story-*.md
1701
+ if (parts.length === 4 && parts[2] === "stories") entry = { name: parts[3], rel: x.rel };
1702
+ else if (parts.length === 5 && parts[2] === "stories" && parts[4] === "README.md") entry = { name: parts[3], rel: x.rel };
1703
+ else if (parts.length === 3 && parts[2].startsWith("story-")) entry = { name: parts[2], rel: x.rel };
1704
+ if (!entry || INFRA_NAMES.has(entry.name)) continue;
1705
+ if (!byEpic.has(parts[1])) byEpic.set(parts[1], []);
1706
+ byEpic.get(parts[1]).push(entry);
1707
+ }
1708
+ for (const [epicId, entries] of byEpic) {
1709
+ scopes.push({ label: `${epicFolder.path}/${epicId}`, entries, opts: { story: true } });
1710
+ }
1711
+ }
1712
+
1713
+ for (const { label, entries, opts } of scopes) {
1714
+ const groups = new Map(); // candidate identity -> hits
1715
+ const numbers = new Map(); // normalized display number -> entries
1716
+ for (const entry of entries) {
1717
+ const idn = slugIdentity(entry.name, opts);
1718
+ for (const c of idn.candidates) {
1719
+ if (!groups.has(c.id)) groups.set(c.id, []);
1720
+ groups.get(c.id).push({ entry, via: c.via, digitLeading: idn.digitLeading });
1721
+ }
1722
+ if (idn.legacyNumber) {
1723
+ const k = String(parseInt(idn.legacyNumber, 10));
1724
+ if (!numbers.has(k)) numbers.set(k, []);
1725
+ numbers.get(k).push(entry);
1726
+ }
1727
+ }
1728
+ const reported = new Set(); // one finding per file group, not per shared candidate
1729
+ for (const [ident, hits] of groups) {
1730
+ const uniq = [...new Map(hits.map((h) => [h.entry.rel, h])).values()];
1731
+ if (uniq.length < 2) continue;
1732
+ const key = uniq.map((h) => h.entry.rel).sort().join("|");
1733
+ if (reported.has(key)) continue;
1734
+ reported.add(key);
1735
+ const selfOnly = uniq.every((h) => h.via === "self");
1736
+ const classed = uniq.map((h) => ({ h, era: eraOf(h.entry, opts) }));
1737
+ const level = selfOnly ? "issue"
1738
+ // Overlap reached via the legacy reading of a PROVEN slug-era file
1739
+ // (its machine id is the full stem) is spurious — the file's real
1740
+ // identity is the unstripped slug.
1741
+ : classed.some(({ h, era }) => h.via !== "self" && era === "new") ? "warn"
1742
+ : classed.some(({ era }) => era === "uncertain") ? "warn"
1743
+ : classed.every(({ era }) => era === "legacy") ? "info"
1744
+ : "issue"; // a new-era name colliding with a certain legacy one
1745
+ const note = level === "warn" ? "; the overlap depends on a legacy reading that frontmatter does not confirm"
1746
+ : level === "info" ? "; all carry legacy numbers — legal in the numbered era, duplicate topic is hygiene"
1747
+ : "";
1748
+ out.push(finding("vault", level, "identity",
1749
+ `Same normalized identity "${ident}" in ${label}: ${uniq.map((h) => h.entry.name).join(", ")} — two artifacts claim one topic (ADR-010${note}).`,
1750
+ uniq[0].entry.rel));
1751
+ }
1752
+ for (const [num, ents] of numbers) {
1753
+ if (new Set(ents.map((e) => e.rel)).size < 2) continue;
1754
+ out.push(finding("vault", "info", "identity",
1755
+ `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.`,
1756
+ ents[0].rel));
1757
+ }
1758
+ }
1759
+ return out;
1760
+ }
1761
+
1762
+ // Block-form YAML trap for `external_refs` (SPEC-002 contract 3): the
1763
+ // line-based parseFrontmatter reads a block map as an empty scalar, so every
1764
+ // deterministic consumer goes blind — the same guard class protects `specs:`
1765
+ // in checkSpecLinks. Applies to every artifact kind that carries the field.
1766
+ export function checkExternalRefsForm(artifacts) {
1767
+ const out = [];
1768
+ for (const a of artifacts) {
1769
+ if (a.fm.external_refs !== "") continue;
1770
+ const fmBlock = a.body.match(/^---\n[\s\S]*?\n---/);
1771
+ if (fmBlock && /\nexternal_refs:\s*\n\s+\S/.test(fmBlock[0])) {
1772
+ out.push(finding("vault", "issue", "external-refs",
1773
+ "`external_refs:` uses block-form YAML which projectstore cannot parse — use inline flow: external_refs: {jira: \"ABC-123\"}.", a.rel));
1774
+ }
1775
+ }
1776
+ return out;
1777
+ }
1778
+
1779
+ // Filename-shape checks over the WHOLE vault walk (SPEC-002 contract 7):
1780
+ // sync-conflict shapes by blacklist at warn (a legal-form whitelist would
1781
+ // flag hand-created legacy notes), cross-folder basename collisions at info
1782
+ // (short wiki-links to that basename become ambiguous).
1783
+ export function checkArtifactNames(vaultFiles) {
1784
+ const out = [];
1785
+ const byName = new Map();
1786
+ for (const x of vaultFiles) {
1787
+ const bad = legalArtifactName(x.name);
1788
+ if (bad) {
1789
+ out.push(finding("vault", "warn", "artifact-name",
1790
+ `Sync-conflict filename shape — ${bad}. Merge or remove; sync engines leave these beside the original.`, x.rel));
1791
+ }
1792
+ if (INFRA_NAMES.has(x.name)) continue;
1793
+ if (!byName.has(x.name)) byName.set(x.name, []);
1794
+ byName.get(x.name).push(x.rel);
1795
+ }
1796
+ for (const [name, rels] of byName) {
1797
+ if (rels.length < 2) continue;
1798
+ out.push(finding("vault", "info", "artifact-name",
1799
+ `Basename "${name}" appears in ${rels.length} folders (${rels.map((r) => dirname(r)).join(", ")}) — short wiki-links [[${name.replace(/\.md$/, "")}]] are ambiguous.`,
1800
+ rels[0]));
1801
+ }
1802
+ return out;
1803
+ }
1804
+
1805
+ // code_refs: status-aware (ADR-004) — required to resolve only for
1806
+ // in-progress / done artifacts; globs are skipped in v1 (documented).
1807
+ // Story refs must fall under the parent epic's refs (subset) — that is how
1808
+ // drift between the two levels is caught.
1809
+ function refsOf(fm) {
1810
+ return listOf(fm, "code_refs");
1811
+ }
1812
+
1813
+ // Untracked work, after the fact (spec contract 18). The reminder fires at the
1814
+ // moment of the act and cannot see Bash-mediated writes at all; this is the
1815
+ // backstop, and it is where the reported incident was actually caught — at
1816
+ // "done". Warn, never issue: spikes and hotfixes legitimately produce this
1817
+ // state, and an issue would poison the SessionStart line.
1818
+ export function checkWorkWithoutStory(cfg, proj) {
1819
+ const out = [];
1820
+ if (!cfg || !cfg.vault_path) return out;
1821
+
1822
+ // The same predicate the hook uses, fed by a plain read: doctor is not on a
1823
+ // hot path and needs no budget, but it must not answer a different question.
1824
+ const files = listVaultStoryFiles(cfg.vault_path);
1825
+ const fms = [];
1826
+ let unreadable = 0;
1827
+ for (const f of files) {
1828
+ try { fms.push(parseFrontmatter(readFileSync(f, "utf8")).data); } catch { unreadable++; }
1829
+ }
1830
+ if (unreadable > 0 && !openStoryFrom(fms)) {
1831
+ // A diagnostic that goes quiet because it could not read is a false clean.
1832
+ out.push(finding("vault", "warn", "work-without-story",
1833
+ `Could not read ${unreadable} story file(s), so "is any story in progress" is unproven — this check is inconclusive rather than clean.`));
1834
+ return out;
1835
+ }
1836
+ if (openStoryFrom(fms)) return out;
1837
+
1838
+ // ENTRY_IGNORE, not the shared set: /projectstore:bind writes AGENTS.md,
1839
+ // CLAUDE.md and .gitignore in a session that by construction has no story, so
1840
+ // the shared set would make this fire on every project's first run.
1841
+ const dirty = uncommittedProjectFiles(proj, ENTRY_IGNORE);
1842
+ if (dirty === null) return out; // not a git repo, shallow, no commits, detached
1843
+
1844
+ // The other half: work that WAS committed, with no story to attribute it to.
1845
+ // In a repo of small frequent commits that is the common shape, and a
1846
+ // dirty-tree-only check would never see it.
1847
+ const vaultMs = lastVaultActivityMs(cfg.vault_path);
1848
+ const commitMs = lastCommitMs(proj);
1849
+ const committedSince = vaultMs !== null && commitMs !== null && commitMs > vaultMs;
1850
+
1851
+ if (dirty.length === 0 && !committedSince) return out;
1852
+
1853
+ // kind: null — entry reminders only. The log is shared with the session-name
1854
+ // offer, whose breadcrumbs would otherwise be reported as reminders delivered.
1855
+ const fired = readEntryLog(proj, { withinDays: 30, kind: null }).length;
1856
+ const firedNote = fired > 0
1857
+ ? ` 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.`
1858
+ : " No entry reminder fired in the last 30 days on this machine (the log is machine-local — .claude/ is gitignored).";
1859
+ const what = [];
1860
+ if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
1861
+ if (committedSince) what.push("commits newer than the vault's last activity");
1862
+ 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}`));
1864
+ return out;
1865
+ }
1866
+
1867
+ export function checkCodeRefs(artifacts, proj) {
1868
+ const out = [];
1869
+ const epicRefs = new Map();
1870
+ for (const e of artifacts.filter((a) => a.kind === "epic")) {
1871
+ epicRefs.set(e.rel.replace(/\/epic\.md$/, ""), refsOf(e.fm));
1872
+ }
1873
+ for (const a of artifacts) {
1874
+ const refs = refsOf(a.fm);
1875
+ if (!refs.length) continue;
1876
+ const status = (a.fm.status || "").toLowerCase();
1877
+ if (["in-progress", "in_progress", "done"].includes(status)) {
1878
+ for (const ref of refs) {
1879
+ if (ref.includes("*")) continue;
1880
+ if (!existsSync(join(proj, ref))) {
1881
+ out.push(finding("vault", "issue", "code-refs",
1882
+ `code_refs path "${ref}" does not resolve inside the project (status: ${status}).`, a.rel));
1883
+ }
1884
+ }
1885
+ }
1886
+ if (a.kind === "story") {
1887
+ const dir = a.rel.replace(/\/stories\/[^/]+$/, "");
1888
+ const parent = epicRefs.get(dir) || [];
1889
+ if (!parent.length) {
1890
+ out.push(finding("vault", "warn", "code-refs",
1891
+ "Story has code_refs but its epic has none — set the epic's footprint first.", a.rel));
1892
+ } else {
1893
+ const norm = (r) => r.replace(/\/+$/, "");
1894
+ for (const ref of refs) {
1895
+ if (!parent.some((p) => norm(ref).startsWith(norm(p)))) {
1896
+ out.push(finding("vault", "warn", "code-refs",
1897
+ `Story code_ref "${ref}" falls outside the parent epic's code_refs.`, a.rel));
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1902
+ }
1903
+ return out;
1904
+ }
1905
+
1906
+ // code-map.md staleness: regenerate with the real generator and compare
1907
+ // (same pattern as the kanban check).
1908
+ export function checkCodeMap(cfg) {
1909
+ const p = join(cfg.vault_path, "code-map.md");
1910
+ if (!existsSync(p)) return [];
1911
+ const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "codemap.mjs")], {
1912
+ encoding: "utf8",
1913
+ timeout: 10000,
1914
+ env: childEnv(process.env, { projectRoot: projectRoot() }),
1915
+ });
1916
+ if (r.status !== 0) return [finding("vault", "warn", "code-map", "codemap generator failed.")];
1917
+ let expected;
1918
+ try { expected = JSON.parse(r.stdout).content; } catch {
1919
+ return [finding("vault", "warn", "code-map", "codemap generator returned unparseable output.")];
1920
+ }
1921
+ const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1922
+ if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1923
+ return [finding("vault", "issue", "code-map",
1924
+ "code-map.md is stale against frontmatter code_refs — run /projectstore:codemap (or reconcile).", "code-map.md")];
1925
+ }
1926
+ return [];
1927
+ }
1928
+
1929
+ // graph.md staleness: regenerate with the real generator and compare —
1930
+ // kanban's variant of the pattern, INCLUDING its missing-file info
1931
+ // (checkCodeMap stays silent on a missing file; the graph deliberately
1932
+ // picks the louder branch, because bare reconcile never re-mints a deleted
1933
+ // graph.md — this info is the standing signal; spec contract 6).
1934
+ export function checkGraph(cfg) {
1935
+ const p = join(cfg.vault_path, "graph.md");
1936
+ if (!existsSync(p)) {
1937
+ return [finding("vault", "info", "graph", "No graph.md yet — run /projectstore:graph to create the link graph.")];
1938
+ }
1939
+ const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
1940
+ encoding: "utf8",
1941
+ timeout: 10000,
1942
+ env: childEnv(process.env, { projectRoot: projectRoot() }),
1943
+ });
1944
+ if (r.status !== 0) return [finding("vault", "warn", "graph", `graph generator failed: ${(r.stderr || "").trim()}`)];
1945
+ let expected;
1946
+ try { expected = JSON.parse(r.stdout).content; } catch {
1947
+ return [finding("vault", "warn", "graph", "graph generator returned unparseable output.")];
1948
+ }
1949
+ const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
1950
+ if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
1951
+ return [finding("vault", "issue", "graph",
1952
+ "graph.md is out of sync with vault links — run /projectstore:graph (or reconcile).", "graph.md")];
1953
+ }
1954
+ return [];
1955
+ }
1956
+
1957
+ // ─── Runners ───────────────────────────────────────────────────────────
1958
+
1959
+ export async function runInstallChecks(cfg, proj, opts = {}) {
1960
+ const out = [...checkConfig(cfg, proj)];
1961
+ if (!cfg || !cfg.vault_path) return out;
1962
+ out.push(...checkVaultPath(cfg));
1963
+ if (out.some((f) => f.check === "vault-path" && f.level === "issue")) return out;
1964
+ out.push(
1965
+ ...checkLayoutTemplates(cfg),
1966
+ ...checkHooksAlive(cfg),
1967
+ ...checkStatusline(cfg, proj),
1968
+ ...checkAgentsBlock(proj, { env: opts.env, root: opts.root }),
1969
+ ...checkOverrideCopies(proj),
1970
+ ...checkEnvModel(),
1971
+ ...checkEnvEffort(),
1972
+ ...checkGitignore(proj),
1973
+ ...checkTrackedRuntime(proj),
1974
+ ...checkVaultGit(cfg),
1975
+ ...checkAutoUpdate(),
1976
+ ...checkMcpRegistration(),
1977
+ ...checkLayout(proj, undefined, opts),
1978
+ ...checkOverlays(cfg, proj, opts),
1979
+ );
1980
+ let read = null;
1981
+ try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
1982
+ out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
1983
+ if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home }));
1984
+ if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
1985
+ return foldIntoMove(out);
1986
+ }
1987
+
1988
+ export function runVaultChecks(cfg) {
1989
+ let layout;
1990
+ try { layout = loadLayout(cfg.layout); } catch (e) {
1991
+ return [finding("vault", "issue", "layout", `Layout not loadable: ${e.message}`)];
1992
+ }
1993
+ const artifacts = scanArtifacts(cfg, layout);
1994
+ // Vault-side policy read ONCE (ADR-007 Decision 4): spec gates and lifecycle
1995
+ // gates key off <vault>/.projectstore.json, never the machine-local config.
1996
+ const vaultCfg = readVaultConfig(cfg.vault_path);
1997
+ const findings = [...checkKanbanSync(cfg), ...checkIndexes(cfg, layout, artifacts)];
1998
+ // Registry-dependent checks: a missing/corrupt scaffold/headings.json must
1999
+ // become a finding, never a crash that swallows the whole report.
2000
+ const guarded = [
2001
+ () => checkIndexHeaders(cfg, layout),
2002
+ () => checkStoriesAndEpics(artifacts),
2003
+ () => checkSpecLinks(cfg, layout, artifacts),
2004
+ () => checkSpecCoverage(artifacts, vaultCfg, layout),
2005
+ () => checkSpecAcceptance(layout, artifacts, vaultCfg),
2006
+ () => checkLifecycleGates(artifacts, vaultCfg),
2007
+ ];
2008
+ for (const step of guarded) {
2009
+ try { findings.push(...step()); } catch (e) {
2010
+ findings.push(finding("vault", "issue", "registry", e.message));
2011
+ break;
2012
+ }
2013
+ }
2014
+ const vaultFiles = walkVaultFiles(cfg.vault_path); // one walk, three consumers
2015
+ findings.push(
2016
+ ...checkVaultPolicy(cfg, layout, artifacts, vaultCfg),
2017
+ ...checkWikilinks(cfg, artifacts, vaultFiles, buildNodeIndex(cfg, layout)),
2018
+ ...checkArtifactIdentity(layout, vaultFiles, artifacts),
2019
+ ...checkArtifactNames(vaultFiles),
2020
+ ...checkExternalRefsForm(artifacts),
2021
+ ...checkCodeRefs(artifacts, projectRoot()),
2022
+ ...checkWorkWithoutStory(cfg, projectRoot()),
2023
+ ...checkCodeMap(cfg),
2024
+ ...checkGraph(cfg),
2025
+ );
2026
+ return findings;
2027
+ }
2028
+
2029
+ // SessionStart subset: install/fs checks only (never the vault group — ADR-005
2030
+ // Decision 4). Aborts past the budget rather than reporting a false "clean".
2031
+ export function runStartupChecks(cfg, proj, budgetMs = 150) {
2032
+ const started = Date.now();
2033
+ const steps = [
2034
+ () => checkConfig(cfg, proj),
2035
+ () => (cfg && cfg.vault_path ? checkVaultPath(cfg) : []),
2036
+ () => (cfg && cfg.vault_path ? checkStatusline(cfg, proj) : []),
2037
+ () => (cfg && cfg.vault_path && cfg.statusline && cfg.statusline.enabled === true ? checkPendingUpgrade(proj) : []),
2038
+ () => checkAgentsBlock(proj),
2039
+ () => checkGitignore(proj),
2040
+ // The layout move is offered from the startup line (info here, warn in the
2041
+ // report) — a user who never runs doctor still learns to migrate.
2042
+ () => checkLayout(proj, undefined, { level: "info" }),
2043
+ () => checkEnvModel(),
2044
+ () => checkEnvEffort(),
2045
+ ];
2046
+ const findings = [];
2047
+ for (const step of steps) {
2048
+ if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
2049
+ try { findings.push(...step()); } catch {}
2050
+ }
2051
+ // The findings the move repairs are not counted beside it (foldIntoMove).
2052
+ const settled = foldIntoMove(findings);
2053
+ // While the move is pending, the re-stamp is not a step of its own: the move
2054
+ // re-stamps the launcher at its new path (the layout spec, contract 7 as
2055
+ // amended 2026-10-03), and an in-session `doctor --fix` would write that
2056
+ // launcher and then stop at the deferred move with exit 1. So the line names
2057
+ // one step, and says what it covers when the dropped offer would have fired.
2058
+ const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
2059
+ const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
2060
+ const offers = settled
2061
+ .filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
2062
+ .map((f) => (movePending && restamp && f.check === "layout-legacy" ? `${f.message} The same run re-stamps the status line launcher.` : f.message));
2063
+ return {
2064
+ skipped: false,
2065
+ count: settled.filter((f) => f.level === "issue").length,
2066
+ offers,
2067
+ findings: settled,
2068
+ };
2069
+ }
2070
+
2071
+ // ─── CLI ───────────────────────────────────────────────────────────────
2072
+
2073
+ function icon(level) {
2074
+ return level === "issue" ? "✖" : level === "warn" ? "⚠" : "ℹ";
2075
+ }
2076
+
2077
+ function report(findings, groups) {
2078
+ const ver = pluginVersion();
2079
+ const lines = [`projectstore doctor — plugin v${ver || "?"}, ${new Date().toISOString().slice(0, 10)}`];
2080
+ for (const g of groups) {
2081
+ const fs = findings.filter((f) => f.group === g);
2082
+ lines.push("", `## ${g} (${fs.filter((f) => f.level === "issue").length} issue(s), ${fs.filter((f) => f.level === "warn").length} warning(s))`);
2083
+ if (!fs.length) lines.push(" ✓ clean");
2084
+ for (const f of fs) {
2085
+ lines.push(` ${icon(f.level)} [${f.check}] ${f.message}${f.file ? ` — ${f.file}` : ""}`);
2086
+ }
2087
+ }
2088
+ const issues = findings.filter((f) => f.level === "issue").length;
2089
+ 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."}`);
2091
+ return lines.join("\n");
2092
+ }
2093
+
2094
+ async function main() {
2095
+ const args = process.argv.slice(2);
2096
+ const wantJson = args.includes("--json");
2097
+ const startup = args.includes("--startup");
2098
+ let install = args.includes("--install");
2099
+ let vault = args.includes("--vault");
2100
+ if (!install && !vault && !startup) { install = true; vault = true; }
2101
+
2102
+ const cfg = readConfig();
2103
+ const proj = projectRoot();
2104
+
2105
+ if (startup) {
2106
+ const r = runStartupChecks(cfg, proj);
2107
+ process.stdout.write(JSON.stringify(r) + "\n");
2108
+ return;
2109
+ }
2110
+
2111
+ const findings = [];
2112
+ const groups = [];
2113
+ if (install) { groups.push("install"); findings.push(...await runInstallChecks(cfg, proj)); }
2114
+ if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
2115
+ groups.push("vault");
2116
+ findings.push(...runVaultChecks(cfg));
2117
+ } else if (vault) {
2118
+ groups.push("vault");
2119
+ findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
2120
+ }
2121
+
2122
+ process.stdout.write((wantJson ? JSON.stringify(findings, null, 2) : report(findings, groups)) + "\n");
2123
+ }
2124
+
2125
+ if (isMain(import.meta.url)) {
2126
+ main();
2127
+ }