projectstore-claude 0.29.3 → 0.30.0
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 +13 -12
- package/bin/projectstore-claude.mjs +2 -2
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +153 -77
- package/node_modules/projectstore/commands/bind.md +6 -17
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/extending.md +11 -6
- package/node_modules/projectstore/docs/getting-started.md +21 -1
- package/node_modules/projectstore/docs/harnesses.md +13 -10
- package/node_modules/projectstore/docs/how-it-works.md +6 -4
- package/node_modules/projectstore/harnesses/claude-code.json +16 -15
- package/node_modules/projectstore/harnesses/codex.json +12 -13
- package/node_modules/projectstore/hooks/band.tsx +48 -20
- 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 +50 -18
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +11 -2
- package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
- package/node_modules/projectstore/scripts/band.mjs +72 -21
- package/node_modules/projectstore/scripts/binding.mjs +616 -85
- package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
- package/node_modules/projectstore/scripts/cli.mjs +1015 -185
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +253 -83
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
- package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +123 -48
- package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
- package/node_modules/projectstore/scripts/kanban.mjs +2 -1
- package/node_modules/projectstore/scripts/lib.mjs +694 -159
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +378 -80
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +62 -17
- package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +5 -1
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +32 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
- package/node_modules/projectstore/scripts/term.mjs +382 -97
- 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 +74 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/package.json +3 -3
|
@@ -17,15 +17,7 @@
|
|
|
17
17
|
// Without --json the findings are drawn by doctor-report.mjs, the
|
|
18
18
|
// renderer the bin uses, in its plain layout.
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
existsSync,
|
|
22
|
-
readFileSync,
|
|
23
|
-
readdirSync,
|
|
24
|
-
statSync,
|
|
25
|
-
accessSync,
|
|
26
|
-
constants,
|
|
27
|
-
realpathSync,
|
|
28
|
-
} from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, realpathSync } from "node:fs";
|
|
29
21
|
import { join, basename, resolve, dirname, relative } from "node:path";
|
|
30
22
|
import { homedir } from "node:os";
|
|
31
23
|
import { spawnSync } from "node:child_process";
|
|
@@ -45,7 +37,6 @@ import {
|
|
|
45
37
|
statusLineIsOurWiring,
|
|
46
38
|
isLegacyStory,
|
|
47
39
|
sectionOf,
|
|
48
|
-
headingLineRe,
|
|
49
40
|
indexHeaderRe,
|
|
50
41
|
evidenceSuffixRe,
|
|
51
42
|
storiesAttributionRe,
|
|
@@ -68,17 +59,23 @@ import {
|
|
|
68
59
|
findAgentsBlock,
|
|
69
60
|
statusLineLauncherPath,
|
|
70
61
|
LAUNCHER_HEADER,
|
|
71
|
-
installedPluginEntries,
|
|
62
|
+
installedPluginEntries,
|
|
63
|
+
selectCopy,
|
|
64
|
+
takesNoRegister,
|
|
65
|
+
copyUpdateAdvice,
|
|
66
|
+
isMain,
|
|
72
67
|
channelMark,
|
|
73
68
|
pickExisting,
|
|
74
69
|
legacyStatusLineLauncherPath,
|
|
75
70
|
stateDir,
|
|
76
71
|
legacyStateDir,
|
|
77
|
-
layoutPaths,
|
|
72
|
+
layoutPaths,
|
|
73
|
+
sessionsDir,
|
|
78
74
|
isLauncherPath,
|
|
79
75
|
LAYOUT,
|
|
80
76
|
hostSettingsPath,
|
|
81
|
-
readOverlayAt,
|
|
77
|
+
readOverlayAt,
|
|
78
|
+
layoutRoster,
|
|
82
79
|
installChannel,
|
|
83
80
|
cmpPrecedence,
|
|
84
81
|
cmpVersion,
|
|
@@ -89,8 +86,19 @@ import {
|
|
|
89
86
|
inheritForm,
|
|
90
87
|
bindInherits,
|
|
91
88
|
doctorFallbackText,
|
|
89
|
+
fromTerminalIn,
|
|
92
90
|
} from "./lib.mjs";
|
|
93
|
-
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";
|
|
94
102
|
|
|
95
103
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
96
104
|
// measured 2026-09-06, NO harness gives its Bash tool that variable, and a
|
|
@@ -167,9 +175,7 @@ export function aboutHarness(f, h, speaker = speakingHarness()) {
|
|
|
167
175
|
|
|
168
176
|
function pluginVersion(root = pluginRoot()) {
|
|
169
177
|
try {
|
|
170
|
-
return JSON.parse(
|
|
171
|
-
readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"),
|
|
172
|
-
).version;
|
|
178
|
+
return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
173
179
|
} catch {
|
|
174
180
|
return null;
|
|
175
181
|
}
|
|
@@ -196,19 +202,43 @@ export function checkConfig(cfg, proj = projectRoot()) {
|
|
|
196
202
|
// new one. Assigned rather than appended: two instructions for one problem
|
|
197
203
|
// is how a person ends up binding a second vault by hand.
|
|
198
204
|
let b = null;
|
|
199
|
-
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).
|
|
200
211
|
if (b && b.state === "inheritable") {
|
|
201
|
-
return [
|
|
202
|
-
|
|
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
|
+
];
|
|
203
220
|
}
|
|
204
221
|
const p = layoutPaths(proj);
|
|
205
222
|
const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
|
|
206
223
|
if (present) {
|
|
207
|
-
return [
|
|
208
|
-
|
|
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
|
+
];
|
|
209
233
|
}
|
|
210
|
-
return [
|
|
211
|
-
|
|
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
|
+
];
|
|
212
242
|
}
|
|
213
243
|
const out = [];
|
|
214
244
|
if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
|
|
@@ -272,8 +302,14 @@ export function checkLayoutTemplates(cfg) {
|
|
|
272
302
|
out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
|
|
273
303
|
}
|
|
274
304
|
if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
|
|
275
|
-
out.push(
|
|
276
|
-
|
|
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
|
+
);
|
|
277
313
|
}
|
|
278
314
|
return out;
|
|
279
315
|
}
|
|
@@ -281,16 +317,34 @@ export function checkLayoutTemplates(cfg) {
|
|
|
281
317
|
export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
|
|
282
318
|
const dir = sessionsDir(cfg.vault_path);
|
|
283
319
|
if (!existsSync(dir)) {
|
|
284
|
-
return [
|
|
285
|
-
|
|
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
|
+
];
|
|
286
328
|
}
|
|
287
329
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
288
330
|
const fresh = readdirSync(dir).some((n) => {
|
|
289
331
|
if (!n.endsWith(".json")) return false;
|
|
290
|
-
try {
|
|
332
|
+
try {
|
|
333
|
+
return statSync(join(dir, n)).mtimeMs >= cutoff;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
291
337
|
});
|
|
292
|
-
return fresh
|
|
293
|
-
|
|
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
|
+
];
|
|
294
348
|
}
|
|
295
349
|
|
|
296
350
|
// Which plugin version a wired statusLine script belongs to. Null for our
|
|
@@ -299,9 +353,7 @@ export function checkHooksAlive(cfg, maxAgeMinutes = 30) {
|
|
|
299
353
|
export function statusLineScriptVersion(scriptPath) {
|
|
300
354
|
try {
|
|
301
355
|
const root = dirname(dirname(scriptPath)); // <root>/scripts/statusline.mjs
|
|
302
|
-
return (
|
|
303
|
-
JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null
|
|
304
|
-
);
|
|
356
|
+
return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version || null;
|
|
305
357
|
} catch {
|
|
306
358
|
return null;
|
|
307
359
|
}
|
|
@@ -331,20 +383,38 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
331
383
|
|
|
332
384
|
if (st && st.enabled === true) {
|
|
333
385
|
if (!curCmd) {
|
|
334
|
-
out.push(
|
|
335
|
-
|
|
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
|
+
);
|
|
336
394
|
} else if (!isOurs) {
|
|
337
|
-
out.push(
|
|
338
|
-
|
|
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
|
+
);
|
|
339
403
|
} else {
|
|
340
404
|
const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
|
|
341
405
|
const wiredRoot = m ? dirname(dirname(m[1])) : null;
|
|
342
406
|
const isLauncher = m ? isLauncherPath(m[1]) : false;
|
|
343
407
|
if (m && !existsSync(m[1])) {
|
|
344
|
-
out.push(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
);
|
|
348
418
|
} else if (m && isPluginCacheRoot(wiredRoot, home)) {
|
|
349
419
|
// Only a versioned cache path can go stale this way. The launcher
|
|
350
420
|
// carries no version, and a dev checkout is wired deliberately and
|
|
@@ -352,23 +422,48 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
352
422
|
const wired = statusLineScriptVersion(m[1]);
|
|
353
423
|
const inst = installedPluginRoot(home, dirname(wiredRoot));
|
|
354
424
|
if (wired && inst && inst.version && wired !== inst.version) {
|
|
355
|
-
out.push(
|
|
356
|
-
|
|
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
|
+
);
|
|
357
433
|
}
|
|
358
434
|
}
|
|
359
435
|
}
|
|
360
436
|
} else if (st && st.enabled === false && isOurs) {
|
|
361
|
-
out.push(
|
|
362
|
-
|
|
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
|
+
);
|
|
363
445
|
} else if ((!st || typeof st.enabled !== "boolean") && isOurs) {
|
|
364
|
-
out.push(
|
|
365
|
-
|
|
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
|
+
);
|
|
366
454
|
}
|
|
367
455
|
|
|
368
456
|
try {
|
|
369
457
|
const base = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"))?.statusLine?.command;
|
|
370
458
|
if (base && !statusLineIsOurs(base)) {
|
|
371
|
-
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
|
+
);
|
|
372
467
|
}
|
|
373
468
|
} catch {}
|
|
374
469
|
// session_id divergence (ADR-006): the renderer's breadcrumb names the id
|
|
@@ -383,8 +478,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
383
478
|
.filter((n) => n.endsWith(".json") && !n.startsWith("."))
|
|
384
479
|
.map((n) => n.replace(/\.json$/, ""));
|
|
385
480
|
if (hookIds.length && !hookIds.includes(bc.session_id)) {
|
|
386
|
-
out.push(
|
|
387
|
-
|
|
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
|
+
);
|
|
388
489
|
}
|
|
389
490
|
}
|
|
390
491
|
// What the user is actually looking at: the renderer stamps the version
|
|
@@ -393,8 +494,14 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
393
494
|
if (bc && bc.version && isPluginCacheRoot(bc.root, home)) {
|
|
394
495
|
const inst = installedPluginRoot(home, dirname(bc.root));
|
|
395
496
|
if (inst && inst.version && inst.version !== bc.version) {
|
|
396
|
-
out.push(
|
|
397
|
-
|
|
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
|
+
);
|
|
398
505
|
}
|
|
399
506
|
}
|
|
400
507
|
} catch {}
|
|
@@ -416,15 +523,27 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
|
|
|
416
523
|
// session with a command that cannot clear it (the critic of the layout
|
|
417
524
|
// spec's 2026-10-03 amendment, case S1).
|
|
418
525
|
let wired = null;
|
|
419
|
-
try {
|
|
526
|
+
try {
|
|
527
|
+
wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
|
|
528
|
+
} catch {}
|
|
420
529
|
if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
|
|
421
530
|
const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
|
|
422
531
|
let text;
|
|
423
|
-
try {
|
|
532
|
+
try {
|
|
533
|
+
text = readFileSync(lp, "utf8");
|
|
534
|
+
} catch {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
424
537
|
if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
|
|
425
|
-
return [
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
];
|
|
428
547
|
}
|
|
429
548
|
|
|
430
549
|
// The two findings the layout move itself repairs carry what they say without
|
|
@@ -449,7 +568,11 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
449
568
|
const p = join(proj, name);
|
|
450
569
|
if (!existsSync(p)) continue;
|
|
451
570
|
let text;
|
|
452
|
-
try {
|
|
571
|
+
try {
|
|
572
|
+
text = readFileSync(p, "utf8");
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
453
576
|
texts[name] = text;
|
|
454
577
|
const f = findAgentsBlock(text);
|
|
455
578
|
if (!f) continue;
|
|
@@ -457,16 +580,30 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
457
580
|
blocks += f.count;
|
|
458
581
|
if (f.wrapped) {
|
|
459
582
|
wrappedFiles++;
|
|
460
|
-
out.push(
|
|
461
|
-
|
|
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
|
+
);
|
|
462
592
|
continue;
|
|
463
593
|
}
|
|
464
594
|
if (f.unclosed) {
|
|
465
595
|
// Named here as the wrapped marker is, so the startup count carries it:
|
|
466
596
|
// the agents-block plan refuses it, and the layout move with it.
|
|
467
597
|
unclosedFiles++;
|
|
468
|
-
out.push(
|
|
469
|
-
|
|
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
|
+
);
|
|
470
607
|
}
|
|
471
608
|
for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
|
|
472
609
|
const v = parseInt(m[1], 10);
|
|
@@ -474,8 +611,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
474
611
|
}
|
|
475
612
|
}
|
|
476
613
|
if (blocks === 0) {
|
|
477
|
-
out.push(
|
|
478
|
-
|
|
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
|
+
);
|
|
479
622
|
}
|
|
480
623
|
if (blocks > 1) {
|
|
481
624
|
// One block in each file is a state install resolves (it keeps the
|
|
@@ -484,13 +627,26 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
484
627
|
// advice is withheld while one is wrapped or never closes.
|
|
485
628
|
const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
|
|
486
629
|
if (twiceInOne) {
|
|
487
|
-
out.push(
|
|
488
|
-
|
|
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
|
+
);
|
|
489
639
|
} else if (wrappedFiles || unclosedFiles) {
|
|
490
640
|
// already named above, file by file
|
|
491
641
|
} else {
|
|
492
|
-
out.push(
|
|
493
|
-
|
|
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
|
+
);
|
|
494
650
|
}
|
|
495
651
|
}
|
|
496
652
|
// A state the agents-block plan refuses — a wrapped marker, a block that
|
|
@@ -499,7 +655,13 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
499
655
|
const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
|
|
500
656
|
for (const s of staleVersions) {
|
|
501
657
|
const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
|
|
502
|
-
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
|
+
);
|
|
503
665
|
out.push(refuses ? f : moveRepairs(f, fact));
|
|
504
666
|
}
|
|
505
667
|
// Placement, held to the predicate install plans from (the install spec,
|
|
@@ -516,7 +678,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
516
678
|
const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
|
|
517
679
|
if (blockFile) {
|
|
518
680
|
const identified = identifiedHarnessId(env);
|
|
519
|
-
const used = new Set([
|
|
681
|
+
const used = new Set([
|
|
682
|
+
...detectHarnesses(proj, { state: true }).map((d) => d.id),
|
|
683
|
+
...(identified ? [identified] : []),
|
|
684
|
+
]);
|
|
520
685
|
for (const id of used) {
|
|
521
686
|
const m = loadHarness(id);
|
|
522
687
|
if (!m || blockVisibleTo(m, blockFile, texts)) continue;
|
|
@@ -525,9 +690,16 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
525
690
|
? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
|
|
526
691
|
: `it reads ${(ab.files || []).join(" and ")} only`;
|
|
527
692
|
const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
|
|
528
|
-
// The resolved-root form the surface remedy uses: the running copy's own
|
|
529
|
-
|
|
530
|
-
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
|
+
);
|
|
531
703
|
// The layout's harness is the one the move's command installs for, so the
|
|
532
704
|
// move plans this import; another harness's is not the move's to repair.
|
|
533
705
|
out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
|
|
@@ -547,10 +719,14 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
547
719
|
// finding leaves here carrying it.
|
|
548
720
|
export function foldIntoMove(findings) {
|
|
549
721
|
const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
|
|
550
|
-
const step =
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
+
);
|
|
554
730
|
}
|
|
555
731
|
|
|
556
732
|
// Both scopes are walked. The original reason ("project > user > plugin, so a
|
|
@@ -580,24 +756,44 @@ export function foldIntoMove(findings) {
|
|
|
580
756
|
// surfaces.mjs needs — out of the SessionStart module graph. The startup
|
|
581
757
|
// checks never call this.
|
|
582
758
|
// Read the states once per doctor run; both checks below consume the result.
|
|
583
|
-
export async function readSurfaceStates(
|
|
759
|
+
export async function readSurfaceStates(
|
|
760
|
+
proj,
|
|
761
|
+
{ home = homedir(), root = pluginRoot(), manifestDir = undefined, env = process.env } = {},
|
|
762
|
+
) {
|
|
584
763
|
const { surfaceStates, FOREIGN_TEXT } = await import("./surfaces.mjs");
|
|
585
764
|
return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
|
|
586
765
|
}
|
|
587
766
|
|
|
588
|
-
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
|
+
) {
|
|
589
779
|
const out = [];
|
|
590
780
|
let r, FOREIGN_TEXT;
|
|
591
781
|
try {
|
|
592
|
-
({ 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 })));
|
|
593
783
|
} catch (e) {
|
|
594
784
|
return [finding("install", "warn", "surface", `Installed-surface states could not be read: ${e && e.message}`)];
|
|
595
785
|
}
|
|
596
786
|
// Contract 16: a harness is reported only when the project uses it; when
|
|
597
787
|
// none does and nothing of ours is installed, one line names what can be.
|
|
598
788
|
if (!r.used.length) {
|
|
599
|
-
out.push(
|
|
600
|
-
|
|
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
|
+
);
|
|
601
797
|
return out;
|
|
602
798
|
}
|
|
603
799
|
for (const s of r.states) {
|
|
@@ -607,15 +803,35 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
607
803
|
// steps in its forms, and no status-line hint where it has no status line.
|
|
608
804
|
// A shared surface — the agents block, one file every harness owns — is
|
|
609
805
|
// every harness's: never marked, its step in the listener's form.
|
|
610
|
-
const sh = s.kind === "shared" ? speaker :
|
|
806
|
+
const sh = s.kind === "shared" ? speaker : loadHarness(s.harness) || speaker;
|
|
611
807
|
const push = (f) => out.push(aboutHarness(f, sh, speaker));
|
|
612
808
|
if (s.kind === "exclusive") {
|
|
613
809
|
if (s.state === "foreign") {
|
|
614
|
-
push(
|
|
615
|
-
|
|
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
|
+
);
|
|
616
819
|
} else if (s.state === "stale" && s.produced) {
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
);
|
|
619
835
|
} else if (s.state === "stale" && !s.produced) {
|
|
620
836
|
push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
|
|
621
837
|
} else if (s.state === "current" && s.writtenBy && !s.sameProject) {
|
|
@@ -630,7 +846,15 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
630
846
|
if (s.state === "unparseable") {
|
|
631
847
|
push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
|
|
632
848
|
} else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
|
|
633
|
-
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
|
+
);
|
|
634
858
|
}
|
|
635
859
|
}
|
|
636
860
|
// The statusline entry's states are checkStatusline's, under its own id.
|
|
@@ -651,8 +875,26 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
651
875
|
const out = [];
|
|
652
876
|
const o = readOverlayAt(proj);
|
|
653
877
|
const where = relative(proj, o.path);
|
|
654
|
-
if (o.unparseable)
|
|
655
|
-
|
|
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
|
+
);
|
|
656
898
|
if (cfg && cfg.agents && typeof cfg.agents === "object") {
|
|
657
899
|
// The binding that carries the block is the one the reader found — before
|
|
658
900
|
// the migration that is the legacy path, and naming the new one would send
|
|
@@ -664,7 +906,19 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
664
906
|
const remedy = layoutRemedy(proj, { root, home });
|
|
665
907
|
// The block and its move are the source harness's — a pre-0.28 binding
|
|
666
908
|
// and that harness's command — so its fact under any other listener.
|
|
667
|
-
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
|
+
);
|
|
668
922
|
}
|
|
669
923
|
// A project can be used from more than one harness, and a model name is
|
|
670
924
|
// harness-specific (ADR-008) — so each one has its own overlay and they do
|
|
@@ -684,12 +938,18 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
684
938
|
if (configured.length) {
|
|
685
939
|
for (const { id, o } of overlays) {
|
|
686
940
|
if (o.present) continue;
|
|
687
|
-
out.push(
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
+
);
|
|
693
953
|
}
|
|
694
954
|
}
|
|
695
955
|
}
|
|
@@ -699,7 +959,16 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
699
959
|
const roster = layoutRoster(cfg);
|
|
700
960
|
if (roster) {
|
|
701
961
|
for (const n of Object.keys(o.agents.per_agent)) {
|
|
702
|
-
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
|
+
);
|
|
703
972
|
}
|
|
704
973
|
}
|
|
705
974
|
return out;
|
|
@@ -708,25 +977,39 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
708
977
|
// Contract 4′ (2026-09-05): the registration the package made through the
|
|
709
978
|
// host's CLI — one row per registration surface the manifest declares. Current
|
|
710
979
|
// → one info; stale → an issue naming the refresh in the form that can make
|
|
711
|
-
// it (the package's
|
|
712
|
-
// itself); a competing copy enabled beside ours → an issue (two
|
|
713
|
-
// of one plugin); a competitor alone → an info naming the npm
|
|
714
|
-
// never repairable; the host CLI missing → an info.
|
|
715
|
-
|
|
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
|
+
) {
|
|
716
999
|
const out = [];
|
|
717
1000
|
// A checkout whose binding records a channel switch (the converge story's
|
|
718
1001
|
// rule 3) chose the npm install: the info that ends "If moving to npm was
|
|
719
|
-
// meant,
|
|
1002
|
+
// meant, record it…" is the one thing it no longer needs. Doctor never
|
|
720
1003
|
// reads who made the mark.
|
|
721
1004
|
const marked = Boolean(channelMark(cfg));
|
|
722
1005
|
for (const s of states.filter((x) => x.kind === "registration")) {
|
|
723
|
-
// The
|
|
724
|
-
// command a
|
|
1006
|
+
// The core's form with this registration's --harness (packageCommand):
|
|
1007
|
+
// the command a person can paste, built in one place.
|
|
725
1008
|
const h = loadHarness(s.harness) || { id: s.harness };
|
|
726
1009
|
// A registration of ANOTHER harness in the same project is that
|
|
727
1010
|
// harness's fact: named, marked, its steps in its own forms (aboutHarness).
|
|
728
1011
|
const push = (f) => out.push(aboutHarness(f, h, speaker));
|
|
729
|
-
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}` });
|
|
730
1013
|
// A copy this registration silenced for the checkout and the checkout
|
|
731
1014
|
// still holds off, one per key (the install spec, contract 13 as amended
|
|
732
1015
|
// after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
|
|
@@ -736,44 +1019,117 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
|
|
|
736
1019
|
if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
|
|
737
1020
|
// The row this checkout loads: its own local-scope row first, then a
|
|
738
1021
|
// user-scope one — never another checkout's (layoutRemedy's rule).
|
|
739
|
-
const real = (x) => {
|
|
1022
|
+
const real = (x) => {
|
|
1023
|
+
try {
|
|
1024
|
+
return realpathSync.native(x);
|
|
1025
|
+
} catch {
|
|
1026
|
+
return resolve(x);
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
740
1029
|
const here = real(proj);
|
|
741
|
-
const rows = installedPluginEntries(home, proj).filter(
|
|
1030
|
+
const rows = installedPluginEntries(home, proj).filter(
|
|
1031
|
+
(e) => e.present && (!e.projectPath || real(e.projectPath) === here),
|
|
1032
|
+
);
|
|
742
1033
|
for (const key of s.silenced) {
|
|
743
|
-
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];
|
|
744
1037
|
if (!row) {
|
|
745
|
-
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
|
+
);
|
|
746
1047
|
continue;
|
|
747
1048
|
}
|
|
748
1049
|
if (marked) continue;
|
|
749
1050
|
// The release line, not the build: a 0.28 release candidate reads a
|
|
750
1051
|
// moved project; 0.27.x reads it as unbound. No version reads as old.
|
|
751
1052
|
const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
|
|
752
|
-
push(
|
|
753
|
-
|
|
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
|
+
);
|
|
754
1069
|
}
|
|
755
1070
|
}
|
|
756
1071
|
const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
|
|
757
1072
|
if (s.state === "foreign") {
|
|
758
|
-
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
|
+
);
|
|
759
1082
|
} else if (s.state === "unavailable") {
|
|
760
|
-
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
|
+
);
|
|
761
1091
|
} else if (s.state === "absent") {
|
|
762
1092
|
// A git-marketplace install alone is not a finding: a permanent info
|
|
763
1093
|
// advertising the npm path to every marketplace user is noise (2026-09-05).
|
|
764
1094
|
} else if (s.state === "stale") {
|
|
765
|
-
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
|
+
);
|
|
766
1104
|
} else if (s.state === "current") {
|
|
767
1105
|
// The host loads a host plugin registration in place, from the
|
|
768
1106
|
// plugin's root inside our marketplace directory (the payload — what
|
|
769
1107
|
// its plugin-root variable names); the install path is the registry's
|
|
770
1108
|
// copy, the one the launcher renders (measured on host 2.1.293 and
|
|
771
1109
|
// 2.1.296). A portable registration's host loads the copy.
|
|
772
|
-
const loaded =
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
+
);
|
|
777
1133
|
}
|
|
778
1134
|
}
|
|
779
1135
|
return out;
|
|
@@ -794,10 +1150,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
794
1150
|
const registryOf = sourceHarness()?.id || null;
|
|
795
1151
|
for (const e of installedPluginEntries(home, proj)) {
|
|
796
1152
|
// A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
|
|
797
|
-
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
|
+
});
|
|
798
1159
|
}
|
|
799
1160
|
for (const s of states) {
|
|
800
|
-
if (s.installedPkg)
|
|
1161
|
+
if (s.installedPkg)
|
|
1162
|
+
seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
|
|
801
1163
|
}
|
|
802
1164
|
const versions = new Set(seen.map((x) => x.version));
|
|
803
1165
|
if (versions.size < 2) return [];
|
|
@@ -807,9 +1169,22 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
807
1169
|
// for copies of several harnesses, the listener — has one.
|
|
808
1170
|
const owners = [...new Set(seen.map((x) => x.harness))];
|
|
809
1171
|
const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
|
|
810
|
-
const launcher =
|
|
811
|
-
|
|
812
|
-
|
|
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
|
+
];
|
|
813
1188
|
}
|
|
814
1189
|
|
|
815
1190
|
export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
|
|
@@ -826,7 +1201,11 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
826
1201
|
for (const { dir, label, scope } of scopes) {
|
|
827
1202
|
for (const f of listMd(dir)) {
|
|
828
1203
|
let text;
|
|
829
|
-
try {
|
|
1204
|
+
try {
|
|
1205
|
+
text = readFileSync(join(dir, f), "utf8");
|
|
1206
|
+
} catch {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
830
1209
|
const m = text.match(/#\s*source:\s*projectstore\s+v(\S+)/);
|
|
831
1210
|
const { data } = parseFrontmatter(text);
|
|
832
1211
|
// A provenance-marked copy with no `name:` still deserves a usable
|
|
@@ -845,14 +1224,27 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
845
1224
|
// without a provenance marker we cannot prove lineage — hedge FIRST,
|
|
846
1225
|
// not 40 words in, or the false premise leads for user-authored agents
|
|
847
1226
|
const lead = m ? `${f}` : `If ${f} began as a projectstore copy (no provenance marker — ignore otherwise): it`;
|
|
848
|
-
out.push(
|
|
849
|
-
|
|
850
|
-
|
|
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
|
+
);
|
|
851
1236
|
continue; // a stale-version note on top would just be noise
|
|
852
1237
|
}
|
|
853
1238
|
if (!CURRENT_AGENT_NAMES.includes(name)) {
|
|
854
|
-
out.push(
|
|
855
|
-
|
|
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
|
+
);
|
|
856
1248
|
continue;
|
|
857
1249
|
}
|
|
858
1250
|
// ADR-008. A current-name copy overrides nothing either: the bundled agent
|
|
@@ -862,9 +1254,7 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
862
1254
|
// refreshing such a copy fixes nothing; that is why it replaces the old
|
|
863
1255
|
// "frozen at vX — re-run configure" advice instead of sitting beside it.
|
|
864
1256
|
// Staleness is demoted to a parenthetical: still true, no longer the point.
|
|
865
|
-
const stale = m && ver && m[1] !== ver
|
|
866
|
-
? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)`
|
|
867
|
-
: "";
|
|
1257
|
+
const stale = m && ver && m[1] !== ver ? ` (It is also frozen at projectstore v${m[1]}, installed v${ver}.)` : "";
|
|
868
1258
|
// Without a marker we cannot prove lineage — hedge FIRST, and never issue
|
|
869
1259
|
// a delete imperative against a file the user may well have written.
|
|
870
1260
|
const lead = m
|
|
@@ -873,15 +1263,22 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
873
1263
|
// `configure` only ever touched PROJECT scope, so pointing a user-scope
|
|
874
1264
|
// copy at it would name a command that will not act — the scope split
|
|
875
1265
|
// fca8def introduced for staleness applies here for the same reason.
|
|
876
|
-
const remove =
|
|
877
|
-
|
|
878
|
-
|
|
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.`;
|
|
879
1270
|
const advice = m
|
|
880
1271
|
? remove
|
|
881
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.`;
|
|
882
|
-
out.push(
|
|
883
|
-
|
|
884
|
-
|
|
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
|
+
);
|
|
885
1282
|
}
|
|
886
1283
|
}
|
|
887
1284
|
return out;
|
|
@@ -895,60 +1292,130 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
895
1292
|
// The variable names come from the manifest (runtime.agent_overrides); the
|
|
896
1293
|
// wording is projectstore's, because the claim — ADR-008's quality floor — is.
|
|
897
1294
|
export function checkEnvEffort() {
|
|
898
|
-
return agentOverrides()
|
|
899
|
-
|
|
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
|
+
);
|
|
900
1305
|
}
|
|
901
1306
|
|
|
902
1307
|
export function checkEnvModel() {
|
|
903
|
-
return agentOverrides()
|
|
904
|
-
|
|
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
|
+
);
|
|
905
1318
|
}
|
|
906
1319
|
|
|
907
1320
|
// The project-level layout (the layout ADR): a legacy .projectstore/projectstore.json
|
|
908
1321
|
// or .projectstore/state/ is one warn naming the upgrade; two bindings is an
|
|
909
1322
|
// issue. Cheap — a handful of existsSync — so the startup line carries the
|
|
910
1323
|
// warn as an offer (OFFER_CHECKS).
|
|
911
|
-
|
|
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
|
+
) {
|
|
912
1332
|
const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
|
|
913
|
-
const legacyBinding = existsSync(p.legacy.binding),
|
|
1333
|
+
const legacyBinding = existsSync(p.legacy.binding),
|
|
1334
|
+
legacyRuntime = existsSync(p.legacy.runtime);
|
|
914
1335
|
let resumable = false;
|
|
915
1336
|
if (legacyBinding && existsSync(p.binding)) {
|
|
916
|
-
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 {}
|
|
917
1341
|
}
|
|
918
1342
|
if (legacyBinding && existsSync(p.binding) && !resumable) {
|
|
919
|
-
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
|
+
];
|
|
920
1352
|
}
|
|
921
1353
|
if (!legacyBinding && !legacyRuntime && !existsSync(p.legacy.welcomed) && !existsSync(p.legacy.sessionId)) return [];
|
|
922
1354
|
// The command in the channel of the copy the project runs (the layout spec,
|
|
923
1355
|
// contract 12 as amended 2026-10-03; maintainer decision the same day). The
|
|
924
|
-
// package's
|
|
925
|
-
// git-marketplace user it added projectstore@projectstore-npm at
|
|
926
|
-
// and turned the git copy off for the checkout (reproduced with
|
|
927
|
-
// fake host), and without the host CLI on PATH it stopped
|
|
928
|
-
// exit 1.
|
|
929
|
-
//
|
|
930
|
-
//
|
|
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).
|
|
931
1366
|
const remedy = layoutRemedy(proj, { root, home, harness });
|
|
932
|
-
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(", ");
|
|
933
1375
|
// The files sit in that harness's legacy directory and the move is its
|
|
934
1376
|
// command: its fact under any other listener (aboutHarness).
|
|
935
|
-
return [
|
|
936
|
-
|
|
937
|
-
|
|
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
|
+
];
|
|
938
1398
|
}
|
|
939
1399
|
|
|
940
1400
|
// The one command that moves this project's files (the layout spec, contract
|
|
941
1401
|
// 12 as amended 2026-10-03), in the channel of the copy the project runs. A
|
|
942
1402
|
// session runs that copy, so its root answers: the package's own registration
|
|
943
|
-
// takes the
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
//
|
|
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).
|
|
1415
|
+
//
|
|
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).
|
|
952
1419
|
//
|
|
953
1420
|
// The selection, the capability read and the copy advice are lib's
|
|
954
1421
|
// (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
|
|
@@ -956,16 +1423,34 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
|
|
|
956
1423
|
// re-exported here, where its callers already import it.
|
|
957
1424
|
export { takesNoRegister };
|
|
958
1425
|
|
|
959
|
-
|
|
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
|
+
) {
|
|
960
1441
|
// A session under a relocated host home hands it on: a terminal without it
|
|
961
1442
|
// would classify the copy as a checkout, render no launcher and never clear
|
|
962
1443
|
// the offer (the second review of the 2026-10-03 fixes, S2).
|
|
963
1444
|
const homeVar = harness?.runtime?.home_env;
|
|
964
1445
|
const prefix = homeVar && env[homeVar] ? `${homeVar}="${env[homeVar]}" ` : "";
|
|
965
|
-
const
|
|
966
|
-
|
|
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
|
+
});
|
|
967
1452
|
const channel = installChannel(root, { home, harness });
|
|
968
|
-
if (channel === "registration") return
|
|
1453
|
+
if (channel === "registration") return npm(pluginVersion(root));
|
|
969
1454
|
if (channel === "marketplace") return own(root);
|
|
970
1455
|
// The selection is lib's selectCopy: this checkout's row first, then
|
|
971
1456
|
// user-scope rows by recency, never another checkout's.
|
|
@@ -973,21 +1458,27 @@ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harn
|
|
|
973
1458
|
if (copy) {
|
|
974
1459
|
const v = copy.version ? ` (${copy.version})` : "";
|
|
975
1460
|
const then = "and the startup line names the command";
|
|
976
|
-
if (copy.missing === "bin")
|
|
1461
|
+
if (copy.missing === "bin")
|
|
1462
|
+
return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
|
|
977
1463
|
// Chosen first, gated after: the copy is what the project runs, so an
|
|
978
1464
|
// incapable one gets advice even beside a capable user-scope copy.
|
|
979
1465
|
const viaRegistration = copy.channel === "registration";
|
|
980
1466
|
if (copy.missing === "no-register") {
|
|
981
|
-
return
|
|
982
|
-
// Its channel's ordinary refresh, at the running version: the plain
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
+
};
|
|
987
1478
|
}
|
|
988
|
-
return viaRegistration ?
|
|
1479
|
+
return viaRegistration ? npm(copy.version) : own(copy.path);
|
|
989
1480
|
}
|
|
990
|
-
return channel === "package" ?
|
|
1481
|
+
return channel === "package" ? npm(pluginVersion(root)) : own(root);
|
|
991
1482
|
}
|
|
992
1483
|
|
|
993
1484
|
export function checkGitignore(proj) {
|
|
@@ -998,31 +1489,61 @@ export function checkGitignore(proj) {
|
|
|
998
1489
|
const p = layoutPaths(proj);
|
|
999
1490
|
if (existsSync(p.root)) {
|
|
1000
1491
|
let lines = null;
|
|
1001
|
-
try {
|
|
1492
|
+
try {
|
|
1493
|
+
lines = readFileSync(p.gitignore, "utf8")
|
|
1494
|
+
.split("\n")
|
|
1495
|
+
.map((l) => l.trim());
|
|
1496
|
+
} catch {}
|
|
1002
1497
|
if (lines === null) {
|
|
1003
1498
|
// Contract 5's ignore file is what keeps a machine-local binding — with
|
|
1004
1499
|
// an absolute vault path — out of a commit. Until 2026-09-06 bind never
|
|
1005
1500
|
// wrote it, so a project bound and committed before its first session
|
|
1006
1501
|
// carried one; doctor reasoned from the self-ignoring without ever
|
|
1007
1502
|
// checking it.
|
|
1008
|
-
out.push(
|
|
1009
|
-
|
|
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
|
+
);
|
|
1010
1512
|
} else {
|
|
1011
|
-
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
|
+
);
|
|
1012
1523
|
else if (!lines.includes("*")) {
|
|
1013
1524
|
// A lone "*" is the pre-2026-09-06 vault-that-is-a-project shape: it
|
|
1014
1525
|
// ignores the binding and state/ already, and only hides harness/ when
|
|
1015
1526
|
// that directory exists — which the branch above is for.
|
|
1016
1527
|
const short = [...LAYOUT.gitignore].filter((l) => !lines.includes(l));
|
|
1017
|
-
if (short.length)
|
|
1018
|
-
|
|
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
|
+
);
|
|
1019
1538
|
}
|
|
1020
1539
|
}
|
|
1021
1540
|
}
|
|
1022
1541
|
if (!existsSync(join(proj, ".git"))) return out;
|
|
1023
1542
|
let lines = [];
|
|
1024
1543
|
try {
|
|
1025
|
-
lines = readFileSync(join(proj, ".gitignore"), "utf8")
|
|
1544
|
+
lines = readFileSync(join(proj, ".gitignore"), "utf8")
|
|
1545
|
+
.split("\n")
|
|
1546
|
+
.map((l) => l.trim());
|
|
1026
1547
|
} catch {}
|
|
1027
1548
|
const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
|
|
1028
1549
|
if (coveredAll) return out;
|
|
@@ -1030,8 +1551,14 @@ export function checkGitignore(proj) {
|
|
|
1030
1551
|
const wanted = [relative(proj, hostSettingsPath(proj))];
|
|
1031
1552
|
const missing = wanted.filter((w) => !lines.includes(w));
|
|
1032
1553
|
if (!missing.length) return out;
|
|
1033
|
-
out.push(
|
|
1034
|
-
|
|
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
|
+
);
|
|
1035
1562
|
return out;
|
|
1036
1563
|
}
|
|
1037
1564
|
|
|
@@ -1045,16 +1572,32 @@ export function checkTrackedRuntime(proj) {
|
|
|
1045
1572
|
const want = [relative(proj, p.binding), relative(proj, p.state)];
|
|
1046
1573
|
const r = spawnSync("git", ["ls-files", "--", ...want], { cwd: proj, encoding: "utf8", timeout: 5000 });
|
|
1047
1574
|
if (r.status !== 0 || !r.stdout) return [];
|
|
1048
|
-
const tracked = r.stdout
|
|
1575
|
+
const tracked = r.stdout
|
|
1576
|
+
.split("\n")
|
|
1577
|
+
.map((l) => l.trim())
|
|
1578
|
+
.filter(Boolean);
|
|
1049
1579
|
if (!tracked.length) return [];
|
|
1050
|
-
return [
|
|
1051
|
-
|
|
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
|
+
];
|
|
1052
1589
|
}
|
|
1053
1590
|
|
|
1054
1591
|
export function checkVaultGit(cfg) {
|
|
1055
1592
|
if (existsSync(join(cfg.vault_path, ".git"))) return [];
|
|
1056
|
-
return [
|
|
1057
|
-
|
|
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
|
+
];
|
|
1058
1601
|
}
|
|
1059
1602
|
|
|
1060
1603
|
// Marketplace auto-update (maintainer request 2026-07-03): third-party
|
|
@@ -1080,12 +1623,20 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1080
1623
|
// session. So fall back to the registered install when our own path says
|
|
1081
1624
|
// nothing, and never claim --plugin-dir as a conclusion.
|
|
1082
1625
|
const marketplaceOf = (p) =>
|
|
1083
|
-
(String(p || "")
|
|
1626
|
+
(String(p || "")
|
|
1627
|
+
.replace(/\\/g, "/")
|
|
1628
|
+
.match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
|
|
1084
1629
|
const inst = installedPluginRoot(home);
|
|
1085
1630
|
const marketplace = marketplaceOf(pluginRoot()) || marketplaceOf(inst && inst.path);
|
|
1086
1631
|
if (!marketplace) {
|
|
1087
|
-
out.push(
|
|
1088
|
-
|
|
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
|
+
);
|
|
1089
1640
|
return out;
|
|
1090
1641
|
}
|
|
1091
1642
|
|
|
@@ -1099,8 +1650,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1099
1650
|
// verb, reported by checkPluginRegistration (2026-09-05).
|
|
1100
1651
|
if (entry && entry.source && entry.source.source === "directory") return out;
|
|
1101
1652
|
if (!entry) {
|
|
1102
|
-
out.push(
|
|
1103
|
-
|
|
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
|
+
);
|
|
1104
1661
|
return out;
|
|
1105
1662
|
}
|
|
1106
1663
|
|
|
@@ -1112,11 +1669,17 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1112
1669
|
} catch {}
|
|
1113
1670
|
}
|
|
1114
1671
|
if (!enabled) {
|
|
1115
|
-
out.push(
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
+
);
|
|
1120
1683
|
}
|
|
1121
1684
|
|
|
1122
1685
|
// Bonus: the marketplace checkout's catalog knows the latest released
|
|
@@ -1132,12 +1695,18 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1132
1695
|
const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
|
|
1133
1696
|
const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
|
|
1134
1697
|
const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
|
|
1135
|
-
const running =
|
|
1136
|
-
|| 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;
|
|
1137
1700
|
// Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
|
|
1138
1701
|
if (latest && running && cmpPrecedence(latest, running) > 0) {
|
|
1139
|
-
out.push(
|
|
1140
|
-
|
|
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
|
+
);
|
|
1141
1710
|
}
|
|
1142
1711
|
} catch {}
|
|
1143
1712
|
return out;
|
|
@@ -1155,18 +1724,46 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
|
|
|
1155
1724
|
const mcp = harness && harness.surfaces && harness.surfaces.mcp;
|
|
1156
1725
|
if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
|
|
1157
1726
|
const p = join(root, mcp.file || ".mcp.json");
|
|
1158
|
-
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
|
+
];
|
|
1159
1736
|
let reg;
|
|
1160
|
-
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
|
+
}
|
|
1161
1744
|
const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
|
|
1162
1745
|
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
1163
1746
|
if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
|
|
1164
|
-
return [
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
+
];
|
|
1167
1765
|
}
|
|
1168
1766
|
|
|
1169
|
-
|
|
1170
1767
|
// ─── Vault checks ──────────────────────────────────────────────────────
|
|
1171
1768
|
|
|
1172
1769
|
// Collect every structured artifact with parsed frontmatter.
|
|
@@ -1175,7 +1772,11 @@ export function scanArtifacts(cfg, layout) {
|
|
|
1175
1772
|
const artifacts = [];
|
|
1176
1773
|
const push = (abs, rel, kind) => {
|
|
1177
1774
|
let md;
|
|
1178
|
-
try {
|
|
1775
|
+
try {
|
|
1776
|
+
md = readFileSync(abs, "utf8");
|
|
1777
|
+
} catch {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1179
1780
|
artifacts.push({ abs, rel, kind, fm: parseFrontmatter(md).data, body: md });
|
|
1180
1781
|
};
|
|
1181
1782
|
for (const folder of layout.folders) {
|
|
@@ -1217,13 +1818,27 @@ export function checkKanbanSync(cfg) {
|
|
|
1217
1818
|
return [finding("vault", "warn", "kanban", `kanban generator failed: ${(r.stderr || "").trim()}`)];
|
|
1218
1819
|
}
|
|
1219
1820
|
let expected;
|
|
1220
|
-
try {
|
|
1821
|
+
try {
|
|
1822
|
+
expected = JSON.parse(r.stdout).content;
|
|
1823
|
+
} catch {
|
|
1221
1824
|
return [finding("vault", "warn", "kanban", "kanban generator returned unparseable output.")];
|
|
1222
1825
|
}
|
|
1223
|
-
const norm = (s) =>
|
|
1826
|
+
const norm = (s) =>
|
|
1827
|
+
s
|
|
1828
|
+
.split("\n")
|
|
1829
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
1830
|
+
.join("\n")
|
|
1831
|
+
.trimEnd();
|
|
1224
1832
|
if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
|
|
1225
|
-
return [
|
|
1226
|
-
|
|
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
|
+
];
|
|
1227
1842
|
}
|
|
1228
1843
|
return [];
|
|
1229
1844
|
}
|
|
@@ -1247,28 +1862,51 @@ export function checkIndexes(cfg, layout, artifacts) {
|
|
|
1247
1862
|
indexed.add(rel);
|
|
1248
1863
|
const art = artifacts.find((a) => a.rel === rel);
|
|
1249
1864
|
if (!art) {
|
|
1250
|
-
out.push(
|
|
1251
|
-
|
|
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
|
+
);
|
|
1252
1874
|
continue;
|
|
1253
1875
|
}
|
|
1254
1876
|
const fmStatus = (art.fm.status || "").trim();
|
|
1255
1877
|
if (fmStatus && row.status && fmStatus !== row.status) {
|
|
1256
|
-
out.push(
|
|
1257
|
-
|
|
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
|
+
);
|
|
1258
1887
|
}
|
|
1259
1888
|
const fmTitle = (art.fm.title || "").trim();
|
|
1260
1889
|
if (fmTitle && row.title && fmTitle !== row.title) {
|
|
1261
|
-
out.push(
|
|
1262
|
-
|
|
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
|
+
);
|
|
1263
1899
|
}
|
|
1264
1900
|
}
|
|
1265
1901
|
for (const a of artifacts) {
|
|
1266
|
-
const inFolder =
|
|
1267
|
-
|
|
1268
|
-
|
|
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)}`;
|
|
1269
1906
|
if (inFolder && !indexed.has(a.rel)) {
|
|
1270
|
-
out.push(
|
|
1271
|
-
`${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
|
+
);
|
|
1272
1910
|
}
|
|
1273
1911
|
}
|
|
1274
1912
|
}
|
|
@@ -1285,7 +1923,10 @@ function linesOutsideFences(s) {
|
|
|
1285
1923
|
const out = [];
|
|
1286
1924
|
let fenced = false;
|
|
1287
1925
|
for (const line of s.split("\n")) {
|
|
1288
|
-
if (/^\s*```/.test(line)) {
|
|
1926
|
+
if (/^\s*```/.test(line)) {
|
|
1927
|
+
fenced = !fenced;
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1289
1930
|
if (!fenced) out.push(line);
|
|
1290
1931
|
}
|
|
1291
1932
|
return out;
|
|
@@ -1305,23 +1946,39 @@ export function checkStoriesAndEpics(artifacts) {
|
|
|
1305
1946
|
const sec = sectionOf(a.body, "acceptance") || "";
|
|
1306
1947
|
const unchecked = (stripCodeSpans(sec).match(/- \[ \]/g) || []).length;
|
|
1307
1948
|
if (unchecked > 0) {
|
|
1308
|
-
out.push(
|
|
1309
|
-
|
|
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
|
+
);
|
|
1310
1958
|
}
|
|
1311
1959
|
}
|
|
1312
1960
|
if ((a.fm.review_status || "") === "reviewed" && (!a.fm.reviewed_at || a.fm.reviewed_at === "null")) {
|
|
1313
|
-
out.push(
|
|
1314
|
-
`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
|
+
);
|
|
1315
1964
|
}
|
|
1316
1965
|
}
|
|
1317
1966
|
for (const epic of artifacts.filter((a) => a.kind === "epic")) {
|
|
1318
1967
|
if ((epic.fm.status || "").toLowerCase() !== "done") continue;
|
|
1319
1968
|
const dir = epic.rel.replace(/\/epic\.md$/, "");
|
|
1320
|
-
const open = artifacts.filter(
|
|
1321
|
-
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
|
+
);
|
|
1322
1972
|
if (open.length) {
|
|
1323
|
-
out.push(
|
|
1324
|
-
|
|
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
|
+
);
|
|
1325
1982
|
}
|
|
1326
1983
|
}
|
|
1327
1984
|
return out;
|
|
@@ -1339,13 +1996,18 @@ export function checkIndexHeaders(cfg, layout) {
|
|
|
1339
1996
|
const readme = join(cfg.vault_path, folder.path, "README.md");
|
|
1340
1997
|
if (!existsSync(readme)) continue;
|
|
1341
1998
|
const lines = readFileSync(readme, "utf8").split("\n");
|
|
1342
|
-
const headIdx = lines.findIndex((l, i) =>
|
|
1343
|
-
/^\|.*\|\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] || ""));
|
|
1344
2000
|
if (headIdx === -1) continue; // no table at all — nothing to lint
|
|
1345
2001
|
if (!headerRe.test(lines[headIdx])) {
|
|
1346
|
-
out.push(
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
+
);
|
|
1349
2011
|
}
|
|
1350
2012
|
}
|
|
1351
2013
|
return out;
|
|
@@ -1383,14 +2045,20 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
|
|
|
1383
2045
|
if (a.kind !== "story" || epicIdOf(a.rel, epicFolderPath) !== epicId) continue;
|
|
1384
2046
|
const tier = storyMatchesEntry(storyId, { id: a.fm.id, stem: storyStemOf(a.rel) });
|
|
1385
2047
|
if (!tier) continue;
|
|
1386
|
-
if (best === 0 || tier < best) {
|
|
1387
|
-
|
|
2048
|
+
if (best === 0 || tier < best) {
|
|
2049
|
+
best = tier;
|
|
2050
|
+
hits = [a];
|
|
2051
|
+
} else if (tier === best) hits.push(a);
|
|
1388
2052
|
}
|
|
1389
2053
|
if (hits.length === 1) return { story: hits[0] };
|
|
1390
2054
|
if (hits.length > 1) {
|
|
1391
|
-
return {
|
|
2055
|
+
return {
|
|
2056
|
+
error: `ambiguous — "${storyId}" matches ${hits.map((a) => storyStemOf(a.rel)).join(", ")}; qualify the reference`,
|
|
2057
|
+
};
|
|
1392
2058
|
}
|
|
1393
|
-
return {
|
|
2059
|
+
return {
|
|
2060
|
+
error: `no story in ${epicFolderPath}/${epicId} matches "${storyId}" (by exact id:, exact filename stem, or legacy story-NNN prefix)`,
|
|
2061
|
+
};
|
|
1394
2062
|
}
|
|
1395
2063
|
|
|
1396
2064
|
// The ONE spec resolver (SPEC-002 contract 5) — shared by checkSpecLinks,
|
|
@@ -1402,7 +2070,7 @@ function resolveSpecStory(entry, artifacts, epicFolderPath) {
|
|
|
1402
2070
|
// grandfathered SPEC-NNN-<slug>.md files. Cross-spec identity clashes are
|
|
1403
2071
|
// the identity check's finding, so first-wins here stays deterministic.
|
|
1404
2072
|
function buildSpecResolver(artifacts, layout = null) {
|
|
1405
|
-
const prefix = layout ? folderByKind(layout, "spec")?.prefix ?? null : null;
|
|
2073
|
+
const prefix = layout ? (folderByKind(layout, "spec")?.prefix ?? null) : null;
|
|
1406
2074
|
const byId = new Map();
|
|
1407
2075
|
const byStem = new Map();
|
|
1408
2076
|
for (const s of artifacts.filter((a) => a.kind === "spec")) {
|
|
@@ -1432,7 +2100,10 @@ export function parseSpecAcceptance(spec) {
|
|
|
1432
2100
|
// story, not silently fall through to "applies to every covered story".
|
|
1433
2101
|
const attr = text.match(attrRe);
|
|
1434
2102
|
const stories = attr
|
|
1435
|
-
? attr[1]
|
|
2103
|
+
? attr[1]
|
|
2104
|
+
.split(",")
|
|
2105
|
+
.map((s) => s.trim())
|
|
2106
|
+
.filter(Boolean)
|
|
1436
2107
|
: null;
|
|
1437
2108
|
items.push({ checked, text, stories });
|
|
1438
2109
|
}
|
|
@@ -1450,14 +2121,27 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1450
2121
|
for (const entry of listOf(spec.fm, "stories")) {
|
|
1451
2122
|
const r = resolveSpecStory(entry, artifacts, epicFolder.path);
|
|
1452
2123
|
if (r.error) {
|
|
1453
|
-
out.push(
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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)) {
|
|
1457
2134
|
// Membership through the SAME resolver — a slug-form back-reference
|
|
1458
2135
|
// to a grandfathered SPEC-NNN file is a valid link, not a gap.
|
|
1459
|
-
out.push(
|
|
1460
|
-
|
|
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
|
+
);
|
|
1461
2145
|
}
|
|
1462
2146
|
}
|
|
1463
2147
|
}
|
|
@@ -1465,19 +2149,32 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1465
2149
|
for (const id of listOf(story.fm, "specs")) {
|
|
1466
2150
|
const spec = resolveSpec(id);
|
|
1467
2151
|
if (!spec) {
|
|
1468
|
-
out.push(
|
|
1469
|
-
|
|
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
|
+
);
|
|
1470
2161
|
} else {
|
|
1471
2162
|
const epicId = epicIdOf(story.rel, epicFolder.path);
|
|
1472
2163
|
const stem = storyStemOf(story.rel);
|
|
1473
2164
|
const covered = listOf(spec.fm, "stories").some((e) => {
|
|
1474
2165
|
const m = String(e).match(/^([^/]+)\/(.+)$/);
|
|
1475
|
-
return m && m[1] === epicId &&
|
|
1476
|
-
storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
|
|
2166
|
+
return m && m[1] === epicId && storyMatchesEntry(m[2], { id: story.fm.id, stem }) > 0;
|
|
1477
2167
|
});
|
|
1478
2168
|
if (!covered) {
|
|
1479
|
-
out.push(
|
|
1480
|
-
|
|
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
|
+
);
|
|
1481
2178
|
}
|
|
1482
2179
|
}
|
|
1483
2180
|
}
|
|
@@ -1486,8 +2183,15 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
1486
2183
|
// the list is invisible to every deterministic check.
|
|
1487
2184
|
const fmBlock = story.body.match(/^---\n[\s\S]*?\n---/);
|
|
1488
2185
|
if (story.fm.specs === "" && fmBlock && /\nspecs:\s*\n\s+-\s/.test(fmBlock[0])) {
|
|
1489
|
-
out.push(
|
|
1490
|
-
|
|
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
|
+
);
|
|
1491
2195
|
}
|
|
1492
2196
|
}
|
|
1493
2197
|
return out;
|
|
@@ -1511,8 +2215,15 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
|
|
|
1511
2215
|
if (isLegacyStory(story.fm, since)) continue;
|
|
1512
2216
|
const ids = listOf(story.fm, "specs");
|
|
1513
2217
|
if (!ids.length) {
|
|
1514
|
-
out.push(
|
|
1515
|
-
|
|
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
|
+
);
|
|
1516
2227
|
continue;
|
|
1517
2228
|
}
|
|
1518
2229
|
for (const id of ids) {
|
|
@@ -1521,12 +2232,26 @@ export function checkSpecCoverage(artifacts, vaultCfg, layout = null) {
|
|
|
1521
2232
|
const st = specStatusOf(spec);
|
|
1522
2233
|
if (status === "done") {
|
|
1523
2234
|
if (!["active", "superseded"].includes(st)) {
|
|
1524
|
-
out.push(
|
|
1525
|
-
|
|
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
|
+
);
|
|
1526
2244
|
}
|
|
1527
2245
|
} else if (st === "draft") {
|
|
1528
|
-
out.push(
|
|
1529
|
-
|
|
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
|
+
);
|
|
1530
2255
|
}
|
|
1531
2256
|
}
|
|
1532
2257
|
}
|
|
@@ -1557,8 +2282,15 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
|
|
|
1557
2282
|
if (!spec) continue;
|
|
1558
2283
|
const items = parseSpecAcceptance(spec);
|
|
1559
2284
|
if (items === null) {
|
|
1560
|
-
out.push(
|
|
1561
|
-
|
|
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
|
+
);
|
|
1562
2294
|
continue;
|
|
1563
2295
|
}
|
|
1564
2296
|
const coveredEntries = listOf(spec.fm, "stories");
|
|
@@ -1571,24 +2303,40 @@ export function checkSpecAcceptance(layout, artifacts, vaultCfg) {
|
|
|
1571
2303
|
applies = item.stories.some((bare) =>
|
|
1572
2304
|
coveredEntries.some((e) => {
|
|
1573
2305
|
const m = String(e).match(/^([^/]+)\/(.+)$/);
|
|
1574
|
-
return m && m[1] === epicId && bare === m[2] &&
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
const ambiguous = item.stories.some(
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
+
);
|
|
1582
2316
|
const ambKey = `${spec.rel}|${item.text}`;
|
|
1583
2317
|
if (ambiguous && !ambiguousReported.has(ambKey)) {
|
|
1584
2318
|
ambiguousReported.add(ambKey);
|
|
1585
|
-
out.push(
|
|
1586
|
-
|
|
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
|
+
);
|
|
1587
2328
|
}
|
|
1588
2329
|
}
|
|
1589
2330
|
if (applies && !item.checked) {
|
|
1590
|
-
out.push(
|
|
1591
|
-
|
|
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
|
+
);
|
|
1592
2340
|
}
|
|
1593
2341
|
}
|
|
1594
2342
|
}
|
|
@@ -1615,25 +2363,53 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
|
|
|
1615
2363
|
for (const line of linesOutsideFences(acc)) {
|
|
1616
2364
|
const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
|
|
1617
2365
|
if (m && !evidenceRe.test(m[2])) {
|
|
1618
|
-
out.push(
|
|
1619
|
-
|
|
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
|
+
);
|
|
1620
2375
|
}
|
|
1621
2376
|
}
|
|
1622
2377
|
}
|
|
1623
2378
|
|
|
1624
2379
|
const plan = sectionOf(story.body, "implementation_plan");
|
|
1625
2380
|
if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
|
|
1626
|
-
out.push(
|
|
1627
|
-
|
|
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
|
+
);
|
|
1628
2390
|
}
|
|
1629
2391
|
const summary = sectionOf(story.body, "final_summary");
|
|
1630
2392
|
if (summary === null) {
|
|
1631
|
-
out.push(
|
|
1632
|
-
|
|
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
|
+
);
|
|
1633
2402
|
}
|
|
1634
2403
|
if (plan === null) {
|
|
1635
|
-
out.push(
|
|
1636
|
-
|
|
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
|
+
);
|
|
1637
2413
|
}
|
|
1638
2414
|
}
|
|
1639
2415
|
return out;
|
|
@@ -1644,12 +2420,24 @@ export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
|
|
|
1644
2420
|
const out = [];
|
|
1645
2421
|
const hasSpecs = artifacts.some((a) => a.kind === "spec");
|
|
1646
2422
|
if (hasSpecs && !vaultCfg.spec_policy) {
|
|
1647
|
-
out.push(
|
|
1648
|
-
|
|
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
|
+
);
|
|
1649
2431
|
}
|
|
1650
2432
|
if (vaultCfg.spec_policy === "required" && !vaultCfg.spec_policy_since) {
|
|
1651
|
-
out.push(
|
|
1652
|
-
|
|
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
|
+
);
|
|
1653
2441
|
}
|
|
1654
2442
|
return out;
|
|
1655
2443
|
}
|
|
@@ -1665,7 +2453,11 @@ export function walkVaultFiles(vault) {
|
|
|
1665
2453
|
if (n.startsWith(".")) continue;
|
|
1666
2454
|
const p = join(dir, n);
|
|
1667
2455
|
let st;
|
|
1668
|
-
try {
|
|
2456
|
+
try {
|
|
2457
|
+
st = statSync(p);
|
|
2458
|
+
} catch {
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
1669
2461
|
if (st.isDirectory()) walk(p, relDir ? `${relDir}/${n}` : n);
|
|
1670
2462
|
else if (n.endsWith(".md")) files.push({ rel: relDir ? `${relDir}/${n}` : n, name: n });
|
|
1671
2463
|
}
|
|
@@ -1691,7 +2483,9 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
|
|
|
1691
2483
|
const files = vaultFiles ?? walkVaultFiles(cfg.vault_path);
|
|
1692
2484
|
let index = nodeIndex;
|
|
1693
2485
|
if (!index) {
|
|
1694
|
-
try {
|
|
2486
|
+
try {
|
|
2487
|
+
index = buildNodeIndex(cfg, loadLayout(cfg.layout));
|
|
2488
|
+
} catch (e) {
|
|
1695
2489
|
// A silent no-op check would read as "links are fine" — say why not.
|
|
1696
2490
|
return [finding("vault", "warn", "wikilink", `Link check skipped — node index failed: ${e.message}`)];
|
|
1697
2491
|
}
|
|
@@ -1706,12 +2500,21 @@ export function checkWikilinks(cfg, artifacts, vaultFiles = null, nodeIndex = nu
|
|
|
1706
2500
|
for (const link of extractLinks(a.body)) {
|
|
1707
2501
|
const r = resolveLinkTarget(link.target, link.type, ctx);
|
|
1708
2502
|
if (r.outcome === "dead") {
|
|
1709
|
-
out.push(
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
+
);
|
|
1712
2508
|
} else if (r.outcome === "ambiguous") {
|
|
1713
|
-
out.push(
|
|
1714
|
-
|
|
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
|
+
);
|
|
1715
2518
|
}
|
|
1716
2519
|
}
|
|
1717
2520
|
}
|
|
@@ -1773,7 +2576,8 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
|
|
|
1773
2576
|
const parts = x.rel.split("/");
|
|
1774
2577
|
let entry = null; // stories/<f>.md | stories/<dir>/README.md | standalone story-*.md
|
|
1775
2578
|
if (parts.length === 4 && parts[2] === "stories") entry = { name: parts[3], rel: x.rel };
|
|
1776
|
-
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 };
|
|
1777
2581
|
else if (parts.length === 3 && parts[2].startsWith("story-")) entry = { name: parts[2], rel: x.rel };
|
|
1778
2582
|
if (!entry || INFRA_NAMES.has(entry.name)) continue;
|
|
1779
2583
|
if (!byEpic.has(parts[1])) byEpic.set(parts[1], []);
|
|
@@ -1803,31 +2607,53 @@ export function checkArtifactIdentity(layout, vaultFiles, artifacts = []) {
|
|
|
1803
2607
|
for (const [ident, hits] of groups) {
|
|
1804
2608
|
const uniq = [...new Map(hits.map((h) => [h.entry.rel, h])).values()];
|
|
1805
2609
|
if (uniq.length < 2) continue;
|
|
1806
|
-
const key = uniq
|
|
2610
|
+
const key = uniq
|
|
2611
|
+
.map((h) => h.entry.rel)
|
|
2612
|
+
.sort()
|
|
2613
|
+
.join("|");
|
|
1807
2614
|
if (reported.has(key)) continue;
|
|
1808
2615
|
reported.add(key);
|
|
1809
2616
|
const selfOnly = uniq.every((h) => h.via === "self");
|
|
1810
2617
|
const classed = uniq.map((h) => ({ h, era: eraOf(h.entry, opts) }));
|
|
1811
|
-
const level = selfOnly
|
|
1812
|
-
|
|
1813
|
-
//
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
+
);
|
|
1825
2645
|
}
|
|
1826
2646
|
for (const [num, ents] of numbers) {
|
|
1827
2647
|
if (new Set(ents.map((e) => e.rel)).size < 2) continue;
|
|
1828
|
-
out.push(
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
+
);
|
|
1831
2657
|
}
|
|
1832
2658
|
}
|
|
1833
2659
|
return out;
|
|
@@ -1843,8 +2669,15 @@ export function checkExternalRefsForm(artifacts) {
|
|
|
1843
2669
|
if (a.fm.external_refs !== "") continue;
|
|
1844
2670
|
const fmBlock = a.body.match(/^---\n[\s\S]*?\n---/);
|
|
1845
2671
|
if (fmBlock && /\nexternal_refs:\s*\n\s+\S/.test(fmBlock[0])) {
|
|
1846
|
-
out.push(
|
|
1847
|
-
|
|
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
|
+
);
|
|
1848
2681
|
}
|
|
1849
2682
|
}
|
|
1850
2683
|
return out;
|
|
@@ -1860,8 +2693,15 @@ export function checkArtifactNames(vaultFiles) {
|
|
|
1860
2693
|
for (const x of vaultFiles) {
|
|
1861
2694
|
const bad = legalArtifactName(x.name);
|
|
1862
2695
|
if (bad) {
|
|
1863
|
-
out.push(
|
|
1864
|
-
|
|
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
|
+
);
|
|
1865
2705
|
}
|
|
1866
2706
|
if (INFRA_NAMES.has(x.name)) continue;
|
|
1867
2707
|
if (!byName.has(x.name)) byName.set(x.name, []);
|
|
@@ -1869,9 +2709,15 @@ export function checkArtifactNames(vaultFiles) {
|
|
|
1869
2709
|
}
|
|
1870
2710
|
for (const [name, rels] of byName) {
|
|
1871
2711
|
if (rels.length < 2) continue;
|
|
1872
|
-
out.push(
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
+
);
|
|
1875
2721
|
}
|
|
1876
2722
|
return out;
|
|
1877
2723
|
}
|
|
@@ -1899,12 +2745,22 @@ export function checkWorkWithoutStory(cfg, proj) {
|
|
|
1899
2745
|
const fms = [];
|
|
1900
2746
|
let unreadable = 0;
|
|
1901
2747
|
for (const f of files) {
|
|
1902
|
-
try {
|
|
2748
|
+
try {
|
|
2749
|
+
fms.push(parseFrontmatter(readFileSync(f, "utf8")).data);
|
|
2750
|
+
} catch {
|
|
2751
|
+
unreadable++;
|
|
2752
|
+
}
|
|
1903
2753
|
}
|
|
1904
2754
|
if (unreadable > 0 && !openStoryFrom(fms)) {
|
|
1905
2755
|
// A diagnostic that goes quiet because it could not read is a false clean.
|
|
1906
|
-
out.push(
|
|
1907
|
-
|
|
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
|
+
);
|
|
1908
2764
|
return out;
|
|
1909
2765
|
}
|
|
1910
2766
|
if (openStoryFrom(fms)) return out;
|
|
@@ -1927,14 +2783,21 @@ export function checkWorkWithoutStory(cfg, proj) {
|
|
|
1927
2783
|
// kind: null — entry reminders only. The log is shared with the session-name
|
|
1928
2784
|
// offer, whose breadcrumbs would otherwise be reported as reminders delivered.
|
|
1929
2785
|
const fired = readEntryLog(proj, { withinDays: 30, kind: null }).length;
|
|
1930
|
-
const firedNote =
|
|
1931
|
-
|
|
1932
|
-
|
|
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).";
|
|
1933
2790
|
const what = [];
|
|
1934
2791
|
if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
|
|
1935
2792
|
if (committedSince) what.push("commits newer than the vault's last activity");
|
|
1936
|
-
out.push(
|
|
1937
|
-
|
|
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
|
+
);
|
|
1938
2801
|
return out;
|
|
1939
2802
|
}
|
|
1940
2803
|
|
|
@@ -1952,8 +2815,15 @@ export function checkCodeRefs(artifacts, proj) {
|
|
|
1952
2815
|
for (const ref of refs) {
|
|
1953
2816
|
if (ref.includes("*")) continue;
|
|
1954
2817
|
if (!existsSync(join(proj, ref))) {
|
|
1955
|
-
out.push(
|
|
1956
|
-
|
|
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
|
+
);
|
|
1957
2827
|
}
|
|
1958
2828
|
}
|
|
1959
2829
|
}
|
|
@@ -1961,14 +2831,28 @@ export function checkCodeRefs(artifacts, proj) {
|
|
|
1961
2831
|
const dir = a.rel.replace(/\/stories\/[^/]+$/, "");
|
|
1962
2832
|
const parent = epicRefs.get(dir) || [];
|
|
1963
2833
|
if (!parent.length) {
|
|
1964
|
-
out.push(
|
|
1965
|
-
|
|
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
|
+
);
|
|
1966
2843
|
} else {
|
|
1967
2844
|
const norm = (r) => r.replace(/\/+$/, "");
|
|
1968
2845
|
for (const ref of refs) {
|
|
1969
2846
|
if (!parent.some((p) => norm(ref).startsWith(norm(p)))) {
|
|
1970
|
-
out.push(
|
|
1971
|
-
|
|
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
|
+
);
|
|
1972
2856
|
}
|
|
1973
2857
|
}
|
|
1974
2858
|
}
|
|
@@ -1989,13 +2873,27 @@ export function checkCodeMap(cfg) {
|
|
|
1989
2873
|
});
|
|
1990
2874
|
if (r.status !== 0) return [finding("vault", "warn", "code-map", "codemap generator failed.")];
|
|
1991
2875
|
let expected;
|
|
1992
|
-
try {
|
|
2876
|
+
try {
|
|
2877
|
+
expected = JSON.parse(r.stdout).content;
|
|
2878
|
+
} catch {
|
|
1993
2879
|
return [finding("vault", "warn", "code-map", "codemap generator returned unparseable output.")];
|
|
1994
2880
|
}
|
|
1995
|
-
const norm = (s) =>
|
|
2881
|
+
const norm = (s) =>
|
|
2882
|
+
s
|
|
2883
|
+
.split("\n")
|
|
2884
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
2885
|
+
.join("\n")
|
|
2886
|
+
.trimEnd();
|
|
1996
2887
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
1997
|
-
return [
|
|
1998
|
-
|
|
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
|
+
];
|
|
1999
2897
|
}
|
|
2000
2898
|
return [];
|
|
2001
2899
|
}
|
|
@@ -2008,7 +2906,9 @@ export function checkCodeMap(cfg) {
|
|
|
2008
2906
|
export function checkGraph(cfg) {
|
|
2009
2907
|
const p = join(cfg.vault_path, "graph.md");
|
|
2010
2908
|
if (!existsSync(p)) {
|
|
2011
|
-
return [
|
|
2909
|
+
return [
|
|
2910
|
+
finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`),
|
|
2911
|
+
];
|
|
2012
2912
|
}
|
|
2013
2913
|
const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
|
|
2014
2914
|
encoding: "utf8",
|
|
@@ -2017,13 +2917,27 @@ export function checkGraph(cfg) {
|
|
|
2017
2917
|
});
|
|
2018
2918
|
if (r.status !== 0) return [finding("vault", "warn", "graph", `graph generator failed: ${(r.stderr || "").trim()}`)];
|
|
2019
2919
|
let expected;
|
|
2020
|
-
try {
|
|
2920
|
+
try {
|
|
2921
|
+
expected = JSON.parse(r.stdout).content;
|
|
2922
|
+
} catch {
|
|
2021
2923
|
return [finding("vault", "warn", "graph", "graph generator returned unparseable output.")];
|
|
2022
2924
|
}
|
|
2023
|
-
const norm = (s) =>
|
|
2925
|
+
const norm = (s) =>
|
|
2926
|
+
s
|
|
2927
|
+
.split("\n")
|
|
2928
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
2929
|
+
.join("\n")
|
|
2930
|
+
.trimEnd();
|
|
2024
2931
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
2025
|
-
return [
|
|
2026
|
-
|
|
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
|
+
];
|
|
2027
2941
|
}
|
|
2028
2942
|
return [];
|
|
2029
2943
|
}
|
|
@@ -2052,8 +2966,10 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
2052
2966
|
...checkOverlays(cfg, proj, opts),
|
|
2053
2967
|
);
|
|
2054
2968
|
let read = null;
|
|
2055
|
-
try {
|
|
2056
|
-
|
|
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 })));
|
|
2057
2973
|
if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
|
|
2058
2974
|
if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
|
|
2059
2975
|
return foldIntoMove(out);
|
|
@@ -2061,7 +2977,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
2061
2977
|
|
|
2062
2978
|
export function runVaultChecks(cfg) {
|
|
2063
2979
|
let layout;
|
|
2064
|
-
try {
|
|
2980
|
+
try {
|
|
2981
|
+
layout = loadLayout(cfg.layout);
|
|
2982
|
+
} catch (e) {
|
|
2065
2983
|
return [finding("vault", "issue", "layout", `Layout not loadable: ${e.message}`)];
|
|
2066
2984
|
}
|
|
2067
2985
|
const artifacts = scanArtifacts(cfg, layout);
|
|
@@ -2080,7 +2998,9 @@ export function runVaultChecks(cfg) {
|
|
|
2080
2998
|
() => checkLifecycleGates(artifacts, vaultCfg),
|
|
2081
2999
|
];
|
|
2082
3000
|
for (const step of guarded) {
|
|
2083
|
-
try {
|
|
3001
|
+
try {
|
|
3002
|
+
findings.push(...step());
|
|
3003
|
+
} catch (e) {
|
|
2084
3004
|
findings.push(finding("vault", "issue", "registry", e.message));
|
|
2085
3005
|
break;
|
|
2086
3006
|
}
|
|
@@ -2104,6 +3024,7 @@ export function runVaultChecks(cfg) {
|
|
|
2104
3024
|
// Decision 4). Aborts past the budget rather than reporting a false "clean".
|
|
2105
3025
|
export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
2106
3026
|
const started = Date.now();
|
|
3027
|
+
const findings = [];
|
|
2107
3028
|
const steps = [
|
|
2108
3029
|
() => checkConfig(cfg, proj),
|
|
2109
3030
|
() => (cfg && cfg.vault_path ? checkVaultPath(cfg) : []),
|
|
@@ -2112,15 +3033,22 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2112
3033
|
() => checkAgentsBlock(proj),
|
|
2113
3034
|
() => checkGitignore(proj),
|
|
2114
3035
|
// The layout move is offered from the startup line (info here, warn in the
|
|
2115
|
-
// report) — a user who never runs doctor still learns to migrate.
|
|
2116
|
-
|
|
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
|
+
}),
|
|
2117
3044
|
() => checkEnvModel(),
|
|
2118
3045
|
() => checkEnvEffort(),
|
|
2119
3046
|
];
|
|
2120
|
-
const findings = [];
|
|
2121
3047
|
for (const step of steps) {
|
|
2122
3048
|
if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
|
|
2123
|
-
try {
|
|
3049
|
+
try {
|
|
3050
|
+
findings.push(...step());
|
|
3051
|
+
} catch {}
|
|
2124
3052
|
}
|
|
2125
3053
|
// The findings the move repairs are not counted beside it (foldIntoMove).
|
|
2126
3054
|
const settled = foldIntoMove(findings);
|
|
@@ -2128,12 +3056,13 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2128
3056
|
// re-stamps the launcher at its new path (the layout spec, contract 7 as
|
|
2129
3057
|
// amended 2026-10-03), and an in-session `doctor --fix` would write that
|
|
2130
3058
|
// launcher and then stop at the deferred move with exit 1. So the line names
|
|
2131
|
-
// 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.
|
|
2132
3062
|
const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
|
|
2133
|
-
const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
|
|
2134
3063
|
const offers = settled
|
|
2135
3064
|
.filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
|
|
2136
|
-
.map((f) =>
|
|
3065
|
+
.map((f) => f.message);
|
|
2137
3066
|
return {
|
|
2138
3067
|
skipped: false,
|
|
2139
3068
|
count: settled.filter((f) => f.level === "issue").length,
|
|
@@ -2150,7 +3079,10 @@ async function main() {
|
|
|
2150
3079
|
const startup = args.includes("--startup");
|
|
2151
3080
|
let install = args.includes("--install");
|
|
2152
3081
|
let vault = args.includes("--vault");
|
|
2153
|
-
if (!install && !vault && !startup) {
|
|
3082
|
+
if (!install && !vault && !startup) {
|
|
3083
|
+
install = true;
|
|
3084
|
+
vault = true;
|
|
3085
|
+
}
|
|
2154
3086
|
|
|
2155
3087
|
const cfg = readConfig();
|
|
2156
3088
|
const proj = projectRoot();
|
|
@@ -2163,7 +3095,10 @@ async function main() {
|
|
|
2163
3095
|
|
|
2164
3096
|
const findings = [];
|
|
2165
3097
|
const groups = [];
|
|
2166
|
-
if (install) {
|
|
3098
|
+
if (install) {
|
|
3099
|
+
groups.push("install");
|
|
3100
|
+
findings.push(...(await runInstallChecks(cfg, proj)));
|
|
3101
|
+
}
|
|
2167
3102
|
if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
|
|
2168
3103
|
groups.push("vault");
|
|
2169
3104
|
findings.push(...runVaultChecks(cfg));
|
|
@@ -2172,7 +3107,10 @@ async function main() {
|
|
|
2172
3107
|
findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
|
|
2173
3108
|
}
|
|
2174
3109
|
|
|
2175
|
-
if (wantJson) {
|
|
3110
|
+
if (wantJson) {
|
|
3111
|
+
process.stdout.write(JSON.stringify(findings, null, 2) + "\n");
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
2176
3114
|
process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
|
|
2177
3115
|
}
|
|
2178
3116
|
|
|
@@ -2182,12 +3120,20 @@ async function main() {
|
|
|
2182
3120
|
// statically, and the renderer's term.mjs is not on its path. A renderer
|
|
2183
3121
|
// that cannot load, or throws, costs the reader nothing: the findings one
|
|
2184
3122
|
// per line and the Summary line, still last (lib.mjs). `load` is a test's.
|
|
2185
|
-
export async function textReport(
|
|
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
|
+
) {
|
|
2186
3129
|
try {
|
|
2187
3130
|
const { report } = await load();
|
|
2188
3131
|
return report(findings, groups, { version, project, env });
|
|
2189
3132
|
} catch (e) {
|
|
2190
|
-
if (stderr)
|
|
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
|
+
);
|
|
2191
3137
|
return doctorFallbackText(findings, { env });
|
|
2192
3138
|
}
|
|
2193
3139
|
}
|