projectstore-claude 0.30.3 → 0.30.5

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.
@@ -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,
@@ -100,6 +102,7 @@ import {
100
102
  packageCommand,
101
103
  invocation,
102
104
  harnessForOverlay,
105
+ activeHarness,
103
106
  } from "./harness.mjs";
104
107
  import { effortSpec } from "./models.mjs";
105
108
 
@@ -369,7 +372,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
369
372
  // A harness without a status line hears nothing about one, whatever the
370
373
  // shared binding asks for (generation spec, contract 18).
371
374
  if (harness?.surfaces?.statusline?.supported === false) return out;
372
- const local = hostSettingsPath(proj);
375
+ const local = hostSettingsPath(proj, process.env, harness);
373
376
  let cur = null;
374
377
  if (existsSync(local)) {
375
378
  try {
@@ -527,10 +530,10 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
527
530
  // spec's 2026-10-03 amendment, case S1).
528
531
  let wired = null;
529
532
  try {
530
- wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
533
+ wired = JSON.parse(readFileSync(hostSettingsPath(proj, process.env, harness), "utf8"))?.statusLine?.command || null;
531
534
  } catch {}
532
535
  if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
533
- const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
536
+ const lp = pickExisting(statusLineLauncherPath(proj, harness?.id), legacyStatusLineLauncherPath(proj));
534
537
  let text;
535
538
  try {
536
539
  text = readFileSync(lp, "utf8");
@@ -567,6 +570,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
567
570
  let unclosedFiles = 0;
568
571
  const perFile = {};
569
572
  const staleVersions = [];
573
+ const newerVersions = [];
570
574
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
571
575
  const p = join(proj, name);
572
576
  if (!existsSync(p)) continue;
@@ -610,7 +614,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
610
614
  }
611
615
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
612
616
  const v = parseInt(m[1], 10);
613
- if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
617
+ // A marker above this copy's template is never stale: this copy writes
618
+ // no block down (the install spec, contract 6, as amended 2026-10-14).
619
+ if (isNewerBlock(v, AGENT_BLOCK_VERSION)) newerVersions.push({ file: name, v });
620
+ else if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
614
621
  }
615
622
  }
616
623
  if (blocks === 0) {
@@ -667,6 +674,22 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
667
674
  );
668
675
  out.push(refuses ? f : moveRepairs(f, fact));
669
676
  }
677
+ // A newer block: an info in the install report only — the startup line
678
+ // counts issues alone and offers none of this — and only for one
679
+ // well-formed block. Its way out is the running copy's own bin from a
680
+ // terminal, never the session's command, whose copy would write its own
681
+ // version. Not folded into the layout move, which leaves it as it is too.
682
+ if (blocks === 1 && !refuses)
683
+ for (const s of newerVersions)
684
+ out.push(
685
+ finding(
686
+ "install",
687
+ "info",
688
+ "agents-block",
689
+ `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`,
690
+ s.file,
691
+ ),
692
+ );
670
693
  // Placement, held to the predicate install plans from (the install spec,
671
694
  // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
672
695
  // every harness the project uses. rc.1 and rc.2 left a block in an
@@ -711,6 +734,19 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
711
734
  return out;
712
735
  }
713
736
 
737
+ // The harness a newer block's way out names: the one this run identified,
738
+ // else one the project uses whose files hold the block, else the source
739
+ // harness — never Claude Code's by default in a Codex-only project, whose
740
+ // install would create CLAUDE.md and overwrite nothing.
741
+ function newerReader(proj, file, env) {
742
+ const identified = identifiedHarnessId(env);
743
+ if (identified) return identified;
744
+ const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
745
+ return (
746
+ used.find((id) => (loadHarness(id)?.surfaces?.agents_block?.files || []).includes(file)) || sourceHarness()?.id
747
+ );
748
+ }
749
+
714
750
  // While the layout move is pending, the findings the move itself repairs fold
715
751
  // into it instead of being counted beside it (the layout spec, contract 7 as
716
752
  // amended after the rc.3 tag; the upgrade story's O1): the stale block, which
@@ -908,17 +944,22 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
908
944
  // 2026-10-03 amendment, finding 7).
909
945
  const remedy = layoutRemedy(proj, { root, home });
910
946
  // The block and its move are the source harness's — a pre-0.28 binding
911
- // and that harness's command — so its fact under any other listener.
947
+ // and that harness's command — so its fact under any other listener. The
948
+ // move writes the source harness's overlay, whoever listens: since the
949
+ // overlay follows the shell that bundles the core (layout spec, contract 2,
950
+ // as amended 2026-10-15), the listener's `where` would name another file.
951
+ const src = sourceHarness();
952
+ const dest = relative(proj, layoutPaths(proj).overlay(src?.runtime?.overlay || src?.id));
912
953
  out.push(
913
954
  aboutHarness(
914
955
  finding(
915
956
  "install",
916
957
  "warn",
917
958
  "agents-in-binding",
918
- `${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")}`,
959
+ `${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")}`,
919
960
  b,
920
961
  ),
921
- sourceHarness(),
962
+ src,
922
963
  speaker,
923
964
  ),
924
965
  );
