projectstore-claude 0.30.5 → 0.30.7
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 -17
- 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/images/icon.png +0 -0
- package/node_modules/projectstore/harnesses/claude-code.json +60 -33
- package/node_modules/projectstore/hooks/hooks.json +6 -6
- package/node_modules/projectstore/hooks/session-start.mjs +55 -24
- package/node_modules/projectstore/package.json +11 -12
- 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/configure-menu.mjs +9 -7
- package/node_modules/projectstore/scripts/configure.mjs +0 -1
- package/node_modules/projectstore/scripts/convergence.mjs +59 -16
- package/node_modules/projectstore/scripts/doctor.mjs +184 -32
- package/node_modules/projectstore/scripts/harness.mjs +34 -3
- 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 +167 -68
- 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 +25 -0
- package/node_modules/projectstore/scripts/models.mjs +26 -120
- 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/verbs.mjs +1202 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +18 -6
- 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
|
@@ -444,16 +444,17 @@ function effortOptions(es, display, item, name, isDefault, levels, model, d, men
|
|
|
444
444
|
return opts;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
// The values frame's footer: the list's source and age, the
|
|
448
|
-
// asked, or why nothing can be asked (the ADR, decisions 3 and 6).
|
|
447
|
+
// The values frame's footer: the list's source and age, the catalog being
|
|
448
|
+
// asked, or why nothing can be asked (the ADR, decisions 3 and 6). An alias
|
|
449
|
+
// harness with no list says that its aliases are all it offers.
|
|
449
450
|
function caption(cat, now) {
|
|
450
451
|
const k = cat.known;
|
|
451
452
|
if (!k) return null;
|
|
452
453
|
const name = k.caption || k.live?.caption || "";
|
|
453
|
-
if (cat.busy) return { text: `asking ${k.live?.
|
|
454
|
+
if (cat.busy) return { text: `asking ${k.live?.caption || name}…`, busy: true };
|
|
454
455
|
if (cat.error) return { text: `${k.live?.caption || name} · ${cat.error}` };
|
|
455
456
|
if (k.at) return { text: `${name} · checked ${checkedWhen(k.at, now())}` };
|
|
456
|
-
if (k.
|
|
457
|
+
if (k.spawnTakes === "alias") return { text: k.why ? `aliases only · ${k.why}` : "aliases only" };
|
|
457
458
|
return k.why ? { text: k.why } : null;
|
|
458
459
|
}
|
|
459
460
|
|
|
@@ -503,7 +504,9 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
|
|
|
503
504
|
section: opts.length ? undefined : "",
|
|
504
505
|
description: k?.hidden?.has(v)
|
|
505
506
|
? `${display} hides it from its own picker. It is kept as it is.`
|
|
506
|
-
:
|
|
507
|
+
: k?.spawnTakes === "alias"
|
|
508
|
+
? `Not one of ${display}'s aliases. It is kept as it is.`
|
|
509
|
+
: `Not in ${source ? `${source}'s list` : "any list"}. It is kept as it is.`,
|
|
507
510
|
});
|
|
508
511
|
// No free text where the spawn takes aliases: every other name fails at
|
|
509
512
|
// spawn, and --set is the way to name one (the ADR, decision 5).
|
|
@@ -524,7 +527,7 @@ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now,
|
|
|
524
527
|
description: cat.busy
|
|
525
528
|
? `Asking ${liveSource} for its list now.`
|
|
526
529
|
: !k?.live
|
|
527
|
-
? `Nothing to ask: ${k?.why || "this harness declares no list to read"}.`
|
|
530
|
+
? `Nothing to ask: ${k?.why || (k.spawnTakes === "alias" ? `${display}'s list is its aliases` : "this harness declares no list to read")}.`
|
|
528
531
|
: cat.error
|
|
529
532
|
? `The last ask failed: ${cat.error}. The list is as it was.`
|
|
530
533
|
: `Asks ${liveSource} for its list now.${k.at ? ` Last checked ${checkedWhen(k.at, now())}.` : ""} A list is kept for a day, so opening it never waits on the network.`,
|
|
@@ -612,7 +615,6 @@ export async function runMenu(ctx) {
|
|
|
612
615
|
home,
|
|
613
616
|
now,
|
|
614
617
|
signal: ac.signal,
|
|
615
|
-
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
616
618
|
...(ctx.spawn ? { spawn: ctx.spawn } : {}),
|
|
617
619
|
...(ctx.timers ? { timers: ctx.timers } : {}),
|
|
618
620
|
}).then((r) => {
|
|
@@ -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,
|
|
@@ -103,8 +106,12 @@ import {
|
|
|
103
106
|
invocation,
|
|
104
107
|
harnessForOverlay,
|
|
105
108
|
activeHarness,
|
|
109
|
+
hostRegistry,
|
|
110
|
+
autoUpdateDefault,
|
|
106
111
|
} from "./harness.mjs";
|
|
107
|
-
|
|
112
|
+
// The pure reader, never models.mjs: that file starts a catalog command, and
|
|
113
|
+
// the SessionStart hook imports this one (the portability suite's hook fence).
|
|
114
|
+
import { effortSpec } from "./model-spec.mjs";
|
|
108
115
|
|
|
109
116
|
// A remedy used to interpolate the surface's harness variable here. It cannot:
|
|
110
117
|
// measured 2026-09-06, NO harness gives its Bash tool that variable, and a
|
|
@@ -410,6 +417,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
410
417
|
const m = curCmd.match(/"([^"]+statusline\.mjs)"/) || curCmd.match(/(\S+statusline\.mjs)/);
|
|
411
418
|
const wiredRoot = m ? dirname(dirname(m[1])) : null;
|
|
412
419
|
const isLauncher = m ? isLauncherPath(m[1]) : false;
|
|
420
|
+
const gone = isLauncher ? vanishedSyncedRoot(m[1], proj, home) : null;
|
|
413
421
|
if (m && !existsSync(m[1])) {
|
|
414
422
|
out.push(
|
|
415
423
|
finding(
|
|
@@ -421,6 +429,21 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
421
429
|
: `statusLine points at a missing script (stale plugin path?): ${m[1]}`,
|
|
422
430
|
),
|
|
423
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
|
+
);
|
|
424
447
|
} else if (m && isPluginCacheRoot(wiredRoot, home)) {
|
|
425
448
|
// Only a versioned cache path can go stale this way. The launcher
|
|
426
449
|
// carries no version, and a dev checkout is wired deliberately and
|
|
@@ -514,6 +537,27 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
514
537
|
return out;
|
|
515
538
|
}
|
|
516
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
|
+
|
|
517
561
|
// The one-time offer after a plugin update (the story "Seamless upgrade from
|
|
518
562
|
// 0.27.1 to 0.28"): a launcher we wrote before file stamps existed still
|
|
519
563
|
// renders, but its embedded fallback root is frozen at the old version until
|
|
@@ -1123,7 +1167,7 @@ export function checkPluginRegistration(
|
|
|
1123
1167
|
"info",
|
|
1124
1168
|
"plugin-registration",
|
|
1125
1169
|
[
|
|
1126
|
-
`${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:`,
|
|
1127
1171
|
packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface}` }),
|
|
1128
1172
|
"Restart, then in the session:",
|
|
1129
1173
|
invocation(loadHarness(h.id) || speaker, "doctor", { args: "--fix" }),
|
|
@@ -1228,7 +1272,10 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
1228
1272
|
// A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
|
|
1229
1273
|
if (e.version && e.present && e.enabled !== false)
|
|
1230
1274
|
seen.push({
|
|
1231
|
-
|
|
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}`,
|
|
1232
1279
|
version: e.version,
|
|
1233
1280
|
harness: registryOf,
|
|
1234
1281
|
});
|
|
@@ -1263,6 +1310,44 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
|
|
|
1263
1310
|
];
|
|
1264
1311
|
}
|
|
1265
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
|
+
|
|
1266
1351
|
export function checkOverrideCopies(proj, home = homedir(), harness = speakingHarness()) {
|
|
1267
1352
|
const out = [];
|
|
1268
1353
|
// Copies of our agents a host loads from its own agents directory: a fact
|
|
@@ -1540,13 +1625,16 @@ export function layoutRemedy(
|
|
|
1540
1625
|
});
|
|
1541
1626
|
const channel = installChannel(root, { home, harness });
|
|
1542
1627
|
if (channel === "registration") return npm(pluginVersion(root));
|
|
1543
|
-
|
|
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);
|
|
1544
1632
|
// The selection is lib's selectCopy: this checkout's row first, then
|
|
1545
1633
|
// user-scope rows by recency, never another checkout's.
|
|
1546
1634
|
const copy = selectCopy(home, proj, { harness });
|
|
1547
1635
|
if (copy) {
|
|
1548
1636
|
const v = copy.version ? ` (${copy.version})` : "";
|
|
1549
|
-
const then = "and the startup line names the command";
|
|
1637
|
+
const then = "and the next session's startup line names the command";
|
|
1550
1638
|
if (copy.missing === "bin")
|
|
1551
1639
|
return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
|
|
1552
1640
|
// Chosen first, gated after: the copy is what the project runs, so an
|
|
@@ -1709,16 +1797,34 @@ export function checkVaultGit(cfg) {
|
|
|
1709
1797
|
];
|
|
1710
1798
|
}
|
|
1711
1799
|
|
|
1712
|
-
// Marketplace auto-update (maintainer request 2026-07-03):
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
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() } = {}) {
|
|
1717
1805
|
const out = [];
|
|
1718
1806
|
// Auto-update is a toggle of the host's own plugin marketplace. A harness
|
|
1719
1807
|
// whose registration is not the host's plugin system (Codex's is a portable
|
|
1720
1808
|
// marketplace driven by our shell) has no such toggle to report on.
|
|
1721
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
|
+
}
|
|
1722
1828
|
// Two corrections over the first version of this check, both found by running
|
|
1723
1829
|
// doctor straight out of a checkout (2026-08-05):
|
|
1724
1830
|
//
|
|
@@ -1736,7 +1842,7 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1736
1842
|
.replace(/\\/g, "/")
|
|
1737
1843
|
.match(/\/plugins\/(?:cache|marketplaces)\/([^/]+)(?:\/|$)/) || [])[1] || null;
|
|
1738
1844
|
const inst = installedPluginRoot(home);
|
|
1739
|
-
const marketplace = marketplaceOf(
|
|
1845
|
+
const marketplace = marketplaceOf(root) || marketplaceOf(inst && inst.path);
|
|
1740
1846
|
if (!marketplace) {
|
|
1741
1847
|
out.push(
|
|
1742
1848
|
finding(
|
|
@@ -1749,9 +1855,13 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1749
1855
|
return out;
|
|
1750
1856
|
}
|
|
1751
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"];
|
|
1752
1862
|
let registry = null;
|
|
1753
1863
|
try {
|
|
1754
|
-
registry = JSON.parse(readFileSync(join(claudeHome(home),
|
|
1864
|
+
registry = JSON.parse(readFileSync(join(claudeHome(home), ...file), "utf8"));
|
|
1755
1865
|
} catch {}
|
|
1756
1866
|
const entry = registry ? registry[marketplace] : null;
|
|
1757
1867
|
// A directory marketplace — the package's own registration — has no remote
|
|
@@ -1764,29 +1874,42 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1764
1874
|
"install",
|
|
1765
1875
|
"warn",
|
|
1766
1876
|
"auto-update",
|
|
1767
|
-
`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>."),
|
|
1768
1881
|
),
|
|
1769
1882
|
);
|
|
1770
1883
|
return out;
|
|
1771
1884
|
}
|
|
1772
1885
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
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"];
|
|
1780
1903
|
if (!enabled) {
|
|
1781
1904
|
out.push(
|
|
1782
1905
|
finding(
|
|
1783
1906
|
"install",
|
|
1784
1907
|
"warn",
|
|
1785
1908
|
"auto-update",
|
|
1786
|
-
`Auto-update is OFF for marketplace "${marketplace}" — new projectstore releases will not be noticed. ` +
|
|
1787
|
-
`Correct values: "autoUpdate": true on the "${marketplace}" entry in
|
|
1788
|
-
`(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in
|
|
1789
|
-
`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}.`,
|
|
1790
1913
|
),
|
|
1791
1914
|
);
|
|
1792
1915
|
}
|
|
@@ -1798,14 +1921,14 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1798
1921
|
// On the fallback path our own script is a checkout, so pluginRoot() and
|
|
1799
1922
|
// pluginVersion() describe the checkout, not the install — reporting that as
|
|
1800
1923
|
// the running version is the very confusion this check was just fixed for.
|
|
1801
|
-
const named = marketplaceOf(
|
|
1924
|
+
const named = marketplaceOf(root) ? { path: root, version: null } : inst;
|
|
1802
1925
|
try {
|
|
1803
|
-
const
|
|
1804
|
-
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;
|
|
1805
1928
|
const catalog = JSON.parse(readFileSync(join(entry.installLocation, ".claude-plugin", "marketplace.json"), "utf8"));
|
|
1806
1929
|
const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
|
|
1807
1930
|
const running =
|
|
1808
|
-
(named && named.version) || JSON.parse(readFileSync(join(
|
|
1931
|
+
(named && named.version) || JSON.parse(readFileSync(join(from, ".claude-plugin", "plugin.json"), "utf8")).version;
|
|
1809
1932
|
// Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
|
|
1810
1933
|
if (latest && running && cmpPrecedence(latest, running) > 0) {
|
|
1811
1934
|
out.push(
|
|
@@ -1813,7 +1936,7 @@ export function checkAutoUpdate(home = homedir(), harness = speakingHarness()) {
|
|
|
1813
1936
|
"install",
|
|
1814
1937
|
"warn",
|
|
1815
1938
|
"auto-update",
|
|
1816
|
-
`A newer ${name} is available: v${latest} (running v${running}) —
|
|
1939
|
+
`A newer ${name} is available: v${latest} (running v${running}) — ${COPY_UPDATE_STEPS}.`,
|
|
1817
1940
|
),
|
|
1818
1941
|
);
|
|
1819
1942
|
}
|
|
@@ -1850,19 +1973,47 @@ export function checkMcpRegistration(root = pluginRoot(), harness = speakingHarn
|
|
|
1850
1973
|
finding("install", "issue", "mcp", `.mcp.json at the plugin root is not valid JSON: ${e.message}`, ".mcp.json"),
|
|
1851
1974
|
];
|
|
1852
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 {}
|
|
1853
1993
|
const server = reg && reg.mcpServers && reg.mcpServers.projectstore;
|
|
1854
1994
|
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
1855
|
-
|
|
1995
|
+
const launches = args.some((a) => String(a).endsWith(`/${entry}`)) && (!verbArg || args.includes(verbArg));
|
|
1996
|
+
if (!server || !launches) {
|
|
1856
1997
|
return [
|
|
1857
1998
|
finding(
|
|
1858
1999
|
"install",
|
|
1859
2000
|
"issue",
|
|
1860
2001
|
"mcp",
|
|
1861
|
-
|
|
2002
|
+
`.mcp.json at the plugin root does not launch ${entry} — the MCP read tools will not answer.`,
|
|
1862
2003
|
".mcp.json",
|
|
1863
2004
|
),
|
|
1864
2005
|
];
|
|
1865
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
|
+
];
|
|
1866
2017
|
return [
|
|
1867
2018
|
finding(
|
|
1868
2019
|
"install",
|
|
@@ -3070,7 +3221,8 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
3070
3221
|
...checkHostIgnored(proj, { env: opts.env }),
|
|
3071
3222
|
...checkTrackedRuntime(proj),
|
|
3072
3223
|
...checkVaultGit(cfg),
|
|
3073
|
-
...checkAutoUpdate(),
|
|
3224
|
+
...checkAutoUpdate(opts.home, undefined, { root: opts.root }),
|
|
3225
|
+
...checkSyncedCopy(proj, { home: opts.home, cfg }),
|
|
3074
3226
|
...checkMcpRegistration(),
|
|
3075
3227
|
...checkLayout(proj, undefined, opts),
|
|
3076
3228
|
...checkOverlays(cfg, proj, opts),
|