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.
- package/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +163 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +36 -14
- package/plugin.json +53 -0
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- 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
|
+
}
|