@@ -1327,10 +1368,15 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
1327
1368
  // The variable names come from the manifest (runtime.agent_overrides); the
1328
1369
  // overlay is read only when one is set, so a clean startup reads nothing.
1329
1370
  export function checkEnvEffort(proj = null) {
1371
+ // The variable is the active harness's (agentOverrides keys on detection), so
1372
+ // the overlay it outranks is that harness's too — not the listener's, which
1373
+ // in a shell's tree can be another harness (layout spec, contract 2, as
1374
+ // amended 2026-10-15).
1375
+ const h = activeHarness();
1330
1376
  return agentOverrides()
1331
1377
  .filter((o) => o.kind === "effort")
1332
1378
  .map((o) => {
1333
- const ov = proj ? readOverlayAt(proj) : null;
1379
+ const ov = proj ? readOverlayAt(proj, h?.runtime?.overlay || h?.id) : null;
1334
1380
  const set = ov
1335
1381
  ? [
1336
1382
  ...(ov.efforts.default ? ["agents.default.effort"] : []),
@@ -1597,7 +1643,7 @@ function hostIgnoreLineMissing(proj, env) {
1597
1643
  .map((l) => l.trim());
1598
1644
  } catch {}
1599
1645
  if (lines.includes(".claude/") || lines.includes(".claude")) return [];
1600
- return [relative(proj, hostSettingsPath(proj, env))].filter((w) => !lines.includes(w));
1646
+ return [relative(proj, hostSettingsPath(proj, env, sourceHarness()))].filter((w) => !lines.includes(w));
1601
1647
  }
1602
1648
 
1603
1649
  // Whether git ignores the host's machine-local settings file (the layout spec,
@@ -1611,7 +1657,7 @@ function hostIgnoreLineMissing(proj, env) {
1611
1657
  // line read. Full report only: a subprocess, like checkTrackedRuntime.
1612
1658
  export function checkHostIgnored(proj, { env = process.env } = {}) {
1613
1659
  if (!existsSync(join(proj, ".git"))) return [];
1614
- const file = relative(proj, hostSettingsPath(proj, env));
1660
+ const file = relative(proj, hostSettingsPath(proj, env, sourceHarness()));
1615
1661
  const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
1616
1662
  const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
1617
1663
  if (!missing.length) return [];
@@ -411,6 +411,46 @@ export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
411
411
  return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
412
412
  }
413
413
 
414
+ // ─── The Stop carrier (generation spec, contract 17; entry spec, contract 14,
415
+ // as amended 2026-10-15) ────────────────────────────────────────────────
416
+ //
417
+ // The shape the entry reminder's Stop carrier prints, from the manifest of the
418
+ // harness that IDENTIFIES itself — a forced harness, a parent's decision, a
419
+ // strong signal or a session marker. Never detection's cwd folder, weak ranking or source
420
+ // fallback: a wrong answer here is a hook the host refuses (Codex's Stop schema
421
+ // denies hookSpecificOutput, measured 2026-10-15), and a refused print would
422
+ // still spend the session's reminder. Nothing identified, or no carrier
423
+ // measured, is null: the hook stays silent.
424
+ export const STOP_CARRIERS = Object.freeze(["additionalContext", "block"]);
425
+ export function stopCarrier(env = process.env, dir = MANIFEST_DIR) {
426
+ const id = identifiedHarnessId(env, dir);
427
+ const carrier = id ? loadHarness(id, dir)?.output_channels?.Stop?.carrier : null;
428
+ return STOP_CARRIERS.includes(carrier) ? carrier : null;
429
+ }
430
+
431
+ // What is wrong with a manifest's Stop row, or null. The carrier is our choice
432
+ // and must be one its row supports: context needs `additionalContext` among the
433
+ // honoured fields, a block needs the block decision, and a carrier is only
434
+ // ever chosen from a measured row. Shared by the manifest check and its
435
+ // planted fixtures.
436
+ export function stopCarrierProblem(manifest) {
437
+ const row = manifest?.output_channels?.Stop;
438
+ if (row === null || row === undefined) return null;
439
+ if (typeof row !== "object") return "output_channels.Stop is not an object";
440
+ if (!("carrier" in row)) return "output_channels.Stop has no carrier (null when none is measured)";
441
+ if (![...STOP_CARRIERS, null].includes(row.carrier))
442
+ return `output_channels.Stop.carrier ${JSON.stringify(row.carrier)} is not one of ${STOP_CARRIERS.join(", ")} or null`;
443
+ if (![null, "block"].includes(row.decision))
444
+ return `output_channels.Stop.decision ${JSON.stringify(row.decision)} is not null or "block"`;
445
+ if (row.carrier === null) return null;
446
+ if (row.evidence !== "measured") return "output_channels.Stop.carrier is set on a row that is not measured";
447
+ if (row.carrier === "additionalContext" && !(row.fields || []).includes("additionalContext"))
448
+ return "output_channels.Stop.carrier is additionalContext, but its fields do not honour it";
449
+ if (row.carrier === "block" && row.decision !== "block")
450
+ return 'output_channels.Stop.carrier is block, but its decision is not "block"';
451
+ return null;
452
+ }
453
+
414
454
  // ─── Who a message is for (generation spec, contract 18) ───────────────
415
455
  //
416
456
  // The core prints for ONE harness at a time, and a command it names must be
@@ -503,10 +543,12 @@ export function speakingHarness(env = process.env, dir = MANIFEST_DIR) {
503
543
 
504
544
  // ─── Runtime names and paths ───────────────────────────────────────────
505
545
 
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 || {};
546
+ // The three branded names a harness uses — the active one unless the caller
547
+ // names it — so a caller can say which variable it is talking about without
548
+ // spelling it. A surface rendered FOR a harness (the status-line launcher is
549
+ // Claude Code's) names that harness, whichever process renders it.
550
+ export function runtimeEnvNames(env = process.env, harness = activeHarness(env)) {
551
+ const r = harness?.runtime || {};
510
552
  return { projectDir: r.project_dir_env || null, pluginRoot: r.plugin_root_env || null, home: r.home_env || null };
511
553
  }
512
554
 
@@ -763,9 +805,20 @@ export function shellHarnessId(env = process.env, dir = MANIFEST_DIR) {
763
805
 
764
806
  // The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json —
765
807
  // the manifest's runtime.overlay, the harness id by convention (the layout ADR,
766
- // decision 3). Null only when no manifest at all can be found.
767
- export function overlayId(env = process.env, dir = MANIFEST_DIR) {
768
- const h = activeHarness(env, dir);
808
+ // decision 3). Whose (layout spec, contract 2, as amended 2026-10-15): the
809
+ // harness that identifies itself; else the shell that bundles this core; else
810
+ // the shell the environment names; else detectHarnessId's answer (the cwd's
811
+ // harness folder, the weak ranking, the source harness). Contract 18's middle
812
+ // rungs with detection's tail: a rendered skill's exec tool carries no variable
813
+ // of its harness, and the cwd's folder would answer for it. --harness stays the
814
+ // caller's (cli.mjs runAgents). Null only when no manifest at all can be found.
815
+ export function overlayId(env = process.env, dir = MANIFEST_DIR, { core } = {}) {
816
+ const id =
817
+ identifiedHarnessId(env, dir) ||
818
+ bundlingShellHarnessId({ core, dir }) ||
819
+ shellHarnessId(env, dir) ||
820
+ detectHarnessId(env, dir);
821
+ const h = loadHarness(id, dir) || sourceHarness(dir);
769
822
  return h?.runtime?.overlay || h?.id || null;
770
823
  }
771
824
 
@@ -816,17 +869,20 @@ export function pickExisting(current, legacy) {
816
869
 
817
870
  // The per-project directory the HARNESS discovers (".claude" for the source
818
871
  // 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";
872
+ // live. Not where our config is: that is layoutPaths() (2026-09-06). A
873
+ // caller reading ONE harness's settings names it: which harness launched the
874
+ // process is not whose directory that is (the install spec, contract 6, as
875
+ // amended 2026-10-14).
876
+ export function projectConfigDir(env = process.env, harness = activeHarness(env)) {
877
+ return harness?.runtime?.harness_dir || ".claude";
822
878
  }
823
879
 
824
880
  // The host's own machine-local settings file in a project (the statusline
825
881
  // surface's file in the manifest — `.claude/settings.local.json` for Claude
826
882
  // Code). A harness surface, not ours: the one `.claude` path the core may
827
883
  // build, and it builds it from the manifest (2026-09-06).
828
- export function hostSettingsPath(projectDir, env = process.env) {
829
- const h = activeHarness(env);
884
+ export function hostSettingsPath(projectDir, env = process.env, harness = activeHarness(env)) {
885
+ const h = harness;
830
886
  const file = h?.surfaces?.statusline?.file || join(h?.runtime?.harness_dir || ".claude", "settings.local.json");
831
887
  return join(projectDir, file);
832
888
  }
@@ -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,