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.
Files changed (50) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
  3. package/node_modules/projectstore/.mcp.json +1 -2
  4. package/node_modules/projectstore/README.md +65 -16
  5. package/node_modules/projectstore/agents/archaeologist.md +1 -1
  6. package/node_modules/projectstore/agents/librarian.md +1 -1
  7. package/node_modules/projectstore/agents/planner.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +14 -11
  9. package/node_modules/projectstore/commands/reconcile.md +1 -1
  10. package/node_modules/projectstore/commands/review.md +2 -2
  11. package/node_modules/projectstore/commands/story.md +1 -1
  12. package/node_modules/projectstore/docs/getting-started.md +3 -4
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/docs/images/icon.png +0 -0
  16. package/node_modules/projectstore/harnesses/claude-code.json +61 -11
  17. package/node_modules/projectstore/harnesses/codex.json +45 -15
  18. package/node_modules/projectstore/hooks/band.tsx +19 -8
  19. package/node_modules/projectstore/hooks/hooks.json +6 -6
  20. package/node_modules/projectstore/hooks/session-start.mjs +55 -24
  21. package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
  22. package/node_modules/projectstore/package.json +11 -12
  23. package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
  24. package/node_modules/projectstore/scripts/band.mjs +20 -2
  25. package/node_modules/projectstore/scripts/binding.mjs +1 -1
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
  27. package/node_modules/projectstore/scripts/cli.mjs +74 -1373
  28. package/node_modules/projectstore/scripts/convergence.mjs +59 -16
  29. package/node_modules/projectstore/scripts/doctor.mjs +199 -36
  30. package/node_modules/projectstore/scripts/harness.mjs +88 -6
  31. package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
  32. package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
  33. package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
  34. package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
  35. package/node_modules/projectstore/scripts/lib.mjs +387 -107
  36. package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
  37. package/node_modules/projectstore/scripts/mcp.mjs +60 -60
  38. package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
  39. package/node_modules/projectstore/scripts/models.mjs +6 -10
  40. package/node_modules/projectstore/scripts/query.mjs +7 -6
  41. package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
  42. package/node_modules/projectstore/scripts/setup.mjs +62 -12
  43. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
  44. package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
  45. package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
  46. package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
  47. package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
  48. package/package.json +3 -3
  49. package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
  50. 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
- if (["registration", "marketplace"].includes(installChannel(root, { home }))) return true;
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. Under channel "git"
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
- for (const e of projectCopiesByKey(home, projectDir)) {
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 gate's text
441
- // (rule 2), for a switch planned under no channel answer without a
442
- // terminal, the run's own --harness repeated in each command. setup takes
443
- // no --surface, so a narrowed run's --surface is not repeated. A block with
444
- // no lead follows the one before it.
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 from = gate.keys?.length ? ` (${gate.keys.join(", ")})` : "";
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 the git marketplace${from}, and moving it to the npm install is a choice made at a terminal in this project:`,
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 text = `this project runs a newer copy (${copy.key} ${copy.version}) than this package (${running}), so it is not switched off${c ? ` — ${c}` : ""}.`;
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 /plugin update no longer reaches it — from now on it is updated by ${coreCommand("setup")}`;
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}` : ""}; turning it off runs this project on the older ${runs}`,
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
- return `${head}: ${middle}. Update it (${COPY_UPDATE_STEPS}).`;
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
- export function npmRowBeside({ harness, id, surface }) {
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 enabled beside the kept copy. To drop it, from a terminal in this project:`,
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
- import { effortSpec } from "./models.mjs";
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 ${where} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
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
- sourceHarness(),
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 a /plugin update no longer reaches the project. ${old ? "Update that copy first — 0.27.x reads a moved project as unbound. " : ""}To go back to it, ${fromTerminalIn(proj)}, outside the session:`,
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
- source: `registry ${e.key}${e.scope ? " (" + e.scope + ")" : ""} at ${e.path}`,
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
- if (channel === "marketplace") return own(root);
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): third-party
1702
- // marketplaces do NOT auto-update by default, so a stale plugin looks like
1703
- // "the feature is broken". Read the real registries and, when the flag is
1704
- // off, tell the user the exact correct values.
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(pluginRoot()) || marketplaceOf(inst && inst.path);
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), "plugins", "known_marketplaces.json"), "utf8"));
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 ~/.claude/plugins/known_marketplaces.json — updates cannot be tracked. Re-add it: /plugin marketplace add <owner/repo>.`,
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
- let enabled = entry.autoUpdate === true;
1763
- if (!enabled) {
1764
- try {
1765
- const s = JSON.parse(readFileSync(join(claudeHome(home), "settings.json"), "utf8"));
1766
- if (s?.extraKnownMarketplaces?.[marketplace]?.autoUpdate === true) enabled = true;
1767
- } catch {}
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 ~/.claude/plugins/known_marketplaces.json ` +
1777
- `(set via /plugin → Marketplaces → ${marketplace} → toggle auto-update), or in ~/.claude/settings.json → ` +
1778
- `extraKnownMarketplaces.${marketplace}.autoUpdate: true. Manual path: /plugin marketplace update ${marketplace}, then /reload-plugins.`,
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(pluginRoot()) ? { path: pluginRoot(), version: null } : inst;
1924
+ const named = marketplaceOf(root) ? { path: root, version: null } : inst;
1791
1925
  try {
1792
- const root = (named && named.path) || pluginRoot();
1793
- const name = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
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(root, ".claude-plugin", "plugin.json"), "utf8")).version;
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}) — run /plugin marketplace update ${marketplace}, then /reload-plugins.`,
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
- if (!server || !args.some((a) => String(a).endsWith("/bin/projectstore.mjs")) || !args.includes("mcp")) {
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
- ".mcp.json at the plugin root does not launch bin/projectstore.mjs mcp — the MCP read tools will not answer.",
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),