projectstore-claude 0.29.2 → 0.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -14,16 +14,10 @@
|
|
|
14
14
|
//
|
|
15
15
|
// CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
|
|
16
16
|
// default = --install --vault. Exit code is always 0 (reporting tool).
|
|
17
|
+
// Without --json the findings are drawn by doctor-report.mjs, the
|
|
18
|
+
// renderer the bin uses, in its plain layout.
|
|
17
19
|
|
|
18
|
-
import {
|
|
19
|
-
existsSync,
|
|
20
|
-
readFileSync,
|
|
21
|
-
readdirSync,
|
|
22
|
-
statSync,
|
|
23
|
-
accessSync,
|
|
24
|
-
constants,
|
|
25
|
-
realpathSync,
|
|
26
|
-
} from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, realpathSync } from "node:fs";
|
|
27
21
|
import { join, basename, resolve, dirname, relative } from "node:path";
|
|
28
22
|
import { homedir } from "node:os";
|
|
29
23
|
import { spawnSync } from "node:child_process";
|
|
@@ -43,7 +37,6 @@ import {
|
|
|
43
37
|
statusLineIsOurWiring,
|
|
44
38
|
isLegacyStory,
|
|
45
39
|
sectionOf,
|
|
46
|
-
headingLineRe,
|
|
47
40
|
indexHeaderRe,
|
|
48
41
|
evidenceSuffixRe,
|
|
49
42
|
storiesAttributionRe,
|
|
@@ -66,16 +59,23 @@ import {
|
|
|
66
59
|
findAgentsBlock,
|
|
67
60
|
statusLineLauncherPath,
|
|
68
61
|
LAUNCHER_HEADER,
|
|
69
|
-
installedPluginEntries,
|
|
62
|
+
installedPluginEntries,
|
|
63
|
+
selectCopy,
|
|
64
|
+
takesNoRegister,
|
|
65
|
+
copyUpdateAdvice,
|
|
66
|
+
isMain,
|
|
67
|
+
channelMark,
|
|
70
68
|
pickExisting,
|
|
71
69
|
legacyStatusLineLauncherPath,
|
|
72
70
|
stateDir,
|
|
73
71
|
legacyStateDir,
|
|
74
|
-
layoutPaths,
|
|
72
|
+
layoutPaths,
|
|
73
|
+
sessionsDir,
|
|
75
74
|
isLauncherPath,
|
|
76
75
|
LAYOUT,
|
|
77
76
|
hostSettingsPath,
|
|
78
|
-
readOverlayAt,
|
|
77
|
+
readOverlayAt,
|
|
78
|
+
layoutRoster,
|
|
79
79
|
installChannel,
|
|
80
80
|
cmpPrecedence,
|
|
81
81
|
cmpVersion,
|
|
@@ -85,8 +85,20 @@ import {
|
|
|
85
85
|
speakingHarness,
|
|
86
86
|
inheritForm,
|
|
87
87
|
bindInherits,
|
|
88
|
+
doctorFallbackText,
|
|
89
|
+
fromTerminalIn,
|
|
88
90
|
} from "./lib.mjs";
|
|
89
|
-
import {
|
|
91
|
+
import {
|
|
92
|
+
agentOverrides,
|
|
93
|
+
childEnv,
|
|
94
|
+
sourceHarness,
|
|
95
|
+
loadHarness,
|
|
96
|
+
detectHarnesses,
|
|
97
|
+
identifiedHarnessId,
|
|
98
|
+
configPath as harnessConfigPath,
|
|
99
|
+
packageCommand,
|
|
100
|
+
invocation,
|
|
101
|
+
} from "./harness.mjs";
|
|
90
102
|
|
|
91
103
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
92
104
|
// measured 2026-09-06, NO harness gives its Bash tool that variable, and a
|
|
@@ -101,6 +113,7 @@ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness,
|
|
|
101
113
|
// brand, and --harness still carries the target.
|
|
102
114
|
import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
|
|
103
115
|
import { resolveBinding } from "./worktree.mjs";
|
|
116
|
+
import { missingScaffoldRequirements } from "./scaffold.mjs";
|
|
104
117
|
|
|
105
118
|
const AGENT_BLOCK_MARKER = new RegExp(AGENTS_BLOCK_OPEN_SRC, "g");
|
|
106
119
|
// The provenance grammar's prefix, duplicated here on purpose: the startup
|
|
@@ -162,9 +175,7 @@ export function aboutHarness(f, h, speaker = speakingHarness()) {
|
|
|
162
175
|
|
|
163
176
|
function pluginVersion(root = pluginRoot()) {
|
|
164
177
|
try {
|
|
165
|
-
return JSON.parse(
|
|
166
|
-
readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"),
|
|
167
|
-
).version;
|
|
178
|
+
return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
168
179
|
} catch {
|
|
169
180
|
return null;
|
|
170
181
|
}
|
|
@@ -191,19 +202,43 @@ export function checkConfig(cfg, proj = projectRoot()) {
|
|
|
191
202
|
// new one. Assigned rather than appended: two instructions for one problem
|
|
192
203
|
// is how a person ends up binding a second vault by hand.
|
|
193
204
|
let b = null;
|
|
194
|
-
try {
|
|
205
|
+
try {
|
|
206
|
+
b = resolveBinding(proj);
|
|
207
|
+
} catch {}
|
|
208
|
+
// What binding does first, then the command alone on its line: an
|
|
209
|
+
// in-session form that can carry the parent's vault path (the story
|
|
210
|
+
// "User-facing text says where to run…", rule 4).
|
|
195
211
|
if (b && b.state === "inheritable") {
|
|
196
|
-
return [
|
|
197
|
-
|
|
212
|
+
return [
|
|
213
|
+
finding(
|
|
214
|
+
"install",
|
|
215
|
+
"issue",
|
|
216
|
+
"worktree-unbound",
|
|
217
|
+
`This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. ${bindInherits() ? "Adopt that binding" : "Bind it to the same vault, layout and language"}, in the session:\n${inheritForm(b.vaultPath, { layout: b.layout, language: b.language })}`,
|
|
218
|
+
),
|
|
219
|
+
];
|
|
198
220
|
}
|
|
199
221
|
const p = layoutPaths(proj);
|
|
200
222
|
const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
|
|
201
223
|
if (present) {
|
|
202
|
-
return [
|
|
203
|
-
|
|
224
|
+
return [
|
|
225
|
+
finding(
|
|
226
|
+
"install",
|
|
227
|
+
"issue",
|
|
228
|
+
"config-unparseable",
|
|
229
|
+
`${relative(proj, present)} exists but is not valid JSON — the project reads as unbound until it is fixed; bind refuses to overwrite it.`,
|
|
230
|
+
relative(proj, present),
|
|
231
|
+
),
|
|
232
|
+
];
|
|
204
233
|
}
|
|
205
|
-
return [
|
|
206
|
-
|
|
234
|
+
return [
|
|
235
|
+
finding(
|
|
236
|
+
"install",
|
|
237
|
+
"issue",
|
|
238
|
+
"config",
|
|
239
|
+
`No projectstore config (.projectstore/projectstore.json). Run ${commandForm("bind", { args: "<vault-path>" })}.`,
|
|
240
|
+
),
|
|
241
|
+
];
|
|
207
242
|
}
|
|
208
243
|
const out = [];
|
|
209
244
|
if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
|
|
@@ -257,9 +292,24 @@ export function checkLayoutTemplates(cfg) {
|
|
|
257
292
|
out.push(finding("install", "issue", "templates", `Missing template for language "${lang}": ${k}.md.tmpl`));
|
|
258
293
|
}
|
|
259
294
|
}
|
|
295
|
+
// What `scaffold` (and `init`) refuse on, through the same check the plan
|
|
296
|
+
// makes: the vault README's template, and a name and a description for every
|
|
297
|
+
// folder kind the layout declares — `diagram` included. A warn, not an issue:
|
|
298
|
+
// a vault already scaffolded is not broken by a hand-added kind; only its
|
|
299
|
+
// next scaffold is refused. The folder README is the issue above.
|
|
300
|
+
for (const m of missingScaffoldRequirements(layout, lang, pluginRoot())) {
|
|
301
|
+
if (m.what === "template" && m.name === "folder-readme") continue;
|
|
302
|
+
out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
|
|
303
|
+
}
|
|
260
304
|
if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
|
|
261
|
-
out.push(
|
|
262
|
-
|
|
305
|
+
out.push(
|
|
306
|
+
finding(
|
|
307
|
+
"install",
|
|
308
|
+
"issue",
|
|
309
|
+
"templates",
|
|
310
|
+
"scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?",
|
|
311
|
+
),
|
|
312
|
+
);
|
|
263
313
|
}
|
|
264
314
|
return out;
|
|
265
315
|
}
|
|
@@ -267,16 +317,34 @@ export function checkLayoutTemplates(cfg) {
|
|
|
267
317
|
export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
|
|
268
318
|
const dir = sessionsDir(cfg.vault_path);
|
|
269
319
|
if (!existsSync(dir)) {
|
|
270
|
-
return [
|
|
271
|
-
|
|
320
|
+
return [
|
|
321
|
+
finding(
|
|
322
|
+
"install",
|
|
323
|
+
"warn",
|
|
324
|
+
"hooks",
|
|
325
|
+
"No session registry in the vault — SessionStart hook may not be firing (or no session started yet).",
|
|
326
|
+
),
|
|
327
|
+
];
|
|
272
328
|
}
|
|
273
329
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
274
330
|
const fresh = readdirSync(dir).some((n) => {
|
|
275
331
|
if (!n.endsWith(".json")) return false;
|
|
276
|
-
try {
|
|
332
|
+
try {
|
|
333
|
+
return statSync(join(dir, n)).mtimeMs >= cutoff;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
277
337
|
});
|
|
278
|
-
return fresh
|
|
279
|
-
|
|
338
|
+
return fresh
|
|
339
|
+
? []
|
|
340
|
+
: [
|
|
341
|
+
finding(
|
|
342
|
+
"install",
|
|
343
|
+
"warn",
|
|
344
|
+
"hooks",
|
|
345
|
+
`No session registration fresher than ${maxAgeMinutes} min — hooks may not be firing.`,
|
|
346
|
+
),
|
|
347
|
+
];
|
|
280
348
|
}
|
|
281
349
|
|
|
282
350
|
// Which plugin version a wired statusLine script belongs to. Null for our
|
|
@@ -285,9 +353,7 @@ export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
|
|
|
285
353
|
export function statusLineScriptVersion(scriptPath) {
|
|
286
354
|
try {
|
|
287
355
|
const root = dirname(dirname(scriptPath)); // <root>/scripts/statusline.mjs
|
|
288
|
-
return (
|
|
289
|
-
JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null
|
|
290
|
-
);
|
|
356
|
+
return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null;
|
|
291
357
|
} catch {
|
|
292
358
|
return null;
|
|
293
359
|
}
|
|
@@ -317,20 +383,38 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
317
383
|
|
|
318
384
|
if (st && st.enabled === true) {
|
|
319
385
|
if (!curCmd) {
|
|
320
|
-
out.push(
|
|
321
|
-
|
|
386
|
+
out.push(
|
|
387
|
+
finding(
|
|
388
|
+
"install",
|
|
389
|
+
"issue",
|
|
390
|
+
"statusline",
|
|
391
|
+
`statusline.enabled=true but no statusLine wired in settings.local.json — run ${commandForm("statusline", { args: "on" })} (it installs the entry and the launcher behind a preview); the SessionStart hook only refreshes an entry that already exists.`,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
322
394
|
} else if (!isOurs) {
|
|
323
|
-
out.push(
|
|
324
|
-
|
|
395
|
+
out.push(
|
|
396
|
+
finding(
|
|
397
|
+
"install",
|
|
398
|
+
"issue",
|
|
399
|
+
"statusline",
|
|
400
|
+
"statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag.",
|
|
401
|
+
),
|
|
402
|
+
);
|
|
325
403
|
} else {
|
|
326
404
|
const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
|
|
327
405
|
const wiredRoot = m ? dirname(dirname(m[1])) : null;
|
|
328
406
|
const isLauncher = m ? isLauncherPath(m[1]) : false;
|
|
329
407
|
if (m && !existsSync(m[1])) {
|
|
330
|
-
out.push(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
408
|
+
out.push(
|
|
409
|
+
finding(
|
|
410
|
+
"install",
|
|
411
|
+
"issue",
|
|
412
|
+
"statusline",
|
|
413
|
+
isLauncher
|
|
414
|
+
? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run ${commandForm("statusline", { args: "on" })} to reinstall it; until then the next session start repoints the entry at the installed script.`
|
|
415
|
+
: `statusLine points at a missing script (stale plugin path?): ${m[1]}`,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
334
418
|
} else if (m && isPluginCacheRoot(wiredRoot, home)) {
|
|
335
419
|
// Only a versioned cache path can go stale this way. The launcher
|
|
336
420
|
// carries no version, and a dev checkout is wired deliberately and
|
|
@@ -338,23 +422,48 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
338
422
|
const wired = statusLineScriptVersion(m[1]);
|
|
339
423
|
const inst = installedPluginRoot(home, dirname(wiredRoot));
|
|
340
424
|
if (wired && inst && inst.version && wired !== inst.version) {
|
|
341
|
-
out.push(
|
|
342
|
-
|
|
425
|
+
out.push(
|
|
426
|
+
finding(
|
|
427
|
+
"install",
|
|
428
|
+
"warn",
|
|
429
|
+
"statusline",
|
|
430
|
+
`statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run ${commandForm("statusline", { args: "on" })} to install the version-agnostic launcher; the SessionStart hook only repoints the pinned path at the current install.`,
|
|
431
|
+
),
|
|
432
|
+
);
|
|
343
433
|
}
|
|
344
434
|
}
|
|
345
435
|
}
|
|
346
436
|
} else if (st && st.enabled === false && isOurs) {
|
|
347
|
-
out.push(
|
|
348
|
-
|
|
437
|
+
out.push(
|
|
438
|
+
finding(
|
|
439
|
+
"install",
|
|
440
|
+
"warn",
|
|
441
|
+
"statusline",
|
|
442
|
+
"statusline.enabled=false but our statusLine entry is still wired — the hook removes it on next session start.",
|
|
443
|
+
),
|
|
444
|
+
);
|
|
349
445
|
} else if ((!st || typeof st.enabled !== "boolean") && isOurs) {
|
|
350
|
-
out.push(
|
|
351
|
-
|
|
446
|
+
out.push(
|
|
447
|
+
finding(
|
|
448
|
+
"install",
|
|
449
|
+
"info",
|
|
450
|
+
"statusline",
|
|
451
|
+
"statusLine wired manually (no statusline flag in projectstore.json) — the hook will leave it alone.",
|
|
452
|
+
),
|
|
453
|
+
);
|
|
352
454
|
}
|
|
353
455
|
|
|
354
456
|
try {
|
|
355
457
|
const base = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"))?.statusLine?.command;
|
|
356
458
|
if (base && !statusLineIsOurs(base)) {
|
|
357
|
-
out.push(
|
|
459
|
+
out.push(
|
|
460
|
+
finding(
|
|
461
|
+
"install",
|
|
462
|
+
"info",
|
|
463
|
+
"statusline",
|
|
464
|
+
"Base HUD present in your user settings.json — projectstore composes above it.",
|
|
465
|
+
),
|
|
466
|
+
);
|
|
358
467
|
}
|
|
359
468
|
} catch {}
|
|
360
469
|
// session_id divergence (ADR-006): the renderer's breadcrumb names the id
|
|
@@ -369,8 +478,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
369
478
|
.filter((n) => n.endsWith(".json") && !n.startsWith("."))
|
|
370
479
|
.map((n) => n.replace(/\.json$/, ""));
|
|
371
480
|
if (hookIds.length && !hookIds.includes(bc.session_id)) {
|
|
372
|
-
out.push(
|
|
373
|
-
|
|
481
|
+
out.push(
|
|
482
|
+
finding(
|
|
483
|
+
"install",
|
|
484
|
+
"warn",
|
|
485
|
+
"statusline",
|
|
486
|
+
`statusLine renderer last saw session_id ${String(bc.session_id).slice(0, 8)}… with no hook-side state file — possible session_id divergence (renderer shows the cold-start line while hooks log activity).`,
|
|
487
|
+
),
|
|
488
|
+
);
|
|
374
489
|
}
|
|
375
490
|
}
|
|
376
491
|
// What the user is actually looking at: the renderer stamps the version
|
|
@@ -379,8 +494,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
379
494
|
if (bc && bc.version && isPluginCacheRoot(bc.root, home)) {
|
|
380
495
|
const inst = installedPluginRoot(home, dirname(bc.root));
|
|
381
496
|
if (inst && inst.version && inst.version !== bc.version) {
|
|
382
|
-
out.push(
|
|
383
|
-
|
|
497
|
+
out.push(
|
|
498
|
+
finding(
|
|
499
|
+
"install",
|
|
500
|
+
"warn",
|
|
501
|
+
"statusline",
|
|
502
|
+
`The last status line rendered in this project came from projectstore ${bc.version} while ${inst.version} is installed — a session that resolved its statusLine command before the update (the breadcrumb is per project, so it may belong to a sibling session). Restart that session; after that the launcher picks up the installed version at every render.`,
|
|
503
|
+
),
|
|
504
|
+
);
|
|
384
505
|
}
|
|
385
506
|
}
|
|
386
507
|
} catch {}
|
|
@@ -402,15 +523,27 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
|
|
|
402
523
|
// session with a command that cannot clear it (the critic of the layout
|
|
403
524
|
// spec's 2026-10-03 amendment, case S1).
|
|
404
525
|
let wired = null;
|
|
405
|
-
try {
|
|
526
|
+
try {
|
|
527
|
+
wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
|
|
528
|
+
} catch {}
|
|
406
529
|
if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
|
|
407
530
|
const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
|
|
408
531
|
let text;
|
|
409
|
-
try {
|
|
532
|
+
try {
|
|
533
|
+
text = readFileSync(lp, "utf8");
|
|
534
|
+
} catch {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
410
537
|
if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
|
|
411
|
-
return [
|
|
412
|
-
|
|
413
|
-
|
|
538
|
+
return [
|
|
539
|
+
finding(
|
|
540
|
+
"install",
|
|
541
|
+
"info",
|
|
542
|
+
"upgrade",
|
|
543
|
+
`The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering. Re-stamp it once, in the session:\n${commandForm("doctor", { args: "--fix" })}`,
|
|
544
|
+
relative(proj, lp),
|
|
545
|
+
),
|
|
546
|
+
];
|
|
414
547
|
}
|
|
415
548
|
|
|
416
549
|
// The two findings the layout move itself repairs carry what they say without
|
|
@@ -435,7 +568,11 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
435
568
|
const p = join(proj, name);
|
|
436
569
|
if (!existsSync(p)) continue;
|
|
437
570
|
let text;
|
|
438
|
-
try {
|
|
571
|
+
try {
|
|
572
|
+
text = readFileSync(p, "utf8");
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
439
576
|
texts[name] = text;
|
|
440
577
|
const f = findAgentsBlock(text);
|
|
441
578
|
if (!f) continue;
|
|
@@ -443,16 +580,30 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
443
580
|
blocks += f.count;
|
|
444
581
|
if (f.wrapped) {
|
|
445
582
|
wrappedFiles++;
|
|
446
|
-
out.push(
|
|
447
|
-
|
|
583
|
+
out.push(
|
|
584
|
+
finding(
|
|
585
|
+
"install",
|
|
586
|
+
"issue",
|
|
587
|
+
"agents-block",
|
|
588
|
+
`${name}:${f.line}: the projectstore:agents open marker does not close on its own line, and install and uninstall refuse until it does. Put \`-->\` back on the marker's line, then in the session:\n${commandForm("agents", { args: "register" })}`,
|
|
589
|
+
name,
|
|
590
|
+
),
|
|
591
|
+
);
|
|
448
592
|
continue;
|
|
449
593
|
}
|
|
450
594
|
if (f.unclosed) {
|
|
451
595
|
// Named here as the wrapped marker is, so the startup count carries it:
|
|
452
596
|
// the agents-block plan refuses it, and the layout move with it.
|
|
453
597
|
unclosedFiles++;
|
|
454
|
-
out.push(
|
|
455
|
-
|
|
598
|
+
out.push(
|
|
599
|
+
finding(
|
|
600
|
+
"install",
|
|
601
|
+
"issue",
|
|
602
|
+
"agents-block",
|
|
603
|
+
`${name}: the projectstore:agents block opens and never closes, and install and uninstall refuse until then. Close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then in the session:\n${commandForm("agents", { args: "register" })}`,
|
|
604
|
+
name,
|
|
605
|
+
),
|
|
606
|
+
);
|
|
456
607
|
}
|
|
457
608
|
for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
|
|
458
609
|
const v = parseInt(m[1], 10);
|
|
@@ -460,8 +611,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
460
611
|
}
|
|
461
612
|
}
|
|
462
613
|
if (blocks === 0) {
|
|
463
|
-
out.push(
|
|
464
|
-
|
|
614
|
+
out.push(
|
|
615
|
+
finding(
|
|
616
|
+
"install",
|
|
617
|
+
"info",
|
|
618
|
+
"agents-block",
|
|
619
|
+
`Agent routing block not registered — optional (v0.13). It comes with the agents command, in the session:\n${commandForm("agents")}`,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
465
622
|
}
|
|
466
623
|
if (blocks > 1) {
|
|
467
624
|
// One block in each file is a state install resolves (it keeps the
|
|
@@ -470,13 +627,26 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
470
627
|
// advice is withheld while one is wrapped or never closes.
|
|
471
628
|
const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
|
|
472
629
|
if (twiceInOne) {
|
|
473
|
-
out.push(
|
|
474
|
-
|
|
630
|
+
out.push(
|
|
631
|
+
finding(
|
|
632
|
+
"install",
|
|
633
|
+
"issue",
|
|
634
|
+
"agents-block",
|
|
635
|
+
`${twiceInOne[0]} carries the projectstore:agents block ${twiceInOne[1]} times — keep exactly one; install refuses until it does.`,
|
|
636
|
+
twiceInOne[0],
|
|
637
|
+
),
|
|
638
|
+
);
|
|
475
639
|
} else if (wrappedFiles || unclosedFiles) {
|
|
476
640
|
// already named above, file by file
|
|
477
641
|
} else {
|
|
478
|
-
out.push(
|
|
479
|
-
|
|
642
|
+
out.push(
|
|
643
|
+
finding(
|
|
644
|
+
"install",
|
|
645
|
+
"warn",
|
|
646
|
+
"agents-block",
|
|
647
|
+
`The projectstore:agents block is in both CLAUDE.md and AGENTS.md. Registering it again keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other, in the session:\n${commandForm("agents", { args: "register" })}`,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
480
650
|
}
|
|
481
651
|
}
|
|
482
652
|
// A state the agents-block plan refuses — a wrapped marker, a block that
|
|
@@ -485,21 +655,33 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
485
655
|
const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
|
|
486
656
|
for (const s of staleVersions) {
|
|
487
657
|
const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
|
|
488
|
-
const f = finding(
|
|
658
|
+
const f = finding(
|
|
659
|
+
"install",
|
|
660
|
+
"issue",
|
|
661
|
+
"agents-block",
|
|
662
|
+
`${fact}. Register it again, in the session:\n${commandForm("agents", { args: "register" })}`,
|
|
663
|
+
s.file,
|
|
664
|
+
);
|
|
489
665
|
out.push(refuses ? f : moveRepairs(f, fact));
|
|
490
666
|
}
|
|
491
667
|
// Placement, held to the predicate install plans from (the install spec,
|
|
492
668
|
// contract 6 as amended after the rc.3 tag): one well-formed block, seen by
|
|
493
669
|
// every harness the project uses. rc.1 and rc.2 left a block in an
|
|
494
670
|
// AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
|
|
495
|
-
// nothing said so. Used means detected by directory
|
|
496
|
-
// environment — never the source harness
|
|
497
|
-
// Codex-only project hears nothing about
|
|
671
|
+
// nothing said so. Used means detected by directory or by its state
|
|
672
|
+
// directory, or identified from the environment — never the source harness
|
|
673
|
+
// a terminal run falls back to, so a Codex-only project hears nothing about
|
|
674
|
+
// CLAUDE.md (contract 16). The state directory is what a bare upgrade and a
|
|
675
|
+
// bare uninstall select by, so doctor warns about exactly the block they
|
|
676
|
+
// move (the story "One run plans the agents block once…", rule 5). An issue
|
|
498
677
|
// for the harness that identified itself, a warning for one only detected.
|
|
499
678
|
const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
|
|
500
679
|
if (blockFile) {
|
|
501
680
|
const identified = identifiedHarnessId(env);
|
|
502
|
-
const used = new Set([
|
|
681
|
+
const used = new Set([
|
|
682
|
+
...detectHarnesses(proj, { state: true }).map((d) => d.id),
|
|
683
|
+
...(identified ? [identified] : []),
|
|
684
|
+
]);
|
|
503
685
|
for (const id of used) {
|
|
504
686
|
const m = loadHarness(id);
|
|
505
687
|
if (!m || blockVisibleTo(m, blockFile, texts)) continue;
|
|
@@ -508,9 +690,16 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
508
690
|
? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
|
|
509
691
|
: `it reads ${(ab.files || []).join(" and ")} only`;
|
|
510
692
|
const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
|
|
511
|
-
// The resolved-root form the surface remedy uses: the running copy's own
|
|
512
|
-
|
|
513
|
-
const
|
|
693
|
+
// The resolved-root form the surface remedy uses: the running copy's own
|
|
694
|
+
// bin, the directory in prose and the command alone on the last line.
|
|
695
|
+
const remedy = `Install the block for ${m.display_name} ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block`;
|
|
696
|
+
const f = finding(
|
|
697
|
+
"install",
|
|
698
|
+
id === identified ? "issue" : "warn",
|
|
699
|
+
"agents-block",
|
|
700
|
+
`${fact}. ${remedy}`,
|
|
701
|
+
blockFile,
|
|
702
|
+
);
|
|
514
703
|
// The layout's harness is the one the move's command installs for, so the
|
|
515
704
|
// move plans this import; another harness's is not the move's to repair.
|
|
516
705
|
out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
|
|
@@ -530,10 +719,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
530
719
|
// finding leaves here carrying it.
|
|
531
720
|
export function foldIntoMove(findings) {
|
|
532
721
|
const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
|
|
533
|
-
const step =
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
722
|
+
const step =
|
|
723
|
+
layout &&
|
|
724
|
+
(layout.check === "layout-two-configs"
|
|
725
|
+
? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
|
|
726
|
+
: "the layout move repairs this: run what the layout-legacy finding names");
|
|
727
|
+
return findings.map(({ byMove, ...f }) =>
|
|
728
|
+
byMove && layout ? { ...f, level: "info", message: `${byMove} — ${step}.` } : f,
|
|
729
|
+
);
|
|
537
730
|
}
|
|
538
731
|
|
|
539
732
|
// Both scopes are walked. The original reason ("project > user > plugin, so a
|
|
@@ -563,24 +756,44 @@ export function foldIntoMove(findings) {
|
|
|
563
756
|
// surfaces.mjs needs — out of the SessionStart module graph. The startup
|
|
564
757
|
// checks never call this.
|
|
565
758
|
// Read the states once per doctor run; both checks below consume the result.
|
|
566
|
-
export async function readSurfaceStates(
|
|
759
|
+
export async function readSurfaceStates(
|
|
760
|
+
proj,
|
|
761
|
+
{ home = homedir(), root = pluginRoot(), manifestDir = undefined, env = process.env } = {},
|
|
762
|
+
) {
|
|
567
763
|
const { surfaceStates, FOREIGN_TEXT } = await import("./surfaces.mjs");
|
|
568
764
|
return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
|
|
569
765
|
}
|
|
570
766
|
|
|
571
|
-
export async function checkHarnessSurfaces(
|
|
767
|
+
export async function checkHarnessSurfaces(
|
|
768
|
+
_cfg,
|
|
769
|
+
proj,
|
|
770
|
+
{
|
|
771
|
+
home = homedir(),
|
|
772
|
+
root = pluginRoot(),
|
|
773
|
+
manifestDir = undefined,
|
|
774
|
+
read = null,
|
|
775
|
+
env = process.env,
|
|
776
|
+
speaker = speakingHarness(),
|
|
777
|
+
} = {},
|
|
778
|
+
) {
|
|
572
779
|
const out = [];
|
|
573
780
|
let r, FOREIGN_TEXT;
|
|
574
781
|
try {
|
|
575
|
-
({ result: r, FOREIGN_TEXT } = read || await readSurfaceStates(proj, { home, root, manifestDir, env }));
|
|
782
|
+
({ result: r, FOREIGN_TEXT } = read || (await readSurfaceStates(proj, { home, root, manifestDir, env })));
|
|
576
783
|
} catch (e) {
|
|
577
784
|
return [finding("install", "warn", "surface", `Installed-surface states could not be read: ${e && e.message}`)];
|
|
578
785
|
}
|
|
579
786
|
// Contract 16: a harness is reported only when the project uses it; when
|
|
580
787
|
// none does and nothing of ours is installed, one line names what can be.
|
|
581
788
|
if (!r.used.length) {
|
|
582
|
-
out.push(
|
|
583
|
-
|
|
789
|
+
out.push(
|
|
790
|
+
finding(
|
|
791
|
+
"install",
|
|
792
|
+
"info",
|
|
793
|
+
"harness",
|
|
794
|
+
`No harness detected in this project and nothing of ours installed — install can target: ${r.installable.join(", ")}.`,
|
|
795
|
+
),
|
|
796
|
+
);
|
|
584
797
|
return out;
|
|
585
798
|
}
|
|
586
799
|
for (const s of r.states) {
|
|
@@ -590,15 +803,35 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
590
803
|
// steps in its forms, and no status-line hint where it has no status line.
|
|
591
804
|
// A shared surface — the agents block, one file every harness owns — is
|
|
592
805
|
// every harness's: never marked, its step in the listener's form.
|
|
593
|
-
const sh = s.kind === "shared" ? speaker :
|
|
806
|
+
const sh = s.kind === "shared" ? speaker : loadHarness(s.harness) || speaker;
|
|
594
807
|
const push = (f) => out.push(aboutHarness(f, sh, speaker));
|
|
595
808
|
if (s.kind === "exclusive") {
|
|
596
809
|
if (s.state === "foreign") {
|
|
597
|
-
push(
|
|
598
|
-
|
|
810
|
+
push(
|
|
811
|
+
finding(
|
|
812
|
+
"install",
|
|
813
|
+
"issue",
|
|
814
|
+
"surface-foreign",
|
|
815
|
+
`${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`,
|
|
816
|
+
where,
|
|
817
|
+
),
|
|
818
|
+
);
|
|
599
819
|
} else if (s.state === "stale" && s.produced) {
|
|
600
|
-
|
|
601
|
-
|
|
820
|
+
// Two commands, two paragraphs, each ending with its command alone on
|
|
821
|
+
// its line (the story "User-facing text says where to run…", rule 4).
|
|
822
|
+
const status =
|
|
823
|
+
sh?.surfaces?.statusline?.supported === false
|
|
824
|
+
? ""
|
|
825
|
+
: `\nFor the status line, in the session:\n${invocation(sh, "statusline", { args: "on" })}`;
|
|
826
|
+
push(
|
|
827
|
+
finding(
|
|
828
|
+
"install",
|
|
829
|
+
"issue",
|
|
830
|
+
"surface",
|
|
831
|
+
`${where} — stale: ${s.reason}. Reinstall it ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${s.harness} --surface ${s.surface}${status}`,
|
|
832
|
+
where,
|
|
833
|
+
),
|
|
834
|
+
);
|
|
602
835
|
} else if (s.state === "stale" && !s.produced) {
|
|
603
836
|
push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
|
|
604
837
|
} else if (s.state === "current" && s.writtenBy && !s.sameProject) {
|
|
@@ -613,12 +846,23 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
613
846
|
if (s.state === "unparseable") {
|
|
614
847
|
push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
|
|
615
848
|
} else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
|
|
616
|
-
push(
|
|
849
|
+
push(
|
|
850
|
+
finding(
|
|
851
|
+
"install",
|
|
852
|
+
"warn",
|
|
853
|
+
"surface",
|
|
854
|
+
`${where} [projectstore:agents] — ${s.reason}. Register it again, in the session:\n${invocation(sh, "agents", { args: "register" })}`,
|
|
855
|
+
where,
|
|
856
|
+
),
|
|
857
|
+
);
|
|
617
858
|
}
|
|
618
859
|
}
|
|
619
|
-
// The statusline entry's states are checkStatusline's, under its own id
|
|
620
|
-
//
|
|
621
|
-
// self-healing: syncStatusLine
|
|
860
|
+
// The statusline entry's states are checkStatusline's, under its own id.
|
|
861
|
+
// Its ours-stale (a command this installation would not write) is not
|
|
862
|
+
// always self-healing: syncStatusLine re-points a direct entry at the
|
|
863
|
+
// running root's script and never touches an entry naming a launcher on
|
|
864
|
+
// disk, so install is what re-points an entry at the launcher
|
|
865
|
+
// (contract 0 as amended 2026-10-10).
|
|
622
866
|
}
|
|
623
867
|
return out;
|
|
624
868
|
}
|
|
@@ -631,8 +875,26 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
631
875
|
const out = [];
|
|
632
876
|
const o = readOverlayAt(proj);
|
|
633
877
|
const where = relative(proj, o.path);
|
|
634
|
-
if (o.unparseable)
|
|
635
|
-
|
|
878
|
+
if (o.unparseable)
|
|
879
|
+
out.push(
|
|
880
|
+
finding(
|
|
881
|
+
"install",
|
|
882
|
+
"issue",
|
|
883
|
+
"overlay-unparseable",
|
|
884
|
+
`${where} is not valid JSON — the agents run on their frontmatter models until it is fixed.`,
|
|
885
|
+
where,
|
|
886
|
+
),
|
|
887
|
+
);
|
|
888
|
+
for (const k of o.rejected)
|
|
889
|
+
out.push(
|
|
890
|
+
finding(
|
|
891
|
+
"install",
|
|
892
|
+
"issue",
|
|
893
|
+
"overlay-forbidden-key",
|
|
894
|
+
`${where} carries \`${k}\`, which an overlay may not: only agents.default.model and agents.per_agent.<name>.model are read; the key is ignored.`,
|
|
895
|
+
where,
|
|
896
|
+
),
|
|
897
|
+
);
|
|
636
898
|
if (cfg && cfg.agents && typeof cfg.agents === "object") {
|
|
637
899
|
// The binding that carries the block is the one the reader found — before
|
|
638
900
|
// the migration that is the legacy path, and naming the new one would send
|
|
@@ -644,7 +906,19 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
644
906
|
const remedy = layoutRemedy(proj, { root, home });
|
|
645
907
|
// The block and its move are the source harness's — a pre-0.28 binding
|
|
646
908
|
// and that harness's command — so its fact under any other listener.
|
|
647
|
-
out.push(
|
|
909
|
+
out.push(
|
|
910
|
+
aboutHarness(
|
|
911
|
+
finding(
|
|
912
|
+
"install",
|
|
913
|
+
"warn",
|
|
914
|
+
"agents-in-binding",
|
|
915
|
+
`${b} still carries an agents block — since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
|
|
916
|
+
b,
|
|
917
|
+
),
|
|
918
|
+
sourceHarness(),
|
|
919
|
+
speaker,
|
|
920
|
+
),
|
|
921
|
+
);
|
|
648
922
|
}
|
|
649
923
|
// A project can be used from more than one harness, and a model name is
|
|
650
924
|
// harness-specific (ADR-008) — so each one has its own overlay and they do
|
|
@@ -655,20 +929,27 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
655
929
|
// Only when another harness in this project HAS an overlay. A project with
|
|
656
930
|
// none at all is the ordinary fresh state and needs no advice; the asymmetry
|
|
657
931
|
// is what is actionable, because it is almost always the second harness that
|
|
658
|
-
// was forgotten rather than the first that was deliberate.
|
|
659
|
-
|
|
932
|
+
// was forgotten rather than the first that was deliberate. Used as a bare
|
|
933
|
+
// upgrade or uninstall selects: by directory or by state directory.
|
|
934
|
+
const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
|
|
660
935
|
if (used.length > 1) {
|
|
661
936
|
const overlays = used.map((id) => ({ id, o: readOverlayAt(proj, loadHarness(id)?.runtime?.overlay || id) }));
|
|
662
937
|
const configured = overlays.filter(({ o }) => o.present);
|
|
663
938
|
if (configured.length) {
|
|
664
939
|
for (const { id, o } of overlays) {
|
|
665
940
|
if (o.present) continue;
|
|
666
|
-
out.push(
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
941
|
+
out.push(
|
|
942
|
+
finding(
|
|
943
|
+
"install",
|
|
944
|
+
"info",
|
|
945
|
+
"overlay-absent",
|
|
946
|
+
`This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — ` +
|
|
947
|
+
`its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} ` +
|
|
948
|
+
`${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). ` +
|
|
949
|
+
`Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
|
|
950
|
+
relative(proj, o.path),
|
|
951
|
+
),
|
|
952
|
+
);
|
|
672
953
|
}
|
|
673
954
|
}
|
|
674
955
|
}
|
|
@@ -678,7 +959,16 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
678
959
|
const roster = layoutRoster(cfg);
|
|
679
960
|
if (roster) {
|
|
680
961
|
for (const n of Object.keys(o.agents.per_agent)) {
|
|
681
|
-
if (!roster.includes(n))
|
|
962
|
+
if (!roster.includes(n))
|
|
963
|
+
out.push(
|
|
964
|
+
finding(
|
|
965
|
+
"install",
|
|
966
|
+
"warn",
|
|
967
|
+
"overlay-unknown-agent",
|
|
968
|
+
`${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so the model never applies. A typo, or an agent this package does not ship yet.`,
|
|
969
|
+
where,
|
|
970
|
+
),
|
|
971
|
+
);
|
|
682
972
|
}
|
|
683
973
|
}
|
|
684
974
|
return out;
|
|
@@ -687,20 +977,39 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
687
977
|
// Contract 4′ (2026-09-05): the registration the package made through the
|
|
688
978
|
// host's CLI — one row per registration surface the manifest declares. Current
|
|
689
979
|
// → one info; stale → an issue naming the refresh in the form that can make
|
|
690
|
-
// it (the package's
|
|
691
|
-
// itself); a competing copy enabled beside ours → an issue (two
|
|
692
|
-
// of one plugin); a competitor alone → an info naming the npm
|
|
693
|
-
// never repairable; the host CLI missing → an info.
|
|
694
|
-
|
|
980
|
+
// it (the npm package's core via npx, `--harness <id>`: a cache install never
|
|
981
|
+
// re-registers itself); a competing copy enabled beside ours → an issue (two
|
|
982
|
+
// enabled copies of one plugin); a competitor alone → an info naming the npm
|
|
983
|
+
// path; foreign → never repairable; the host CLI missing → an info.
|
|
984
|
+
//
|
|
985
|
+
// Every command here ends its message, alone on its line after a lead ending
|
|
986
|
+
// with `:`, and carries no --project: the lead names the directory (the story
|
|
987
|
+
// "User-facing text says where to run and names no project path and no
|
|
988
|
+
// shell", rules 1, 4 and 5). The report draws each lead as prose and each
|
|
989
|
+
// command alone after a blank line (doctor-report.mjs); --json carries the
|
|
990
|
+
// message one part per line. The silenced copy's info ends with how to record
|
|
991
|
+
// the switch (the setup story, rule 17): setup's `--channel npm`, in the core
|
|
992
|
+
// form, the last of its three paragraphs.
|
|
993
|
+
const SETUP_NPM = "npx projectstore setup --channel npm";
|
|
994
|
+
export function checkPluginRegistration(
|
|
995
|
+
proj,
|
|
996
|
+
states = [],
|
|
997
|
+
{ home = homedir(), speaker = speakingHarness(), cfg = null } = {},
|
|
998
|
+
) {
|
|
695
999
|
const out = [];
|
|
1000
|
+
// A checkout whose binding records a channel switch (the converge story's
|
|
1001
|
+
// rule 3) chose the npm install: the info that ends "If moving to npm was
|
|
1002
|
+
// meant, record it…" is the one thing it no longer needs. Doctor never
|
|
1003
|
+
// reads who made the mark.
|
|
1004
|
+
const marked = Boolean(channelMark(cfg));
|
|
696
1005
|
for (const s of states.filter((x) => x.kind === "registration")) {
|
|
697
|
-
// The
|
|
698
|
-
// command a
|
|
1006
|
+
// The core's form with this registration's --harness (packageCommand):
|
|
1007
|
+
// the command a person can paste, built in one place.
|
|
699
1008
|
const h = loadHarness(s.harness) || { id: s.harness };
|
|
700
1009
|
// A registration of ANOTHER harness in the same project is that
|
|
701
1010
|
// harness's fact: named, marked, its steps in its own forms (aboutHarness).
|
|
702
1011
|
const push = (f) => out.push(aboutHarness(f, h, speaker));
|
|
703
|
-
const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface}
|
|
1012
|
+
const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface}` });
|
|
704
1013
|
// A copy this registration silenced for the checkout and the checkout
|
|
705
1014
|
// still holds off, one per key (the install spec, contract 13 as amended
|
|
706
1015
|
// after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
|
|
@@ -710,35 +1019,117 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
|
|
|
710
1019
|
if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
|
|
711
1020
|
// The row this checkout loads: its own local-scope row first, then a
|
|
712
1021
|
// user-scope one — never another checkout's (layoutRemedy's rule).
|
|
713
|
-
const real = (x) => {
|
|
1022
|
+
const real = (x) => {
|
|
1023
|
+
try {
|
|
1024
|
+
return realpathSync.native(x);
|
|
1025
|
+
} catch {
|
|
1026
|
+
return resolve(x);
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
714
1029
|
const here = real(proj);
|
|
715
|
-
const rows = installedPluginEntries(home, proj).filter(
|
|
1030
|
+
const rows = installedPluginEntries(home, proj).filter(
|
|
1031
|
+
(e) => e.present && (!e.projectPath || real(e.projectPath) === here),
|
|
1032
|
+
);
|
|
716
1033
|
for (const key of s.silenced) {
|
|
717
|
-
const row = rows
|
|
1034
|
+
const row = rows
|
|
1035
|
+
.filter((e) => e.key === key)
|
|
1036
|
+
.sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
|
|
718
1037
|
if (!row) {
|
|
719
|
-
push(
|
|
1038
|
+
push(
|
|
1039
|
+
finding(
|
|
1040
|
+
"install",
|
|
1041
|
+
"info",
|
|
1042
|
+
"plugin-registration",
|
|
1043
|
+
`${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`,
|
|
1044
|
+
s.path,
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
720
1047
|
continue;
|
|
721
1048
|
}
|
|
1049
|
+
if (marked) continue;
|
|
722
1050
|
// The release line, not the build: a 0.28 release candidate reads a
|
|
723
1051
|
// moved project; 0.27.x reads it as unbound. No version reads as old.
|
|
724
1052
|
const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
|
|
725
|
-
push(
|
|
726
|
-
|
|
1053
|
+
push(
|
|
1054
|
+
finding(
|
|
1055
|
+
"install",
|
|
1056
|
+
"info",
|
|
1057
|
+
"plugin-registration",
|
|
1058
|
+
[
|
|
1059
|
+
`${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it, ${fromTerminalIn(proj)}, outside the session:`,
|
|
1060
|
+
packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface}` }),
|
|
1061
|
+
"Restart, then in the session:",
|
|
1062
|
+
invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" }),
|
|
1063
|
+
"If moving to npm was meant, record it from a terminal in this project:",
|
|
1064
|
+
SETUP_NPM,
|
|
1065
|
+
].join("\n"),
|
|
1066
|
+
s.path,
|
|
1067
|
+
),
|
|
1068
|
+
);
|
|
727
1069
|
}
|
|
728
1070
|
}
|
|
729
1071
|
const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
|
|
730
1072
|
if (s.state === "foreign") {
|
|
731
|
-
push(
|
|
1073
|
+
push(
|
|
1074
|
+
finding(
|
|
1075
|
+
"install",
|
|
1076
|
+
"issue",
|
|
1077
|
+
"plugin-registration-foreign",
|
|
1078
|
+
`${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`,
|
|
1079
|
+
s.path,
|
|
1080
|
+
),
|
|
1081
|
+
);
|
|
732
1082
|
} else if (s.state === "unavailable") {
|
|
733
|
-
push(
|
|
1083
|
+
push(
|
|
1084
|
+
finding(
|
|
1085
|
+
"install",
|
|
1086
|
+
"info",
|
|
1087
|
+
"plugin-registration",
|
|
1088
|
+
`No npm registration of projectstore for this project, and ${s.reason}.`,
|
|
1089
|
+
),
|
|
1090
|
+
);
|
|
734
1091
|
} else if (s.state === "absent") {
|
|
735
1092
|
// A git-marketplace install alone is not a finding: a permanent info
|
|
736
1093
|
// advertising the npm path to every marketplace user is noise (2026-09-05).
|
|
737
1094
|
} else if (s.state === "stale") {
|
|
738
|
-
push(
|
|
1095
|
+
push(
|
|
1096
|
+
finding(
|
|
1097
|
+
"install",
|
|
1098
|
+
"issue",
|
|
1099
|
+
"plugin-registration",
|
|
1100
|
+
`${s.entry} — stale: ${s.reason}. Refresh it ${fromTerminalIn(proj)}:\n${refresh}`,
|
|
1101
|
+
s.path,
|
|
1102
|
+
),
|
|
1103
|
+
);
|
|
739
1104
|
} else if (s.state === "current") {
|
|
740
|
-
|
|
741
|
-
|
|
1105
|
+
// The host loads a host plugin registration in place, from the
|
|
1106
|
+
// plugin's root inside our marketplace directory (the payload — what
|
|
1107
|
+
// its plugin-root variable names); the install path is the registry's
|
|
1108
|
+
// copy, the one the launcher renders (measured on host 2.1.293 and
|
|
1109
|
+
// 2.1.296). A portable registration's host loads the copy.
|
|
1110
|
+
const loaded =
|
|
1111
|
+
h.surfaces?.[s.surface]?.format === "host-plugin-registration" && s.payload
|
|
1112
|
+
? `install path ${s.installPath}; loaded in place from ${s.payload}`
|
|
1113
|
+
: `loaded from ${s.installPath}`;
|
|
1114
|
+
if (others)
|
|
1115
|
+
push(
|
|
1116
|
+
finding(
|
|
1117
|
+
"install",
|
|
1118
|
+
"issue",
|
|
1119
|
+
"plugin-registration",
|
|
1120
|
+
`${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. The host's own disable turns the other off, at the scope the manifest names — never the committed project scope. Or install silences it for this project, ${fromTerminalIn(proj)}:\n${packageCommand(h, "install", { args: `--surface ${s.surface}` })}`,
|
|
1121
|
+
s.path,
|
|
1122
|
+
),
|
|
1123
|
+
);
|
|
1124
|
+
else
|
|
1125
|
+
push(
|
|
1126
|
+
finding(
|
|
1127
|
+
"install",
|
|
1128
|
+
"info",
|
|
1129
|
+
"plugin-registration",
|
|
1130
|
+
`${s.entry} ${s.installedVersion} registered from the npm package for this project (${loaded}). Refresh it ${fromTerminalIn(proj)}:\n${refresh}`,
|
|
1131
|
+
),
|
|
1132
|
+
);
|
|
742
1133
|
}
|
|
743
1134
|
}
|
|
744
1135
|
return out;
|
|
@@ -759,10 +1150,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
759
1150
|
const registryOf = sourceHarness()?.id || null;
|
|
760
1151
|
for (const e of installedPluginEntries(home, proj)) {
|
|
761
1152
|
// A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
|
|
762
|
-
if (e.version && e.present && e.enabled !== false)
|
|
1153
|
+
if (e.version && e.present && e.enabled !== false)
|
|
1154
|
+
seen.push({
|
|
1155
|
+
source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`,
|
|
1156
|
+
version: e.version,
|
|
1157
|
+
harness: registryOf,
|
|
1158
|
+
});
|
|
763
1159
|
}
|
|
764
1160
|
for (const s of states) {
|
|
765
|
-
if (s.installedPkg)
|
|
1161
|
+
if (s.installedPkg)
|
|
1162
|
+
seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
|
|
766
1163
|
}
|
|
767
1164
|
const versions = new Set(seen.map((x) => x.version));
|
|
768
1165
|
if (versions.size < 2) return [];
|
|
@@ -772,9 +1169,22 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
772
1169
|
// for copies of several harnesses, the listener — has one.
|
|
773
1170
|
const owners = [...new Set(seen.map((x) => x.harness))];
|
|
774
1171
|
const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
|
|
775
|
-
const launcher =
|
|
776
|
-
|
|
777
|
-
|
|
1172
|
+
const launcher =
|
|
1173
|
+
(subject || speaker)?.surfaces?.statusline?.supported === false
|
|
1174
|
+
? ""
|
|
1175
|
+
: "; the launcher renders whichever is registered";
|
|
1176
|
+
return [
|
|
1177
|
+
aboutHarness(
|
|
1178
|
+
finding(
|
|
1179
|
+
"install",
|
|
1180
|
+
"warn",
|
|
1181
|
+
"version-drift",
|
|
1182
|
+
`projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one${launcher}.`,
|
|
1183
|
+
),
|
|
1184
|
+
subject,
|
|
1185
|
+
speaker,
|
|
1186
|
+
),
|
|
1187
|
+
];
|
|
778
1188
|
}
|
|
779
1189
|
|
|
780
1190
|
export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
|
|
@@ -791,7 +1201,11 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
791
1201
|
for (const { dir, label, scope } of scopes) {
|
|
792
1202
|
for (const f of listMd(dir)) {
|
|
793
1203
|
let text;
|
|
794
|
-
try {
|
|
1204
|
+
try {
|
|
1205
|
+
text = readFileSync(join(dir, f), "utf8");
|
|
1206
|
+
} catch {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
795
1209
|
const m = text.match(/#\s*source:\s*projectstore\s+v(\S+)/);
|
|
796
1210
|
const { data } = parseFrontmatter(text);
|
|
797
1211
|
// A provenance-marked copy with no `name:` still deserves a usable
|
|
@@ -810,14 +1224,27 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
810
1224
|
// without a provenance marker we cannot prove lineage — hedge FIRST,
|
|
811
1225
|
// not 40 words in, or the false premise leads for user-authored agents
|
|
812
1226
|
const lead = m ? `${f}` : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it`;
|
|
813
|
-
out.push(
|
|
814
|
-
|
|
815
|
-
|
|
1227
|
+
out.push(
|
|
1228
|
+
finding(
|
|
1229
|
+
"install",
|
|
1230
|
+
m ? "warn" : "info",
|
|
1231
|
+
"override-copies",
|
|
1232
|
+
`${lead} carries the pre-v0.13 name "${name}", which no longer matches a bundled agent, so it overrides nothing and stands alongside "${now}" in the roster.${everywhere} ${advice}`,
|
|
1233
|
+
where,
|
|
1234
|
+
),
|
|
1235
|
+
);
|
|
816
1236
|
continue; // a stale-version note on top would just be noise
|
|
817
1237
|
}
|
|
818
1238
|
if (!CURRENT_AGENT_NAMES.includes(name)) {
|
|
819
|
-
out.push(
|
|
820
|
-
|
|
1239
|
+
out.push(
|
|
1240
|
+
finding(
|
|
1241
|
+
"install",
|
|
1242
|
+
"warn",
|
|
1243
|
+
"override-copies",
|
|
1244
|
+
`Override copy ${f} has name "${name}" which matches no bundled agent — it duplicates instead of overriding.${everywhere}`,
|
|
1245
|
+
where,
|
|
1246
|
+
),
|
|
1247
|
+
);
|
|
821
1248
|
continue;
|
|
822
1249
|
}
|
|
823
1250
|
// ADR-008. A current-name copy overrides nothing either: the bundled agent
|
|
@@ -827,9 +1254,7 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
827
1254
|
// refreshing such a copy fixes nothing; that is why it replaces the old
|
|
828
1255
|
// "frozen at vX — re-run configure" advice instead of sitting beside it.
|
|
829
1256
|
// Staleness is demoted to a parenthetical: still true, no longer the point.
|
|
830
|
-
const stale = m && ver && m[1] !== ver
|
|
831
|
-
? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)`
|
|
832
|
-
: "";
|
|
1257
|
+
const stale = m && ver && m[1] !== ver ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)` : "";
|
|
833
1258
|
// Without a marker we cannot prove lineage — hedge FIRST, and never issue
|
|
834
1259
|
// a delete imperative against a file the user may well have written.
|
|
835
1260
|
const lead = m
|
|
@@ -838,15 +1263,22 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
838
1263
|
// `configure` only ever touched PROJECT scope, so pointing a user-scope
|
|
839
1264
|
// copy at it would name a command that will not act — the scope split
|
|
840
1265
|
// fca8def introduced for staleness applies here for the same reason.
|
|
841
|
-
const remove =
|
|
842
|
-
|
|
843
|
-
|
|
1266
|
+
const remove =
|
|
1267
|
+
scope === "user"
|
|
1268
|
+
? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
|
|
1269
|
+
: `Delete it via ${commandForm("agents", { args: "configure" })}, which now records the model in .projectstore/harness/<harness>.json (the active harness's overlay) and passes it per invocation.`;
|
|
844
1270
|
const advice = m
|
|
845
1271
|
? remove
|
|
846
1272
|
: `If you wrote it yourself, nothing is broken; if you meant to change the bundled agent's model, that is ${commandForm("agents", { args: "configure" })}, not a copy.`;
|
|
847
|
-
out.push(
|
|
848
|
-
|
|
849
|
-
|
|
1273
|
+
out.push(
|
|
1274
|
+
finding(
|
|
1275
|
+
"install",
|
|
1276
|
+
m ? "warn" : "info",
|
|
1277
|
+
"override-copies",
|
|
1278
|
+
`${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
|
|
1279
|
+
where,
|
|
1280
|
+
),
|
|
1281
|
+
);
|
|
850
1282
|
}
|
|
851
1283
|
}
|
|
852
1284
|
return out;
|
|
@@ -860,112 +1292,193 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
860
1292
|
// The variable names come from the manifest (runtime.agent_overrides); the
|
|
861
1293
|
// wording is projectstore's, because the claim — ADR-008's quality floor — is.
|
|
862
1294
|
export function checkEnvEffort() {
|
|
863
|
-
return agentOverrides()
|
|
864
|
-
|
|
1295
|
+
return agentOverrides()
|
|
1296
|
+
.filter((o) => o.kind === "effort")
|
|
1297
|
+
.map((o) =>
|
|
1298
|
+
finding(
|
|
1299
|
+
"install",
|
|
1300
|
+
"warn",
|
|
1301
|
+
"env-effort",
|
|
1302
|
+
`${o.env}=${o.value} is set — it overrides the bundled agents' "effort: max" frontmatter, so every projectstore agent runs at "${o.value}". Effort is not configurable per project (ADR-008); unset the variable to restore the quality floor.`,
|
|
1303
|
+
),
|
|
1304
|
+
);
|
|
865
1305
|
}
|
|
866
1306
|
|
|
867
1307
|
export function checkEnvModel() {
|
|
868
|
-
return agentOverrides()
|
|
869
|
-
|
|
1308
|
+
return agentOverrides()
|
|
1309
|
+
.filter((o) => o.kind === "model")
|
|
1310
|
+
.map((o) =>
|
|
1311
|
+
finding(
|
|
1312
|
+
"install",
|
|
1313
|
+
"warn",
|
|
1314
|
+
"env-model",
|
|
1315
|
+
`${o.env}=${o.value} is set — it overrides ALL projectstore agent model configuration, per-invocation parameter included.`,
|
|
1316
|
+
),
|
|
1317
|
+
);
|
|
870
1318
|
}
|
|
871
1319
|
|
|
872
1320
|
// The project-level layout (the layout ADR): a legacy .projectstore/projectstore.json
|
|
873
1321
|
// or .projectstore/state/ is one warn naming the upgrade; two bindings is an
|
|
874
1322
|
// issue. Cheap — a handful of existsSync — so the startup line carries the
|
|
875
1323
|
// warn as an offer (OFFER_CHECKS).
|
|
876
|
-
|
|
1324
|
+
// `restamp`: the startup line's pending re-stamp, which the move covers — its
|
|
1325
|
+
// sentence goes before the move's command, never after it (the story
|
|
1326
|
+
// "User-facing text says where to run…", rule 4).
|
|
1327
|
+
export function checkLayout(
|
|
1328
|
+
proj,
|
|
1329
|
+
harness = sourceHarness(),
|
|
1330
|
+
{ level = "warn", root = pluginRoot(), home = homedir(), speaker = speakingHarness(), restamp = false } = {},
|
|
1331
|
+
) {
|
|
877
1332
|
const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
|
|
878
|
-
const legacyBinding = existsSync(p.legacy.binding),
|
|
1333
|
+
const legacyBinding = existsSync(p.legacy.binding),
|
|
1334
|
+
legacyRuntime = existsSync(p.legacy.runtime);
|
|
879
1335
|
let resumable = false;
|
|
880
1336
|
if (legacyBinding && existsSync(p.binding)) {
|
|
881
|
-
try {
|
|
1337
|
+
try {
|
|
1338
|
+
const { agents: _agents, ...rest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
|
|
1339
|
+
resumable = JSON.stringify(rest) === JSON.stringify(JSON.parse(readFileSync(p.binding, "utf8")));
|
|
1340
|
+
} catch {}
|
|
882
1341
|
}
|
|
883
1342
|
if (legacyBinding && existsSync(p.binding) && !resumable) {
|
|
884
|
-
return [
|
|
1343
|
+
return [
|
|
1344
|
+
finding(
|
|
1345
|
+
"install",
|
|
1346
|
+
"issue",
|
|
1347
|
+
"layout-two-configs",
|
|
1348
|
+
`Two bindings: ${relative(proj, p.legacy.binding)} (legacy) and ${relative(proj, p.binding)} — keep one and delete the other; install and upgrade refuse while both exist. Usually the legacy one goes: it is the copy an interrupted migration or a 0.27.x re-bind left behind (when both name the same vault, upgrade removes it itself).`,
|
|
1349
|
+
relative(proj, p.legacy.binding),
|
|
1350
|
+
),
|
|
1351
|
+
];
|
|
885
1352
|
}
|
|
886
1353
|
if (!legacyBinding && !legacyRuntime && !existsSync(p.legacy.welcomed) && !existsSync(p.legacy.sessionId)) return [];
|
|
887
1354
|
// The command in the channel of the copy the project runs (the layout spec,
|
|
888
1355
|
// contract 12 as amended 2026-10-03; maintainer decision the same day). The
|
|
889
|
-
// package's
|
|
890
|
-
// git-marketplace user it added projectstore@projectstore-npm at
|
|
891
|
-
// and turned the git copy off for the checkout (reproduced with
|
|
892
|
-
// fake host), and without the host CLI on PATH it stopped
|
|
893
|
-
// exit 1.
|
|
894
|
-
//
|
|
895
|
-
//
|
|
1356
|
+
// npm package's plain upgrade registers the plugin through the npm channel:
|
|
1357
|
+
// run for a git-marketplace user it added projectstore@projectstore-npm at
|
|
1358
|
+
// local scope and turned the git copy off for the checkout (reproduced with
|
|
1359
|
+
// the suite's fake host), and without the host CLI on PATH it stopped
|
|
1360
|
+
// part-way with exit 1. So the npm form is named only for the npm
|
|
1361
|
+
// registration's copy; any other copy runs its own bin, and both carry
|
|
1362
|
+
// --no-register, which leaves the registration out of the plan — no host
|
|
1363
|
+
// command by construction, not by recognising the root (a checkout, a
|
|
1364
|
+
// symlinked or relocated home). Neither carries --project: the message names
|
|
1365
|
+
// the directory in prose, the command alone on its last line (remedyWords).
|
|
896
1366
|
const remedy = layoutRemedy(proj, { root, home, harness });
|
|
897
|
-
const held = [
|
|
1367
|
+
const held = [
|
|
1368
|
+
legacyBinding && relative(proj, p.legacy.binding),
|
|
1369
|
+
legacyRuntime && relative(proj, p.legacy.runtime) + "/",
|
|
1370
|
+
existsSync(p.legacy.welcomed) && relative(proj, p.legacy.welcomed),
|
|
1371
|
+
existsSync(p.legacy.sessionId) && relative(proj, p.legacy.sessionId),
|
|
1372
|
+
]
|
|
1373
|
+
.filter(Boolean)
|
|
1374
|
+
.join(", ");
|
|
898
1375
|
// The files sit in that harness's legacy directory and the move is its
|
|
899
1376
|
// command: its fact under any other listener (aboutHarness).
|
|
900
|
-
return [
|
|
901
|
-
|
|
902
|
-
|
|
1377
|
+
return [
|
|
1378
|
+
aboutHarness(
|
|
1379
|
+
finding(
|
|
1380
|
+
"install",
|
|
1381
|
+
level,
|
|
1382
|
+
"layout-legacy",
|
|
1383
|
+
`The project layout moved to .projectstore/ (the layout ADR, 0.28); this project still holds ${held}. Readers fall back to the old paths through 0.30.${restamp ? " The same run re-stamps the status line launcher." : ""} ${remedyWords(remedy, proj, "Migrate it")}`,
|
|
1384
|
+
relative(
|
|
1385
|
+
proj,
|
|
1386
|
+
[
|
|
1387
|
+
legacyBinding && p.legacy.binding,
|
|
1388
|
+
legacyRuntime && p.legacy.runtime,
|
|
1389
|
+
existsSync(p.legacy.welcomed) && p.legacy.welcomed,
|
|
1390
|
+
p.legacy.sessionId,
|
|
1391
|
+
].find(Boolean),
|
|
1392
|
+
),
|
|
1393
|
+
),
|
|
1394
|
+
harness,
|
|
1395
|
+
speaker,
|
|
1396
|
+
),
|
|
1397
|
+
];
|
|
903
1398
|
}
|
|
904
1399
|
|
|
905
1400
|
// The one command that moves this project's files (the layout spec, contract
|
|
906
1401
|
// 12 as amended 2026-10-03), in the channel of the copy the project runs. A
|
|
907
1402
|
// session runs that copy, so its root answers: the package's own registration
|
|
908
|
-
// takes the
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
1403
|
+
// takes the core's npm form, `npx projectstore@<v> upgrade --harness <id>
|
|
1404
|
+
// --no-register` (the front-door ADR, decision 5), any other host copy its own
|
|
1405
|
+
// bin. A run from anywhere else — a terminal `npx projectstore doctor`, a
|
|
1406
|
+
// checkout — asks the host's registry, counting only rows this project loads:
|
|
1407
|
+
// its own local-scope row first, then user-scope rows, never another
|
|
1408
|
+
// checkout's. With none enabled, a package root takes the npm form and a
|
|
1409
|
+
// checkout its own bin. BOTH forms carry --no-register: the move never needs
|
|
1410
|
+
// the registration, so a misread channel can cost a launcher stamp but never a
|
|
1411
|
+
// channel switch. Neither carries --project: the finding names the directory
|
|
1412
|
+
// in prose (remedyWords). A copy that predates the move (no
|
|
1413
|
+
// bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first. Nor
|
|
1414
|
+
// can one whose CLI predates --no-register (lib's takesNoRegister).
|
|
917
1415
|
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1416
|
+
// It answers { command } (a form above), { advice } (words with no command of
|
|
1417
|
+
// ours), or { advice, refresh } (the npm registration's incapable copy: words,
|
|
1418
|
+
// then the refresh that moves the project too).
|
|
1419
|
+
//
|
|
1420
|
+
// The selection, the capability read and the copy advice are lib's
|
|
1421
|
+
// (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
|
|
1422
|
+
// installer's keep (the converge story's rule 5). takesNoRegister is
|
|
1423
|
+
// re-exported here, where its callers already import it.
|
|
1424
|
+
export { takesNoRegister };
|
|
1425
|
+
|
|
1426
|
+
// The layout remedy's closing words (the story "User-facing text says where to
|
|
1427
|
+
// run and names no project path and no shell", rules 1, 4 and 5): the
|
|
1428
|
+
// directory in prose, then the command alone on the message's last line.
|
|
1429
|
+
// Advice that names no command of ours ends the message with its own period.
|
|
1430
|
+
export function remedyWords(remedy, proj, verb) {
|
|
1431
|
+
const where = `${fromTerminalIn(proj)}, outside the session:`;
|
|
1432
|
+
if (remedy.command) return `${verb} ${where}\n${remedy.command}`;
|
|
1433
|
+
if (remedy.refresh) return `${remedy.advice}, ${where}\n${remedy.refresh}`;
|
|
1434
|
+
return `${remedy.advice}.`;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export function layoutRemedy(
|
|
1438
|
+
proj,
|
|
1439
|
+
{ root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {},
|
|
1440
|
+
) {
|
|
932
1441
|
// A session under a relocated host home hands it on: a terminal without it
|
|
933
1442
|
// would classify the copy as a checkout, render no launcher and never clear
|
|
934
1443
|
// the offer (the second review of the 2026-10-03 fixes, S2).
|
|
935
1444
|
const homeVar = harness?.runtime?.home_env;
|
|
936
1445
|
const prefix = homeVar && env[homeVar] ? `${homeVar}="${env[homeVar]}" ` : "";
|
|
937
|
-
const
|
|
938
|
-
|
|
1446
|
+
const npm = (version) => ({
|
|
1447
|
+
command: prefix + packageCommand(harness, "upgrade", { version: version || "latest", args: "--no-register" }),
|
|
1448
|
+
});
|
|
1449
|
+
const own = (copy) => ({
|
|
1450
|
+
command: `${prefix}node "${join(copy, "bin", "projectstore.mjs")}" upgrade --harness ${harness?.id || "<id>"} --no-register`,
|
|
1451
|
+
});
|
|
939
1452
|
const channel = installChannel(root, { home, harness });
|
|
940
|
-
if (channel === "registration") return
|
|
1453
|
+
if (channel === "registration") return npm(pluginVersion(root));
|
|
941
1454
|
if (channel === "marketplace") return own(root);
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
const
|
|
945
|
-
const here = real(proj);
|
|
946
|
-
const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
|
|
947
|
-
const copy = installedPluginEntries(home, proj)
|
|
948
|
-
.filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
|
|
949
|
-
.sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
|
|
1455
|
+
// The selection is lib's selectCopy: this checkout's row first, then
|
|
1456
|
+
// user-scope rows by recency, never another checkout's.
|
|
1457
|
+
const copy = selectCopy(home, proj, { harness });
|
|
950
1458
|
if (copy) {
|
|
951
1459
|
const v = copy.version ? ` (${copy.version})` : "";
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1460
|
+
const then = "and the startup line names the command";
|
|
1461
|
+
if (copy.missing === "bin")
|
|
1462
|
+
return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
|
|
955
1463
|
// Chosen first, gated after: the copy is what the project runs, so an
|
|
956
1464
|
// incapable one gets advice even beside a capable user-scope copy.
|
|
957
|
-
const viaRegistration =
|
|
958
|
-
if (
|
|
959
|
-
return
|
|
960
|
-
// Its channel's ordinary refresh, at the running version: the plain
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1465
|
+
const viaRegistration = copy.channel === "registration";
|
|
1466
|
+
if (copy.missing === "no-register") {
|
|
1467
|
+
return viaRegistration
|
|
1468
|
+
? // Its channel's ordinary refresh, at the running version: the plain
|
|
1469
|
+
// upgrade re-registers from npm — not a switch for an npm project — and
|
|
1470
|
+
// moves the project in the same run.
|
|
1471
|
+
{
|
|
1472
|
+
advice: `This project's projectstore plugin${v} is the npm registration's copy and predates --no-register, so it cannot run the move as named. Refresh that registration, which moves the project too`,
|
|
1473
|
+
refresh: prefix + packageCommand(harness, "upgrade", { version: pluginVersion(root) || "latest" }),
|
|
1474
|
+
}
|
|
1475
|
+
: {
|
|
1476
|
+
advice: copyUpdateAdvice(copy, { cannot: "predates --no-register and cannot run the move as named", then }),
|
|
1477
|
+
};
|
|
965
1478
|
}
|
|
966
|
-
return viaRegistration ?
|
|
1479
|
+
return viaRegistration ? npm(copy.version) : own(copy.path);
|
|
967
1480
|
}
|
|
968
|
-
return channel === "package" ?
|
|
1481
|
+
return channel === "package" ? npm(pluginVersion(root)) : own(root);
|
|
969
1482
|
}
|
|
970
1483
|
|
|
971
1484
|
export function checkGitignore(proj) {
|
|
@@ -976,31 +1489,61 @@ export function checkGitignore(proj) {
|
|
|
976
1489
|
const p = layoutPaths(proj);
|
|
977
1490
|
if (existsSync(p.root)) {
|
|
978
1491
|
let lines = null;
|
|
979
|
-
try {
|
|
1492
|
+
try {
|
|
1493
|
+
lines = readFileSync(p.gitignore, "utf8")
|
|
1494
|
+
.split("\n")
|
|
1495
|
+
.map((l) => l.trim());
|
|
1496
|
+
} catch {}
|
|
980
1497
|
if (lines === null) {
|
|
981
1498
|
// Contract 5's ignore file is what keeps a machine-local binding — with
|
|
982
1499
|
// an absolute vault path — out of a commit. Until 2026-09-06 bind never
|
|
983
1500
|
// wrote it, so a project bound and committed before its first session
|
|
984
1501
|
// carried one; doctor reasoned from the self-ignoring without ever
|
|
985
1502
|
// checking it.
|
|
986
|
-
out.push(
|
|
987
|
-
|
|
1503
|
+
out.push(
|
|
1504
|
+
finding(
|
|
1505
|
+
"install",
|
|
1506
|
+
"warn",
|
|
1507
|
+
"gitignore",
|
|
1508
|
+
`.projectstore/ exists without its .gitignore — the binding and state/ are machine-local and would be committed. Write the lines ${[...LAYOUT.gitignore].map((l) => JSON.stringify(l)).join(", ")}.`,
|
|
1509
|
+
".projectstore/.gitignore",
|
|
1510
|
+
),
|
|
1511
|
+
);
|
|
988
1512
|
} else {
|
|
989
|
-
if (lines.includes("*") && existsSync(p.overlayDir))
|
|
1513
|
+
if (lines.includes("*") && existsSync(p.overlayDir))
|
|
1514
|
+
out.push(
|
|
1515
|
+
finding(
|
|
1516
|
+
"install",
|
|
1517
|
+
"warn",
|
|
1518
|
+
"gitignore",
|
|
1519
|
+
`.projectstore/.gitignore carries "*", which hides harness/ (the committed overlays) from git — replace it with the lines ${[...LAYOUT.gitignore, LAYOUT.vaultSessions + "/"].map((l) => JSON.stringify(l)).join(", ")}.`,
|
|
1520
|
+
".projectstore/.gitignore",
|
|
1521
|
+
),
|
|
1522
|
+
);
|
|
990
1523
|
else if (!lines.includes("*")) {
|
|
991
1524
|
// A lone "*" is the pre-2026-09-06 vault-that-is-a-project shape: it
|
|
992
1525
|
// ignores the binding and state/ already, and only hides harness/ when
|
|
993
1526
|
// that directory exists — which the branch above is for.
|
|
994
1527
|
const short = [...LAYOUT.gitignore].filter((l) => !lines.includes(l));
|
|
995
|
-
if (short.length)
|
|
996
|
-
|
|
1528
|
+
if (short.length)
|
|
1529
|
+
out.push(
|
|
1530
|
+
finding(
|
|
1531
|
+
"install",
|
|
1532
|
+
"warn",
|
|
1533
|
+
"gitignore",
|
|
1534
|
+
`.projectstore/.gitignore is missing ${short.map((l) => JSON.stringify(l)).join(", ")} — the file is line-merged, so add the line rather than rewriting it.`,
|
|
1535
|
+
".projectstore/.gitignore",
|
|
1536
|
+
),
|
|
1537
|
+
);
|
|
997
1538
|
}
|
|
998
1539
|
}
|
|
999
1540
|
}
|
|
1000
1541
|
if (!existsSync(join(proj, ".git"))) return out;
|
|
1001
1542
|
let lines = [];
|
|
1002
1543
|
try {
|
|
1003
|
-
lines = readFileSync(join(proj, ".gitignore"), "utf8")
|
|
1544
|
+
lines = readFileSync(join(proj, ".gitignore"), "utf8")
|
|
1545
|
+
.split("\n")
|
|
1546
|
+
.map((l) => l.trim());
|
|
1004
1547
|
} catch {}
|
|
1005
1548
|
const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
|
|
1006
1549
|
if (coveredAll) return out;
|
|
@@ -1008,8 +1551,14 @@ export function checkGitignore(proj) {
|
|
|
1008
1551
|
const wanted = [relative(proj, hostSettingsPath(proj))];
|
|
1009
1552
|
const missing = wanted.filter((w) => !lines.includes(w));
|
|
1010
1553
|
if (!missing.length) return out;
|
|
1011
|
-
out.push(
|
|
1012
|
-
|
|
1554
|
+
out.push(
|
|
1555
|
+
finding(
|
|
1556
|
+
"install",
|
|
1557
|
+
"warn",
|
|
1558
|
+
"gitignore",
|
|
1559
|
+
`Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`,
|
|
1560
|
+
),
|
|
1561
|
+
);
|
|
1013
1562
|
return out;
|
|
1014
1563
|
}
|
|
1015
1564
|
|
|
@@ -1023,16 +1572,32 @@ export function checkTrackedRuntime(proj) {
|
|
|
1023
1572
|
const want = [relative(proj, p.binding), relative(proj, p.state)];
|
|
1024
1573
|
const r = spawnSync("git", ["ls-files", "--", ...want], { cwd: proj, encoding: "utf8", timeout: 5000 });
|
|
1025
1574
|
if (r.status !== 0 || !r.stdout) return [];
|
|
1026
|
-
const tracked = r.stdout
|
|
1575
|
+
const tracked = r.stdout
|
|
1576
|
+
.split("\n")
|
|
1577
|
+
.map((l) => l.trim())
|
|
1578
|
+
.filter(Boolean);
|
|
1027
1579
|
if (!tracked.length) return [];
|
|
1028
|
-
return [
|
|
1029
|
-
|
|
1580
|
+
return [
|
|
1581
|
+
finding(
|
|
1582
|
+
"install",
|
|
1583
|
+
"warn",
|
|
1584
|
+
"gitignore-tracked",
|
|
1585
|
+
`git already tracks ${tracked.join(", ")} — machine-local files with an absolute vault path, committed before the ignore lines existed. An ignore line does not untrack them: run \`git rm --cached ${tracked.join(" ")}\` and commit.`,
|
|
1586
|
+
tracked[0],
|
|
1587
|
+
),
|
|
1588
|
+
];
|
|
1030
1589
|
}
|
|
1031
1590
|
|
|
1032
1591
|
export function checkVaultGit(cfg) {
|
|
1033
1592
|
if (existsSync(join(cfg.vault_path, ".git"))) return [];
|
|
1034
|
-
return [
|
|
1035
|
-
|
|
1593
|
+
return [
|
|
1594
|
+
finding(
|
|
1595
|
+
"install",
|
|
1596
|
+
"warn",
|
|
1597
|
+
"vault-git",
|
|
1598
|
+
"Vault is not a git repository — the knowledge has no history/blame/review. Consider `git init` (doctor --fix offers it).",
|
|
1599
|
+
),
|
|
1600
|
+
];
|
|
1036
1601
|
}
|
|
1037
1602
|
|
|
1038
1603
|
// Marketplace auto-update (maintainer request 2026-07-03): third-party
|
|
@@ -1058,12 +1623,20 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1058
1623
|
// session. So fall back to the registered install when our own path says
|
|
1059
1624
|
// nothing, and never claim --plugin-dir as a conclusion.
|
|
1060
1625
|
const marketplaceOf = (p) =>
|
|
1061
|
-
(String(p || "")
|
|
1626
|
+
(String(p || "")
|
|
1627
|
+
.replace(/\\/g, "/")
|
|
1628
|
+
.match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
|
|
1062
1629
|
const inst = installedPluginRoot(home);
|
|
1063
1630
|
const marketplace = marketplaceOf(pluginRoot()) || marketplaceOf(inst && inst.path);
|
|
1064
1631
|
if (!marketplace) {
|
|
1065
|
-
out.push(
|
|
1066
|
-
|
|
1632
|
+
out.push(
|
|
1633
|
+
finding(
|
|
1634
|
+
"install",
|
|
1635
|
+
"info",
|
|
1636
|
+
"auto-update",
|
|
1637
|
+
"No marketplace install of projectstore found (--plugin-dir, or a checkout with none registered) — marketplace auto-update not applicable.",
|
|
1638
|
+
),
|
|
1639
|
+
);
|
|
1067
1640
|
return out;
|
|
1068
1641
|
}
|
|
1069
1642
|
|
|
@@ -1077,8 +1650,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1077
1650
|
// verb, reported by checkPluginRegistration (2026-09-05).
|
|
1078
1651
|
if (entry && entry.source && entry.source.source === "directory") return out;
|
|
1079
1652
|
if (!entry) {
|
|
1080
|
-
out.push(
|
|
1081
|
-
|
|
1653
|
+
out.push(
|
|
1654
|
+
finding(
|
|
1655
|
+
"install",
|
|
1656
|
+
"warn",
|
|
1657
|
+
"auto-update",
|
|
1658
|
+
`Marketplace "${marketplace}" is missing from ~/.claude/plugins/known_marketplaces.json — updates cannot be tracked. Re-add it: /plugin marketplace add <owner/repo>.`,
|
|
1659
|
+
),
|
|
1660
|
+
);
|
|
1082
1661
|
return out;
|
|
1083
1662
|
}
|
|
1084
1663
|
|
|
@@ -1090,11 +1669,17 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1090
1669
|
} catch {}
|
|
1091
1670
|
}
|
|
1092
1671
|
if (!enabled) {
|
|
1093
|
-
out.push(
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1672
|
+
out.push(
|
|
1673
|
+
finding(
|
|
1674
|
+
"install",
|
|
1675
|
+
"warn",
|
|
1676
|
+
"auto-update",
|
|
1677
|
+
`Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
|
|
1678
|
+
`Correct values: "autoUpdate": true on the "${marketplace}" entry in ~/.claude/plugins/known_marketplaces.json ` +
|
|
1679
|
+
`(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
|
|
1680
|
+
`extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path: /plugin marketplace update ${marketplace}, then /reload-plugins.`,
|
|
1681
|
+
),
|
|
1682
|
+
);
|
|
1098
1683
|
}
|
|
1099
1684
|
|
|
1100
1685
|
// Bonus: the marketplace checkout's catalog knows the latest released
|
|
@@ -1110,12 +1695,18 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1110
1695
|
const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
|
|
1111
1696
|
const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
|
|
1112
1697
|
const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
|
|
1113
|
-
const running =
|
|
1114
|
-
|| JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
1698
|
+
const running =
|
|
1699
|
+
(named && named.version) || JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
1115
1700
|
// Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
|
|
1116
1701
|
if (latest && running && cmpPrecedence(latest, running) > 0) {
|
|
1117
|
-
out.push(
|
|
1118
|
-
|
|
1702
|
+
out.push(
|
|
1703
|
+
finding(
|
|
1704
|
+
"install",
|
|
1705
|
+
"warn",
|
|
1706
|
+
"auto-update",
|
|
1707
|
+
`A newer ${name} is available: v${latest} (running v${running}) — run /plugin marketplace update ${marketplace}, then /reload-plugins.`,
|
|
1708
|
+
),
|
|
1709
|
+
);
|
|
1119
1710
|
}
|
|
1120
1711
|
} catch {}
|
|
1121
1712
|
return out;
|
|
@@ -1133,18 +1724,46 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
|
|
|
1133
1724
|
const mcp = harness && harness.surfaces && harness.surfaces.mcp;
|
|
1134
1725
|
if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
|
|
1135
1726
|
const p = join(root, mcp.file || ".mcp.json");
|
|
1136
|
-
if (!existsSync(p))
|
|
1727
|
+
if (!existsSync(p))
|
|
1728
|
+
return [
|
|
1729
|
+
finding(
|
|
1730
|
+
"install",
|
|
1731
|
+
"warn",
|
|
1732
|
+
"mcp",
|
|
1733
|
+
"No .mcp.json at the plugin root — the MCP read tools are not registered; the package ships one, so this install is incomplete or predates the MCP surface (0.28).",
|
|
1734
|
+
),
|
|
1735
|
+
];
|
|
1137
1736
|
let reg;
|
|
1138
|
-
try {
|
|
1737
|
+
try {
|
|
1738
|
+
reg = JSON.parse(readFileSync(p, "utf8"));
|
|
1739
|
+
} catch (e) {
|
|
1740
|
+
return [
|
|
1741
|
+
finding("install", "issue", "mcp", `.mcp.json at the plugin root is not valid JSON: ${e.message}`, ".mcp.json"),
|
|
1742
|
+
];
|
|
1743
|
+
}
|
|
1139
1744
|
const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
|
|
1140
1745
|
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
1141
1746
|
if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
|
|
1142
|
-
return [
|
|
1143
|
-
|
|
1144
|
-
|
|
1747
|
+
return [
|
|
1748
|
+
finding(
|
|
1749
|
+
"install",
|
|
1750
|
+
"issue",
|
|
1751
|
+
"mcp",
|
|
1752
|
+
".mcp.json at the plugin root does not launch bin/projectstore.mjs mcp — the MCP read tools will not answer.",
|
|
1753
|
+
".mcp.json",
|
|
1754
|
+
),
|
|
1755
|
+
];
|
|
1756
|
+
}
|
|
1757
|
+
return [
|
|
1758
|
+
finding(
|
|
1759
|
+
"install",
|
|
1760
|
+
"info",
|
|
1761
|
+
"mcp",
|
|
1762
|
+
"MCP read tools registered by the plugin's .mcp.json (one server per project, bound through the host's project directory).",
|
|
1763
|
+
),
|
|
1764
|
+
];
|
|
1145
1765
|
}
|
|
1146
1766
|
|
|
1147
|
-
|
|
1148
1767
|
// ─── Vault checks ──────────────────────────────────────────────────────
|
|
1149
1768
|
|
|
1150
1769
|
// Collect every structured artifact with parsed frontmatter.
|
|
@@ -1153,7 +1772,11 @@ export function scanArtifacts(cfg, layout) {
|
|
|
1153
1772
|
const artifacts = [];
|
|
1154
1773
|
const push = (abs, rel, kind) => {
|
|
1155
1774
|
let md;
|
|
1156
|
-
try {
|
|
1775
|
+
try {
|
|
1776
|
+
md = readFileSync(abs, "utf8");
|
|
1777
|
+
} catch {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1157
1780
|
artifacts.push({ abs, rel, kind, fm: parseFrontmatter(md).data, body: md });
|
|
1158
1781
|
};
|
|
1159
1782
|
for (const folder of layout.folders) {
|
|
@@ -1195,13 +1818,27 @@ export function checkKanbanSync(cfg) {
|
|
|
1195
1818
|
return [finding("vault", "warn", "kanban", `kanban generator failed: ${(r.stderr || "").trim()}`)];
|
|
1196
1819
|
}
|
|
1197
1820
|
let expected;
|
|
1198
|
-
try {
|
|
1821
|
+
try {
|
|
1822
|
+
expected = JSON.parse(r.stdout).content;
|
|
1823
|
+
} catch {
|
|
1199
1824
|
return [finding("vault", "warn", "kanban", "kanban generator returned unparseable output.")];
|
|
1200
1825
|
}
|
|
1201
|
-
const norm = (s) =>
|
|
1826
|
+
const norm = (s) =>
|
|
1827
|
+
s
|
|
1828
|
+
.split("\n")
|
|
1829
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
1830
|
+
.join("\n")
|
|
1831
|
+
.trimEnd();
|
|
1202
1832
|
if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
|
|
1203
|
-
return [
|
|
1204
|
-
|
|
1833
|
+
return [
|
|
1834
|
+
finding(
|
|
1835
|
+
"vault",
|
|
1836
|
+
"issue",
|
|
1837
|
+
"kanban",
|
|
1838
|
+
`kanban.md is out of sync with story frontmatter — run ${commandForm("kanban")} (or reconcile).`,
|
|
1839
|
+
"kanban.md",
|
|
1840
|
+
),
|
|
1841
|
+
];
|
|
1205
1842
|
}
|
|
1206
1843
|
return [];
|
|
1207
1844
|
}
|
|
@@ -1225,28 +1862,51 @@ export function checkIndexes(cfg, layout, artifacts) {
|
|
|
1225
1862
|
indexed.add(rel);
|
|
1226
1863
|
const art = artifacts.find((a) => a.rel === rel);
|
|
1227
1864
|
if (!art) {
|
|
1228
|
-
out.push(
|
|
1229
|
-
|
|
1865
|
+
out.push(
|
|
1866
|
+
finding(
|
|
1867
|
+
"vault",
|
|
1868
|
+
"issue",
|
|
1869
|
+
"index",
|
|
1870
|
+
`${folder.path}/README.md row "${row.label}" points at a missing file: ${row.target}`,
|
|
1871
|
+
`${folder.path}/README.md`,
|
|
1872
|
+
),
|
|
1873
|
+
);
|
|
1230
1874
|
continue;
|
|
1231
1875
|
}
|
|
1232
1876
|
const fmStatus = (art.fm.status || "").trim();
|
|
1233
1877
|
if (fmStatus && row.status && fmStatus !== row.status) {
|
|
1234
|
-
out.push(
|
|
1235
|
-
|
|
1878
|
+
out.push(
|
|
1879
|
+
finding(
|
|
1880
|
+
"vault",
|
|
1881
|
+
"issue",
|
|
1882
|
+
"index",
|
|
1883
|
+
`${folder.path}/README.md lists "${row.label}" as "${row.status}" but its frontmatter says "${fmStatus}".`,
|
|
1884
|
+
art.rel,
|
|
1885
|
+
),
|
|
1886
|
+
);
|
|
1236
1887
|
}
|
|
1237
1888
|
const fmTitle = (art.fm.title || "").trim();
|
|
1238
1889
|
if (fmTitle && row.title && fmTitle !== row.title) {
|
|
1239
|
-
out.push(
|
|
1240
|
-
|
|
1890
|
+
out.push(
|
|
1891
|
+
finding(
|
|
1892
|
+
"vault",
|
|
1893
|
+
"warn",
|
|
1894
|
+
"index",
|
|
1895
|
+
`${folder.path}/README.md title for "${row.label}" differs from frontmatter title.`,
|
|
1896
|
+
art.rel,
|
|
1897
|
+
),
|
|
1898
|
+
);
|
|
1241
1899
|
}
|
|
1242
1900
|
}
|
|
1243
1901
|
for (const a of artifacts) {
|
|
1244
|
-
const inFolder =
|
|
1245
|
-
|
|
1246
|
-
|
|
1902
|
+
const inFolder =
|
|
1903
|
+
folder.kind === "epic"
|
|
1904
|
+
? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
|
|
1905
|
+
: a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`;
|
|
1247
1906
|
if (inFolder && !indexed.has(a.rel)) {
|
|
1248
|
-
out.push(
|
|
1249
|
-
`${a.rel} is not listed in ${folder.path}/README.md's index.`, a.rel)
|
|
1907
|
+
out.push(
|
|
1908
|
+
finding("vault", "warn", "index", `${a.rel} is not listed in ${folder.path}/README.md's index.`, a.rel),
|
|
1909
|
+
);
|
|
1250
1910
|
}
|
|
1251
1911
|
}
|
|
1252
1912
|
}
|
|
@@ -1263,7 +1923,10 @@ function linesOutsideFences(s) {
|
|
|
1263
1923
|
const out = [];
|
|
1264
1924
|
let fenced = false;
|
|
1265
1925
|
for (const line of s.split("\n")) {
|
|
1266
|
-
if (/^\s*```/.test(line)) {
|
|
1926
|
+
if (/^\s*```/.test(line)) {
|
|
1927
|
+
fenced = !fenced;
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1267
1930
|
if (!fenced) out.push(line);
|
|
1268
1931
|
}
|
|
1269
1932
|
return out;
|
|
@@ -1283,23 +1946,39 @@ export function checkStoriesAndEpics(artifacts) {
|
|
|
1283
1946
|
const sec = sectionOf(a.body, "acceptance") || "";
|
|
1284
1947
|
const unchecked = (stripCodeSpans(sec).match(/- \[ \]/g) || []).length;
|
|
1285
1948
|
if (unchecked > 0) {
|
|
1286
|
-
out.push(
|
|
1287
|
-
|
|
1949
|
+
out.push(
|
|
1950
|
+
finding(
|
|
1951
|
+
"vault",
|
|
1952
|
+
"warn",
|
|
1953
|
+
"acceptance",
|
|
1954
|
+
`Story is "done" with ${unchecked} unchecked acceptance criteria.`,
|
|
1955
|
+
a.rel,
|
|
1956
|
+
),
|
|
1957
|
+
);
|
|
1288
1958
|
}
|
|
1289
1959
|
}
|
|
1290
1960
|
if ((a.fm.review_status || "") === "reviewed" && (!a.fm.reviewed_at || a.fm.reviewed_at === "null")) {
|
|
1291
|
-
out.push(
|
|
1292
|
-
`review_status is "reviewed" but reviewed_at is empty.`, a.rel)
|
|
1961
|
+
out.push(
|
|
1962
|
+
finding("vault", "issue", "review-status", `review_status is "reviewed" but reviewed_at is empty.`, a.rel),
|
|
1963
|
+
);
|
|
1293
1964
|
}
|
|
1294
1965
|
}
|
|
1295
1966
|
for (const epic of artifacts.filter((a) => a.kind === "epic")) {
|
|
1296
1967
|
if ((epic.fm.status || "").toLowerCase() !== "done") continue;
|
|
1297
1968
|
const dir = epic.rel.replace(/\/epic\.md$/, "");
|
|
1298
|
-
const open = artifacts.filter(
|
|
1299
|
-
s.kind === "story" && s.rel.startsWith(dir + "/") && (s.fm.status || "").toLowerCase() !== "done"
|
|
1969
|
+
const open = artifacts.filter(
|
|
1970
|
+
(s) => s.kind === "story" && s.rel.startsWith(dir + "/") && (s.fm.status || "").toLowerCase() !== "done",
|
|
1971
|
+
);
|
|
1300
1972
|
if (open.length) {
|
|
1301
|
-
out.push(
|
|
1302
|
-
|
|
1973
|
+
out.push(
|
|
1974
|
+
finding(
|
|
1975
|
+
"vault",
|
|
1976
|
+
"issue",
|
|
1977
|
+
"epic-status",
|
|
1978
|
+
`Epic is "done" while ${open.length} child stor${open.length === 1 ? "y is" : "ies are"} not.`,
|
|
1979
|
+
epic.rel,
|
|
1980
|
+
),
|
|
1981
|
+
);
|
|
1303
1982
|
}
|
|
1304
1983
|
}
|
|
1305
1984
|
return out;
|
|
@@ -1317,13 +1996,18 @@ export function checkIndexHeaders(cfg, layout) {
|
|
|
1317
1996
|
const readme = join(cfg.vault_path, folder.path, "README.md");
|
|
1318
1997
|
if (!existsSync(readme)) continue;
|
|
1319
1998
|
const lines = readFileSync(readme, "utf8").split("\n");
|
|
1320
|
-
const headIdx = lines.findIndex((l, i) =>
|
|
1321
|
-
/^\|.*\|\s*$/.test(l) && /^\|[-\s|]+\|\s*$/.test(lines[i + 1] || ""));
|
|
1999
|
+
const headIdx = lines.findIndex((l, i) => /^\|.*\|\s*$/.test(l) && /^\|[-\s|]+\|\s*$/.test(lines[i + 1] || ""));
|
|
1322
2000
|
if (headIdx === -1) continue; // no table at all — nothing to lint
|
|
1323
2001
|
if (!headerRe.test(lines[headIdx])) {
|
|
1324
|
-
out.push(
|
|
1325
|
-
|
|
1326
|
-
|
|
2002
|
+
out.push(
|
|
2003
|
+
finding(
|
|
2004
|
+
"vault",
|
|
2005
|
+
"warn",
|
|
2006
|
+
"index-header",
|
|
2007
|
+
`${folder.path}/README.md index header "${lines[headIdx].trim()}" matches no registered form — reconcile cannot rebuild this index (standard form: | File | Title | Status | Date |, localized forms in scaffold/headings.json).`,
|
|
2008
|
+
`${folder.path}/README.md`,
|
|
2009
|
+
),
|
|
2010
|
+
);
|
|
1327
2011
|
}
|
|
1328
2012
|
}
|
|
1329
2013
|
return out;
|
|
@@ -1361,14 +2045,20 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
|
|
|
1361
2045
|
if (a.kind !== "story" || epicIdOf(a.rel, epicFolderPath) !== epicId) continue;
|
|
1362
2046
|
const tier = storyMatchesEntry(storyId, { id: a.fm.id, stem: storyStemOf(a.rel) });
|
|
1363
2047
|
if (!tier) continue;
|
|
1364
|
-
if (best === 0 || tier < best) {
|
|
1365
|
-
|
|
2048
|
+
if (best === 0 || tier < best) {
|
|
2049
|
+
best = tier;
|
|
2050
|
+
hits = [a];
|
|
2051
|
+
} else if (tier === best) hits.push(a);
|
|
1366
2052
|
}
|
|
1367
2053
|
if (hits.length === 1) return { story: hits[0] };
|
|
1368
2054
|
if (hits.length > 1) {
|
|
1369
|
-
return {
|
|
2055
|
+
return {
|
|
2056
|
+
error: `ambiguous — "${storyId}" matches ${hits.map((a) => storyStemOf(a.rel)).join(", ")}; qualify the reference`,
|
|
2057
|
+
};
|
|
1370
2058
|
}
|
|
1371
|
-
return {
|
|
2059
|
+
return {
|
|
2060
|
+
error: `no story in ${epicFolderPath}/${epicId} matches "${storyId}" (by exact id:, exact filename stem, or legacy story-NNN prefix)`,
|
|
2061
|
+
};
|
|
1372
2062
|
}
|
|
1373
2063
|
|
|
1374
2064
|
// The ONE spec resolver (SPEC-002 contract 5) — shared by checkSpecLinks,
|
|
@@ -1380,7 +2070,7 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
|
|
|
1380
2070
|
// grandfathered SPEC-NNN-<slug>.md files. Cross-spec identity clashes are
|
|
1381
2071
|
// the identity check's finding, so first-wins here stays deterministic.
|
|
1382
2072
|
function buildSpecResolver(artifacts, layout = null) {
|
|
1383
|
-
const prefix = layout ? folderByKind(layout, "spec")?.prefix ?? null : null;
|
|
2073
|
+
const prefix = layout ? (folderByKind(layout, "spec")?.prefix ?? null) : null;
|
|
1384
2074
|
const byId = new Map();
|
|
1385
2075
|
const byStem = new Map();
|
|
1386
2076
|
for (const s of artifacts.filter((a) => a.kind === "spec")) {
|
|
@@ -1410,7 +2100,10 @@ export function parseSpecAcceptance(spec) {
|
|
|
1410
2100
|
// story, not silently fall through to "applies to every covered story".
|
|
1411
2101
|
const attr = text.match(attrRe);
|
|
1412
2102
|
const stories = attr
|
|
1413
|
-
? attr[1]
|
|
2103
|
+
? attr[1]
|
|
2104
|
+
.split(",")
|
|
2105
|
+
.map((s) => s.trim())
|
|
2106
|
+
.filter(Boolean)
|
|
1414
2107
|
: null;
|
|
1415
2108
|
items.push({ checked, text, stories });
|
|
1416
2109
|
}
|
|
@@ -1428,14 +2121,27 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1428
2121
|
for (const entry of listOf(spec.fm, "stories")) {
|
|
1429
2122
|
const r = resolveSpecStory(entry, artifacts, epicFolder.path);
|
|
1430
2123
|
if (r.error) {
|
|
1431
|
-
out.push(
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2124
|
+
out.push(
|
|
2125
|
+
finding(
|
|
2126
|
+
"vault",
|
|
2127
|
+
"issue",
|
|
2128
|
+
"spec-links",
|
|
2129
|
+
`Spec "${spec.fm.id}" stories entry "${entry}" does not resolve: ${r.error}.`,
|
|
2130
|
+
spec.rel,
|
|
2131
|
+
),
|
|
2132
|
+
);
|
|
2133
|
+
} else if (spec.fm.id && !listOf(r.story.fm, "specs").some((ref) => resolveSpec(ref) === spec)) {
|
|
1435
2134
|
// Membership through the SAME resolver — a slug-form back-reference
|
|
1436
2135
|
// to a grandfathered SPEC-NNN file is a valid link, not a gap.
|
|
1437
|
-
out.push(
|
|
1438
|
-
|
|
2136
|
+
out.push(
|
|
2137
|
+
finding(
|
|
2138
|
+
"vault",
|
|
2139
|
+
"warn",
|
|
2140
|
+
"spec-links",
|
|
2141
|
+
`Spec "${spec.fm.id}" covers ${entry} but the story's \`specs:\` list lacks "${spec.fm.id}" (bidirectional link).`,
|
|
2142
|
+
r.story.rel,
|
|
2143
|
+
),
|
|
2144
|
+
);
|
|
1439
2145
|
}
|
|
1440
2146
|
}
|
|
1441
2147
|
}
|
|
@@ -1443,19 +2149,32 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1443
2149
|
for (const id of listOf(story.fm, "specs")) {
|
|
1444
2150
|
const spec = resolveSpec(id);
|
|
1445
2151
|
if (!spec) {
|
|
1446
|
-
out.push(
|
|
1447
|
-
|
|
2152
|
+
out.push(
|
|
2153
|
+
finding(
|
|
2154
|
+
"vault",
|
|
2155
|
+
"issue",
|
|
2156
|
+
"spec-links",
|
|
2157
|
+
`Story lists spec "${id}" which does not exist in specs/.`,
|
|
2158
|
+
story.rel,
|
|
2159
|
+
),
|
|
2160
|
+
);
|
|
1448
2161
|
} else {
|
|
1449
2162
|
const epicId = epicIdOf(story.rel, epicFolder.path);
|
|
1450
2163
|
const stem = storyStemOf(story.rel);
|
|
1451
2164
|
const covered = listOf(spec.fm, "stories").some((e) => {
|
|
1452
2165
|
const m = String(e).match(/^([^/]+)\/(.+)$/);
|
|
1453
|
-
return m && m[1] === epicId &&
|
|
1454
|
-
storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
|
|
2166
|
+
return m && m[1] === epicId && storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
|
|
1455
2167
|
});
|
|
1456
2168
|
if (!covered) {
|
|
1457
|
-
out.push(
|
|
1458
|
-
|
|
2169
|
+
out.push(
|
|
2170
|
+
finding(
|
|
2171
|
+
"vault",
|
|
2172
|
+
"warn",
|
|
2173
|
+
"spec-links",
|
|
2174
|
+
`Story lists spec "${id}" but that spec's \`stories:\` does not list it back.`,
|
|
2175
|
+
story.rel,
|
|
2176
|
+
),
|
|
2177
|
+
);
|
|
1459
2178
|
}
|
|
1460
2179
|
}
|
|
1461
2180
|
}
|
|
@@ -1464,8 +2183,15 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1464
2183
|
// the list is invisible to every deterministic check.
|
|
1465
2184
|
const fmBlock = story.body.match(/^---\n[\s\S]*?\n---/);
|
|
1466
2185
|
if (story.fm.specs === "" && fmBlock && /\nspecs:\s*\n\s+-\s/.test(fmBlock[0])) {
|
|
1467
|
-
out.push(
|
|
1468
|
-
|
|
2186
|
+
out.push(
|
|
2187
|
+
finding(
|
|
2188
|
+
"vault",
|
|
2189
|
+
"issue",
|
|
2190
|
+
"spec-links",
|
|
2191
|
+
'`specs:` uses block-sequence YAML which projectstore cannot parse — use inline flow: specs: ["SPEC-001"].',
|
|
2192
|
+
story.rel,
|
|
2193
|
+
),
|
|
2194
|
+
);
|
|
1469
2195
|
}
|
|
1470
2196
|
}
|
|
1471
2197
|
return out;
|
|
@@ -1489,8 +2215,15 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
|
|
|
1489
2215
|
if (isLegacyStory(story.fm, since)) continue;
|
|
1490
2216
|
const ids = listOf(story.fm, "specs");
|
|
1491
2217
|
if (!ids.length) {
|
|
1492
|
-
out.push(
|
|
1493
|
-
|
|
2218
|
+
out.push(
|
|
2219
|
+
finding(
|
|
2220
|
+
"vault",
|
|
2221
|
+
"issue",
|
|
2222
|
+
"spec-coverage",
|
|
2223
|
+
`Story is ${status} with no covering spec (spec_policy: required — every story needs a spec; ADR-007).`,
|
|
2224
|
+
story.rel,
|
|
2225
|
+
),
|
|
2226
|
+
);
|
|
1494
2227
|
continue;
|
|
1495
2228
|
}
|
|
1496
2229
|
for (const id of ids) {
|
|
@@ -1499,12 +2232,26 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
|
|
|
1499
2232
|
const st = specStatusOf(spec);
|
|
1500
2233
|
if (status === "done") {
|
|
1501
2234
|
if (!["active", "superseded"].includes(st)) {
|
|
1502
|
-
out.push(
|
|
1503
|
-
|
|
2235
|
+
out.push(
|
|
2236
|
+
finding(
|
|
2237
|
+
"vault",
|
|
2238
|
+
"issue",
|
|
2239
|
+
"spec-status",
|
|
2240
|
+
`Story is done while covering spec "${id}" is ${st} — a story may close only against an active spec.`,
|
|
2241
|
+
story.rel,
|
|
2242
|
+
),
|
|
2243
|
+
);
|
|
1504
2244
|
}
|
|
1505
2245
|
} else if (st === "draft") {
|
|
1506
|
-
out.push(
|
|
1507
|
-
|
|
2246
|
+
out.push(
|
|
2247
|
+
finding(
|
|
2248
|
+
"vault",
|
|
2249
|
+
"warn",
|
|
2250
|
+
"spec-status",
|
|
2251
|
+
`Story is ${status} while covering spec "${id}" is still draft — the spec must go active before implementation.`,
|
|
2252
|
+
story.rel,
|
|
2253
|
+
),
|
|
2254
|
+
);
|
|
1508
2255
|
}
|
|
1509
2256
|
}
|
|
1510
2257
|
}
|
|
@@ -1535,8 +2282,15 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
|
|
|
1535
2282
|
if (!spec) continue;
|
|
1536
2283
|
const items = parseSpecAcceptance(spec);
|
|
1537
2284
|
if (items === null) {
|
|
1538
|
-
out.push(
|
|
1539
|
-
|
|
2285
|
+
out.push(
|
|
2286
|
+
finding(
|
|
2287
|
+
"vault",
|
|
2288
|
+
"warn",
|
|
2289
|
+
"spec-acceptance",
|
|
2290
|
+
`Covering spec "${id}" has no Acceptance section — its criteria cannot gate this story.`,
|
|
2291
|
+
spec.rel,
|
|
2292
|
+
),
|
|
2293
|
+
);
|
|
1540
2294
|
continue;
|
|
1541
2295
|
}
|
|
1542
2296
|
const coveredEntries = listOf(spec.fm, "stories");
|
|
@@ -1549,24 +2303,40 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
|
|
|
1549
2303
|
applies = item.stories.some((bare) =>
|
|
1550
2304
|
coveredEntries.some((e) => {
|
|
1551
2305
|
const m = String(e).match(/^([^/]+)\/(.+)$/);
|
|
1552
|
-
return m && m[1] === epicId && bare === m[2] &&
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
const ambiguous = item.stories.some(
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
2306
|
+
return m && m[1] === epicId && bare === m[2] && storyMatchesEntry(bare, { id: story.fm.id, stem }) > 0;
|
|
2307
|
+
}),
|
|
2308
|
+
);
|
|
2309
|
+
const ambiguous = item.stories.some(
|
|
2310
|
+
(bare) =>
|
|
2311
|
+
coveredEntries.filter((e) => {
|
|
2312
|
+
const m = String(e).match(/^([^/]+)\/(.+)$/);
|
|
2313
|
+
return m && bare === m[2];
|
|
2314
|
+
}).length > 1,
|
|
2315
|
+
);
|
|
1560
2316
|
const ambKey = `${spec.rel}|${item.text}`;
|
|
1561
2317
|
if (ambiguous && !ambiguousReported.has(ambKey)) {
|
|
1562
2318
|
ambiguousReported.add(ambKey);
|
|
1563
|
-
out.push(
|
|
1564
|
-
|
|
2319
|
+
out.push(
|
|
2320
|
+
finding(
|
|
2321
|
+
"vault",
|
|
2322
|
+
"warn",
|
|
2323
|
+
"ambiguous-attribution",
|
|
2324
|
+
`Spec "${id}" acceptance item attributes bare id(s) "${item.stories.join(", ")}" that match more than one covered epic — qualify as <epic-id>/<story-id>.`,
|
|
2325
|
+
spec.rel,
|
|
2326
|
+
),
|
|
2327
|
+
);
|
|
1565
2328
|
}
|
|
1566
2329
|
}
|
|
1567
2330
|
if (applies && !item.checked) {
|
|
1568
|
-
out.push(
|
|
1569
|
-
|
|
2331
|
+
out.push(
|
|
2332
|
+
finding(
|
|
2333
|
+
"vault",
|
|
2334
|
+
"issue",
|
|
2335
|
+
"spec-acceptance",
|
|
2336
|
+
`Story is done but spec "${id}" acceptance item is unchecked: "${item.text.slice(0, 80)}".`,
|
|
2337
|
+
story.rel,
|
|
2338
|
+
),
|
|
2339
|
+
);
|
|
1570
2340
|
}
|
|
1571
2341
|
}
|
|
1572
2342
|
}
|
|
@@ -1593,25 +2363,53 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
|
|
|
1593
2363
|
for (const line of linesOutsideFences(acc)) {
|
|
1594
2364
|
const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
|
|
1595
2365
|
if (m && !evidenceRe.test(m[2])) {
|
|
1596
|
-
out.push(
|
|
1597
|
-
|
|
2366
|
+
out.push(
|
|
2367
|
+
finding(
|
|
2368
|
+
"vault",
|
|
2369
|
+
"warn",
|
|
2370
|
+
"evidence",
|
|
2371
|
+
`Checked acceptance criterion carries no evidence suffix ("— evidence: <test | command | file:line>"): "${m[2].slice(0, 70)}".`,
|
|
2372
|
+
story.rel,
|
|
2373
|
+
),
|
|
2374
|
+
);
|
|
1598
2375
|
}
|
|
1599
2376
|
}
|
|
1600
2377
|
}
|
|
1601
2378
|
|
|
1602
2379
|
const plan = sectionOf(story.body, "implementation_plan");
|
|
1603
2380
|
if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
|
|
1604
|
-
out.push(
|
|
1605
|
-
|
|
2381
|
+
out.push(
|
|
2382
|
+
finding(
|
|
2383
|
+
"vault",
|
|
2384
|
+
"warn",
|
|
2385
|
+
"plan-gate",
|
|
2386
|
+
`Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the ${commandForm("story", { args: "plan" })} gate.`,
|
|
2387
|
+
story.rel,
|
|
2388
|
+
),
|
|
2389
|
+
);
|
|
1606
2390
|
}
|
|
1607
2391
|
const summary = sectionOf(story.body, "final_summary");
|
|
1608
2392
|
if (summary === null) {
|
|
1609
|
-
out.push(
|
|
1610
|
-
|
|
2393
|
+
out.push(
|
|
2394
|
+
finding(
|
|
2395
|
+
"vault",
|
|
2396
|
+
"warn",
|
|
2397
|
+
"final-summary",
|
|
2398
|
+
"Done story has no Final Summary section (lifecycle_gates: on requires a close-out record).",
|
|
2399
|
+
story.rel,
|
|
2400
|
+
),
|
|
2401
|
+
);
|
|
1611
2402
|
}
|
|
1612
2403
|
if (plan === null) {
|
|
1613
|
-
out.push(
|
|
1614
|
-
|
|
2404
|
+
out.push(
|
|
2405
|
+
finding(
|
|
2406
|
+
"vault",
|
|
2407
|
+
"warn",
|
|
2408
|
+
"plan-gate",
|
|
2409
|
+
"Done story has no Implementation Plan section (lifecycle_gates: on requires the plan to live in the story).",
|
|
2410
|
+
story.rel,
|
|
2411
|
+
),
|
|
2412
|
+
);
|
|
1615
2413
|
}
|
|
1616
2414
|
}
|
|
1617
2415
|
return out;
|
|
@@ -1622,12 +2420,24 @@ export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
|
|
|
1622
2420
|
const out = [];
|
|
1623
2421
|
const hasSpecs = artifacts.some((a) => a.kind === "spec");
|
|
1624
2422
|
if (hasSpecs && !vaultCfg.spec_policy) {
|
|
1625
|
-
out.push(
|
|
1626
|
-
|
|
2423
|
+
out.push(
|
|
2424
|
+
finding(
|
|
2425
|
+
"vault",
|
|
2426
|
+
"info",
|
|
2427
|
+
"spec-policy",
|
|
2428
|
+
'Vault contains specs but declares no spec_policy — consider enabling spec-first: add { "spec_policy": "required" } to <vault>/.projectstore.json (doctor gates activate; ADR-007).',
|
|
2429
|
+
),
|
|
2430
|
+
);
|
|
1627
2431
|
}
|
|
1628
2432
|
if (vaultCfg.spec_policy === "required" && !vaultCfg.spec_policy_since) {
|
|
1629
|
-
out.push(
|
|
1630
|
-
|
|
2433
|
+
out.push(
|
|
2434
|
+
finding(
|
|
2435
|
+
"vault",
|
|
2436
|
+
"warn",
|
|
2437
|
+
"spec-policy",
|
|
2438
|
+
"spec_policy is required but spec_policy_since is missing — the legacy exemption cannot be evaluated; stamp it with the enable date (ISO-8601).",
|
|
2439
|
+
),
|
|
2440
|
+
);
|
|
1631
2441
|
}
|
|
1632
2442
|
return out;
|
|
1633
2443
|
}
|
|
@@ -1643,7 +2453,11 @@ export function walkVaultFiles(vault) {
|
|
|
1643
2453
|
if (n.startsWith(".")) continue;
|
|
1644
2454
|
const p = join(dir, n);
|
|
1645
2455
|
let st;
|
|
1646
|
-
try {
|
|
2456
|
+
try {
|
|
2457
|
+
st = statSync(p);
|
|
2458
|
+
} catch {
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
1647
2461
|
if (st.isDirectory()) walk(p, relDir ? `${relDir}/${n}` : n);
|
|
1648
2462
|
else if (n.endsWith(".md")) files.push({ rel: relDir ? `${relDir}/${n}` : n, name: n });
|
|
1649
2463
|
}
|
|
@@ -1669,7 +2483,9 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
|
|
|
1669
2483
|
const files = vaultFiles ?? walkVaultFiles(cfg.vault_path);
|
|
1670
2484
|
let index = nodeIndex;
|
|
1671
2485
|
if (!index) {
|
|
1672
|
-
try {
|
|
2486
|
+
try {
|
|
2487
|
+
index = buildNodeIndex(cfg, loadLayout(cfg.layout));
|
|
2488
|
+
} catch (e) {
|
|
1673
2489
|
// A silent no-op check would read as "links are fine" — say why not.
|
|
1674
2490
|
return [finding("vault", "warn", "wikilink", `Link check skipped — node index failed: ${e.message}`)];
|
|
1675
2491
|
}
|
|
@@ -1684,12 +2500,21 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
|
|
|
1684
2500
|
for (const link of extractLinks(a.body)) {
|
|
1685
2501
|
const r = resolveLinkTarget(link.target, link.type, ctx);
|
|
1686
2502
|
if (r.outcome === "dead") {
|
|
1687
|
-
out.push(
|
|
1688
|
-
|
|
1689
|
-
|
|
2503
|
+
out.push(
|
|
2504
|
+
link.type === "wikilink"
|
|
2505
|
+
? finding("vault", "issue", "wikilink", `Dead wiki-link [[${link.target}]].`, a.rel)
|
|
2506
|
+
: finding("vault", "issue", "rel-link", `Dead relative link (${link.target}).`, a.rel),
|
|
2507
|
+
);
|
|
1690
2508
|
} else if (r.outcome === "ambiguous") {
|
|
1691
|
-
out.push(
|
|
1692
|
-
|
|
2509
|
+
out.push(
|
|
2510
|
+
finding(
|
|
2511
|
+
"vault",
|
|
2512
|
+
"warn",
|
|
2513
|
+
"wikilink",
|
|
2514
|
+
`Ambiguous wiki-link [[${link.target}]] — matches ${r.candidates.join(", ")}; qualify with a path.`,
|
|
2515
|
+
a.rel,
|
|
2516
|
+
),
|
|
2517
|
+
);
|
|
1693
2518
|
}
|
|
1694
2519
|
}
|
|
1695
2520
|
}
|
|
@@ -1751,7 +2576,8 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
|
|
|
1751
2576
|
const parts = x.rel.split("/");
|
|
1752
2577
|
let entry = null; // stories/<f>.md | stories/<dir>/README.md | standalone story-*.md
|
|
1753
2578
|
if (parts.length === 4 && parts[2] === "stories") entry = { name: parts[3], rel: x.rel };
|
|
1754
|
-
else if (parts.length === 5 && parts[2] === "stories" && parts[4] === "README.md")
|
|
2579
|
+
else if (parts.length === 5 && parts[2] === "stories" && parts[4] === "README.md")
|
|
2580
|
+
entry = { name: parts[3], rel: x.rel };
|
|
1755
2581
|
else if (parts.length === 3 && parts[2].startsWith("story-")) entry = { name: parts[2], rel: x.rel };
|
|
1756
2582
|
if (!entry || INFRA_NAMES.has(entry.name)) continue;
|
|
1757
2583
|
if (!byEpic.has(parts[1])) byEpic.set(parts[1], []);
|
|
@@ -1781,31 +2607,53 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
|
|
|
1781
2607
|
for (const [ident, hits] of groups) {
|
|
1782
2608
|
const uniq = [...new Map(hits.map((h) => [h.entry.rel, h])).values()];
|
|
1783
2609
|
if (uniq.length < 2) continue;
|
|
1784
|
-
const key = uniq
|
|
2610
|
+
const key = uniq
|
|
2611
|
+
.map((h) => h.entry.rel)
|
|
2612
|
+
.sort()
|
|
2613
|
+
.join("|");
|
|
1785
2614
|
if (reported.has(key)) continue;
|
|
1786
2615
|
reported.add(key);
|
|
1787
2616
|
const selfOnly = uniq.every((h) => h.via === "self");
|
|
1788
2617
|
const classed = uniq.map((h) => ({ h, era: eraOf(h.entry, opts) }));
|
|
1789
|
-
const level = selfOnly
|
|
1790
|
-
|
|
1791
|
-
//
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2618
|
+
const level = selfOnly
|
|
2619
|
+
? "issue"
|
|
2620
|
+
: // Overlap reached via the legacy reading of a PROVEN slug-era file
|
|
2621
|
+
// (its machine id is the full stem) is spurious — the file's real
|
|
2622
|
+
// identity is the unstripped slug.
|
|
2623
|
+
classed.some(({ h, era }) => h.via !== "self" && era === "new")
|
|
2624
|
+
? "warn"
|
|
2625
|
+
: classed.some(({ era }) => era === "uncertain")
|
|
2626
|
+
? "warn"
|
|
2627
|
+
: classed.every(({ era }) => era === "legacy")
|
|
2628
|
+
? "info"
|
|
2629
|
+
: "issue"; // a new-era name colliding with a certain legacy one
|
|
2630
|
+
const note =
|
|
2631
|
+
level === "warn"
|
|
2632
|
+
? "; the overlap depends on a legacy reading that frontmatter does not confirm"
|
|
2633
|
+
: level === "info"
|
|
2634
|
+
? "; all carry legacy numbers — legal in the numbered era, duplicate topic is hygiene"
|
|
2635
|
+
: "";
|
|
2636
|
+
out.push(
|
|
2637
|
+
finding(
|
|
2638
|
+
"vault",
|
|
2639
|
+
level,
|
|
2640
|
+
"identity",
|
|
2641
|
+
`Same normalized identity "${ident}" in ${label}: ${uniq.map((h) => h.entry.name).join(", ")} — two artifacts claim one topic (ADR-010${note}).`,
|
|
2642
|
+
uniq[0].entry.rel,
|
|
2643
|
+
),
|
|
2644
|
+
);
|
|
1803
2645
|
}
|
|
1804
2646
|
for (const [num, ents] of numbers) {
|
|
1805
2647
|
if (new Set(ents.map((e) => e.rel)).size < 2) continue;
|
|
1806
|
-
out.push(
|
|
1807
|
-
|
|
1808
|
-
|
|
2648
|
+
out.push(
|
|
2649
|
+
finding(
|
|
2650
|
+
"vault",
|
|
2651
|
+
"info",
|
|
2652
|
+
"identity",
|
|
2653
|
+
`Display number ${num} is carried by ${ents.length} artifacts in ${label}: ${ents.map((e) => e.name).join(", ")} — numbers are reference metadata (ADR-010), duplicates confuse humans.`,
|
|
2654
|
+
ents[0].rel,
|
|
2655
|
+
),
|
|
2656
|
+
);
|
|
1809
2657
|
}
|
|
1810
2658
|
}
|
|
1811
2659
|
return out;
|
|
@@ -1821,8 +2669,15 @@ export function checkExternalRefsForm(artifacts) {
|
|
|
1821
2669
|
if (a.fm.external_refs !== "") continue;
|
|
1822
2670
|
const fmBlock = a.body.match(/^---\n[\s\S]*?\n---/);
|
|
1823
2671
|
if (fmBlock && /\nexternal_refs:\s*\n\s+\S/.test(fmBlock[0])) {
|
|
1824
|
-
out.push(
|
|
1825
|
-
|
|
2672
|
+
out.push(
|
|
2673
|
+
finding(
|
|
2674
|
+
"vault",
|
|
2675
|
+
"issue",
|
|
2676
|
+
"external-refs",
|
|
2677
|
+
'`external_refs:` uses block-form YAML which projectstore cannot parse — use inline flow: external_refs: {jira: "ABC-123"}.',
|
|
2678
|
+
a.rel,
|
|
2679
|
+
),
|
|
2680
|
+
);
|
|
1826
2681
|
}
|
|
1827
2682
|
}
|
|
1828
2683
|
return out;
|
|
@@ -1838,8 +2693,15 @@ export function checkArtifactNames(vaultFiles) {
|
|
|
1838
2693
|
for (const x of vaultFiles) {
|
|
1839
2694
|
const bad = legalArtifactName(x.name);
|
|
1840
2695
|
if (bad) {
|
|
1841
|
-
out.push(
|
|
1842
|
-
|
|
2696
|
+
out.push(
|
|
2697
|
+
finding(
|
|
2698
|
+
"vault",
|
|
2699
|
+
"warn",
|
|
2700
|
+
"artifact-name",
|
|
2701
|
+
`Sync-conflict filename shape — ${bad}. Merge or remove; sync engines leave these beside the original.`,
|
|
2702
|
+
x.rel,
|
|
2703
|
+
),
|
|
2704
|
+
);
|
|
1843
2705
|
}
|
|
1844
2706
|
if (INFRA_NAMES.has(x.name)) continue;
|
|
1845
2707
|
if (!byName.has(x.name)) byName.set(x.name, []);
|
|
@@ -1847,9 +2709,15 @@ export function checkArtifactNames(vaultFiles) {
|
|
|
1847
2709
|
}
|
|
1848
2710
|
for (const [name, rels] of byName) {
|
|
1849
2711
|
if (rels.length < 2) continue;
|
|
1850
|
-
out.push(
|
|
1851
|
-
|
|
1852
|
-
|
|
2712
|
+
out.push(
|
|
2713
|
+
finding(
|
|
2714
|
+
"vault",
|
|
2715
|
+
"info",
|
|
2716
|
+
"artifact-name",
|
|
2717
|
+
`Basename "${name}" appears in ${rels.length} folders (${rels.map((r) => dirname(r)).join(", ")}) — short wiki-links [[${name.replace(/\.md$/, "")}]] are ambiguous.`,
|
|
2718
|
+
rels[0],
|
|
2719
|
+
),
|
|
2720
|
+
);
|
|
1853
2721
|
}
|
|
1854
2722
|
return out;
|
|
1855
2723
|
}
|
|
@@ -1877,12 +2745,22 @@ export function checkWorkWithoutStory(cfg, proj) {
|
|
|
1877
2745
|
const fms = [];
|
|
1878
2746
|
let unreadable = 0;
|
|
1879
2747
|
for (const f of files) {
|
|
1880
|
-
try {
|
|
2748
|
+
try {
|
|
2749
|
+
fms.push(parseFrontmatter(readFileSync(f, "utf8")).data);
|
|
2750
|
+
} catch {
|
|
2751
|
+
unreadable++;
|
|
2752
|
+
}
|
|
1881
2753
|
}
|
|
1882
2754
|
if (unreadable > 0 && !openStoryFrom(fms)) {
|
|
1883
2755
|
// A diagnostic that goes quiet because it could not read is a false clean.
|
|
1884
|
-
out.push(
|
|
1885
|
-
|
|
2756
|
+
out.push(
|
|
2757
|
+
finding(
|
|
2758
|
+
"vault",
|
|
2759
|
+
"warn",
|
|
2760
|
+
"work-without-story",
|
|
2761
|
+
`Could not read ${unreadable} story file(s), so "is any story in progress" is unproven — this check is inconclusive rather than clean.`,
|
|
2762
|
+
),
|
|
2763
|
+
);
|
|
1886
2764
|
return out;
|
|
1887
2765
|
}
|
|
1888
2766
|
if (openStoryFrom(fms)) return out;
|
|
@@ -1905,14 +2783,21 @@ export function checkWorkWithoutStory(cfg, proj) {
|
|
|
1905
2783
|
// kind: null — entry reminders only. The log is shared with the session-name
|
|
1906
2784
|
// offer, whose breadcrumbs would otherwise be reported as reminders delivered.
|
|
1907
2785
|
const fired = readEntryLog(proj, { withinDays: 30, kind: null }).length;
|
|
1908
|
-
const firedNote =
|
|
1909
|
-
|
|
1910
|
-
|
|
2786
|
+
const firedNote =
|
|
2787
|
+
fired > 0
|
|
2788
|
+
? ` An entry reminder fired ${fired} time(s) in the last 30 days on this machine, so the prompt was delivered and the work still went untracked.`
|
|
2789
|
+
: " No entry reminder fired in the last 30 days on this machine (the log is machine-local — .claude/ is gitignored).";
|
|
1911
2790
|
const what = [];
|
|
1912
2791
|
if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
|
|
1913
2792
|
if (committedSince) what.push("commits newer than the vault's last activity");
|
|
1914
|
-
out.push(
|
|
1915
|
-
|
|
2793
|
+
out.push(
|
|
2794
|
+
finding(
|
|
2795
|
+
"vault",
|
|
2796
|
+
"warn",
|
|
2797
|
+
"work-without-story",
|
|
2798
|
+
`${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault: ${commandForm("story", { args: '<EPIC> "<title>"' })}.${firedNote}`,
|
|
2799
|
+
),
|
|
2800
|
+
);
|
|
1916
2801
|
return out;
|
|
1917
2802
|
}
|
|
1918
2803
|
|
|
@@ -1930,8 +2815,15 @@ export function checkCodeRefs(artifacts, proj) {
|
|
|
1930
2815
|
for (const ref of refs) {
|
|
1931
2816
|
if (ref.includes("*")) continue;
|
|
1932
2817
|
if (!existsSync(join(proj, ref))) {
|
|
1933
|
-
out.push(
|
|
1934
|
-
|
|
2818
|
+
out.push(
|
|
2819
|
+
finding(
|
|
2820
|
+
"vault",
|
|
2821
|
+
"issue",
|
|
2822
|
+
"code-refs",
|
|
2823
|
+
`code_refs path "${ref}" does not resolve inside the project (status: ${status}).`,
|
|
2824
|
+
a.rel,
|
|
2825
|
+
),
|
|
2826
|
+
);
|
|
1935
2827
|
}
|
|
1936
2828
|
}
|
|
1937
2829
|
}
|
|
@@ -1939,14 +2831,28 @@ export function checkCodeRefs(artifacts, proj) {
|
|
|
1939
2831
|
const dir = a.rel.replace(/\/stories\/[^/]+$/, "");
|
|
1940
2832
|
const parent = epicRefs.get(dir) || [];
|
|
1941
2833
|
if (!parent.length) {
|
|
1942
|
-
out.push(
|
|
1943
|
-
|
|
2834
|
+
out.push(
|
|
2835
|
+
finding(
|
|
2836
|
+
"vault",
|
|
2837
|
+
"warn",
|
|
2838
|
+
"code-refs",
|
|
2839
|
+
"Story has code_refs but its epic has none — set the epic's footprint first.",
|
|
2840
|
+
a.rel,
|
|
2841
|
+
),
|
|
2842
|
+
);
|
|
1944
2843
|
} else {
|
|
1945
2844
|
const norm = (r) => r.replace(/\/+$/, "");
|
|
1946
2845
|
for (const ref of refs) {
|
|
1947
2846
|
if (!parent.some((p) => norm(ref).startsWith(norm(p)))) {
|
|
1948
|
-
out.push(
|
|
1949
|
-
|
|
2847
|
+
out.push(
|
|
2848
|
+
finding(
|
|
2849
|
+
"vault",
|
|
2850
|
+
"warn",
|
|
2851
|
+
"code-refs",
|
|
2852
|
+
`Story code_ref "${ref}" falls outside the parent epic's code_refs.`,
|
|
2853
|
+
a.rel,
|
|
2854
|
+
),
|
|
2855
|
+
);
|
|
1950
2856
|
}
|
|
1951
2857
|
}
|
|
1952
2858
|
}
|
|
@@ -1967,13 +2873,27 @@ export function checkCodeMap(cfg) {
|
|
|
1967
2873
|
});
|
|
1968
2874
|
if (r.status !== 0) return [finding("vault", "warn", "code-map", "codemap generator failed.")];
|
|
1969
2875
|
let expected;
|
|
1970
|
-
try {
|
|
2876
|
+
try {
|
|
2877
|
+
expected = JSON.parse(r.stdout).content;
|
|
2878
|
+
} catch {
|
|
1971
2879
|
return [finding("vault", "warn", "code-map", "codemap generator returned unparseable output.")];
|
|
1972
2880
|
}
|
|
1973
|
-
const norm = (s) =>
|
|
2881
|
+
const norm = (s) =>
|
|
2882
|
+
s
|
|
2883
|
+
.split("\n")
|
|
2884
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
2885
|
+
.join("\n")
|
|
2886
|
+
.trimEnd();
|
|
1974
2887
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
1975
|
-
return [
|
|
1976
|
-
|
|
2888
|
+
return [
|
|
2889
|
+
finding(
|
|
2890
|
+
"vault",
|
|
2891
|
+
"issue",
|
|
2892
|
+
"code-map",
|
|
2893
|
+
`code-map.md is stale against frontmatter code_refs — run ${commandForm("codemap")} (or reconcile).`,
|
|
2894
|
+
"code-map.md",
|
|
2895
|
+
),
|
|
2896
|
+
];
|
|
1977
2897
|
}
|
|
1978
2898
|
return [];
|
|
1979
2899
|
}
|
|
@@ -1986,7 +2906,9 @@ export function checkCodeMap(cfg) {
|
|
|
1986
2906
|
export function checkGraph(cfg) {
|
|
1987
2907
|
const p = join(cfg.vault_path, "graph.md");
|
|
1988
2908
|
if (!existsSync(p)) {
|
|
1989
|
-
return [
|
|
2909
|
+
return [
|
|
2910
|
+
finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`),
|
|
2911
|
+
];
|
|
1990
2912
|
}
|
|
1991
2913
|
const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
|
|
1992
2914
|
encoding: "utf8",
|
|
@@ -1995,13 +2917,27 @@ export function checkGraph(cfg) {
|
|
|
1995
2917
|
});
|
|
1996
2918
|
if (r.status !== 0) return [finding("vault", "warn", "graph", `graph generator failed: ${(r.stderr || "").trim()}`)];
|
|
1997
2919
|
let expected;
|
|
1998
|
-
try {
|
|
2920
|
+
try {
|
|
2921
|
+
expected = JSON.parse(r.stdout).content;
|
|
2922
|
+
} catch {
|
|
1999
2923
|
return [finding("vault", "warn", "graph", "graph generator returned unparseable output.")];
|
|
2000
2924
|
}
|
|
2001
|
-
const norm = (s) =>
|
|
2925
|
+
const norm = (s) =>
|
|
2926
|
+
s
|
|
2927
|
+
.split("\n")
|
|
2928
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
2929
|
+
.join("\n")
|
|
2930
|
+
.trimEnd();
|
|
2002
2931
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
2003
|
-
return [
|
|
2004
|
-
|
|
2932
|
+
return [
|
|
2933
|
+
finding(
|
|
2934
|
+
"vault",
|
|
2935
|
+
"issue",
|
|
2936
|
+
"graph",
|
|
2937
|
+
`graph.md is out of sync with vault links — run ${commandForm("graph")} (or reconcile).`,
|
|
2938
|
+
"graph.md",
|
|
2939
|
+
),
|
|
2940
|
+
];
|
|
2005
2941
|
}
|
|
2006
2942
|
return [];
|
|
2007
2943
|
}
|
|
@@ -2030,16 +2966,20 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
2030
2966
|
...checkOverlays(cfg, proj, opts),
|
|
2031
2967
|
);
|
|
2032
2968
|
let read = null;
|
|
2033
|
-
try {
|
|
2034
|
-
|
|
2035
|
-
|
|
2969
|
+
try {
|
|
2970
|
+
read = await readSurfaceStates(proj, opts);
|
|
2971
|
+
} catch {} // reported as a warn by checkHarnessSurfaces
|
|
2972
|
+
out.push(...(await checkHarnessSurfaces(cfg, proj, { ...opts, read })));
|
|
2973
|
+
if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
|
|
2036
2974
|
if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
|
|
2037
2975
|
return foldIntoMove(out);
|
|
2038
2976
|
}
|
|
2039
2977
|
|
|
2040
2978
|
export function runVaultChecks(cfg) {
|
|
2041
2979
|
let layout;
|
|
2042
|
-
try {
|
|
2980
|
+
try {
|
|
2981
|
+
layout = loadLayout(cfg.layout);
|
|
2982
|
+
} catch (e) {
|
|
2043
2983
|
return [finding("vault", "issue", "layout", `Layout not loadable: ${e.message}`)];
|
|
2044
2984
|
}
|
|
2045
2985
|
const artifacts = scanArtifacts(cfg, layout);
|
|
@@ -2058,7 +2998,9 @@ export function runVaultChecks(cfg) {
|
|
|
2058
2998
|
() => checkLifecycleGates(artifacts, vaultCfg),
|
|
2059
2999
|
];
|
|
2060
3000
|
for (const step of guarded) {
|
|
2061
|
-
try {
|
|
3001
|
+
try {
|
|
3002
|
+
findings.push(...step());
|
|
3003
|
+
} catch (e) {
|
|
2062
3004
|
findings.push(finding("vault", "issue", "registry", e.message));
|
|
2063
3005
|
break;
|
|
2064
3006
|
}
|
|
@@ -2082,6 +3024,7 @@ export function runVaultChecks(cfg) {
|
|
|
2082
3024
|
// Decision 4). Aborts past the budget rather than reporting a false "clean".
|
|
2083
3025
|
export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
2084
3026
|
const started = Date.now();
|
|
3027
|
+
const findings = [];
|
|
2085
3028
|
const steps = [
|
|
2086
3029
|
() => checkConfig(cfg, proj),
|
|
2087
3030
|
() => (cfg && cfg.vault_path ? checkVaultPath(cfg) : []),
|
|
@@ -2090,15 +3033,22 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2090
3033
|
() => checkAgentsBlock(proj),
|
|
2091
3034
|
() => checkGitignore(proj),
|
|
2092
3035
|
// The layout move is offered from the startup line (info here, warn in the
|
|
2093
|
-
// report) — a user who never runs doctor still learns to migrate.
|
|
2094
|
-
|
|
3036
|
+
// report) — a user who never runs doctor still learns to migrate. A
|
|
3037
|
+
// pending re-stamp, found by the step above, is covered by the move: its
|
|
3038
|
+
// sentence goes before the move's command (below).
|
|
3039
|
+
() =>
|
|
3040
|
+
checkLayout(proj, undefined, {
|
|
3041
|
+
level: "info",
|
|
3042
|
+
restamp: findings.some((f) => f.level === "info" && f.check === "upgrade"),
|
|
3043
|
+
}),
|
|
2095
3044
|
() => checkEnvModel(),
|
|
2096
3045
|
() => checkEnvEffort(),
|
|
2097
3046
|
];
|
|
2098
|
-
const findings = [];
|
|
2099
3047
|
for (const step of steps) {
|
|
2100
3048
|
if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
|
|
2101
|
-
try {
|
|
3049
|
+
try {
|
|
3050
|
+
findings.push(...step());
|
|
3051
|
+
} catch {}
|
|
2102
3052
|
}
|
|
2103
3053
|
// The findings the move repairs are not counted beside it (foldIntoMove).
|
|
2104
3054
|
const settled = foldIntoMove(findings);
|
|
@@ -2106,12 +3056,13 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2106
3056
|
// re-stamps the launcher at its new path (the layout spec, contract 7 as
|
|
2107
3057
|
// amended 2026-10-03), and an in-session `doctor --fix` would write that
|
|
2108
3058
|
// launcher and then stop at the deferred move with exit 1. So the line names
|
|
2109
|
-
// one step, and says what it covers when the dropped offer would have fired
|
|
3059
|
+
// one step, and says what it covers when the dropped offer would have fired:
|
|
3060
|
+
// checkLayout's `restamp`, a sentence before the move's command, so the
|
|
3061
|
+
// command still ends the offer, alone on its line.
|
|
2110
3062
|
const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
|
|
2111
|
-
const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
|
|
2112
3063
|
const offers = settled
|
|
2113
3064
|
.filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
|
|
2114
|
-
.map((f) =>
|
|
3065
|
+
.map((f) => f.message);
|
|
2115
3066
|
return {
|
|
2116
3067
|
skipped: false,
|
|
2117
3068
|
count: settled.filter((f) => f.level === "issue").length,
|
|
@@ -2122,34 +3073,16 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2122
3073
|
|
|
2123
3074
|
// ─── CLI ───────────────────────────────────────────────────────────────
|
|
2124
3075
|
|
|
2125
|
-
function icon(level) {
|
|
2126
|
-
return level === "issue" ? "✖" : level === "warn" ? "⚠" : "ℹ";
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
function report(findings, groups) {
|
|
2130
|
-
const ver = pluginVersion();
|
|
2131
|
-
const lines = [`projectstore doctor — plugin v${ver || "?"}, ${new Date().toISOString().slice(0, 10)}`];
|
|
2132
|
-
for (const g of groups) {
|
|
2133
|
-
const fs = findings.filter((f) => f.group === g);
|
|
2134
|
-
lines.push("", `## ${g} (${fs.filter((f) => f.level === "issue").length} issue(s), ${fs.filter((f) => f.level === "warn").length} warning(s))`);
|
|
2135
|
-
if (!fs.length) lines.push(" ✓ clean");
|
|
2136
|
-
for (const f of fs) {
|
|
2137
|
-
lines.push(` ${icon(f.level)} [${f.check}] ${f.message}${f.file ? ` — ${f.file}` : ""}`);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
const issues = findings.filter((f) => f.level === "issue").length;
|
|
2141
|
-
const warns = findings.filter((f) => f.level === "warn").length;
|
|
2142
|
-
lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
|
|
2143
|
-
return lines.join("\n");
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
3076
|
async function main() {
|
|
2147
3077
|
const args = process.argv.slice(2);
|
|
2148
3078
|
const wantJson = args.includes("--json");
|
|
2149
3079
|
const startup = args.includes("--startup");
|
|
2150
3080
|
let install = args.includes("--install");
|
|
2151
3081
|
let vault = args.includes("--vault");
|
|
2152
|
-
if (!install && !vault && !startup) {
|
|
3082
|
+
if (!install && !vault && !startup) {
|
|
3083
|
+
install = true;
|
|
3084
|
+
vault = true;
|
|
3085
|
+
}
|
|
2153
3086
|
|
|
2154
3087
|
const cfg = readConfig();
|
|
2155
3088
|
const proj = projectRoot();
|
|
@@ -2162,7 +3095,10 @@ async function main() {
|
|
|
2162
3095
|
|
|
2163
3096
|
const findings = [];
|
|
2164
3097
|
const groups = [];
|
|
2165
|
-
if (install) {
|
|
3098
|
+
if (install) {
|
|
3099
|
+
groups.push("install");
|
|
3100
|
+
findings.push(...(await runInstallChecks(cfg, proj)));
|
|
3101
|
+
}
|
|
2166
3102
|
if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
|
|
2167
3103
|
groups.push("vault");
|
|
2168
3104
|
findings.push(...runVaultChecks(cfg));
|
|
@@ -2171,7 +3107,35 @@ async function main() {
|
|
|
2171
3107
|
findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
|
|
2172
3108
|
}
|
|
2173
3109
|
|
|
2174
|
-
|
|
3110
|
+
if (wantJson) {
|
|
3111
|
+
process.stdout.write(JSON.stringify(findings, null, 2) + "\n");
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
// The text is the bin's renderer's, in its plain layout (the presentation
|
|
3118
|
+
// spec, contract 11) — one report, whoever prints it. Imported at run time,
|
|
3119
|
+
// and nowhere else in this file: the SessionStart hook imports this module
|
|
3120
|
+
// statically, and the renderer's term.mjs is not on its path. A renderer
|
|
3121
|
+
// that cannot load, or throws, costs the reader nothing: the findings one
|
|
3122
|
+
// per line and the Summary line, still last (lib.mjs). `load` is a test's.
|
|
3123
|
+
export async function textReport(
|
|
3124
|
+
findings,
|
|
3125
|
+
groups,
|
|
3126
|
+
{ version = null, project = null, env = process.env, stderr = process.stderr } = {},
|
|
3127
|
+
{ load = () => import("./doctor-report.mjs") } = {},
|
|
3128
|
+
) {
|
|
3129
|
+
try {
|
|
3130
|
+
const { report } = await load();
|
|
3131
|
+
return report(findings, groups, { version, project, env });
|
|
3132
|
+
} catch (e) {
|
|
3133
|
+
if (stderr)
|
|
3134
|
+
stderr.write(
|
|
3135
|
+
`doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`,
|
|
3136
|
+
);
|
|
3137
|
+
return doctorFallbackText(findings, { env });
|
|
3138
|
+
}
|
|
2175
3139
|
}
|
|
2176
3140
|
|
|
2177
3141
|
if (isMain(import.meta.url)) {
|