projectstore-claude 0.30.3 โ†’ 0.30.4

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.
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "๐Ÿ“š Your agent runs the project through a verified loop: task โ†’ artifact (ADR ยท spec ยท epic ยท story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ€” and any model can pick the project up tomorrow.",
15
- "version": "0.30.3",
15
+ "version": "0.30.4",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.30.3",
4
+ "version": "0.30.4",
5
5
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -12,7 +12,10 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
12
12
  1. **Ask** via AskUserQuestion: "Register projectstore's agents in
13
13
  CLAUDE.md/AGENTS.md so every session routes to them (critic after
14
14
  authoring artifacts, planner before implementing, reviewer before commit)?
15
- [Yes / No]". On No, stop.
15
+ [Yes / No]". On No, stop. When the block is already there marked newer
16
+ than this copy's template (doctor's info, or `status` showing
17
+ `ours-newer`), name both numbers in the question: registering writes this
18
+ copy's older block over the one marked vN.
16
19
  2. **Run the verb** and print its output verbatim:
17
20
  ```bash
18
21
  node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" install --harness claude-code --surface agents_block --project "${CLAUDE_PROJECT_DIR}"
@@ -53,7 +56,7 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
53
56
 
54
57
  Run `node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" plan --json --surface agents_block --project "${CLAUDE_PROJECT_DIR}"`
55
58
  and report each item's `state` from `result.items[]` โ€” the bin wraps the plan in its envelope (`ours-current`, `ours-stale` with its reason,
56
- `ours-absent`, or a refusal). Then:
59
+ `ours-newer` โ€” marked above this copy's template: install, upgrade and setup leave it, and a named run such as `register` writes this copy's over it โ€” `ours-absent`, or a refusal). Then:
57
60
 
58
61
  - Block: present in which file, marker version vs the installed template, agent
59
62
  names vs the layout roster.
@@ -42,6 +42,10 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
42
42
  the verb adds the import. For another harness the finding names its own
43
43
  `--harness`; relay that command. Never Edit or Write the block yourself โ€”
44
44
  the verb is its only writer (install spec, contract 6).
45
+ - `agents-block` **info** that the block is "marked vN, newer than this
46
+ copy's vM" โ†’ **report only.** Install, upgrade and setup leave such a block as it is (install spec, contract 6 as amended
47
+ 2026-10-14). Relay the finding and its command verbatim; run it only if
48
+ the user asks, because it writes this copy's older block over the one marked vN.
45
49
  - `statusline` issues โ†’ offer running `/projectstore:statusline on|off`,
46
50
  which installs or removes the entry and the launcher behind a preview
47
51
  (the SessionStart hook only refreshes an entry that already exists), and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.30.3",
3
+ "version": "0.30.4",
4
4
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -56,6 +56,7 @@ import {
56
56
  writeFileAtomic,
57
57
  envFlag,
58
58
  isEphemeralRoot,
59
+ COPY_UPDATE_STEPS,
59
60
  } from "./lib.mjs";
60
61
  import { loadHarness, loadHarnesses } from "./harness.mjs";
61
62
  import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
@@ -348,6 +349,34 @@ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
348
349
  ].join(" ");
349
350
  }
350
351
 
352
+ // The run a deferral sends to a terminal (the install spec, contract 6, as
353
+ // amended 2026-10-14): the run's own verb for one harness, its --surface
354
+ // selection, --global for a global removal, and setup's --channel npm when
355
+ // that was the answer for the harness whose channel it is (setup's rule 13 โ€”
356
+ // a keep is never repeated). `plan` writes nothing, so it names install. One
357
+ // builder for a deferred row's reason and setup's DONE, so the two agree.
358
+ export function terminalRun({
359
+ verb = "install",
360
+ harness = null,
361
+ version = null,
362
+ surfaces = null,
363
+ globalRemoval = false,
364
+ channel = null,
365
+ } = {}) {
366
+ const run = verb === "plan" ? "install" : verb;
367
+ const hostRegistration = Object.values(harness?.surfaces || {}).some(
368
+ (s) => s && typeof s === "object" && s.format === "host-plugin-registration",
369
+ );
370
+ const args = [
371
+ runArgs({ harnesses: harness ? [harness.id] : [], surfaces }),
372
+ globalRemoval ? "--global" : "",
373
+ run === "setup" && channel === "npm" && hostRegistration ? "--channel npm" : "",
374
+ ]
375
+ .filter(Boolean)
376
+ .join(" ");
377
+ return coreCommand(run, { version, args });
378
+ }
379
+
351
380
  // Stop when the registry's latest is at least a newer copy this project runs
352
381
  // โ€” the newest such copy is named. A copy the latest does not reach, or no
353
382
  // answer, proceeds: the rows say which case it was (proceedCase). `read` is
@@ -577,9 +606,15 @@ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget
577
606
  ...(Array.isArray(result.refusals) ? result.refusals : []),
578
607
  ...items.filter((i) => i.action === "refuse").map((i) => i.reason),
579
608
  ].filter((x) => typeof x === "string" && x);
609
+ // A deferred row's reason may carry its run on the lines after its prose
610
+ // (the install spec, contract 6, as amended 2026-10-14): only the prose is
611
+ // quoted inside our own sentence, never another copy's command.
580
612
  const deferred = items
581
613
  .filter((i) => i.deferred === true)
582
- .map((i) => ({ surface: String(i.surface ?? ""), reason: typeof i.reason === "string" ? i.reason : null }));
614
+ .map((i) => ({
615
+ surface: String(i.surface ?? ""),
616
+ reason: typeof i.reason === "string" ? i.reason.split("\n")[0] : null,
617
+ }));
583
618
  return {
584
619
  argv,
585
620
  envelope: true,
@@ -603,6 +638,26 @@ export function ownUpgrade(copy, harness) {
603
638
  };
604
639
  }
