projectstore-claude 0.30.4 → 0.30.6
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/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
- package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
- package/node_modules/projectstore/.mcp.json +1 -2
- package/node_modules/projectstore/README.md +65 -16
- package/node_modules/projectstore/agents/archaeologist.md +1 -1
- package/node_modules/projectstore/agents/librarian.md +1 -1
- package/node_modules/projectstore/agents/planner.md +1 -1
- package/node_modules/projectstore/commands/doctor.md +14 -11
- package/node_modules/projectstore/commands/reconcile.md +1 -1
- package/node_modules/projectstore/commands/review.md +2 -2
- package/node_modules/projectstore/commands/story.md +1 -1
- package/node_modules/projectstore/docs/getting-started.md +3 -4
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/docs/images/icon.png +0 -0
- package/node_modules/projectstore/harnesses/claude-code.json +61 -11
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/hooks.json +6 -6
- package/node_modules/projectstore/hooks/session-start.mjs +55 -24
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +11 -12
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/binding.mjs +1 -1
- package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
- package/node_modules/projectstore/scripts/cli.mjs +74 -1373
- package/node_modules/projectstore/scripts/convergence.mjs +59 -16
- package/node_modules/projectstore/scripts/doctor.mjs +199 -36
- package/node_modules/projectstore/scripts/harness.mjs +88 -6
- package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
- package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
- package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
- package/node_modules/projectstore/scripts/lib.mjs +387 -107
- package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
- package/node_modules/projectstore/scripts/mcp.mjs +60 -60
- package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
- package/node_modules/projectstore/scripts/models.mjs +6 -10
- package/node_modules/projectstore/scripts/query.mjs +7 -6
- package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
- package/node_modules/projectstore/scripts/setup.mjs +62 -12
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
- package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
- package/package.json +3 -3
- package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
- package/node_modules/projectstore/docs/images/team-light.svg +0 -79
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
// newerCopyDeferral({ copy, running, decision, harness, pin })
|
|
21
21
|
// → a newer copy not switched off: { reason, advice }
|
|
22
22
|
// switchConsequence() → a switch row's consequence line (rule 2)
|
|
23
|
+
// directoryConsequence(display), directoryKeepConsequence(display, key)
|
|
24
|
+
// → an npm registration over a directory copy,
|
|
25
|
+
// and the keep of that copy (PS-DIRECTORY)
|
|
26
|
+
// copyOrigin(copy), DIRECTORY → where a kept or competing copy comes from
|
|
23
27
|
// switchReason({ copy, running, runs, decision, unbound })
|
|
24
28
|
// → what a switch row adds under the channel "npm"
|
|
25
29
|
// noCopyToKeep({ harness, surface, off }) → the channel "git" with nothing to keep
|
|
@@ -57,6 +61,8 @@ import {
|
|
|
57
61
|
envFlag,
|
|
58
62
|
isEphemeralRoot,
|
|
59
63
|
COPY_UPDATE_STEPS,
|
|
64
|
+
SYNCED_UPDATE_STEPS,
|
|
65
|
+
isSyncedCopy,
|
|
60
66
|
} from "./lib.mjs";
|
|
61
67
|
import { loadHarness, loadHarnesses } from "./harness.mjs";
|
|
62
68
|
import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
|
|
@@ -120,7 +126,9 @@ const within = (root, dir) => {
|
|
|
120
126
|
// Doctor keeps installChannel's answers; only these two checks read this.
|
|
121
127
|
export function hostCopyRoot(root, { home = homedir(), env = process.env } = {}) {
|
|
122
128
|
if (!root) return false;
|
|
123
|
-
|
|
129
|
+
// A copy synced from claude.ai (Anthropic's directory) is one too: claude.ai
|
|
130
|
+
// updates it (the install spec, contract 21, rule 2).
|
|
131
|
+
if (["registration", "marketplace", "synced"].includes(installChannel(root, { home }))) return true;
|
|
124
132
|
for (const m of loadHarnesses().values()) {
|
|
125
133
|
for (const s of Object.values(m.surfaces || {})) {
|
|
126
134
|
if (s?.kind !== "registration") continue;
|
|
@@ -153,7 +161,9 @@ export const updateCheckOff = (env) => envFlag(env, NO_CHECK_ENV);
|
|
|
153
161
|
// - a host plugin registration: contract 12's rows, one per key — the row the
|
|
154
162
|
// host loads here (lib's projectCopiesByKey, the same rows the registration's
|
|
155
163
|
// competing copies are read from) — of any marketplace: the npm
|
|
156
|
-
// registration's row and a git-marketplace copy alike
|
|
164
|
+
// registration's row and a git-marketplace copy alike, and a copy synced
|
|
165
|
+
// from claude.ai (Anthropic's directory) while no copy of another origin is
|
|
166
|
+
// enabled here — Claude Code loads any of them over it. Under channel "git"
|
|
157
167
|
// none: that run renders nothing for it;
|
|
158
168
|
// - a portable registration: the global registration's recorded version,
|
|
159
169
|
// when it is effectively enabled for this project.
|
|
@@ -170,7 +180,10 @@ export function runningCopies(
|
|
|
170
180
|
if (key.startsWith("_") || s?.kind !== "registration") continue;
|
|
171
181
|
if (s.format === "host-plugin-registration") {
|
|
172
182
|
if (channel === "git") continue;
|
|
173
|
-
|
|
183
|
+
const rows = projectCopiesByKey(home, projectDir);
|
|
184
|
+
const shadowed = rows.some((e) => !e.synced);
|
|
185
|
+
for (const e of rows) {
|
|
186
|
+
if (e.synced && shadowed) continue;
|
|
174
187
|
if (typeof e.version === "string" && e.version)
|
|
175
188
|
out.push({ harness: id, key: e.key, version: e.version, path: e.path, scope: e.scope, own: e.own });
|
|
176
189
|
}
|
|
@@ -437,17 +450,22 @@ export function pinFor(root, version) {
|
|
|
437
450
|
// npm` and `--channel git`, each alone on its line (a `|` copied into a shell
|
|
438
451
|
// is a pipe). Each names `@<version>` when the pin says so (pinFor), and
|
|
439
452
|
// never --project (the ADR's decision 5).
|
|
440
|
-
// - `gate` ({ args: { harnesses }, keys, version }): the channel
|
|
441
|
-
// (rule 2), for a switch planned under no channel answer
|
|
442
|
-
// terminal, the run's own --harness repeated in each command.
|
|
443
|
-
//
|
|
444
|
-
//
|
|
453
|
+
// - `gate` ({ args: { harnesses }, keys, directory, version }): the channel
|
|
454
|
+
// gate's text (rule 2), for a switch planned under no channel answer
|
|
455
|
+
// without a terminal, the run's own --harness repeated in each command.
|
|
456
|
+
// `keys` are the git-marketplace copies a switch turns off, `directory` the
|
|
457
|
+
// synced copies an npm registration loads over (PS-DIRECTORY): each named
|
|
458
|
+
// by where it comes from. setup takes no --surface, so a narrowed run's
|
|
459
|
+
// --surface is not repeated. A block with no lead follows the one before it.
|
|
445
460
|
// - otherwise, a newer copy that is not switched off (rule 6), for `harness`:
|
|
446
461
|
// keeping it lets that copy write this project's surfaces (the keep), and
|
|
447
462
|
// switching runs this older package — both named, after the terminal run.
|
|
448
463
|
export function channelChoices({ harness = null, copy = null, running = null, gate = null, pin = null } = {}) {
|
|
449
464
|
if (gate) {
|
|
450
|
-
const
|
|
465
|
+
const named = (where, keys) => (keys?.length ? `${where} (${keys.join(", ")})` : null);
|
|
466
|
+
const from =
|
|
467
|
+
[named("the git marketplace", gate.keys), named(DIRECTORY, gate.directory)].filter(Boolean).join(" and ") ||
|
|
468
|
+
"the git marketplace";
|
|
451
469
|
const run = (extra = "") =>
|
|
452
470
|
coreCommand("setup", {
|
|
453
471
|
version: gate.version || null,
|
|
@@ -455,7 +473,7 @@ export function channelChoices({ harness = null, copy = null, running = null, ga
|
|
|
455
473
|
});
|
|
456
474
|
return [
|
|
457
475
|
{
|
|
458
|
-
lead: `Nothing written: this project loads ${PACKAGE} from
|
|
476
|
+
lead: `Nothing written: this project loads ${PACKAGE} from ${from}, and moving it to the npm install is a choice made at a terminal in this project:`,
|
|
459
477
|
command: run(),
|
|
460
478
|
},
|
|
461
479
|
{ lead: "Without a terminal, name the channel:", command: run("--channel npm") },
|
|
@@ -482,7 +500,8 @@ export function channelChoices({ harness = null, copy = null, running = null, ga
|
|
|
482
500
|
// The rows name both versions and the case, then the ways out.
|
|
483
501
|
export function newerCopyDeferral({ copy, running, decision, harness, pin = null }) {
|
|
484
502
|
const c = proceedCase(decision, copy.version);
|
|
485
|
-
const
|
|
503
|
+
const not = isSyncedCopy(copy) ? "the npm install is not registered over it" : "it is not switched off";
|
|
504
|
+
const text = `this project runs a newer copy (${copy.key} ${copy.version}) than this package (${running}), so ${not}${c ? ` — ${c}` : ""}.`;
|
|
486
505
|
return advised(text, channelChoices({ harness, copy, running, pin }));
|
|
487
506
|
}
|
|
488
507
|
|
|
@@ -493,7 +512,23 @@ export function newerCopyDeferral({ copy, running, decision, harness, pin = null
|
|
|
493
512
|
// setup's, in the core form and unpinned (the setup story, rule 17): what
|
|
494
513
|
// updates the project from now on is whichever version runs it next.
|
|
495
514
|
export function switchConsequence() {
|
|
496
|
-
return `this project stops loading the git-marketplace copy, and
|
|
515
|
+
return `this project stops loading the git-marketplace copy, and that copy's updates no longer reach it — from now on it is updated by ${coreCommand("setup")}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Where a copy of ours from another channel comes from, in a person's words.
|
|
519
|
+
export const DIRECTORY = "Anthropic's directory";
|
|
520
|
+
export const copyOrigin = (copy) => (isSyncedCopy(copy) ? DIRECTORY : "the git marketplace");
|
|
521
|
+
|
|
522
|
+
// The consequence line of an npm registration over a copy synced from
|
|
523
|
+
// claude.ai (PS-DIRECTORY, "Setup and install keep a directory copy…",
|
|
524
|
+
// decision 4): never a switch — Claude Code loads the installed copy over the
|
|
525
|
+
// synced one by its own precedence, so nothing is turned off — and of the keep
|
|
526
|
+
// of that copy, setup's other answer (decision 6). `display`: the host's name.
|
|
527
|
+
export function directoryConsequence(display) {
|
|
528
|
+
return `${display} then loads the npm copy here, and reports the directory copy as not loaded`;
|
|
529
|
+
}
|
|
530
|
+
export function directoryKeepConsequence(display, key) {
|
|
531
|
+
return `${display} keeps loading ${key}, updated from claude.ai; setup refreshes this project's files through that copy`;
|
|
497
532
|
}
|
|
498
533
|
|
|
499
534
|
// The words a switch row adds under the channel "npm" (rules 2 and 6): over a
|
|
@@ -501,6 +536,7 @@ export function switchConsequence() {
|
|
|
501
536
|
// found, and that the project will run the older one; for a project not bound
|
|
502
537
|
// yet, that the switch is recorded once it is.
|
|
503
538
|
export function switchReason({ copy, running, runs = running, decision, unbound = false }) {
|
|
539
|
+
const off = isSyncedCopy(copy) ? "loading the npm copy over it" : "turning it off";
|
|
504
540
|
const parts = [];
|
|
505
541
|
// Against what the project runs once the copy is off (`runs`: the shared
|
|
506
542
|
// directory's version when another checkout wrote a newer one), not against
|
|
@@ -509,7 +545,7 @@ export function switchReason({ copy, running, runs = running, decision, unbound
|
|
|
509
545
|
if (copy && typeof copy.version === "string" && runs && cmpPrecedence(copy.version, runs) > 0) {
|
|
510
546
|
const c = proceedCase(decision, copy.version);
|
|
511
547
|
parts.push(
|
|
512
|
-
`${copy.key} ${copy.version} is newer than this package (${running})${c ? ` — ${c}` : ""};
|
|
548
|
+
`${copy.key} ${copy.version} is newer than this package (${running})${c ? ` — ${c}` : ""}; ${off} runs this project on the older ${runs}`,
|
|
513
549
|
);
|
|
514
550
|
}
|
|
515
551
|
if (unbound) parts.push("the project is not bound yet: the switch is recorded in its binding once it is");
|
|
@@ -655,14 +691,21 @@ export function behindAdvice(copy, version, { step = false } = {}) {
|
|
|
655
691
|
const middle = step
|
|
656
692
|
? `its own upgrade runs after this run's agents block and may plan the block back to its own version; until it updates, its doctor may call the block stale, and the next run may plan the block again`
|
|
657
693
|
: `until it updates, the agents block and anything else this run writes for the project are at ${version}, and its own doctor may call the block stale`;
|
|
658
|
-
|
|
694
|
+
const update = isSyncedCopy(copy)
|
|
695
|
+
? `${SYNCED_UPDATE_STEPS[0].toUpperCase()}${SYNCED_UPDATE_STEPS.slice(1)}.`
|
|
696
|
+
: `Update it (${COPY_UPDATE_STEPS}).`;
|
|
697
|
+
return `${head}: ${middle}. ${update}`;
|
|
659
698
|
}
|
|
660
699
|
|
|
661
700
|
// This checkout's npm registration enabled beside the kept copy: the keep
|
|
662
701
|
// changes no registration, so it stays; its uninstall (rule 4) drops it.
|
|
663
|
-
|
|
702
|
+
// A kept directory copy does not load while it stays: the installed copy wins.
|
|
703
|
+
export function npmRowBeside({ harness, id, surface, copy = null }) {
|
|
704
|
+
const stays = isSyncedCopy(copy)
|
|
705
|
+
? `stays enabled, and ${harness.display_name} loads it over the kept directory copy until it goes`
|
|
706
|
+
: "stays enabled beside the kept copy";
|
|
664
707
|
return {
|
|
665
|
-
lead: `This checkout's npm registration (${id}) stays
|
|
708
|
+
lead: `This checkout's npm registration (${id}) ${stays}. To drop it, from a terminal in this project:`,
|
|
666
709
|
command: coreCommand("uninstall", { args: runArgs({ harnesses: [harness.id], surfaces: [surface] }) }),
|
|
667
710
|
};
|
|
668
711
|
}
|
|
@@ -80,6 +80,8 @@ import {
|
|
|
80
80
|
readOverlayAt,
|
|
81
81
|
layoutRoster,
|
|
82
82
|
installChannel,
|
|
83
|
+
updateHint,
|
|
84
|
+
syncedPluginEntries,
|
|
83
85
|
cmpPrecedence,
|
|
84
86
|
cmpVersion,
|
|
85
87
|
blockVisibleTo,
|
|
@@ -90,6 +92,7 @@ import {
|
|
|
90
92
|
bindInherits,
|
|
91
93
|
doctorFallbackText,
|
|
92
94
|
fromTerminalIn,
|
|
95
|
+
COPY_UPDATE_STEPS,
|
|
93
96
|
} from "./lib.mjs";
|
|
94
97
|
import {
|
|
95
98
|
agentOverrides,
|
|
@@ -102,8 +105,13 @@ import {
|
|
|
102
105
|
packageCommand,
|
|
103
106
|
invocation,
|
|
104
107
|
harnessForOverlay,
|
|
108
|
+
activeHarness,
|
|
109
|
+
hostRegistry,
|
|
110
|
+
autoUpdateDefault,
|
|
105
111
|
} from "./harness.mjs";
|
|
106
|
-
|
|
112
|
+
// The pure reader, never models.mjs: that file holds a fetch, and the
|
|
113
|
+
// SessionStart hook imports this one (the portability suite's hook fence).
|
|
114
|
+
import { effortSpec } from "./model-spec.mjs";
|
|
107
115
|
|
|
108
116
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
109
117
|
// measured 2026-09-06, NO harness gives its Bash tool that variable, and a
|
|
@@ -409,6 +417,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
409
417
|
const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
|
|
410
418
|
const wiredRoot = m ? dirname(dirname(m[1])) : null;
|
|
411
419
|
const isLauncher = m ? isLauncherPath(m[1]) : false;
|
|
420
|
+
const gone = isLauncher ? vanishedSyncedRoot(m[1], proj, home) : null;
|
|
412
421
|
if (m && !existsSync(m[1])) {
|
|
413
422
|
out.push(
|
|
414
423
|
finding(
|
|
@@ -420,6 +429,21 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
420
429
|
: `statusLine points at a missing script (stale plugin path?): ${m[1]}`,
|
|
421
430
|
),
|
|
422
431
|
);
|
|
432
|
+
} else if (gone) {
|
|
433
|
+
// A launcher written from a copy synced from claude.ai renders that
|
|
434
|
+
// copy; with sync turned off the host moves it to its trash folder,
|
|
435
|
+
// and the launcher falls back to the person's own status line.
|
|
436
|
+
const src = sourceHarness();
|
|
437
|
+
const r = hostRegistry(src) || {};
|
|
438
|
+
const trash = `~/${src.runtime?.home_default}/${(r.trash_dir || []).join("/")}/`;
|
|
439
|
+
out.push(
|
|
440
|
+
finding(
|
|
441
|
+
"install",
|
|
442
|
+
"warn",
|
|
443
|
+
"statusline",
|
|
444
|
+
`The status line launcher renders the copy from Anthropic's directory at ${gone}, and that copy is gone: the host moves a synced plugin to ${trash} when it is turned off on claude.ai, or when "${r.sync_setting}": false is in your user settings. Until a copy is back the launcher runs your own status line, or none. Two ways back: turn it on again (on claude.ai, or remove "${r.sync_setting}": false) and restart, or install projectstore through a channel of ours, ${fromTerminalIn(proj)}:\n${packageCommand([], "setup")}`,
|
|
445
|
+
),
|
|
446
|
+
);
|
|
423
447
|
} else if (m && isPluginCacheRoot(wiredRoot, home)) {
|
|
424
448
|
// Only a versioned cache path can go stale this way. The launcher
|
|
425
449
|
// carries no version, and a dev checkout is wired deliberately and
|
|
@@ -513,6 +537,27 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
513
537
|
return out;
|
|
514
538
|
}
|
|
515
539
|
|
|
540
|
+
// The root a launcher of ours falls back to, when that root is a copy synced
|
|
541
|
+
// from claude.ai that is no longer on disk and no other synced copy is: the
|
|
542
|
+
// launcher then has nothing of ours to render. Null otherwise.
|
|
543
|
+
// A synced copy vanishes into the host's trash folder (sync turned off, or
|
|
544
|
+
// the plugin turned off on claude.ai). With no trash folder there is nothing
|
|
545
|
+
// to name, and the launcher is not read: one existsSync on every SessionStart,
|
|
546
|
+
// not a file read (the B review, 2026-10-15).
|
|
547
|
+
function vanishedSyncedRoot(launcher, proj, home) {
|
|
548
|
+
const r = hostRegistry(sourceHarness());
|
|
549
|
+
if (!r?.trash_dir || !existsSync(join(claudeHome(home), ...r.trash_dir))) return null;
|
|
550
|
+
let root;
|
|
551
|
+
try {
|
|
552
|
+
root = JSON.parse(readFileSync(launcher, "utf8").match(/const FALLBACK_ROOT = ("(?:[^"\\]|\\.)*");/)[1]);
|
|
553
|
+
} catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
if (installChannel(root, { home, harness: sourceHarness() }) !== "synced") return null;
|
|
557
|
+
if (existsSync(join(root, "scripts", "statusline.mjs"))) return null;
|
|
558
|
+
return syncedPluginEntries(home, proj).length ? null : root;
|
|
559
|
+
}
|
|
560
|
+
|
|
516
561
|
// The one-time offer after a plugin update (the story "Seamless upgrade from
|
|
517
562
|
// 0.27.1 to 0.28"): a launcher we wrote before file stamps existed still
|
|
518
563
|
// renders, but its embedded fallback root is frozen at the old version until
|
|
@@ -943,17 +988,22 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
943
988
|
// 2026-10-03 amendment, finding 7).
|
|
944
989
|
const remedy = layoutRemedy(proj, { root, home });
|
|
945
990
|
// The block and its move are the source harness's — a pre-0.28 binding
|
|
946
|
-
// and that harness's command — so its fact under any other listener.
|
|
991
|
+
// and that harness's command — so its fact under any other listener. The
|
|
992
|
+
// move writes the source harness's overlay, whoever listens: since the
|
|
993
|
+
// overlay follows the shell that bundles the core (layout spec, contract 2,
|
|
994
|
+
// as amended 2026-10-15), the listener's `where` would name another file.
|
|
995
|
+
const src = sourceHarness();
|
|
996
|
+
const dest = relative(proj, layoutPaths(proj).overlay(src?.runtime?.overlay || src?.id));
|
|
947
997
|
out.push(
|
|
948
998
|
aboutHarness(
|
|
949
999
|
finding(
|
|
950
1000
|
"install",
|
|
951
1001
|
"warn",
|
|
952
1002
|
"agents-in-binding",
|
|
953
|
-
`${b} still carries an agents block — since 0.28 the models live in ${
|
|
1003
|
+
`${b} still carries an agents block — since 0.28 the models live in ${dest} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
|
|
954
1004
|
b,
|
|
955
1005
|
),
|
|
956
|
-
|
|
1006
|
+
src,
|
|
957
1007
|
speaker,
|
|
958
1008
|
),
|
|
959
1009
|
);
|
|
@@ -1117,7 +1167,7 @@ export function checkPluginRegistration(
|
|
|
1117
1167
|
"info",
|
|
1118
1168
|
"plugin-registration",
|
|
1119
1169
|
[
|
|
1120
|
-
`${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
|
|
1170
|
+
`${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 its updates no longer reach 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:`,
|
|
1121
1171
|
packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface}` }),
|
|
1122
1172
|
"Restart, then in the session:",
|
|
1123
1173
|
invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" }),
|
|
@@ -1222,7 +1272,10 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
1222
1272
|
// A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
|
|
1223
1273
|
if (e.version && e.present && e.enabled !== false)
|
|
1224
1274
|
seen.push({
|
|
1225
|
-
|
|
1275
|
+
// A synced copy has no registry row: its version is its own plugin.json's.
|
|
1276
|
+
source: e.synced
|
|
1277
|
+
? `synced copy ${e.key} at ${e.path}`
|
|
1278
|
+
: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`,
|
|
1226
1279
|
version: e.version,
|
|
1227
1280
|
harness: registryOf,
|
|
1228
1281
|
});
|
|
@@ -1257,6 +1310,44 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
1257
1310
|
];
|
|
1258
1311
|
}
|
|
1259
1312
|
|
|
1313
|
+
// A copy from Anthropic's directory that does not load (the PS-DIRECTORY
|
|
1314
|
+
// story "A copy installed from Anthropic's directory…", decision 5): Claude
|
|
1315
|
+
// Code loads an enabled copy of the same name from any other origin over a
|
|
1316
|
+
// synced one (the loading reference, "Name conflicts", item 5). Named with the
|
|
1317
|
+
// copy that loads and the way back, which is to turn that copy off, in its own
|
|
1318
|
+
// channel: the npm registration's uninstall, the host's disable for any other.
|
|
1319
|
+
// A checkout whose binding records the switch (`channel_switch`, written by
|
|
1320
|
+
// setup's npm answer) chose the npm copy over it: no info, as the git
|
|
1321
|
+
// copy's silenced-copy info goes under the mark (the B2 review).
|
|
1322
|
+
export function checkSyncedCopy(
|
|
1323
|
+
proj,
|
|
1324
|
+
{ home = homedir(), harness = sourceHarness(), speaker = speakingHarness(), cfg = null } = {},
|
|
1325
|
+
) {
|
|
1326
|
+
const r = hostRegistry(harness);
|
|
1327
|
+
if (!r?.synced_dir || channelMark(cfg)) return [];
|
|
1328
|
+
const copies = projectCopies(home, proj);
|
|
1329
|
+
const synced = copies.find((e) => e.synced);
|
|
1330
|
+
const loads = copies.find((e) => !e.synced);
|
|
1331
|
+
if (!synced || !loads) return [];
|
|
1332
|
+
const v = (e) => (e.version ? ` ${e.version}` : "");
|
|
1333
|
+
const back =
|
|
1334
|
+
installChannel(loads.path, { home, harness }) === "registration"
|
|
1335
|
+
? `remove the npm registration, ${fromTerminalIn(proj)}, outside the session:\n${packageCommand(harness, "uninstall", { version: "latest", args: `--surface ${r.surface}` })}`
|
|
1336
|
+
: `turn ${loads.key} off in the session: /plugin → Installed, then disable ${loads.key}. Claude Code loads the directory copy at the next start.`;
|
|
1337
|
+
return [
|
|
1338
|
+
aboutHarness(
|
|
1339
|
+
finding(
|
|
1340
|
+
"install",
|
|
1341
|
+
"info",
|
|
1342
|
+
"synced-copy",
|
|
1343
|
+
`${synced.key}${v(synced)}, from Anthropic's directory (${synced.path}), is not loaded in this project: Claude Code loads ${loads.key}${v(loads)} over it. To use the directory copy instead, ${back}`,
|
|
1344
|
+
),
|
|
1345
|
+
harness,
|
|
1346
|
+
speaker,
|
|
1347
|
+
),
|
|
1348
|
+
];
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1260
1351
|
export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
|
|
1261
1352
|
const out = [];
|
|
1262
1353
|
// Copies of our agents a host loads from its own agents directory: a fact
|
|
@@ -1362,10 +1453,15 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
|
|
|
1362
1453
|
// The variable names come from the manifest (runtime.agent_overrides); the
|
|
1363
1454
|
// overlay is read only when one is set, so a clean startup reads nothing.
|
|
1364
1455
|
export function checkEnvEffort(proj = null) {
|
|
1456
|
+
// The variable is the active harness's (agentOverrides keys on detection), so
|
|
1457
|
+
// the overlay it outranks is that harness's too — not the listener's, which
|
|
1458
|
+
// in a shell's tree can be another harness (layout spec, contract 2, as
|
|
1459
|
+
// amended 2026-10-15).
|
|
1460
|
+
const h = activeHarness();
|
|
1365
1461
|
return agentOverrides()
|
|
1366
1462
|
.filter((o) => o.kind === "effort")
|
|
1367
1463
|
.map((o) => {
|
|
1368
|
-
const ov = proj ? readOverlayAt(proj) : null;
|
|
1464
|
+
const ov = proj ? readOverlayAt(proj, h?.runtime?.overlay || h?.id) : null;
|
|
1369
1465
|
const set = ov
|
|
1370
1466
|
? [
|
|
1371
1467
|
...(ov.efforts.default ? ["agents.default.effort"] : []),
|
|
@@ -1529,13 +1625,16 @@ export function layoutRemedy(
|
|
|
1529
1625
|
});
|
|
1530
1626
|
const channel = installChannel(root, { home, harness });
|
|
1531
1627
|
if (channel === "registration") return npm(pluginVersion(root));
|
|
1532
|
-
|
|
1628
|
+
// A copy synced from claude.ai runs its own bin too (contract 12, item 1, as
|
|
1629
|
+
// widened by the PS-DIRECTORY story "A copy installed from Anthropic's
|
|
1630
|
+
// directory…"): its channel is claude.ai's, never npm's.
|
|
1631
|
+
if (channel === "marketplace" || channel === "synced") return own(root);
|
|
1533
1632
|
// The selection is lib's selectCopy: this checkout's row first, then
|
|
1534
1633
|
// user-scope rows by recency, never another checkout's.
|
|
1535
1634
|
const copy = selectCopy(home, proj, { harness });
|
|
1536
1635
|
if (copy) {
|
|
1537
1636
|
const v = copy.version ? ` (${copy.version})` : "";
|
|
1538
|
-
const then = "and the startup line names the command";
|
|
1637
|
+
const then = "and the next session's startup line names the command";
|
|
1539
1638
|
if (copy.missing === "bin")
|
|
1540
1639
|
return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
|
|
1541
1640
|
// Chosen first, gated after: the copy is what the project runs, so an
|
|
@@ -1698,16 +1797,34 @@ export function checkVaultGit(cfg) {
|
|
|
1698
1797
|
];
|
|
1699
1798
|
}
|
|
1700
1799
|
|
|
1701
|
-
// Marketplace auto-update (maintainer request 2026-07-03):
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
1800
|
+
// Marketplace auto-update (maintainer request 2026-07-03): a marketplace whose
|
|
1801
|
+
// auto-update is off leaves a stale plugin looking like "the feature is
|
|
1802
|
+
// broken". Read the real registries and, when the flag is off, tell the user
|
|
1803
|
+
// the exact correct values.
|
|
1804
|
+
export function checkAutoUpdate(home = homedir(), harness = speakingHarness(), { root = pluginRoot() } = {}) {
|
|
1706
1805
|
const out = [];
|
|
1707
1806
|
// Auto-update is a toggle of the host's own plugin marketplace. A harness
|
|
1708
1807
|
// whose registration is not the host's plugin system (Codex's is a portable
|
|
1709
1808
|
// marketplace driven by our shell) has no such toggle to report on.
|
|
1710
1809
|
if (harness?.surfaces?.plugin?.format !== "host-plugin-registration") return out;
|
|
1810
|
+
const reg = hostRegistry(harness) || {};
|
|
1811
|
+
const at = (parts) => `~/${harness.runtime?.home_default || ".claude"}/${parts.join("/")}`;
|
|
1812
|
+
// A copy synced from claude.ai (Anthropic's directory) has no marketplace and
|
|
1813
|
+
// no install record: claude.ai updates it. One info, from the manifest's
|
|
1814
|
+
// update hint, and never another copy's install record, which would
|
|
1815
|
+
// describe a copy this session does not run (the PS-DIRECTORY story "A copy
|
|
1816
|
+
// installed from Anthropic's directory…", decision 5).
|
|
1817
|
+
if (installChannel(root, { home, harness }) === "synced") {
|
|
1818
|
+
out.push(
|
|
1819
|
+
finding(
|
|
1820
|
+
"install",
|
|
1821
|
+
"info",
|
|
1822
|
+
"auto-update",
|
|
1823
|
+
`This session runs projectstore from Anthropic's directory (${root}), so no marketplace auto-update applies to it. ${updateHint({ root, home }).line}`,
|
|
1824
|
+
),
|
|
1825
|
+
);
|
|
1826
|
+
return out;
|
|
1827
|
+
}
|
|
1711
1828
|
// Two corrections over the first version of this check, both found by running
|
|
1712
1829
|
// doctor straight out of a checkout (2026-08-05):
|
|
1713
1830
|
//
|
|
@@ -1725,7 +1842,7 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1725
1842
|
.replace(/\\/g, "/")
|
|
1726
1843
|
.match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
|
|
1727
1844
|
const inst = installedPluginRoot(home);
|
|
1728
|
-
const marketplace = marketplaceOf(
|
|
1845
|
+
const marketplace = marketplaceOf(root) || marketplaceOf(inst && inst.path);
|
|
1729
1846
|
if (!marketplace) {
|
|
1730
1847
|
out.push(
|
|
1731
1848
|
finding(
|
|
@@ -1738,9 +1855,13 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1738
1855
|
return out;
|
|
1739
1856
|
}
|
|
1740
1857
|
|
|
1858
|
+
// A marketplace added from claude.ai is recorded in a registry file of its
|
|
1859
|
+
// own, and is added again from the host's list, never by a source.
|
|
1860
|
+
const claudeai = Boolean(reg.claudeai_prefix) && marketplace.startsWith(reg.claudeai_prefix);
|
|
1861
|
+
const file = (claudeai && reg.marketplaces_claudeai) || reg.marketplaces || ["plugins", "known_marketplaces.json"];
|
|
1741
1862
|
let registry = null;
|
|
1742
1863
|
try {
|
|
1743
|
-
registry = JSON.parse(readFileSync(join(claudeHome(home),
|
|
1864
|
+
registry = JSON.parse(readFileSync(join(claudeHome(home), ...file), "utf8"));
|
|
1744
1865
|
} catch {}
|
|
1745
1866
|
const entry = registry ? registry[marketplace] : null;
|
|
1746
1867
|
// A directory marketplace — the package's own registration — has no remote
|
|
@@ -1753,29 +1874,42 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1753
1874
|
"install",
|
|
1754
1875
|
"warn",
|
|
1755
1876
|
"auto-update",
|
|
1756
|
-
`Marketplace "${marketplace}" is missing from
|
|
1877
|
+
`Marketplace "${marketplace}" is missing from ${at(file)} — updates cannot be tracked. ` +
|
|
1878
|
+
(claudeai
|
|
1879
|
+
? "Add it again from /plugin → Marketplaces, where claude.ai lists it."
|
|
1880
|
+
: "Re-add it: /plugin marketplace add <owner/repo>."),
|
|
1757
1881
|
),
|
|
1758
1882
|
);
|
|
1759
1883
|
return out;
|
|
1760
1884
|
}
|
|
1761
1885
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1886
|
+
// Claude Code's documented order (the loading reference, "Which marketplaces
|
|
1887
|
+
// and plugins auto-update"): the settings entry first, then the registry
|
|
1888
|
+
// entry, then the marketplace's default (registry.auto_update) — on for
|
|
1889
|
+
// Anthropic's official marketplaces and claude.ai's, off for every other.
|
|
1890
|
+
const userSettings = reg.user_settings || ["settings.json"];
|
|
1891
|
+
let set;
|
|
1892
|
+
try {
|
|
1893
|
+
set = JSON.parse(readFileSync(join(claudeHome(home), ...userSettings), "utf8"))?.extraKnownMarketplaces?.[
|
|
1894
|
+
marketplace
|
|
1895
|
+
]?.autoUpdate;
|
|
1896
|
+
} catch {}
|
|
1897
|
+
const [enabled, why] =
|
|
1898
|
+
typeof set === "boolean"
|
|
1899
|
+
? [set, `set to false in ${at(userSettings)} → extraKnownMarketplaces, which wins over the registry`]
|
|
1900
|
+
: typeof entry.autoUpdate === "boolean"
|
|
1901
|
+
? [entry.autoUpdate, `set to false in ${at(file)}`]
|
|
1902
|
+
: [autoUpdateDefault(harness, marketplace) === true, "off by default for this marketplace"];
|
|
1769
1903
|
if (!enabled) {
|
|
1770
1904
|
out.push(
|
|
1771
1905
|
finding(
|
|
1772
1906
|
"install",
|
|
1773
1907
|
"warn",
|
|
1774
1908
|
"auto-update",
|
|
1775
|
-
`Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
|
|
1776
|
-
`Correct values: "autoUpdate": true on the "${marketplace}" entry in
|
|
1777
|
-
`(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in
|
|
1778
|
-
`extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path
|
|
1909
|
+
`Auto-update is OFF for marketplace "${marketplace}" (${why}) — new projectstore releases will not be noticed. ` +
|
|
1910
|
+
`Correct values: "autoUpdate": true on the "${marketplace}" entry in ${at(file)} ` +
|
|
1911
|
+
`(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ${at(userSettings)} → ` +
|
|
1912
|
+
`extraKnownMarketplaces.${marketplace}.autoUpdate: true, which wins where that entry exists. Manual path, ${COPY_UPDATE_STEPS}.`,
|
|
1779
1913
|
),
|
|
1780
1914
|
);
|
|
1781
1915
|
}
|
|
@@ -1787,14 +1921,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1787
1921
|
// On the fallback path our own script is a checkout, so pluginRoot() and
|
|
1788
1922
|
// pluginVersion() describe the checkout, not the install — reporting that as
|
|
1789
1923
|
// the running version is the very confusion this check was just fixed for.
|
|
1790
|
-
const named = marketplaceOf(
|
|
1924
|
+
const named = marketplaceOf(root) ? { path: root, version: null } : inst;
|
|
1791
1925
|
try {
|
|
1792
|
-
const
|
|
1793
|
-
const name = JSON.parse(readFileSync(join(
|
|
1926
|
+
const from = (named && named.path) || root;
|
|
1927
|
+
const name = JSON.parse(readFileSync(join(from, ".claude-plugin", "plugin.json"), "utf8")).name;
|
|
1794
1928
|
const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
|
|
1795
1929
|
const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
|
|
1796
1930
|
const running =
|
|
1797
|
-
(named && named.version) || JSON.parse(readFileSync(join(
|
|
1931
|
+
(named && named.version) || JSON.parse(readFileSync(join(from, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
1798
1932
|
// Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
|
|
1799
1933
|
if (latest && running && cmpPrecedence(latest, running) > 0) {
|
|
1800
1934
|
out.push(
|
|
@@ -1802,7 +1936,7 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1802
1936
|
"install",
|
|
1803
1937
|
"warn",
|
|
1804
1938
|
"auto-update",
|
|
1805
|
-
`A newer ${name} is available: v${latest} (running v${running}) —
|
|
1939
|
+
`A newer ${name} is available: v${latest} (running v${running}) — ${COPY_UPDATE_STEPS}.`,
|
|
1806
1940
|
),
|
|
1807
1941
|
);
|
|
1808
1942
|
}
|
|
@@ -1839,19 +1973,47 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
|
|
|
1839
1973
|
finding("install", "issue", "mcp", `.mcp.json at the plugin root is not valid JSON: ${e.message}`, ".mcp.json"),
|
|
1840
1974
|
];
|
|
1841
1975
|
}
|
|
1976
|
+
// What it must launch is the manifest's entry (scripts/mcp-server.mjs, the
|
|
1977
|
+
// PS-DIRECTORY story "The MCP server's entry reaches no install verb…"),
|
|
1978
|
+
// checked as a path and a file, never imported: mcp.mjs on SessionStart's
|
|
1979
|
+
// path would load the server's whole graph into the hook.
|
|
1980
|
+
// The entry is the checked copy's own: a root that carries its own manifest
|
|
1981
|
+
// says what it launches, and one whose manifest names no entry (0.30.5 and
|
|
1982
|
+
// older) launches `bin/projectstore.mjs mcp`, which serves — a newer doctor
|
|
1983
|
+
// run against such a copy must not call it broken (the E2b review). A root
|
|
1984
|
+
// with no manifest of its own is held to this doctor's.
|
|
1985
|
+
let entry = mcp.entry || "scripts/mcp-server.mjs";
|
|
1986
|
+
let verbArg = null;
|
|
1987
|
+
try {
|
|
1988
|
+
const own = JSON.parse(readFileSync(join(root, "harnesses", `${harness.id}.json`), "utf8"));
|
|
1989
|
+
const e = own?.surfaces?.mcp?.entry;
|
|
1990
|
+
if (typeof e === "string" && e) entry = e;
|
|
1991
|
+
else [entry, verbArg] = ["bin/projectstore.mjs", "mcp"];
|
|
1992
|
+
} catch {}
|
|
1842
1993
|
const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
|
|
1843
1994
|
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
1844
|
-
|
|
1995
|
+
const launches = args.some((a) => String(a).endsWith(`/${entry}`)) && (!verbArg || args.includes(verbArg));
|
|
1996
|
+
if (!server || !launches) {
|
|
1845
1997
|
return [
|
|
1846
1998
|
finding(
|
|
1847
1999
|
"install",
|
|
1848
2000
|
"issue",
|
|
1849
2001
|
"mcp",
|
|
1850
|
-
|
|
2002
|
+
`.mcp.json at the plugin root does not launch ${entry} — the MCP read tools will not answer.`,
|
|
1851
2003
|
".mcp.json",
|
|
1852
2004
|
),
|
|
1853
2005
|
];
|
|
1854
2006
|
}
|
|
2007
|
+
if (!existsSync(join(root, entry)))
|
|
2008
|
+
return [
|
|
2009
|
+
finding(
|
|
2010
|
+
"install",
|
|
2011
|
+
"issue",
|
|
2012
|
+
"mcp",
|
|
2013
|
+
`.mcp.json launches ${entry}, which is not in the plugin root — the MCP read tools will not answer; this install is incomplete.`,
|
|
2014
|
+
".mcp.json",
|
|
2015
|
+
),
|
|
2016
|
+
];
|
|
1855
2017
|
return [
|
|
1856
2018
|
finding(
|
|
1857
2019
|
"install",
|
|
@@ -3059,7 +3221,8 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
3059
3221
|
...checkHostIgnored(proj, { env: opts.env }),
|
|
3060
3222
|
...checkTrackedRuntime(proj),
|
|
3061
3223
|
...checkVaultGit(cfg),
|
|
3062
|
-
...checkAutoUpdate(),
|
|
3224
|
+
...checkAutoUpdate(opts.home, undefined, { root: opts.root }),
|
|
3225
|
+
...checkSyncedCopy(proj, { home: opts.home, cfg }),
|
|
3063
3226
|
...checkMcpRegistration(),
|
|
3064
3227
|
...checkLayout(proj, undefined, opts),
|
|
3065
3228
|
...checkOverlays(cfg, proj, opts),
|