projectstore-claude 0.28.2 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/projectstore-claude.mjs +14 -9
- 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 +2 -2
- package/node_modules/projectstore/commands/review.md +1 -1
- package/node_modules/projectstore/harnesses/claude-code.json +20 -2
- package/node_modules/projectstore/harnesses/codex.json +24 -4
- package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
- package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
- package/node_modules/projectstore/hooks/session-start.mjs +20 -18
- package/node_modules/projectstore/package.json +2 -2
- package/node_modules/projectstore/scripts/binding.mjs +3 -3
- package/node_modules/projectstore/scripts/build-adapters.mjs +51 -18
- package/node_modules/projectstore/scripts/cli.mjs +176 -29
- package/node_modules/projectstore/scripts/codemap.mjs +2 -2
- package/node_modules/projectstore/scripts/doctor.mjs +110 -58
- package/node_modules/projectstore/scripts/draft.mjs +4 -3
- package/node_modules/projectstore/scripts/graph.mjs +3 -2
- package/node_modules/projectstore/scripts/harness.mjs +89 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
- package/node_modules/projectstore/scripts/kanban.mjs +3 -2
- package/node_modules/projectstore/scripts/lib.mjs +69 -8
- package/node_modules/projectstore/scripts/mcp.mjs +2 -2
- package/node_modules/projectstore/scripts/query.mjs +2 -1
- package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
- package/node_modules/projectstore/scripts/story-section.mjs +2 -1
- package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
- package/node_modules/projectstore/scripts/term.mjs +149 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
- package/node_modules/projectstore/scripts/worktree.mjs +12 -7
- package/package.json +2 -2
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// the command's --fix flow (install side) and reconcile (vault side).
|
|
9
9
|
//
|
|
10
10
|
// Finding: { group: "install"|"vault", level: "issue"|"warn"|"info",
|
|
11
|
-
// check: "<id>", message: "...", file?: "<path>"
|
|
11
|
+
// check: "<id>", message: "...", file?: "<path>",
|
|
12
|
+
// about?: "<harness id>" } (a fact about another harness: aboutHarness)
|
|
12
13
|
// The SessionStart line counts level==="issue" only.
|
|
13
14
|
//
|
|
14
15
|
// CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
|
|
@@ -79,8 +80,13 @@ import {
|
|
|
79
80
|
cmpPrecedence,
|
|
80
81
|
cmpVersion,
|
|
81
82
|
blockVisibleTo,
|
|
83
|
+
commandForm,
|
|
84
|
+
sharedRoleForm,
|
|
85
|
+
speakingHarness,
|
|
86
|
+
inheritForm,
|
|
87
|
+
bindInherits,
|
|
82
88
|
} from "./lib.mjs";
|
|
83
|
-
import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
|
|
89
|
+
import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand, invocation } from "./harness.mjs";
|
|
84
90
|
|
|
85
91
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
86
92
|
// measured 2026-09-06, NO harness gives its Bash tool that variable, and a
|
|
@@ -142,6 +148,18 @@ function finding(group, level, check, message, file) {
|
|
|
142
148
|
return f;
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
// A finding whose subject is ANOTHER harness in the same project — its
|
|
152
|
+
// registration, its surfaces, its legacy layout, its registry's versions — is
|
|
153
|
+
// that harness's: it opens with that harness's name, carries `about`, and says
|
|
154
|
+
// its steps in that harness's forms and words, because they happen there
|
|
155
|
+
// (generation spec, contract 18). Under that harness itself, or with no
|
|
156
|
+
// harness to name, the finding is unchanged.
|
|
157
|
+
export function aboutHarness(f, h, speaker = speakingHarness()) {
|
|
158
|
+
return speaker && h?.id && h.id !== speaker.id && h.display_name
|
|
159
|
+
? { ...f, message: `${h.display_name}: ${f.message}`, about: h.id }
|
|
160
|
+
: f;
|
|
161
|
+
}
|
|
162
|
+
|
|
145
163
|
function pluginVersion(root = pluginRoot()) {
|
|
146
164
|
try {
|
|
147
165
|
return JSON.parse(
|
|
@@ -176,7 +194,7 @@ export function checkConfig(cfg, proj = projectRoot()) {
|
|
|
176
194
|
try { b = resolveBinding(proj); } catch {}
|
|
177
195
|
if (b && b.state === "inheritable") {
|
|
178
196
|
return [finding("install", "issue", "worktree-unbound",
|
|
179
|
-
`This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. Run
|
|
197
|
+
`This worktree is unbound while the checkout it was forked from (${b.mainCheckout}) is bound to ${b.vaultPath}. Run ${inheritForm(b.vaultPath, { layout: b.layout, language: b.language })} ${bindInherits() ? "to adopt that binding" : "to bind it to the same vault, layout and language"}.`)];
|
|
180
198
|
}
|
|
181
199
|
const p = layoutPaths(proj);
|
|
182
200
|
const present = [p.binding, p.legacy.binding].find((f) => existsSync(f));
|
|
@@ -185,7 +203,7 @@ export function checkConfig(cfg, proj = projectRoot()) {
|
|
|
185
203
|
`${relative(proj, present)} exists but is not valid JSON — the project reads as unbound until it is fixed; bind refuses to overwrite it.`, relative(proj, present))];
|
|
186
204
|
}
|
|
187
205
|
return [finding("install", "issue", "config",
|
|
188
|
-
|
|
206
|
+
`No projectstore config (.projectstore/projectstore.json). Run ${commandForm("bind", { args: "<vault-path>" })}.`)];
|
|
189
207
|
}
|
|
190
208
|
const out = [];
|
|
191
209
|
if (!cfg.vault_path) out.push(finding("install", "issue", "config", "Config has no vault_path."));
|
|
@@ -277,8 +295,11 @@ export function statusLineScriptVersion(scriptPath) {
|
|
|
277
295
|
|
|
278
296
|
// Read-only probe of the statusline wiring (never calls syncStatusLine, which
|
|
279
297
|
// is a mutating self-heal that SessionStart already ran — ADR-005).
|
|
280
|
-
export function checkStatusline(cfg, proj, home = homedir()) {
|
|
298
|
+
export function checkStatusline(cfg, proj, home = homedir(), harness = speakingHarness()) {
|
|
281
299
|
const out = [];
|
|
300
|
+
// A harness without a status line hears nothing about one, whatever the
|
|
301
|
+
// shared binding asks for (generation spec, contract 18).
|
|
302
|
+
if (harness?.surfaces?.statusline?.supported === false) return out;
|
|
282
303
|
const local = hostSettingsPath(proj);
|
|
283
304
|
let cur = null;
|
|
284
305
|
if (existsSync(local)) {
|
|
@@ -297,7 +318,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
|
|
|
297
318
|
if (st && st.enabled === true) {
|
|
298
319
|
if (!curCmd) {
|
|
299
320
|
out.push(finding("install", "issue", "statusline",
|
|
300
|
-
|
|
321
|
+
`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.`));
|
|
301
322
|
} else if (!isOurs) {
|
|
302
323
|
out.push(finding("install", "issue", "statusline",
|
|
303
324
|
"statusline.enabled=true but a foreign statusLine occupies settings.local.json — the hook will not clobber it. Clear it or disable the flag."));
|
|
@@ -308,7 +329,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
|
|
|
308
329
|
if (m && !existsSync(m[1])) {
|
|
309
330
|
out.push(finding("install", "issue", "statusline",
|
|
310
331
|
isLauncher
|
|
311
|
-
? `statusLine points at a generated launcher that no longer exists: ${m[1]} — run
|
|
332
|
+
? `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.`
|
|
312
333
|
: `statusLine points at a missing script (stale plugin path?): ${m[1]}`));
|
|
313
334
|
} else if (m && isPluginCacheRoot(wiredRoot, home)) {
|
|
314
335
|
// Only a versioned cache path can go stale this way. The launcher
|
|
@@ -318,7 +339,7 @@ export function checkStatusline(cfg, proj, home = homedir()) {
|
|
|
318
339
|
const inst = installedPluginRoot(home, dirname(wiredRoot));
|
|
319
340
|
if (wired && inst && inst.version && wired !== inst.version) {
|
|
320
341
|
out.push(finding("install", "warn", "statusline",
|
|
321
|
-
`statusLine is wired to projectstore ${wired} while ${inst.version} is installed — a version-pinned path lags one session behind each update. Run
|
|
342
|
+
`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.`));
|
|
322
343
|
}
|
|
323
344
|
}
|
|
324
345
|
}
|
|
@@ -373,7 +394,8 @@ export function checkStatusline(cfg, proj, home = homedir()) {
|
|
|
373
394
|
// load the provenance leaf). So the startup line names the step. Only for a
|
|
374
395
|
// cache install: a dev checkout does not produce the launcher at all, and its
|
|
375
396
|
// install would leave the file, not re-stamp it.
|
|
376
|
-
export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot()) {
|
|
397
|
+
export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(), harness = speakingHarness()) {
|
|
398
|
+
if (harness?.surfaces?.statusline?.supported === false) return [];
|
|
377
399
|
if (!isPluginCacheRoot(root, home)) return [];
|
|
378
400
|
// Only a launcher our entry runs. Under a foreign status line nothing reads
|
|
379
401
|
// it, install leaves that slot alone, and the offer would repeat every
|
|
@@ -387,7 +409,7 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot())
|
|
|
387
409
|
try { text = readFileSync(lp, "utf8"); } catch { return []; }
|
|
388
410
|
if (!text.includes(LAUNCHER_HEADER) || text.includes(STAMP_PREFIX)) return [];
|
|
389
411
|
return [finding("install", "info", "upgrade",
|
|
390
|
-
|
|
412
|
+
`The status line launcher predates this plugin's file stamps (plugin updated) — it keeps rendering; run ${commandForm("doctor", { args: "--fix" })} once to re-stamp it.`,
|
|
391
413
|
relative(proj, lp))];
|
|
392
414
|
}
|
|
393
415
|
|
|
@@ -422,7 +444,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
422
444
|
if (f.wrapped) {
|
|
423
445
|
wrappedFiles++;
|
|
424
446
|
out.push(finding("install", "issue", "agents-block",
|
|
425
|
-
`${name}:${f.line}: the projectstore:agents open marker does not close on its own line — put \`-->\` back on the marker's line, then run
|
|
447
|
+
`${name}:${f.line}: the projectstore:agents open marker does not close on its own line — put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until it does).`, name));
|
|
426
448
|
continue;
|
|
427
449
|
}
|
|
428
450
|
if (f.unclosed) {
|
|
@@ -430,7 +452,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
430
452
|
// the agents-block plan refuses it, and the layout move with it.
|
|
431
453
|
unclosedFiles++;
|
|
432
454
|
out.push(finding("install", "issue", "agents-block",
|
|
433
|
-
`${name}: the projectstore:agents block opens and never closes — close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run
|
|
455
|
+
`${name}: the projectstore:agents block opens and never closes — close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run ${commandForm("agents", { args: "register" })} (install and uninstall refuse until then).`, name));
|
|
434
456
|
}
|
|
435
457
|
for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
|
|
436
458
|
const v = parseInt(m[1], 10);
|
|
@@ -439,7 +461,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
439
461
|
}
|
|
440
462
|
if (blocks === 0) {
|
|
441
463
|
out.push(finding("install", "info", "agents-block",
|
|
442
|
-
|
|
464
|
+
`Agent routing block not registered — optional; ships with ${commandForm("agents")} (v0.13).`));
|
|
443
465
|
}
|
|
444
466
|
if (blocks > 1) {
|
|
445
467
|
// One block in each file is a state install resolves (it keeps the
|
|
@@ -454,7 +476,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
454
476
|
// already named above, file by file
|
|
455
477
|
} else {
|
|
456
478
|
out.push(finding("install", "warn", "agents-block",
|
|
457
|
-
`The projectstore:agents block is in both CLAUDE.md and AGENTS.md — run
|
|
479
|
+
`The projectstore:agents block is in both CLAUDE.md and AGENTS.md — run ${commandForm("agents", { args: "register" })}: install keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other.`));
|
|
458
480
|
}
|
|
459
481
|
}
|
|
460
482
|
// A state the agents-block plan refuses — a wrapped marker, a block that
|
|
@@ -463,7 +485,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
463
485
|
const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
|
|
464
486
|
for (const s of staleVersions) {
|
|
465
487
|
const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
|
|
466
|
-
const f = finding("install", "issue", "agents-block", `${fact} — re-run
|
|
488
|
+
const f = finding("install", "issue", "agents-block", `${fact} — re-run ${commandForm("agents", { args: "register" })}.`, s.file);
|
|
467
489
|
out.push(refuses ? f : moveRepairs(f, fact));
|
|
468
490
|
}
|
|
469
491
|
// Placement, held to the predicate install plans from (the install spec,
|
|
@@ -546,7 +568,7 @@ export async function readSurfaceStates(proj, { home = homedir(), root = pluginR
|
|
|
546
568
|
return { result: surfaceStates(proj, { home, root, env, ...(manifestDir ? { manifestDir } : {}) }), FOREIGN_TEXT };
|
|
547
569
|
}
|
|
548
570
|
|
|
549
|
-
export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env } = {}) {
|
|
571
|
+
export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root = pluginRoot(), manifestDir = undefined, read = null, env = process.env, speaker = speakingHarness() } = {}) {
|
|
550
572
|
const out = [];
|
|
551
573
|
let r, FOREIGN_TEXT;
|
|
552
574
|
try {
|
|
@@ -564,16 +586,23 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
564
586
|
for (const s of r.states) {
|
|
565
587
|
const where = relative(proj, s.path) || s.path;
|
|
566
588
|
if (s.kind === "registration") continue; // checkPluginRegistration's
|
|
589
|
+
// Another harness's surface is that harness's fact (aboutHarness): its
|
|
590
|
+
// steps in its forms, and no status-line hint where it has no status line.
|
|
591
|
+
// A shared surface — the agents block, one file every harness owns — is
|
|
592
|
+
// every harness's: never marked, its step in the listener's form.
|
|
593
|
+
const sh = s.kind === "shared" ? speaker : (loadHarness(s.harness) || speaker);
|
|
594
|
+
const push = (f) => out.push(aboutHarness(f, sh, speaker));
|
|
567
595
|
if (s.kind === "exclusive") {
|
|
568
596
|
if (s.state === "foreign") {
|
|
569
|
-
|
|
597
|
+
push(finding("install", "issue", "surface-foreign",
|
|
570
598
|
`${where} — ${FOREIGN_TEXT}. install, uninstall and upgrade refuse it; nothing repairs it.`, where));
|
|
571
599
|
} else if (s.state === "stale" && s.produced) {
|
|
572
|
-
|
|
600
|
+
const status = sh?.surfaces?.statusline?.supported === false ? "" : ` (for the status line, ${invocation(sh, "statusline", { args: "on" })})`;
|
|
601
|
+
push(finding("install", "issue", "surface", `${where} — stale: ${s.reason}. Reinstall it: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${s.harness} --surface ${s.surface} --project "${proj}"${status}.`, where));
|
|
573
602
|
} else if (s.state === "stale" && !s.produced) {
|
|
574
|
-
|
|
603
|
+
push(finding("install", "info", "surface", `${where} — ${s.reason}.`, where));
|
|
575
604
|
} else if (s.state === "current" && s.writtenBy && !s.sameProject) {
|
|
576
|
-
|
|
605
|
+
push(finding("install", "info", "surface", `${where} — current, last written by ${s.writtenBy}.`, where));
|
|
577
606
|
}
|
|
578
607
|
} else if (s.surface === "agents_block") {
|
|
579
608
|
// Version drift and duplicates are checkAgentsBlock's; what only the
|
|
@@ -582,9 +611,9 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
582
611
|
// startup line, and the state names it with the file's own reason, as a
|
|
583
612
|
// wrapped marker already was.
|
|
584
613
|
if (s.state === "unparseable") {
|
|
585
|
-
|
|
614
|
+
push(finding("install", "issue", "surface", `${where} — ${s.reason}`, where));
|
|
586
615
|
} else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
|
|
587
|
-
|
|
616
|
+
push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run ${invocation(sh, "agents", { args: "register" })}.`, where));
|
|
588
617
|
}
|
|
589
618
|
}
|
|
590
619
|
// The statusline entry's states are checkStatusline's, under its own id —
|
|
@@ -598,7 +627,7 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
598
627
|
// rejects is an issue naming key and file; a binding still carrying an
|
|
599
628
|
// agents block is a pre-0.28 leftover the migration moves; an overlay that
|
|
600
629
|
// does not parse is an issue.
|
|
601
|
-
export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir() } = {}) {
|
|
630
|
+
export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir(), speaker = speakingHarness() } = {}) {
|
|
602
631
|
const out = [];
|
|
603
632
|
const o = readOverlayAt(proj);
|
|
604
633
|
const where = relative(proj, o.path);
|
|
@@ -613,7 +642,9 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
613
642
|
// installer in no particular channel (the critic of the layout spec's
|
|
614
643
|
// 2026-10-03 amendment, finding 7).
|
|
615
644
|
const remedy = layoutRemedy(proj, { root, home });
|
|
616
|
-
|
|
645
|
+
// The block and its move are the source harness's — a pre-0.28 binding
|
|
646
|
+
// and that harness's command — so its fact under any other listener.
|
|
647
|
+
out.push(aboutHarness(finding("install", "warn", "agents-in-binding", `${b} still carries an agents block — since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedy.command ? `Move it from a terminal outside the session: ${remedy.command}` : remedy.advice}.`, b), sourceHarness(), speaker));
|
|
617
648
|
}
|
|
618
649
|
// A project can be used from more than one harness, and a model name is
|
|
619
650
|
// harness-specific (ADR-008) — so each one has its own overlay and they do
|
|
@@ -636,7 +667,7 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
636
667
|
`This project is used from ${id} too, and ${relative(proj, o.path)} does not exist — `
|
|
637
668
|
+ `its agents run on their frontmatter models (${configured.map((c) => c.id).join(", ")} `
|
|
638
669
|
+ `${configured.length > 1 ? "have" : "has"} an overlay; a model name is harness-specific, so nothing carries over). `
|
|
639
|
-
+ `Configure it:
|
|
670
|
+
+ `Configure it: ${commandForm("agents", { args: `configure --harness ${id}` })}.`,
|
|
640
671
|
relative(proj, o.path)));
|
|
641
672
|
}
|
|
642
673
|
}
|
|
@@ -660,12 +691,15 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
660
691
|
// itself); a competing copy enabled beside ours → an issue (two enabled copies
|
|
661
692
|
// of one plugin); a competitor alone → an info naming the npm path; foreign →
|
|
662
693
|
// never repairable; the host CLI missing → an info.
|
|
663
|
-
export function checkPluginRegistration(proj, states = [], { home = homedir() } = {}) {
|
|
694
|
+
export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness() } = {}) {
|
|
664
695
|
const out = [];
|
|
665
696
|
for (const s of states.filter((x) => x.kind === "registration")) {
|
|
666
697
|
// The shell form when the manifest names a shell (contract 12): the
|
|
667
698
|
// command a user can paste, built in one place.
|
|
668
699
|
const h = loadHarness(s.harness) || { id: s.harness };
|
|
700
|
+
// A registration of ANOTHER harness in the same project is that
|
|
701
|
+
// harness's fact: named, marked, its steps in its own forms (aboutHarness).
|
|
702
|
+
const push = (f) => out.push(aboutHarness(f, h, speaker));
|
|
669
703
|
const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` });
|
|
670
704
|
// A copy this registration silenced for the checkout and the checkout
|
|
671
705
|
// still holds off, one per key (the install spec, contract 13 as amended
|
|
@@ -682,29 +716,29 @@ export function checkPluginRegistration(proj, states = [], { home = homedir() }
|
|
|
682
716
|
for (const key of s.silenced) {
|
|
683
717
|
const row = rows.filter((e) => e.key === key).sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
|
|
684
718
|
if (!row) {
|
|
685
|
-
|
|
719
|
+
push(finding("install", "info", "plugin-registration", `${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`, s.path));
|
|
686
720
|
continue;
|
|
687
721
|
}
|
|
688
722
|
// The release line, not the build: a 0.28 release candidate reads a
|
|
689
723
|
// moved project; 0.27.x reads it as unbound. No version reads as old.
|
|
690
724
|
const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
|
|
691
|
-
|
|
692
|
-
`${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it: from a terminal outside the session, ${packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` })}, restart, then
|
|
725
|
+
push(finding("install", "info", "plugin-registration",
|
|
726
|
+
`${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it: from a terminal outside the session, ${packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` })}, restart, then ${invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" })}. If moving to npm was meant, ignore this.`, s.path));
|
|
693
727
|
}
|
|
694
728
|
}
|
|
695
729
|
const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
|
|
696
730
|
if (s.state === "foreign") {
|
|
697
|
-
|
|
731
|
+
push(finding("install", "issue", "plugin-registration-foreign", `${s.reason} — install, uninstall and upgrade refuse it; nothing repairs it.`, s.path));
|
|
698
732
|
} else if (s.state === "unavailable") {
|
|
699
|
-
|
|
733
|
+
push(finding("install", "info", "plugin-registration", `No npm registration of projectstore for this project, and ${s.reason}.`));
|
|
700
734
|
} else if (s.state === "absent") {
|
|
701
735
|
// A git-marketplace install alone is not a finding: a permanent info
|
|
702
736
|
// advertising the npm path to every marketplace user is noise (2026-09-05).
|
|
703
737
|
} else if (s.state === "stale") {
|
|
704
|
-
|
|
738
|
+
push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
|
|
705
739
|
} else if (s.state === "current") {
|
|
706
|
-
if (others)
|
|
707
|
-
else
|
|
740
|
+
if (others) push(finding("install", "issue", "plugin-registration", `${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. install silences the other for this project: ${packageCommand(h, "install", { args: `--surface ${s.surface} --project "${proj}"` })} (or the host's own disable at the scope the manifest names — never the committed project scope).`, s.path));
|
|
741
|
+
else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (loaded from ${s.installPath}); refresh with ${refresh}.`));
|
|
708
742
|
}
|
|
709
743
|
}
|
|
710
744
|
return out;
|
|
@@ -715,28 +749,40 @@ export function checkPluginRegistration(proj, states = [], { home = homedir() }
|
|
|
715
749
|
// hosts install from different sources. The versions come from the harness
|
|
716
750
|
// registry (one per marketplace key and scope) and from the pkg= field of a
|
|
717
751
|
// file we stamped in this project; each is named with where it was read.
|
|
718
|
-
export function checkVersionDrift(home = homedir(), states = [], proj = null) {
|
|
752
|
+
export function checkVersionDrift(home = homedir(), states = [], proj = null, { speaker = speakingHarness() } = {}) {
|
|
719
753
|
// Pairs, not a map keyed by source: two registrations under one marketplace
|
|
720
754
|
// key and one scope are the common shape (the registry keeps every install
|
|
721
755
|
// it made), and they must both be seen. Only installs still on disk count —
|
|
722
756
|
// a wiped entry is not a copy anyone runs.
|
|
723
757
|
const seen = [];
|
|
758
|
+
// The registry read here is the source harness's own.
|
|
759
|
+
const registryOf = sourceHarness()?.id || null;
|
|
724
760
|
for (const e of installedPluginEntries(home, proj)) {
|
|
725
761
|
// A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
|
|
726
|
-
if (e.version && e.present && e.enabled !== false) seen.push({ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`, version: e.version });
|
|
762
|
+
if (e.version && e.present && e.enabled !== false) seen.push({ source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`, version: e.version, harness: registryOf });
|
|
727
763
|
}
|
|
728
764
|
for (const s of states) {
|
|
729
|
-
if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg });
|
|
765
|
+
if (s.installedPkg) seen.push({ source: `pkg= of ${s.surface}`, version: s.installedPkg, harness: s.harness || null });
|
|
730
766
|
}
|
|
731
767
|
const versions = new Set(seen.map((x) => x.version));
|
|
732
768
|
if (versions.size < 2) return [];
|
|
733
769
|
const list = seen.map(({ source, version }) => `${version} (${source})`).join(", ");
|
|
734
|
-
|
|
735
|
-
|
|
770
|
+
// Every copy one harness's: that harness's fact (aboutHarness). The launcher
|
|
771
|
+
// is the status line's, so its clause is said only where the subject — or,
|
|
772
|
+
// for copies of several harnesses, the listener — has one.
|
|
773
|
+
const owners = [...new Set(seen.map((x) => x.harness))];
|
|
774
|
+
const subject = owners.length === 1 && owners[0] ? loadHarness(owners[0]) : null;
|
|
775
|
+
const launcher = (subject || speaker)?.surfaces?.statusline?.supported === false ? "" : "; the launcher renders whichever is registered";
|
|
776
|
+
return [aboutHarness(finding("install", "warn", "version-drift",
|
|
777
|
+
`projectstore is registered or installed at more than one version on this machine: ${list}. Update the older one${launcher}.`), subject, speaker)];
|
|
736
778
|
}
|
|
737
779
|
|
|
738
|
-
export function checkOverrideCopies(proj, home = homedir()) {
|
|
780
|
+
export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
|
|
739
781
|
const out = [];
|
|
782
|
+
// Copies of our agents a host loads from its own agents directory: a fact
|
|
783
|
+
// only for a harness that loads agents that way (its agents surface); a
|
|
784
|
+
// harness without one hears nothing about them (generation spec, contract 18).
|
|
785
|
+
if (harness && harness.surfaces?.agents?.supported === false) return out;
|
|
740
786
|
const ver = pluginVersion();
|
|
741
787
|
const scopes = [
|
|
742
788
|
{ dir: join(proj, ".claude", "agents"), label: ".claude/agents", scope: "project" },
|
|
@@ -793,13 +839,13 @@ export function checkOverrideCopies(proj, home = homedir()) {
|
|
|
793
839
|
// copy at it would name a command that will not act — the scope split
|
|
794
840
|
// fca8def introduced for staleness applies here for the same reason.
|
|
795
841
|
const remove = scope === "user"
|
|
796
|
-
? `Delete ${where} by hand (or via
|
|
797
|
-
:
|
|
842
|
+
? `Delete ${where} by hand (or via ${commandForm("doctor", { args: "--fix" })}) — ${commandForm("agents", { args: "configure" })} only cleans up project-scope copies.`
|
|
843
|
+
: `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.`;
|
|
798
844
|
const advice = m
|
|
799
845
|
? remove
|
|
800
|
-
:
|
|
846
|
+
: `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.`;
|
|
801
847
|
out.push(finding("install", m ? "warn" : "info", "override-copies",
|
|
802
|
-
`${lead} It registers as "${name}" while the bundled agent registers as "
|
|
848
|
+
`${lead} It registers as "${name}" while the bundled agent registers as "${sharedRoleForm(name)}", so both exist side by side.${everywhere}${stale} ${advice}`,
|
|
803
849
|
where));
|
|
804
850
|
}
|
|
805
851
|
}
|
|
@@ -827,7 +873,7 @@ export function checkEnvModel() {
|
|
|
827
873
|
// or .projectstore/state/ is one warn naming the upgrade; two bindings is an
|
|
828
874
|
// issue. Cheap — a handful of existsSync — so the startup line carries the
|
|
829
875
|
// warn as an offer (OFFER_CHECKS).
|
|
830
|
-
export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir() } = {}) {
|
|
876
|
+
export function checkLayout(proj, harness = sourceHarness(), { level = "warn", root = pluginRoot(), home = homedir(), speaker = speakingHarness() } = {}) {
|
|
831
877
|
const p = layoutPaths(proj, { harnessDir: harness?.runtime?.harness_dir || null });
|
|
832
878
|
const legacyBinding = existsSync(p.legacy.binding), legacyRuntime = existsSync(p.legacy.runtime);
|
|
833
879
|
let resumable = false;
|
|
@@ -849,9 +895,11 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
|
|
|
849
895
|
// recognising the root (a checkout, a symlinked or relocated home).
|
|
850
896
|
const remedy = layoutRemedy(proj, { root, home, harness });
|
|
851
897
|
const held = [legacyBinding && relative(proj, p.legacy.binding), legacyRuntime && relative(proj, p.legacy.runtime) + "/", existsSync(p.legacy.welcomed) && relative(proj, p.legacy.welcomed), existsSync(p.legacy.sessionId) && relative(proj, p.legacy.sessionId)].filter(Boolean).join(", ");
|
|
852
|
-
|
|
898
|
+
// The files sit in that harness's legacy directory and the move is its
|
|
899
|
+
// command: its fact under any other listener (aboutHarness).
|
|
900
|
+
return [aboutHarness(finding("install", level, "layout-legacy",
|
|
853
901
|
`The project layout moved to .projectstore/ (the layout ADR, 0.28); this project still holds ${held}. ${remedy.command ? `Migrate it from a terminal outside the session: ${remedy.command}` : remedy.advice} (readers fall back to the old paths through 0.29).`,
|
|
854
|
-
relative(proj, [legacyBinding && p.legacy.binding, legacyRuntime && p.legacy.runtime, existsSync(p.legacy.welcomed) && p.legacy.welcomed, p.legacy.sessionId].find(Boolean)))];
|
|
902
|
+
relative(proj, [legacyBinding && p.legacy.binding, legacyRuntime && p.legacy.runtime, existsSync(p.legacy.welcomed) && p.legacy.welcomed, p.legacy.sessionId].find(Boolean))), harness, speaker)];
|
|
855
903
|
}
|
|
856
904
|
|
|
857
905
|
// The one command that moves this project's files (the layout spec, contract
|
|
@@ -991,8 +1039,12 @@ export function checkVaultGit(cfg) {
|
|
|
991
1039
|
// marketplaces do NOT auto-update by default, so a stale plugin looks like
|
|
992
1040
|
// "the feature is broken". Read the real registries and, when the flag is
|
|
993
1041
|
// off, tell the user the exact correct values.
|
|
994
|
-
export function checkAutoUpdate(home = homedir()) {
|
|
1042
|
+
export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
995
1043
|
const out = [];
|
|
1044
|
+
// Auto-update is a toggle of the host's own plugin marketplace. A harness
|
|
1045
|
+
// whose registration is not the host's plugin system (Codex's is a portable
|
|
1046
|
+
// marketplace driven by our shell) has no such toggle to report on.
|
|
1047
|
+
if (harness?.surfaces?.plugin?.format !== "host-plugin-registration") return out;
|
|
996
1048
|
// Two corrections over the first version of this check, both found by running
|
|
997
1049
|
// doctor straight out of a checkout (2026-08-05):
|
|
998
1050
|
//
|
|
@@ -1074,12 +1126,12 @@ export function checkAutoUpdate(home = homedir()) {
|
|
|
1074
1126
|
// the placeholders per session, so the check is only that the shipped file is
|
|
1075
1127
|
// there and launches this package's bin. A host surface — nothing to
|
|
1076
1128
|
// install, nothing to derive — so this is not a surfaces.mjs state.
|
|
1077
|
-
export function checkMcpRegistration(root = pluginRoot(), harness =
|
|
1129
|
+
export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarness()) {
|
|
1078
1130
|
// Whether a plugin-root .mcp.json registers anything is the host's fact,
|
|
1079
1131
|
// read from the manifest: a harness whose mcp surface is not host-loaded
|
|
1080
1132
|
// has nothing to check here.
|
|
1081
1133
|
const mcp = harness && harness.surfaces && harness.surfaces.mcp;
|
|
1082
|
-
if (!mcp || mcp.kind !== "host") return [];
|
|
1134
|
+
if (!mcp || mcp.kind !== "host" || mcp.supported === false) return [];
|
|
1083
1135
|
const p = join(root, mcp.file || ".mcp.json");
|
|
1084
1136
|
if (!existsSync(p)) return [finding("install", "warn", "mcp", "No .mcp.json at the plugin root — the MCP read tools are not registered; the package ships one, so this install is incomplete or predates the MCP surface (0.28).")];
|
|
1085
1137
|
let reg;
|
|
@@ -1132,7 +1184,7 @@ export function checkKanbanSync(cfg) {
|
|
|
1132
1184
|
const vault = cfg.vault_path;
|
|
1133
1185
|
const onDisk = join(vault, "kanban.md");
|
|
1134
1186
|
if (!existsSync(onDisk)) {
|
|
1135
|
-
return [finding("vault", "info", "kanban",
|
|
1187
|
+
return [finding("vault", "info", "kanban", `No kanban.md yet — run ${commandForm("kanban")} to create the board.`)];
|
|
1136
1188
|
}
|
|
1137
1189
|
const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "kanban.mjs")], {
|
|
1138
1190
|
encoding: "utf8",
|
|
@@ -1149,7 +1201,7 @@ export function checkKanbanSync(cfg) {
|
|
|
1149
1201
|
const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
|
|
1150
1202
|
if (norm(expected) !== norm(readFileSync(onDisk, "utf8"))) {
|
|
1151
1203
|
return [finding("vault", "issue", "kanban",
|
|
1152
|
-
|
|
1204
|
+
`kanban.md is out of sync with story frontmatter — run ${commandForm("kanban")} (or reconcile).`, "kanban.md")];
|
|
1153
1205
|
}
|
|
1154
1206
|
return [];
|
|
1155
1207
|
}
|
|
@@ -1550,7 +1602,7 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
|
|
|
1550
1602
|
const plan = sectionOf(story.body, "implementation_plan");
|
|
1551
1603
|
if (plan !== null && (!story.fm.plan_updated_at || story.fm.plan_updated_at === "null")) {
|
|
1552
1604
|
out.push(finding("vault", "warn", "plan-gate",
|
|
1553
|
-
|
|
1605
|
+
`Story has an Implementation Plan section but no plan_updated_at — the plan bypassed the ${commandForm("story", { args: "plan" })} gate.`, story.rel));
|
|
1554
1606
|
}
|
|
1555
1607
|
const summary = sectionOf(story.body, "final_summary");
|
|
1556
1608
|
if (summary === null) {
|
|
@@ -1860,7 +1912,7 @@ export function checkWorkWithoutStory(cfg, proj) {
|
|
|
1860
1912
|
if (dirty.length) what.push(`${dirty.length} uncommitted source file(s)`);
|
|
1861
1913
|
if (committedSince) what.push("commits newer than the vault's last activity");
|
|
1862
1914
|
out.push(finding("vault", "warn", "work-without-story",
|
|
1863
|
-
`${what.join(" and ")} in the project, and no story is in progress. If this is feature-sized work, open it in the vault:
|
|
1915
|
+
`${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}`));
|
|
1864
1916
|
return out;
|
|
1865
1917
|
}
|
|
1866
1918
|
|
|
@@ -1921,7 +1973,7 @@ export function checkCodeMap(cfg) {
|
|
|
1921
1973
|
const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
|
|
1922
1974
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
1923
1975
|
return [finding("vault", "issue", "code-map",
|
|
1924
|
-
|
|
1976
|
+
`code-map.md is stale against frontmatter code_refs — run ${commandForm("codemap")} (or reconcile).`, "code-map.md")];
|
|
1925
1977
|
}
|
|
1926
1978
|
return [];
|
|
1927
1979
|
}
|
|
@@ -1934,7 +1986,7 @@ export function checkCodeMap(cfg) {
|
|
|
1934
1986
|
export function checkGraph(cfg) {
|
|
1935
1987
|
const p = join(cfg.vault_path, "graph.md");
|
|
1936
1988
|
if (!existsSync(p)) {
|
|
1937
|
-
return [finding("vault", "info", "graph",
|
|
1989
|
+
return [finding("vault", "info", "graph", `No graph.md yet — run ${commandForm("graph")} to create the link graph.`)];
|
|
1938
1990
|
}
|
|
1939
1991
|
const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", "graph.mjs")], {
|
|
1940
1992
|
encoding: "utf8",
|
|
@@ -1949,7 +2001,7 @@ export function checkGraph(cfg) {
|
|
|
1949
2001
|
const norm = (s) => s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
|
|
1950
2002
|
if (norm(expected) !== norm(readFileSync(p, "utf8"))) {
|
|
1951
2003
|
return [finding("vault", "issue", "graph",
|
|
1952
|
-
|
|
2004
|
+
`graph.md is out of sync with vault links — run ${commandForm("graph")} (or reconcile).`, "graph.md")];
|
|
1953
2005
|
}
|
|
1954
2006
|
return [];
|
|
1955
2007
|
}
|
|
@@ -2087,7 +2139,7 @@ function report(findings, groups) {
|
|
|
2087
2139
|
}
|
|
2088
2140
|
const issues = findings.filter((f) => f.level === "issue").length;
|
|
2089
2141
|
const warns = findings.filter((f) => f.level === "warn").length;
|
|
2090
|
-
lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ?
|
|
2142
|
+
lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
|
|
2091
2143
|
return lines.join("\n");
|
|
2092
2144
|
}
|
|
2093
2145
|
|
|
@@ -54,7 +54,8 @@ import {
|
|
|
54
54
|
slugify,
|
|
55
55
|
findSlugCollision,
|
|
56
56
|
displayNumberOf,
|
|
57
|
-
today, isMain
|
|
57
|
+
today, isMain,
|
|
58
|
+
commandForm,
|
|
58
59
|
} from "./lib.mjs";
|
|
59
60
|
|
|
60
61
|
function die(msg, code = 1) {
|
|
@@ -157,7 +158,7 @@ function buildStory(cfg, layout, args) {
|
|
|
157
158
|
const vault = cfg.vault_path;
|
|
158
159
|
const storiesDir = join(vault, folder.path, epicId, "stories");
|
|
159
160
|
if (!existsSync(join(vault, folder.path, epicId))) {
|
|
160
|
-
die(`Epic folder not found: ${folder.path}/${epicId}. Create the epic first via
|
|
161
|
+
die(`Epic folder not found: ${folder.path}/${epicId}. Create the epic first via ${commandForm("epic")}.`);
|
|
161
162
|
}
|
|
162
163
|
const storyPrefix = folder.story_prefix || "story-";
|
|
163
164
|
const slug = slugify(title);
|
|
@@ -234,7 +235,7 @@ function main() {
|
|
|
234
235
|
const rest = argv.slice(1);
|
|
235
236
|
|
|
236
237
|
const cfg = readConfig();
|
|
237
|
-
if (!cfg) die(
|
|
238
|
+
if (!cfg) die(`No projectstore config. Run ${commandForm("bind", { args: "<vault-path>" })} first.`);
|
|
238
239
|
const layout = loadLayout(cfg.layout);
|
|
239
240
|
|
|
240
241
|
let result;
|
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
buildNodeIndex,
|
|
24
24
|
extractLinks,
|
|
25
25
|
resolveLinkTarget,
|
|
26
|
-
storyMatchesEntry, isMain
|
|
26
|
+
storyMatchesEntry, isMain,
|
|
27
|
+
commandForm,
|
|
27
28
|
} from "./lib.mjs";
|
|
28
29
|
import { walkVaultFiles } from "./doctor.mjs";
|
|
29
30
|
|
|
@@ -204,7 +205,7 @@ export function buildGraph(cfg, layout, { files = null } = {}) {
|
|
|
204
205
|
|
|
205
206
|
function main() {
|
|
206
207
|
const cfg = readConfig();
|
|
207
|
-
if (!cfg) die(
|
|
208
|
+
if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
|
|
208
209
|
const layout = loadLayout(cfg.layout);
|
|
209
210
|
const g = buildGraph(cfg, layout);
|
|
210
211
|
process.stdout.write(JSON.stringify({
|