605
640
 
641
+ // Whether a kept copy is behind the running package (the install spec,
642
+ // contract 21, rule 9, as amended 2026-10-14): semver precedence, rc
643
+ // included. Never for a copy or a run with no semver version recorded โ€” a
644
+ // recorded "unknown" or a sha must not rank.
645
+ export function copyBehind(copy, version) {
646
+ return isSemver(copy?.version) && isSemver(version) && cmpPrecedence(copy.version, version) < 0;
647
+ }
648
+
649
+ // What the keep says when its copy is behind: the project's surfaces are at
650
+ // the running version until the copy updates, and the update in the copy's
651
+ // channel. With a step, the copy's own upgrade runs after this run's agents
652
+ // block, and a copy shipped before this rule plans the block back.
653
+ export function behindAdvice(copy, version, { step = false } = {}) {
654
+ const head = `${copy.key} ${copy.version} is older than this package (${version})`;
655
+ const middle = step
656
+ ? `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
+ : `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}).`;
659
+ }
660
+
606
661
  // This checkout's npm registration enabled beside the kept copy: the keep
607
662
  // changes no registration, so it stays; its uninstall (rule 4) drops it.
608
663
  export function npmRowBeside({ harness, id, surface }) {
@@ -56,6 +56,8 @@ import {
56
56
  AGENTS_BLOCK_OPEN_SRC,
57
57
  AGENTS_BLOCK_CLOSE,
58
58
  agentsBlockVersion,
59
+ newerBlockWords,
60
+ isNewerBlock,
59
61
  findAgentsBlock,
60
62
  statusLineLauncherPath,
61
63
  LAUNCHER_HEADER,
@@ -369,7 +371,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
369
371
  // A harness without a status line hears nothing about one, whatever the
370
372
  // shared binding asks for (generation spec, contract 18).
371
373
  if (harness?.surfaces?.statusline?.supported === false) return out;
372
- const local = hostSettingsPath(proj);
374
+ const local = hostSettingsPath(proj, process.env, harness);
373
375
  let cur = null;
374
376
  if (existsSync(local)) {
375
377
  try {
@@ -527,10 +529,10 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
527
529
  // spec's 2026-10-03 amendment, case S1).
528
530
  let wired = null;
529
531
  try {
530
- wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
532
+ wired = JSON.parse(readFileSync(hostSettingsPath(proj, process.env, harness), "utf8"))?.statusLine?.command || null;
531
533
  } catch {}
532
534
  if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
533
- const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
535
+ const lp = pickExisting(statusLineLauncherPath(proj, harness?.id), legacyStatusLineLauncherPath(proj));
534
536
  let text;
535
537
  try {
536
538
  text = readFileSync(lp, "utf8");
@@ -567,6 +569,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
567
569
  let unclosedFiles = 0;
568
570
  const perFile = {};
569
571
  const staleVersions = [];
572
+ const newerVersions = [];
570
573
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
571
574
  const p = join(proj, name);
572
575
  if (!existsSync(p)) continue;
@@ -610,7 +613,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
610
613
  }
611
614
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
612
615
  const v = parseInt(m[1], 10);
613
- if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
616
+ // A marker above this copy's template is never stale: this copy writes
617
+ // no block down (the install spec, contract 6, as amended 2026-10-14).
618
+ if (isNewerBlock(v, AGENT_BLOCK_VERSION)) newerVersions.push({ file: name, v });
619
+ else if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
614
620
  }
615
621
  }
616
622
  if (blocks === 0) {
@@ -667,6 +673,22 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
667
673
  );
668
674
  out.push(refuses ? f : moveRepairs(f, fact));
669
675
  }
676
+ // A newer block: an info in the install report only โ€” the startup line
677
+ // counts issues alone and offers none of this โ€” and only for one
678
+ // well-formed block. Its way out is the running copy's own bin from a
679
+ // terminal, never the session's command, whose copy would write its own
680
+ // version. Not folded into the layout move, which leaves it as it is too.
681
+ if (blocks === 1 && !refuses)
682
+ for (const s of newerVersions)
683
+ out.push(
684
+ finding(
685
+ "install",
686
+ "info",
687
+ "agents-block",
688
+ `Agents block in ${s.file} is ${newerBlockWords(s.v, AGENT_BLOCK_VERSION)}: install, upgrade and setup leave it as it is. To write this copy's v${AGENT_BLOCK_VERSION} over it ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${newerReader(proj, s.file, env)} --surface agents_block`,
689
+ s.file,
690
+ ),
691
+ );
670
692
  // Placement, held to the predicate install plans from (the install spec,
671
693
  // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
672
694
  // every harness the project uses. rc.1 and rc.2 left a block in an
@@ -711,6 +733,19 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
711
733
  return out;
712
734
  }
713
735
 
736
+ // The harness a newer block's way out names: the one this run identified,
737
+ // else one the project uses whose files hold the block, else the source
738
+ // harness โ€” never Claude Code's by default in a Codex-only project, whose
739
+ // install would create CLAUDE.md and overwrite nothing.
740
+ function newerReader(proj, file, env) {
741
+ const identified = identifiedHarnessId(env);
742
+ if (identified) return identified;
743
+ const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
744
+ return (
745
+ used.find((id) => (loadHarness(id)?.surfaces?.agents_block?.files || []).includes(file)) || sourceHarness()?.id
746
+ );
747
+ }
748
+
714
749
  // While the layout move is pending, the findings the move itself repairs fold
715
750
  // into it instead of being counted beside it (the layout spec, contract 7 as
716
751
  // amended after the rc.3 tag; the upgrade story's O1): the stale block, which
