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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/commands/agents.md +5 -2
- package/node_modules/projectstore/commands/doctor.md +4 -0
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +3 -2
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/convergence.mjs +56 -1
- package/node_modules/projectstore/scripts/doctor.mjs +56 -10
- package/node_modules/projectstore/scripts/harness.mjs +68 -12
- package/node_modules/projectstore/scripts/install-harness.mjs +153 -14
- package/node_modules/projectstore/scripts/lib.mjs +271 -54
- package/node_modules/projectstore/scripts/setup.mjs +7 -5
- package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/version-guard.mjs +48 -2
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
507
|
-
// variable it is talking about without
|
|
508
|
-
|
|
509
|
-
|
|
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).
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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 =
|
|
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(
|
|
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:
|
|
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 : "",
|
|
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,
|
|
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
|
-
|
|
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",
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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,
|