projectstore-claude 0.30.2 → 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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/AGENTS.md +7 -5
- package/node_modules/projectstore/README.md +3 -3
- package/node_modules/projectstore/agents/clerk.md +2 -2
- package/node_modules/projectstore/commands/agents.md +43 -28
- package/node_modules/projectstore/commands/bind.md +1 -1
- package/node_modules/projectstore/commands/doctor.md +19 -3
- package/node_modules/projectstore/commands/reconcile.md +2 -2
- package/node_modules/projectstore/commands/review.md +1 -1
- package/node_modules/projectstore/commands/story.md +2 -1
- package/node_modules/projectstore/docs/getting-started.md +1 -1
- package/node_modules/projectstore/docs/harnesses.md +3 -2
- package/node_modules/projectstore/harnesses/claude-code.json +40 -1
- package/node_modules/projectstore/harnesses/codex.json +50 -14
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
- package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
- package/node_modules/projectstore/scripts/cli.mjs +32 -9
- package/node_modules/projectstore/scripts/configure-menu.mjs +133 -6
- package/node_modules/projectstore/scripts/configure.mjs +156 -19
- package/node_modules/projectstore/scripts/convergence.mjs +56 -1
- package/node_modules/projectstore/scripts/doctor.mjs +134 -35
- package/node_modules/projectstore/scripts/harness.mjs +14 -9
- package/node_modules/projectstore/scripts/install-harness.mjs +182 -14
- package/node_modules/projectstore/scripts/lib.mjs +83 -24
- package/node_modules/projectstore/scripts/menu.mjs +9 -5
- package/node_modules/projectstore/scripts/models.mjs +150 -6
- package/node_modules/projectstore/scripts/setup.mjs +7 -5
- package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
- package/package.json +2 -2
|
@@ -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
|
|
@@ -2630,11 +2768,40 @@ export function plan(
|
|
|
2630
2768
|
placeBlock();
|
|
2631
2769
|
for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
|
|
2632
2770
|
out.items.push(...marks);
|
|
2771
|
+
oneAddPerFile(out.items);
|
|
2633
2772
|
if (out.items.some((i) => i.action === "refuse")) out.ok = false;
|
|
2634
2773
|
if (out.refusals.length) out.ok = false;
|
|
2635
2774
|
return out;
|
|
2636
2775
|
}
|
|
2637
2776
|
|
|
2777
|
+
// One add per file (the presentation spec, contract 7 as amended 2026-10-14):
|
|
2778
|
+
// within one plan, a file an earlier item creates is added once. A later
|
|
2779
|
+
// `create` or `add` on that path changes the file the run has just made, so it
|
|
2780
|
+
// reads `update`; its state, steps and detail stay. Each item is planned
|
|
2781
|
+
// against the tree before the run, so the binding the vault item writes was
|
|
2782
|
+
// "absent" to the status-line flag and the channel mark, and all three read
|
|
2783
|
+
// "add". A creator is an earlier `create` or `add` item that is neither a
|
|
2784
|
+
// registration (its host-owned files are the host CLI's) nor a binding row (it
|
|
2785
|
+
// never makes the binding: apply skips it on an unbound project, and the vault
|
|
2786
|
+
// item or the layout move makes the file), counted for the paths absent at
|
|
2787
|
+
// plan time: its own and each step's `path` — never a move step's `to`, so a
|
|
2788
|
+
// migrating project's binding rows keep `add`. An entry row on a file that was
|
|
2789
|
+
// there keeps `add`.
|
|
2790
|
+
function oneAddPerFile(items) {
|
|
2791
|
+
const created = new Set();
|
|
2792
|
+
const writes = (i) => i.action === "create" || i.action === "add";
|
|
2793
|
+
for (const item of items) {
|
|
2794
|
+
if (!writes(item) || item.kind === "registration") continue;
|
|
2795
|
+
if (item.path && created.has(resolve(item.path))) {
|
|
2796
|
+
item.action = "update";
|
|
2797
|
+
continue;
|
|
2798
|
+
}
|
|
2799
|
+
if (item.kind === "binding") continue;
|
|
2800
|
+
for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
|
|
2801
|
+
if (p && !existsSync(p)) created.add(resolve(p));
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2638
2805
|
// ─── the layout migration (the layout ADR; layout spec contract 6) ──────
|
|
2639
2806
|
//
|
|
2640
2807
|
// Two items, kind `layout`. The FIRST moves what only we read — the legacy
|
|
@@ -5229,6 +5396,7 @@ export async function runVerb(verb, projectDir, opts = {}) {
|
|
|
5229
5396
|
plan(projectDir, {
|
|
5230
5397
|
...opts,
|
|
5231
5398
|
mode,
|
|
5399
|
+
verb,
|
|
5232
5400
|
state: verb === "upgrade" || setup || (verb === "uninstall" && !opts.globalRemoval),
|
|
5233
5401
|
version: prep.version,
|
|
5234
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,
|
|
@@ -196,9 +197,11 @@ export { adoptHookInput, resetHookInput };
|
|
|
196
197
|
// ─── Harness overlays (the layout ADR decision 3; layout spec contracts 2–4) ──
|
|
197
198
|
//
|
|
198
199
|
// <project>/.projectstore/harness/<id>.json carries ONE thing: the agents block
|
|
199
|
-
// for that harness — agents.default.model
|
|
200
|
-
//
|
|
201
|
-
//
|
|
200
|
+
// for that harness — agents.default.model, agents.default.effort,
|
|
201
|
+
// agents.per_agent.<name>.model and agents.per_agent.<name>.effort (the effort
|
|
202
|
+
// ADR, decision 1). Every other key is ignored on read and named for doctor
|
|
203
|
+
// (rejected). The binding never carries agents: ADR-008's two-term chain is
|
|
204
|
+
// read from here, once per field.
|
|
202
205
|
export function readOverlayAt(projectDir, id = overlayId()) {
|
|
203
206
|
// No id (no manifest at all): nothing is read, and the writer below refuses
|
|
204
207
|
// the same id rather than inventing a file name for it.
|
|
@@ -209,6 +212,7 @@ export function readOverlayAt(projectDir, id = overlayId()) {
|
|
|
209
212
|
present: Boolean(path) && existsSync(path),
|
|
210
213
|
unparseable: false,
|
|
211
214
|
agents: { default: null, per_agent: {} },
|
|
215
|
+
efforts: { default: null, per_agent: {} },
|
|
212
216
|
rejected: [],
|
|
213
217
|
raw: null,
|
|
214
218
|
};
|
|
@@ -235,8 +239,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
|
|
|
235
239
|
for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
|
|
236
240
|
if (a.default !== undefined) {
|
|
237
241
|
if (a.default && typeof a.default === "object") {
|
|
238
|
-
for (const k of Object.keys(a.default))
|
|
242
|
+
for (const k of Object.keys(a.default))
|
|
243
|
+
if (k !== "model" && k !== "effort") out.rejected.push(`agents.default.${k}`);
|
|
239
244
|
if (typeof a.default.model === "string" && a.default.model) out.agents.default = a.default.model;
|
|
245
|
+
if (typeof a.default.effort === "string" && a.default.effort) out.efforts.default = a.default.effort;
|
|
240
246
|
} else out.rejected.push("agents.default");
|
|
241
247
|
}
|
|
242
248
|
if (a.per_agent !== undefined) {
|
|
@@ -246,8 +252,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
|
|
|
246
252
|
out.rejected.push(`agents.per_agent.${name}`);
|
|
247
253
|
continue;
|
|
248
254
|
}
|
|
249
|
-
for (const k of Object.keys(v))
|
|
255
|
+
for (const k of Object.keys(v))
|
|
256
|
+
if (k !== "model" && k !== "effort") out.rejected.push(`agents.per_agent.${name}.${k}`);
|
|
250
257
|
if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
|
|
258
|
+
if (typeof v.effort === "string" && v.effort) out.efforts.per_agent[name] = v.effort;
|
|
251
259
|
}
|
|
252
260
|
} else out.rejected.push("agents.per_agent");
|
|
253
261
|
}
|
|
@@ -264,9 +272,22 @@ export function resolveAgentModel(projectDir, name, { harness = overlayId() } =
|
|
|
264
272
|
return { name, model: null, source: null, overlay: o.path, harness };
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
// The same two terms for effort, on their own (the effort ADR, decision 1): an
|
|
276
|
+
// agent's own model does not stop it taking the default's effort. Null passes
|
|
277
|
+
// nothing: the frontmatter's effort, or the harness's own default, runs.
|
|
278
|
+
export function resolveAgentEffort(projectDir, name, { harness = overlayId() } = {}) {
|
|
279
|
+
const o = readOverlayAt(projectDir, harness);
|
|
280
|
+
const per = o.efforts.per_agent[name];
|
|
281
|
+
if (per) return { name, effort: per, source: "per_agent", overlay: o.path, harness };
|
|
282
|
+
if (o.efforts.default) return { name, effort: o.efforts.default, source: "default", overlay: o.path, harness };
|
|
283
|
+
return { name, effort: null, source: null, overlay: o.path, harness };
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
// The one writer of an overlay: rewrites its agents block, keeps any other key
|
|
268
287
|
// as it found it (doctor names them; this never silently drops a user's key).
|
|
269
|
-
|
|
288
|
+
// `efforts` left out keeps the efforts the file holds, so a caller that
|
|
289
|
+
// changes models only never drops one.
|
|
290
|
+
export function writeOverlayAt(projectDir, id, agents, efforts) {
|
|
270
291
|
if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
|
|
271
292
|
const path = layoutPaths(projectDir).overlay(id);
|
|
272
293
|
let raw = {};
|
|
@@ -274,10 +295,13 @@ export function writeOverlayAt(projectDir, id, agents) {
|
|
|
274
295
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
275
296
|
} catch {}
|
|
276
297
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
|
|
298
|
+
const e = efforts === undefined ? readOverlayAt(projectDir, id).efforts : efforts;
|
|
299
|
+
const pair = (model, effort) => ({ ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
|
277
300
|
const block = {};
|
|
278
|
-
if (agents.default) block.default =
|
|
279
|
-
const names = Object.keys(agents.per_agent || {}).sort();
|
|
280
|
-
if (names.length)
|
|
301
|
+
if (agents.default || e.default) block.default = pair(agents.default, e.default);
|
|
302
|
+
const names = [...new Set([...Object.keys(agents.per_agent || {}), ...Object.keys(e.per_agent || {})])].sort();
|
|
303
|
+
if (names.length)
|
|
304
|
+
block.per_agent = Object.fromEntries(names.map((n) => [n, pair(agents.per_agent?.[n], e.per_agent?.[n])]));
|
|
281
305
|
const next = { ...raw };
|
|
282
306
|
if (Object.keys(block).length) next.agents = block;
|
|
283
307
|
else delete next.agents;
|
|
@@ -522,12 +546,18 @@ export function pidAlive(pid) {
|
|
|
522
546
|
|
|
523
547
|
// ─── Installed-plugin resolution ───────────────────────────────────────
|
|
524
548
|
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
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.
|
|
529
559
|
export function claudeHome(home = homedir()) {
|
|
530
|
-
return harnessAgentHome(process.env, home);
|
|
560
|
+
return harnessHome(sourceHarness(), { env: process.env, home }) || harnessAgentHome(process.env, home);
|
|
531
561
|
}
|
|
532
562
|
|
|
533
563
|
// The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
|
|
@@ -610,9 +640,11 @@ export function pluginEnabled(key, home = homedir(), projectDir = null) {
|
|
|
610
640
|
enabled = user.enabledPlugins[key] !== false;
|
|
611
641
|
if (projectDir) {
|
|
612
642
|
// The committed project file, then the checkout's local one over it — the
|
|
613
|
-
// 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());
|
|
614
646
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
615
|
-
const proj = read(join(projectDir,
|
|
647
|
+
const proj = read(join(projectDir, dir, f));
|
|
616
648
|
if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
|
|
617
649
|
enabled = proj.enabledPlugins[key] !== false;
|
|
618
650
|
}
|
|
@@ -735,13 +767,17 @@ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarne
|
|
|
735
767
|
return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
|
|
736
768
|
}
|
|
737
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
|
+
|
|
738
774
|
// The advice for a marketplace copy that cannot run what is named for it, in
|
|
739
775
|
// that copy's own channel: the host's update, then a restart. `cannot` says
|
|
740
776
|
// what it cannot do, `then` what follows the update. One text for doctor's
|
|
741
777
|
// layout remedy and the keep's skip row and DONE line.
|
|
742
778
|
export function copyUpdateAdvice(copy, { cannot, then }) {
|
|
743
779
|
const v = copy?.version ? ` (${copy.version})` : "";
|
|
744
|
-
return `This project's projectstore plugin${v} ${cannot}: update it first (
|
|
780
|
+
return `This project's projectstore plugin${v} ${cannot}: update it first (${COPY_UPDATE_STEPS}), ${then}`;
|
|
745
781
|
}
|
|
746
782
|
|
|
747
783
|
// The mark a channel switch leaves in this checkout's machine-local binding
|
|
@@ -1213,8 +1249,11 @@ export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root =
|
|
|
1213
1249
|
// into state/<harness>/ made wrong; it is named instead, as plan() resolved
|
|
1214
1250
|
// it — the same string the provenance line records, so an npx render and a
|
|
1215
1251
|
// cache-install render stay byte-identical.
|
|
1216
|
-
|
|
1217
|
-
|
|
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);
|
|
1218
1257
|
const subs = [
|
|
1219
1258
|
['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
|
|
1220
1259
|
['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
|
|
@@ -1258,13 +1297,20 @@ export function writeStatusLineLauncher(projectDir, root) {
|
|
|
1258
1297
|
// The command our entry should carry for this installation: the version-free
|
|
1259
1298
|
// launcher for a marketplace-cache install, the plugin's own script for a dev
|
|
1260
1299
|
// checkout (its path has no version to go stale). One resolver, shared by the
|
|
1261
|
-
// SessionStart refresh and the installer, so the two cannot disagree.
|
|
1262
|
-
|
|
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
|
+
) {
|
|
1263
1309
|
const launcher = isPluginCacheRoot(root, home);
|
|
1264
1310
|
return {
|
|
1265
1311
|
launcher,
|
|
1266
1312
|
command: launcher
|
|
1267
|
-
? `node "${statusLineLauncherPath(projectDir)}"`
|
|
1313
|
+
? `node "${statusLineLauncherPath(projectDir, harnessId)}"`
|
|
1268
1314
|
: `node "${join(root, "scripts", "statusline.mjs")}"`,
|
|
1269
1315
|
};
|
|
1270
1316
|
}
|
|
@@ -1393,6 +1439,19 @@ export function agentsBlockVersion(tmpl = null) {
|
|
|
1393
1439
|
return m ? Number(m[1]) : null;
|
|
1394
1440
|
}
|
|
1395
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
|
+
|
|
1396
1455
|
// The first block in `text`: its version, its span, whether it closes, and how
|
|
1397
1456
|
// many open markers the file carries (more than one is a duplicate).
|
|
1398
1457
|
export function findAgentsBlock(text) {
|
|
@@ -3848,7 +3907,7 @@ export function writeAnchorOffer(projectDir, sessionId, rec) {
|
|
|
3848
3907
|
// within moments of each other both pass this check and both offer the same
|
|
3849
3908
|
// name — the race is narrowed, not closed, and the ADR says so.
|
|
3850
3909
|
export function liveSessionNames(selfSessionId, dir = null) {
|
|
3851
|
-
const root = dir || join(
|
|
3910
|
+
const root = dir || join(harnessAgentHome(process.env), "sessions");
|
|
3852
3911
|
const out = [];
|
|
3853
3912
|
let files;
|
|
3854
3913
|
try {
|
|
@@ -3872,7 +3931,7 @@ export function liveSessionNames(selfSessionId, dir = null) {
|
|
|
3872
3931
|
|
|
3873
3932
|
// What this session is currently called, if the harness has recorded a name.
|
|
3874
3933
|
export function ownSessionName(selfSessionId, dir = null) {
|
|
3875
|
-
const root = dir || join(
|
|
3934
|
+
const root = dir || join(harnessAgentHome(process.env), "sessions");
|
|
3876
3935
|
let files;
|
|
3877
3936
|
try {
|
|
3878
3937
|
files = readdirSync(root);
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
//
|
|
13
13
|
// A group is { title, footer, description, items }. An item is
|
|
14
14
|
// { id, label, kind: "choice" | "text" | "info", toggle, value,
|
|
15
|
-
// display(v), tag, describe(v, changed), options(), caption()
|
|
15
|
+
// display(v), tag, describe(v, changed), options(), caption(),
|
|
16
|
+
// indent, path }
|
|
17
|
+
// where `indent` draws the label that many columns in, under the row it
|
|
18
|
+
// belongs to (an agent's effort under its model), and `path` names the item
|
|
19
|
+
// where its label alone would not: a frame's title and the path line.
|
|
16
20
|
// where options() answers the values, each
|
|
17
21
|
// { value, label, note, description, tag, section, kind: "value" | "text" | "refresh" }
|
|
18
22
|
// and caption() the values frame's footer, { text, busy } or null. A value
|
|
@@ -390,13 +394,13 @@ function valueSegs(c, m, it, mode, room) {
|
|
|
390
394
|
}
|
|
391
395
|
|
|
392
396
|
function itemRows(c, m, g, cur, mode, w) {
|
|
393
|
-
const lw = Math.max(...g.items.map((it) => wlen(it.label))) + 1;
|
|
397
|
+
const lw = Math.max(...g.items.map((it) => (it.indent || 0) + wlen(it.label))) + 1;
|
|
394
398
|
const room = w - 4 - 3 - lw - 1;
|
|
395
399
|
return g.items.map((it, k) => [
|
|
396
400
|
cursorSeg(c, k === cur, mode),
|
|
397
401
|
S(m.changed(it) ? icon(c, "update") : " ", "action"),
|
|
398
402
|
S(" "),
|
|
399
|
-
S(padTo(G(c, it.label), lw), labelRole(k === cur, mode)),
|
|
403
|
+
S(padTo(" ".repeat(it.indent || 0) + G(c, it.label), lw), labelRole(k === cur, mode)),
|
|
400
404
|
S(" "),
|
|
401
405
|
...valueSegs(c, m, it, mode, room),
|
|
402
406
|
]);
|
|
@@ -488,7 +492,7 @@ function detailFrame(c, m, it, fr, w, h, focus) {
|
|
|
488
492
|
}
|
|
489
493
|
const cap = focus && it.caption ? it.caption() : null;
|
|
490
494
|
return frame(c, {
|
|
491
|
-
title: it.label,
|
|
495
|
+
title: it.path || it.label,
|
|
492
496
|
rows,
|
|
493
497
|
w,
|
|
494
498
|
h,
|
|
@@ -644,7 +648,7 @@ export function renderMenu(m, { caps: c, columns = 100, rows = 30, head = "proje
|
|
|
644
648
|
const wideEnough = columns >= STAIRCASE.min;
|
|
645
649
|
if (!wideEnough) {
|
|
646
650
|
// One frame: the one the keys act on, under the whole path.
|
|
647
|
-
const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.label] : [])];
|
|
651
|
+
const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.path || it.label] : [])];
|
|
648
652
|
out([
|
|
649
653
|
S(`${icon(c, "ask")} `, "action"),
|
|
650
654
|
S(names.slice(0, -1).join(sep) + (names.length > 1 ? sep : ""), "explanation"),
|