@@ -1597,7 +1632,7 @@ function hostIgnoreLineMissing(proj, env) {
1597
1632
  .map((l) => l.trim());
1598
1633
  } catch {}
1599
1634
  if (lines.includes(".claude/") || lines.includes(".claude")) return [];
1600
- return [relative(proj, hostSettingsPath(proj, env))].filter((w) => !lines.includes(w));
1635
+ return [relative(proj, hostSettingsPath(proj, env, sourceHarness()))].filter((w) => !lines.includes(w));
1601
1636
  }
1602
1637
 
1603
1638
  // Whether git ignores the host's machine-local settings file (the layout spec,
@@ -1611,7 +1646,7 @@ function hostIgnoreLineMissing(proj, env) {
1611
1646
  // line read. Full report only: a subprocess, like checkTrackedRuntime.
1612
1647
  export function checkHostIgnored(proj, { env = process.env } = {}) {
1613
1648
  if (!existsSync(join(proj, ".git"))) return [];
1614
- const file = relative(proj, hostSettingsPath(proj, env));
1649
+ const file = relative(proj, hostSettingsPath(proj, env, sourceHarness()));
1615
1650
  const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
1616
1651
  const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
1617
1652
  if (!missing.length) return [];
@@ -503,10 +503,12 @@ export function speakingHarness(env = process.env, dir = MANIFEST_DIR) {
503
503
 
504
504
  // โ”€โ”€โ”€ Runtime names and paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
505
505
 
506
- // The three branded names the active harness uses, so a caller can say which
507
- // variable it is talking about without spelling it.
508
- export function runtimeEnvNames(env = process.env) {
509
- const r = activeHarness(env)?.runtime || {};
506
+ // The three branded names a harness uses โ€” the active one unless the caller
507
+ // names it โ€” so a caller can say which variable it is talking about without
508
+ // spelling it. A surface rendered FOR a harness (the status-line launcher is
509
+ // Claude Code's) names that harness, whichever process renders it.
510
+ export function runtimeEnvNames(env = process.env, harness = activeHarness(env)) {
511
+ const r = harness?.runtime || {};
510
512
  return { projectDir: r.project_dir_env || null, pluginRoot: r.plugin_root_env || null, home: r.home_env || null };
511
513
  }
512
514
 
@@ -816,17 +818,20 @@ export function pickExisting(current, legacy) {
816
818
 
817
819
  // The per-project directory the HARNESS discovers (".claude" for the source
818
820
  // layout) โ€” how a harness is detected in a project and where its own settings
819
- // live. Not where our config is: that is layoutPaths() (2026-09-06).
820
- export function projectConfigDir(env = process.env) {
821
- return activeHarness(env)?.runtime?.harness_dir || ".claude";
821
+ // live. Not where our config is: that is layoutPaths() (2026-09-06). A
822
+ // caller reading ONE harness's settings names it: which harness launched the
823
+ // process is not whose directory that is (the install spec, contract 6, as
824
+ // amended 2026-10-14).
825
+ export function projectConfigDir(env = process.env, harness = activeHarness(env)) {
826
+ return harness?.runtime?.harness_dir || ".claude";
822
827
  }
823
828
 
824
829
  // The host's own machine-local settings file in a project (the statusline
825
830
  // surface's file in the manifest โ€” `.claude/settings.local.json` for Claude
826
831
  // Code). A harness surface, not ours: the one `.claude` path the core may
827
832
  // build, and it builds it from the manifest (2026-09-06).
828
- export function hostSettingsPath(projectDir, env = process.env) {
829
- const h = activeHarness(env);
833
+ export function hostSettingsPath(projectDir, env = process.env, harness = activeHarness(env)) {
834
+ const h = harness;
830
835
  const file = h?.surfaces?.statusline?.file || join(h?.runtime?.harness_dir || ".claude", "settings.local.json");
831
836
  return join(projectDir, file);
832
837
  }
@@ -193,6 +193,9 @@ import {
193
193
  npmRowBeside,
194
194
  pinFor,
195
195
  coreCommand,
196
+ terminalRun,
197
+ copyBehind,
198
+ behindAdvice,
196
199
  DEFAULT_REGISTRY,
197
200
  NOTICE_BUDGET_MS,
198
201
  READ_TIMEOUT_MS,
@@ -235,6 +238,7 @@ import {
235
238
  removeTreeUnder,
236
239
  projectDirRefusal,
237
240
  fromTerminalIn,
241
+ newerBlockWords,
238
242
  registrationProvenance,
239
243
  envFlag,
240
244
  selectCopy,
@@ -461,12 +465,27 @@ function planAgentsBlock(ctx, key, s) {
461
465
  return items;
462
466
  }
463
467
 
464
- const entry = `projectstore:agents v${a.version}`;
465
468
  const current = a.current;
469
+ // A block marked above this copy's template is never rendered down (the
470
+ // install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
471
+ // migration or a dedupe carries its own bytes, stamped with its own marker.
472
+ const newer = a.newer || null;
473
+ const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
474
+ const body = newer ? newer.block.block : a.desired;
475
+ const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
466
476
  // Two files, one block each: resolve in favour of the preferred file โ€”
467
477
  // register migrates, never duplicates (ADR-002 decision 3).
468
478
  for (const e of a.duplicates || [])
469
- items.push(removal(e, { state: "ours-stale", reason: `duplicate of the block in ${current.file}` }));
479
+ items.push(
480
+ removal(e, {
481
+ state: "ours-stale",
482
+ reason:
483
+ newer === e
484
+ ? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
485
+ : `duplicate of the block in ${current.file}`,
486
+ }),
487
+ );
488
+ let newerInPlace = null;
470
489
 
471
490
  if (!current) {
472
491
  const target = preferred;
@@ -501,10 +520,43 @@ function planAgentsBlock(ctx, key, s) {
501
520
  entry,
502
521
  state: "ours-absent",
503
522
  action: preferred.present ? "add" : "create",
504
- reason: ctx.readers && !current.own ? a.reason : `migrated from ${current.file}`,
523
+ reason:
524
+ ctx.readers && !current.own
525
+ ? a.reason
526
+ : `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
505
527
  before: preferred.present ? preferred.text : null,
506
- after: replaceAgentsBlock(preferred.present ? preferred.text : "", a.desired),
528
+ after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
529
+ });
530
+ } else if (newer && newer !== current) {
531
+ // The duplicate carries the higher marker: its text replaces the stayer's.
532
+ items.push({
533
+ surface: key,
534
+ kind: "shared",
535
+ path: current.path,
536
+ entry,
537
+ state: "ours-stale",
538
+ action: "replace-entry",
539
+ reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
540
+ before: current.text,
541
+ after: replaceAgentsBlock(current.text, body),
507
542
  });
543
+ } else if (newer) {
544
+ // It stays as it is; a named run with nothing else to place overwrites it,
545
+ // decided once the imports are known (below).
546
+ newerInPlace = {
547
+ surface: key,
548
+ kind: "shared",
549
+ path: current.path,
550
+ entry,
551
+ state: "ours-newer",
552
+ action: "skip",
553
+ reason: `${words}: left as it is โ€” ${
554
+ ctx.takesSurface === false
555
+ ? `to write this copy's v${a.version} over it: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
556
+ : `name --surface ${key} to write this copy's v${a.version} over it`
557
+ }`,
558
+ };
559
+ items.push(newerInPlace);
508
560
  } else if (current.block.v === a.version && current.block.block === a.desired) {
509
561
  items.push({
510
562
  surface: key,
@@ -590,6 +642,23 @@ function planAgentsBlock(ctx, key, s) {
590
642
  after,
591
643
  });
592
644
  }
645
+ // Naming the surface confirms the overwrite only when the run has nothing
646
+ // else to do for the block: it is the only block and every import it needs
647
+ // is in place. Doctor's placement remedies name the surface too, and they
648
+ // must never downgrade (contract 9 as amended 2026-10-14).
649
+ if (
650
+ newerInPlace &&
651
+ (ctx.surfaces || []).includes(key) &&
652
+ !(a.duplicates || []).length &&
653
+ !items.some((i) => i.surface === `${key}_import`)
654
+ )
655
+ Object.assign(newerInPlace, {
656
+ entry: `projectstore:agents v${a.version}`,
657
+ action: "replace-entry",
658
+ reason: `v${current.block.v} โ†’ v${a.version}: ${words}, and --surface ${key} names it`,
659
+ before: current.text,
660
+ after: replaceAgentsBlock(current.text, a.desired),
661
+ });
593
662
  return items;
594
663
  }
595
664
 
@@ -758,7 +827,7 @@ function planPortableRegistration(ctx, key, s) {
758
827
  // registration's removal decides them.
759
828
  if (registrationDeferred(env, harness)) {
760
829
  ctx.incomplete = true;
761
- return [{ ...base, action: "skip", deferred: true, reason: deferralReason(harness, env) }];
830
+ return [{ ...base, action: "skip", deferred: true, ...deferredRun(ctx, deferralReason(harness, env)) }];
762
831
  }
763
832
  const steps = [];
764
833
  if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `explicit global removal forgets ${a.id}`));
@@ -827,7 +896,7 @@ function planPortableRegistration(ctx, key, s) {
827
896
  ...base,
828
897
  action: "skip",
829
898
  deferred: true,
830
- reason: `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}`,
899
+ ...deferredRun(ctx, `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}`),
831
900
  },
832
901
  ];
833
902
  }
@@ -932,6 +1001,29 @@ function deferralReason(harness, env) {
932
1001
  return `this runs inside a ${harness.display_name} session (${variable} is set), and the host's CLI would change the configuration that session runs on โ€” run it from a terminal outside the session`;
933
1002
  }
934
1003
 
1004
+ // A deferral names its run (the install spec, contract 6, as amended
1005
+ // 2026-10-14): the prose as before, byte for byte, then where to run it and
1006
+ // the run's own command alone on its line โ€” a way out, as the presentation
1007
+ // spec's contract 6 gives them, so `--json` carries it in `reason` and an
1008
+ // agent relaying the envelope relays the command.
1009
+ function deferredRun(ctx, prose) {
1010
+ // The prose already says "outside the session"; the lead adds only where.
1011
+ const where = fromTerminalIn(ctx.projectDir);
1012
+ return advised(prose, [
1013
+ {
1014
+ lead: `${where.charAt(0).toUpperCase()}${where.slice(1)}:`,
1015
+ command: terminalRun({
1016
+ verb: ctx.verb,
1017
+ harness: ctx.harness,
1018
+ version: ctx.version,
1019
+ surfaces: ctx.surfaces,
1020
+ globalRemoval: ctx.globalRemoval,
1021
+ channel: ctx.channel,
1022
+ }),
1023
+ },
1024
+ ]);
1025
+ }
1026
+
935
1027
  function planRegistration(ctx, key, s) {
936
1028
  const { projectDir, mode, root, home, env, harness } = ctx;
937
1029
  const a = analyseRegistration(projectDir, s, { root, home, harness, env });
@@ -977,7 +1069,7 @@ function planRegistration(ctx, key, s) {
977
1069
  }
978
1070
  if (deferred) {
979
1071
  ctx.incomplete = true;
980
- return [{ ...base, action: "skip", reason: inSession }];
1072
+ return [{ ...base, action: "skip", ...deferredRun(ctx, inSession) }];
981
1073
  }
982
1074
  const steps = [];
983
1075
  if (a.installed)
@@ -1109,7 +1201,14 @@ function planRegistration(ctx, key, s) {
1109
1201
  }
1110
1202
  if (needsHost && deferred) {
1111
1203
  ctx.incomplete = true;
1112
- return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }];
1204
+ return [
1205
+ {
1206
+ ...base,
1207
+ action: "skip",
1208
+ deferred: true,
1209
+ ...deferredRun(ctx, `${a.reason ? a.reason + "; " : ""}${inSession}`),
1210
+ },
1211
+ ];
1113
1212
  }
1114
1213
  // Our directory newer than this package and damaged (the converge story's
1115
1214
  // rule 6): never rewritten from the older package โ€” this checkout and every
@@ -1392,6 +1491,11 @@ function keepTarget(projectDir, ids, { root, home, env }) {
1392
1491
  function keepEnv(env, { harness, projectDir, copyRoot, harnessHome }) {
1393
1492
  const out = childEnv(env);
1394
1493
  for (const m of loadHarnesses().values()) if (m.runtime?.plugin_root_env) delete out[m.runtime.plugin_root_env];
1494
+ // An override this run inherited names THIS process's harness; in the copy
1495
+ // it would turn the copy's own home and directory reads to another harness.
1496
+ // The copy knows itself by the project-dir variable written below (the
1497
+ // install spec, contract 21, as amended 2026-10-14).
1498
+ delete out.PROJECTSTORE_HARNESS;
1395
1499
  const r = harness.runtime || {};
1396
1500
  if (r.plugin_root_env) out[r.plugin_root_env] = copyRoot;
1397
1501
  if (r.project_dir_env) out[r.project_dir_env] = projectDir;
@@ -1410,7 +1514,9 @@ const KEEP_SURFACE = "channel";
1410
1514
  // (keepArgv), its touches the writes the copy's own plan listed (`read`, made
1411
1515
  // by the prepare step) less the agents block's rows, which this run's one
1412
1516
  // block item writes before it (the story "One run plans the agents block
1413
- // onceโ€ฆ", rule 1). No write left: no item. The other rows it can be:
1517
+ // onceโ€ฆ", rule 1). No write left: no item โ€” unless the copy is behind the
1518
+ // running package, when a skip carries that line (contract 21, rule 9, as
1519
+ // amended 2026-10-14), as the update row does. The other rows it can be:
1414
1520
  // - a copy that cannot run the form (lib's capability read): skip, with the
1415
1521
  // update in its channel โ€” in the row's reason, since DONE never prints when
1416
1522
  // nothing else writes, and as DONE's next line;
@@ -1547,11 +1653,29 @@ function planKeep(ctx, target, read) {
1547
1653
  }),
1548
1654
  ];
1549
1655
  }
1550
- if (!writes.length)
1656
+ // A copy behind the running package says so (the install spec, contract
1657
+ // 21, rule 9, as amended 2026-10-14): with no write left, a skip whose
1658
+ // reason is that line โ€” neither deferred nor incomplete, so a re-run stays
1659
+ // zero-change; with writes, a line on the update row and in DONE.
1660
+ const behind = copyBehind(copy, ctx.version);
1661
+ if (!writes.length) {
1662
+ if (behind) {
1663
+ const line = behindAdvice(copy, ctx.version);
1664
+ return [item({ action: "skip", ...advised(line, besides) }, [{ lead: line }])];
1665
+ }
1551
1666
  return beside ? [item({ action: "skip", ...advised(`${named} has nothing to change here`, besides) })] : [];
1667
+ }
1668
+ const note = behind ? [{ lead: behindAdvice(copy, ctx.version, { step: true }) }] : [];
1552
1669
  if (insideHostSession(env, harness)) {
1553
1670
  ctx.incomplete = true;
1554
- return [item({ action: "skip", deferred: true, ...advised(`${kept}; ${inSessionReason(harness)}`, besides) })];
1671
+ const prose = `${kept}; ${inSessionReason(harness)}`;
1672
+ return [
1673
+ item({
1674
+ action: "skip",
1675
+ deferred: true,
1676
+ ...advised(prose, [...note, ...deferredRun(ctx, prose).advice.blocks, ...besides]),
1677
+ }),
1678
+ ];
1555
1679
  }
1556
1680
  const step = {
1557
1681
  kind: "host",
@@ -1560,7 +1684,7 @@ function planKeep(ctx, target, read) {
1560
1684
  why: `the kept copy's own upgrade, from its own bin without registering (the layout remedy's form), read back as JSON; it re-plans from the files as they stand then, after the agents block above`,
1561
1685
  touches: writes,
1562
1686
  };
1563
- return [item({ action: "update", ...advised(kept, besides), steps: [step] })];
1687
+ return [item({ action: "update", ...advised(kept, [...note, ...besides]), steps: [step] }, note)];
1564
1688
  }
1565
1689
 
1566
1690
  // The mark (the converge story's rule 2), for the channel "npm" only: one
@@ -1889,7 +2013,7 @@ function planJsonEntry(ctx, key, s) {
1889
2013
  },
1890
2014
  ];
1891
2015
  }
1892
- const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot });
2016
+ const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
1893
2017
  if (a.state === "unparseable")
1894
2018
  return [
1895
2019
  { surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
@@ -2165,6 +2289,7 @@ export function plan(
2165
2289
  ensureState = false,
2166
2290
  recordKeep = false,
2167
2291
  takesSurface = true,
2292
+ verb = null,
2168
2293
  } = {},
2169
2294
  ) {
2170
2295
  projectDir = resolve(projectDir);
@@ -2409,7 +2534,17 @@ export function plan(
2409
2534
  ? blockRun.map((m) => ({ display: m.display_name, files: agentsBlockRow(m)[1].files || [] }))
2410
2535
  : null;
2411
2536
  blockItems = planAgentsBlock(
2412
- { projectDir, mode, root, harness: owner, run: blockRun, readers, layout, surfaces: surfaces || [] },
2537
+ {
2538
+ projectDir,
2539
+ mode,
2540
+ root,
2541
+ harness: owner,
2542
+ run: blockRun,
2543
+ readers,
2544
+ layout,
2545
+ surfaces: surfaces || [],
2546
+ takesSurface,
2547
+ },
2413
2548
  key,
2414
2549
  { ...s, files: commonBlockFiles(blockRun) },
2415
2550
  );
@@ -2469,6 +2604,9 @@ export function plan(
2469
2604
  channelFacts: null,
2470
2605
  refusals: [],
2471
2606
  takesSurface,
2607
+ // The verb a deferral repeats (the install spec, contract 6, as amended
2608
+ // 2026-10-14): the run's own โ€” upgrade and setup plan in install mode.
2609
+ verb: verb || mode,
2472
2610
  };
2473
2611
  // The keep (rule 5): the run's one block first โ€” over the whole run set,
2474
2612
  // this harness included โ€” then the keep's one item, and none of this
@@ -5258,6 +5396,7 @@ export async function runVerb(verb, projectDir, opts = {}) {
5258
5396
  plan(projectDir, {
5259
5397
  ...opts,
5260
5398
  mode,
5399
+ verb,
5261
5400
  state: verb === "upgrade" || setup || (verb === "uninstall" && !opts.globalRemoval),
5262
5401
  version: prep.version,
5263
5402
  payloadRoots: prep.payloadRoots,
@@ -30,6 +30,7 @@ import {
30
30
  projectRoot as harnessProjectRoot,
31
31
  pluginRoot as harnessPluginRoot,
32
32
  agentHome as harnessAgentHome,
33
+ harnessHome,
33
34
  configPath as harnessConfigPath,
34
35
  projectConfigDir as harnessProjectConfigDir,
35
36
  detectHarnessId,
@@ -545,12 +546,18 @@ export function pidAlive(pid) {
545
546
 
546
547
  // โ”€โ”€โ”€ Installed-plugin resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
547
548
 
548
- // The harness's config directory. Almost always ~/.claude, but the harness's
549
- // home variable relocates it โ€” and a consumer that hardcodes the default
550
- // silently resolves nothing for those users instead of failing loudly. The
551
- // name is kept for its callers; the variable comes from the manifest.
549
+ // Claude Code's config directory. Almost always ~/.claude, but its home
550
+ // variable relocates it โ€” and a consumer that hardcodes the default silently
551
+ // resolves nothing for those users instead of failing loudly. The variable
552
+ // comes from the source harness's manifest, never the active harness's: with
553
+ // another harness's home variable exported (CODEX_HOME), detection makes that
554
+ // harness active in a directory with no harness folder, and every Claude Code
555
+ // path used to follow it into the other home (the install spec, contract 6,
556
+ // as amended 2026-10-14). The host this process runs in is harnessAgentHome's
557
+ // question, asked by its own callers; it stands in here only if a manifest
558
+ // ever named no home at all.
552
559
  export function claudeHome(home = homedir()) {
553
- return harnessAgentHome(process.env, home);
560
+ return harnessHome(sourceHarness(), { env: process.env, home }) || harnessAgentHome(process.env, home);
554
561
  }
555
562
 
556
563
  // The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
@@ -633,9 +640,11 @@ export function pluginEnabled(key, home = homedir(), projectDir = null) {
633
640
  enabled = user.enabledPlugins[key] !== false;
634
641
  if (projectDir) {
635
642
  // The committed project file, then the checkout's local one over it โ€” the
636
- // host's own precedence (measured 2026-09-05, --scope local).
643
+ // host's own precedence (measured 2026-09-05, --scope local). Claude Code's
644
+ // project directory, whichever harness this process runs as.
645
+ const dir = harnessProjectConfigDir(process.env, sourceHarness());
637
646
  for (const f of ["settings.json", "settings.local.json"]) {
638
- const proj = read(join(projectDir, harnessProjectConfigDir(), f));
647
+ const proj = read(join(projectDir, dir, f));
639
648
  if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
640
649
  enabled = proj.enabledPlugins[key] !== false;
641
650
  }
@@ -758,13 +767,17 @@ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarne
758
767
  return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
759
768
  }
760
769
 
770
+ // How a git-marketplace copy is updated, in its own channel: the one phrase
771
+ // every advice about a kept or older copy quotes.
772
+ export const COPY_UPDATE_STEPS = "in Claude Code: /plugin marketplace update, then /plugin update, then restart";
773
+
761
774
  // The advice for a marketplace copy that cannot run what is named for it, in
762
775
  // that copy's own channel: the host's update, then a restart. `cannot` says
763
776
  // what it cannot do, `then` what follows the update. One text for doctor's
764
777
  // layout remedy and the keep's skip row and DONE line.
765
778
  export function copyUpdateAdvice(copy, { cannot, then }) {
766
779
  const v = copy?.version ? ` (${copy.version})` : "";
767
- return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
780
+ return `This project's projectstore plugin${v} ${cannot}: update it first (${COPY_UPDATE_STEPS}), ${then}`;
768
781
  }
769
782
 
770
783
  // The mark a channel switch leaves in this checkout's machine-local binding
@@ -1236,8 +1249,11 @@ export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root =
1236
1249
  // into state/<harness>/ made wrong; it is named instead, as plan() resolved
1237
1250
  // it โ€” the same string the provenance line records, so an npx render and a
1238
1251
  // cache-install render stay byte-identical.
1239
- export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env) {
1240
- const names = runtimeEnvNames(env);
1252
+ // `harness` is the one the launcher is FOR (the plan's harness), so a render
1253
+ // from a process another harness launched still bakes in Claude Code's names;
1254
+ // the SessionStart refresh passes none and renders for its own host.
1255
+ export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env, harness) {
1256
+ const names = harness ? runtimeEnvNames(env, harness) : runtimeEnvNames(env);
1241
1257
  const subs = [
1242
1258
  ['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
1243
1259
  ['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
@@ -1281,13 +1297,20 @@ export function writeStatusLineLauncher(projectDir, root) {
1281
1297
  // The command our entry should carry for this installation: the version-free
1282
1298
  // launcher for a marketplace-cache install, the plugin's own script for a dev
1283
1299
  // checkout (its path has no version to go stale). One resolver, shared by the
1284
- // SessionStart refresh and the installer, so the two cannot disagree.
1285
- export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home = homedir()) {
1300
+ // SessionStart refresh and the installer, so the two cannot disagree. The
1301
+ // installer names the harness it plans (its launcher's state directory); the
1302
+ // refresh names none and means its own host.
1303
+ export function desiredStatusLineCommand(
1304
+ projectDir,
1305
+ root = pluginRoot(),
1306
+ home = homedir(),
1307
+ harnessId = activeHarnessId(),
1308
+ ) {
1286
1309
  const launcher = isPluginCacheRoot(root, home);
1287
1310
  return {
1288
1311
  launcher,
1289
1312
  command: launcher
1290
- ? `node "${statusLineLauncherPath(projectDir)}"`
1313
+ ? `node "${statusLineLauncherPath(projectDir, harnessId)}"`
1291
1314
  : `node "${join(root, "scripts", "statusline.mjs")}"`,
1292
1315
  };
1293
1316
  }
@@ -1416,6 +1439,19 @@ export function agentsBlockVersion(tmpl = null) {
1416
1439
  return m ? Number(m[1]) : null;
1417
1440
  }
1418
1441
 
1442
+ // A block whose marker is above this copy's template (the install spec,
1443
+ // contract 6, as amended 2026-10-14): the words every surface says it with.
1444
+ // Never "written by a newer projectstore" โ€” a hand-edited marker reads the same.
1445
+ export function newerBlockWords(v, version) {
1446
+ return `marked v${v}, newer than this copy's v${version}`;
1447
+ }
1448
+
1449
+ // Whether a marker ranks above the template's. A template with no marker
1450
+ // (or a marker that is not a number) is never outranked.
1451
+ export function isNewerBlock(v, version) {
1452
+ return Number.isInteger(v) && Number.isInteger(version) && v > version;
1453
+ }
1454
+
1419
1455
  // The first block in `text`: its version, its span, whether it closes, and how
1420
1456
  // many open markers the file carries (more than one is a duplicate).
1421
1457
  export function findAgentsBlock(text) {
@@ -3871,7 +3907,7 @@ export function writeAnchorOffer(projectDir, sessionId, rec) {
3871
3907
  // within moments of each other both pass this check and both offer the same
3872
3908
  // name โ€” the race is narrowed, not closed, and the ADR says so.
3873
3909
  export function liveSessionNames(selfSessionId, dir = null) {
3874
- const root = dir || join(claudeHome(), "sessions");
3910
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3875
3911
  const out = [];
3876
3912
  let files;
3877
3913
  try {
@@ -3895,7 +3931,7 @@ export function liveSessionNames(selfSessionId, dir = null) {
3895
3931
 
3896
3932
  // What this session is currently called, if the harness has recorded a name.
3897
3933
  export function ownSessionName(selfSessionId, dir = null) {
3898
- const root = dir || join(claudeHome(), "sessions");
3934
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3899
3935
  let files;
3900
3936
  try {
3901
3937
  files = readdirSync(root);
@@ -64,7 +64,7 @@ import {
64
64
  languageNames,
65
65
  } from "./binding.mjs";
66
66
  import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
67
- import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
67
+ import { runningCopies, coreCommand, channelChoices, pinFor, terminalRun } from "./convergence.mjs";
68
68
  import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
69
69
  import { resolveBinding } from "./worktree.mjs";
70
70
  import * as term from "./term.mjs";
@@ -328,7 +328,9 @@ function factsOf(project, { env, home, root, version, interactive }) {
328
328
  sessionCopy = selectCopy(home, project, { harness: channelHarness });
329
329
  }
330
330
  const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
331
- const slot = slotHarness ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home }).state : null;
331
+ const slot = slotHarness
332
+ ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home, harness: slotHarness }).state
333
+ : null;
332
334
  // A worktree's main checkout (worktree.mjs): only where it can be offered โ€”
333
335
  // at a terminal, unbound, in a linked worktree. Read for the project
334
336
  // answered, never from the git top, which --project leaves unread.
@@ -692,12 +694,12 @@ function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, unde
692
694
  // Rule 13: `--channel npm` when that was the answer, and only for the
693
695
  // harness whose channel it is โ€” setup refuses --channel in a run without
694
696
  // that harness (exit 2). A keep is not repeated: once recorded, it is the
695
- // default already (rule 6).
696
- const again = channel === "npm" && hostRegistrationOf(m) ? "--channel npm" : "";
697
+ // default already (rule 6). The deferred row's reason names the same
698
+ // command, from the same builder.
697
699
  if (left.length)
698
700
  blocks.push({
699
701
  lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
700
- commands: [coreCommand("setup", { version, args: [`--harness ${id}`, again].filter(Boolean).join(" ") })],
702
+ commands: [terminalRun({ verb: "setup", harness: m, version, channel })],
701
703
  after: `then restart ${m.display_name}.`,
702
704
  });
703
705
  }
@@ -19,7 +19,8 @@
19
19
  //
20
20
  // Normative: the install spec โ€” contract 0 (exclusive vs shared), 3โ€“4 (the
21
21
  // four states and the ladder, rungs 1โ€ฒ and 1โ€ณ included), 6 (per-entry states
22
- // for shared files: ours-current, ours-stale, ours-absent, unparseable),
22
+ // for shared files: ours-current, ours-stale, ours-newer โ€” 2026-10-14 โ€”, ours-absent,
23
+ // unparseable),
23
24
  // 12 (current, last written by), 14 (host-managed surfaces have no state to
24
25
  // derive), 16 (a harness is in use when detected or when it has files of
25
26
  // ours). Pure node, no external deps.
@@ -37,6 +38,8 @@ import {
37
38
  desiredStatusLineCommand,
38
39
  findAgentsBlock,
39
40
  agentsBlockVersion,
41
+ newerBlockWords,
42
+ isNewerBlock,
40
43
  agentsBlockTemplatePath,
41
44
  renderAgentsBlock,
42
45
  loadLayout,
@@ -185,6 +188,15 @@ export function analyseBlock(
185
188
  a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
186
189
  a.duplicates = withBlock.filter((e) => e !== a.current);
187
190
  if (!a.current) return { ...a, state: "ours-absent" };
191
+ // A marker above this copy's template is never rendered down (the install
192
+ // spec, contracts 4 and 6, as amended 2026-10-14). Placement still decides
193
+ // first (rungs B4โ€“B6), and a block that moves, migrates or is deduplicated
194
+ // carries the newest block's own text; one that stays is `ours-newer`
195
+ // (B7), before the render is compared, so its roster is never re-rendered.
196
+ const newest = withBlock.reduce((n, e) => (e.block.v > n.block.v ? e : n), a.current);
197
+ a.newer = isNewerBlock(newest.block.v, a.version) ? newest : null;
198
+ const words = a.newer ? newerBlockWords(a.newer.block.v, a.version) : null;
199
+ const carried = a.newer ? ` with its own text (${words})` : "";
188
200
  // A block this harness CANNOT READ moves, and its target is created if it has
189
201
  // to be: that is the whole difference between a second harness arriving and a
190
202
  // preference. A block it can read moves only when the preferred file already
@@ -197,17 +209,25 @@ export function analyseBlock(
197
209
  return {
198
210
  ...a,
199
211
  state: "ours-stale",
200
- reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
212
+ reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}${carried}`,
201
213
  };
202
214
  }
203
215
  if (a.current.file !== preferred.file && preferred.present)
204
- return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
216
+ return {
217
+ ...a,
218
+ state: "ours-stale",
219
+ reason: `in ${a.current.file}; install migrates it to ${preferred.file}${carried}`,
220
+ };
205
221
  if (a.duplicates.length)
206
222
  return {
207
223
  ...a,
208
224
  state: "ours-stale",
209
- reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}`,
225
+ reason:
226
+ a.newer && a.newer !== a.current
227
+ ? `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}, and ${a.newer.file}'s carries v${a.newer.block.v}, newer than this copy's v${a.version}: its text replaces the block in ${a.current.file}`
228
+ : `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}${carried}`,
210
229
  };
230
+ if (a.newer) return { ...a, state: "ours-newer", reason: `${words}: left as it is` };
211
231
  if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
212
232
  return {
213
233
  ...a,
@@ -223,8 +243,15 @@ export function analyseBlock(
223
243
 
224
244
  // `renderRoot` (contract 4โ€ฒ, two phases): the root the surface is planned
225
245
  // AGAINST โ€” the host's install path a registration produces โ€” when it differs
226
- // from the root the templates are read from. Defaults to `root`.
227
- export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = homedir(), renderRoot = root } = {}) {
246
+ // from the root the templates are read from. Defaults to `root`. `harness` is
247
+ // the one the entry is FOR: its launcher lives under its own state directory,
248
+ // whichever harness launched this process (the install spec, contract 6, as
249
+ // amended 2026-10-14).
250
+ export function analyseJsonEntry(
251
+ projectDir,
252
+ s,
253
+ { root = pluginRoot(), home = homedir(), renderRoot = root, harness = null } = {},
254
+ ) {
228
255
  const path = join(projectDir, s.file);
229
256
  const pointer = s.marker?.pointer || "statusLine.command";
230
257
  const entryKey = pointer.split(".")[0];
@@ -263,7 +290,7 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
263
290
  a.ours =
264
291
  statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
265
292
  statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
266
- a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
293
+ a.desired = desiredStatusLineCommand(projectDir, renderRoot, home, harness?.id).command;
267
294
  if (!a.curEntry) return { ...a, state: "ours-absent" };
268
295
  if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
269
296
  if (a.curCmd === a.desired) return { ...a, state: "ours-current" };
@@ -317,7 +344,7 @@ export function analyseStampedFile(
317
344
  const tpl = readText(src);
318
345
  if (!tpl.present || tpl.text === null)
319
346
  return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
320
- const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
347
+ const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir, process.env, harness);
321
348
  if (rendered === null)
322
349
  return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
323
350
  const pkg = pluginVersionAt(root, harness) || "0.0.0";
@@ -791,7 +818,7 @@ export function surfaceStates(
791
818
  const hasOurs = states.some(
792
819
  (x) =>
793
820
  (x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
794
- (x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
821
+ (x.kind === "shared" && ["ours-current", "ours-stale", "ours-newer"].includes(x.state)),
795
822
  );
796
823
  if (detected.includes(m.id) || hasOurs) {
797
824
  out.used.push(m.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.3",
3
+ "version": "0.30.4",
4
4
  "description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.30.3"
40
+ "projectstore": "=0.30.4"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"