projectstore-claude 0.30.3 โ 0.30.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/package.json +1 -1
- package/node_modules/projectstore/scripts/convergence.mjs +56 -1
- package/node_modules/projectstore/scripts/doctor.mjs +41 -6
- package/node_modules/projectstore/scripts/harness.mjs +14 -9
- package/node_modules/projectstore/scripts/install-harness.mjs +153 -14
- package/node_modules/projectstore/scripts/lib.mjs +51 -15
- package/node_modules/projectstore/scripts/setup.mjs +7 -5
- package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "๐ Your agent runs the project through a verified loop: task โ artifact (ADR ยท spec ยท epic ยท story) โ adversarial critic โ backlog โ planner โ reviewer โ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.30.
|
|
15
|
+
"version": "0.30.4",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Evgenii Konev",
|
|
18
18
|
"email": "ekonev@smartandpoint.com",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
3
|
"displayName": "projectstore",
|
|
4
|
-
"version": "0.30.
|
|
4
|
+
"version": "0.30.4",
|
|
5
5
|
"description": "Your agent runs the project through a verified loop: task โ artifact (ADR / spec / epic / story) โ adversarial critic โ backlog โ planner โ reviewer โ done. Plain markdown in git โ any model can pick the project up tomorrow.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Evgenii Konev @ SmartAndPoint",
|
|
@@ -12,7 +12,10 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
|
|
|
12
12
|
1. **Ask** via AskUserQuestion: "Register projectstore's agents in
|
|
13
13
|
CLAUDE.md/AGENTS.md so every session routes to them (critic after
|
|
14
14
|
authoring artifacts, planner before implementing, reviewer before commit)?
|
|
15
|
-
[Yes / No]". On No, stop.
|
|
15
|
+
[Yes / No]". On No, stop. When the block is already there marked newer
|
|
16
|
+
than this copy's template (doctor's info, or `status` showing
|
|
17
|
+
`ours-newer`), name both numbers in the question: registering writes this
|
|
18
|
+
copy's older block over the one marked vN.
|
|
16
19
|
2. **Run the verb** and print its output verbatim:
|
|
17
20
|
```bash
|
|
18
21
|
node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" install --harness claude-code --surface agents_block --project "${CLAUDE_PROJECT_DIR}"
|
|
@@ -53,7 +56,7 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
|
|
|
53
56
|
|
|
54
57
|
Run `node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" plan --json --surface agents_block --project "${CLAUDE_PROJECT_DIR}"`
|
|
55
58
|
and report each item's `state` from `result.items[]` โ the bin wraps the plan in its envelope (`ours-current`, `ours-stale` with its reason,
|
|
56
|
-
`ours-absent`, or a refusal). Then:
|
|
59
|
+
`ours-newer` โ marked above this copy's template: install, upgrade and setup leave it, and a named run such as `register` writes this copy's over it โ `ours-absent`, or a refusal). Then:
|
|
57
60
|
|
|
58
61
|
- Block: present in which file, marker version vs the installed template, agent
|
|
59
62
|
names vs the layout roster.
|
|
@@ -42,6 +42,10 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
|
|
|
42
42
|
the verb adds the import. For another harness the finding names its own
|
|
43
43
|
`--harness`; relay that command. Never Edit or Write the block yourself โ
|
|
44
44
|
the verb is its only writer (install spec, contract 6).
|
|
45
|
+
- `agents-block` **info** that the block is "marked vN, newer than this
|
|
46
|
+
copy's vM" โ **report only.** Install, upgrade and setup leave such a block as it is (install spec, contract 6 as amended
|
|
47
|
+
2026-10-14). Relay the finding and its command verbatim; run it only if
|
|
48
|
+
the user asks, because it writes this copy's older block over the one marked vN.
|
|
45
49
|
- `statusline` issues โ offer running `/projectstore:statusline on|off`,
|
|
46
50
|
which installs or removes the entry and the launcher behind a preview
|
|
47
51
|
(the SessionStart hook only refreshes an entry that already exists), and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.4",
|
|
4
4
|
"description": "Your agent runs the project through a verified loop: task โ artifact (ADR / spec / epic / story) โ adversarial critic โ backlog โ planner โ reviewer โ done. Plain markdown in git โ any model can pick the project up tomorrow.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"project-management",
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
writeFileAtomic,
|
|
57
57
|
envFlag,
|
|
58
58
|
isEphemeralRoot,
|
|
59
|
+
COPY_UPDATE_STEPS,
|
|
59
60
|
} from "./lib.mjs";
|
|
60
61
|
import { loadHarness, loadHarnesses } from "./harness.mjs";
|
|
61
62
|
import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
|
|
@@ -348,6 +349,34 @@ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
|
|
|
348
349
|
].join(" ");
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// The run a deferral sends to a terminal (the install spec, contract 6, as
|
|
353
|
+
// amended 2026-10-14): the run's own verb for one harness, its --surface
|
|
354
|
+
// selection, --global for a global removal, and setup's --channel npm when
|
|
355
|
+
// that was the answer for the harness whose channel it is (setup's rule 13 โ
|
|
356
|
+
// a keep is never repeated). `plan` writes nothing, so it names install. One
|
|
357
|
+
// builder for a deferred row's reason and setup's DONE, so the two agree.
|
|
358
|
+
export function terminalRun({
|
|
359
|
+
verb = "install",
|
|
360
|
+
harness = null,
|
|
361
|
+
version = null,
|
|
362
|
+
surfaces = null,
|
|
363
|
+
globalRemoval = false,
|
|
364
|
+
channel = null,
|
|
365
|
+
} = {}) {
|
|
366
|
+
const run = verb === "plan" ? "install" : verb;
|
|
367
|
+
const hostRegistration = Object.values(harness?.surfaces || {}).some(
|
|
368
|
+
(s) => s && typeof s === "object" && s.format === "host-plugin-registration",
|
|
369
|
+
);
|
|
370
|
+
const args = [
|
|
371
|
+
runArgs({ harnesses: harness ? [harness.id] : [], surfaces }),
|
|
372
|
+
globalRemoval ? "--global" : "",
|
|
373
|
+
run === "setup" && channel === "npm" && hostRegistration ? "--channel npm" : "",
|
|
374
|
+
]
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.join(" ");
|
|
377
|
+
return coreCommand(run, { version, args });
|
|
378
|
+
}
|
|
379
|
+
|
|
351
380
|
// Stop when the registry's latest is at least a newer copy this project runs
|
|
352
381
|
// โ the newest such copy is named. A copy the latest does not reach, or no
|
|
353
382
|
// answer, proceeds: the rows say which case it was (proceedCase). `read` is
|
|
@@ -577,9 +606,15 @@ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget
|
|
|
577
606
|
...(Array.isArray(result.refusals) ? result.refusals : []),
|
|
578
607
|
...items.filter((i) => i.action === "refuse").map((i) => i.reason),
|
|
579
608
|
].filter((x) => typeof x === "string" && x);
|
|
609
|
+
// A deferred row's reason may carry its run on the lines after its prose
|
|
610
|
+
// (the install spec, contract 6, as amended 2026-10-14): only the prose is
|
|
611
|
+
// quoted inside our own sentence, never another copy's command.
|
|
580
612
|
const deferred = items
|
|
581
613
|
.filter((i) => i.deferred === true)
|
|
582
|
-
.map((i) => ({
|
|
614
|
+
.map((i) => ({
|
|
615
|
+
surface: String(i.surface ?? ""),
|
|
616
|
+
reason: typeof i.reason === "string" ? i.reason.split("\n")[0] : null,
|
|
617
|
+
}));
|
|
583
618
|
return {
|
|
584
619
|
argv,
|
|
585
620
|
envelope: true,
|
|
@@ -603,6 +638,26 @@ export function ownUpgrade(copy, harness) {
|
|
|
603
638
|
};
|
|
604
639
|
}
|
|
605
640
|
|
|
641
|
+
// Whether a kept copy is behind the running package (the install spec,
|
|
642
|
+
// contract 21, rule 9, as amended 2026-10-14): semver precedence, rc
|
|
643
|
+
// included. Never for a copy or a run with no semver version recorded โ a
|
|
644
|
+
// recorded "unknown" or a sha must not rank.
|
|
645
|
+
export function copyBehind(copy, version) {
|
|
646
|
+
return isSemver(copy?.version) && isSemver(version) && cmpPrecedence(copy.version, version) < 0;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// What the keep says when its copy is behind: the project's surfaces are at
|
|
650
|
+
// the running version until the copy updates, and the update in the copy's
|
|
651
|
+
// channel. With a step, the copy's own upgrade runs after this run's agents
|
|
652
|
+
// block, and a copy shipped before this rule plans the block back.
|
|
653
|
+
export function behindAdvice(copy, version, { step = false } = {}) {
|
|
654
|
+
const head = `${copy.key} ${copy.version} is older than this package (${version})`;
|
|
655
|
+
const middle = step
|
|
656
|
+
? `its own upgrade runs after this run's agents block and may plan the block back to its own version; until it updates, its doctor may call the block stale, and the next run may plan the block again`
|
|
657
|
+
: `until it updates, the agents block and anything else this run writes for the project are at ${version}, and its own doctor may call the block stale`;
|
|
658
|
+
return `${head}: ${middle}. Update it (${COPY_UPDATE_STEPS}).`;
|
|
659
|
+
}
|
|
660
|
+
|
|
606
661
|
// This checkout's npm registration enabled beside the kept copy: the keep
|
|
607
662
|
// changes no registration, so it stays; its uninstall (rule 4) drops it.
|
|
608
663
|
export function npmRowBeside({ harness, id, surface }) {
|
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
AGENTS_BLOCK_OPEN_SRC,
|
|
57
57
|
AGENTS_BLOCK_CLOSE,
|
|
58
58
|
agentsBlockVersion,
|
|
59
|
+
newerBlockWords,
|
|
60
|
+
isNewerBlock,
|
|
59
61
|
findAgentsBlock,
|
|
60
62
|
statusLineLauncherPath,
|
|
61
63
|
LAUNCHER_HEADER,
|
|
@@ -369,7 +371,7 @@ export function checkStatusline(cfg, proj, home = homedir(), harness = speakingH
|
|
|
369
371
|
// A harness without a status line hears nothing about one, whatever the
|
|
370
372
|
// shared binding asks for (generation spec, contract 18).
|
|
371
373
|
if (harness?.surfaces?.statusline?.supported === false) return out;
|
|
372
|
-
const local = hostSettingsPath(proj);
|
|
374
|
+
const local = hostSettingsPath(proj, process.env, harness);
|
|
373
375
|
let cur = null;
|
|
374
376
|
if (existsSync(local)) {
|
|
375
377
|
try {
|
|
@@ -527,10 +529,10 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot(),
|
|
|
527
529
|
// spec's 2026-10-03 amendment, case S1).
|
|
528
530
|
let wired = null;
|
|
529
531
|
try {
|
|
530
|
-
wired = JSON.parse(readFileSync(hostSettingsPath(proj), "utf8"))?.statusLine?.command || null;
|
|
532
|
+
wired = JSON.parse(readFileSync(hostSettingsPath(proj, process.env, harness), "utf8"))?.statusLine?.command || null;
|
|
531
533
|
} catch {}
|
|
532
534
|
if (!wired || !statusLineIsOurWiring(wired, proj, home, root)) return [];
|
|
533
|
-
const lp = pickExisting(statusLineLauncherPath(proj), legacyStatusLineLauncherPath(proj));
|
|
535
|
+
const lp = pickExisting(statusLineLauncherPath(proj, harness?.id), legacyStatusLineLauncherPath(proj));
|
|
534
536
|
let text;
|
|
535
537
|
try {
|
|
536
538
|
text = readFileSync(lp, "utf8");
|
|
@@ -567,6 +569,7 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
567
569
|
let unclosedFiles = 0;
|
|
568
570
|
const perFile = {};
|
|
569
571
|
const staleVersions = [];
|
|
572
|
+
const newerVersions = [];
|
|
570
573
|
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
571
574
|
const p = join(proj, name);
|
|
572
575
|
if (!existsSync(p)) continue;
|
|
@@ -610,7 +613,10 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
610
613
|
}
|
|
611
614
|
for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
|
|
612
615
|
const v = parseInt(m[1], 10);
|
|
613
|
-
|
|
616
|
+
// A marker above this copy's template is never stale: this copy writes
|
|
617
|
+
// no block down (the install spec, contract 6, as amended 2026-10-14).
|
|
618
|
+
if (isNewerBlock(v, AGENT_BLOCK_VERSION)) newerVersions.push({ file: name, v });
|
|
619
|
+
else if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
|
|
614
620
|
}
|
|
615
621
|
}
|
|
616
622
|
if (blocks === 0) {
|
|
@@ -667,6 +673,22 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
667
673
|
);
|
|
668
674
|
out.push(refuses ? f : moveRepairs(f, fact));
|
|
669
675
|
}
|
|
676
|
+
// A newer block: an info in the install report only โ the startup line
|
|
677
|
+
// counts issues alone and offers none of this โ and only for one
|
|
678
|
+
// well-formed block. Its way out is the running copy's own bin from a
|
|
679
|
+
// terminal, never the session's command, whose copy would write its own
|
|
680
|
+
// version. Not folded into the layout move, which leaves it as it is too.
|
|
681
|
+
if (blocks === 1 && !refuses)
|
|
682
|
+
for (const s of newerVersions)
|
|
683
|
+
out.push(
|
|
684
|
+
finding(
|
|
685
|
+
"install",
|
|
686
|
+
"info",
|
|
687
|
+
"agents-block",
|
|
688
|
+
`Agents block in ${s.file} is ${newerBlockWords(s.v, AGENT_BLOCK_VERSION)}: install, upgrade and setup leave it as it is. To write this copy's v${AGENT_BLOCK_VERSION} over it ${fromTerminalIn(proj)}:\nnode "${join(root, "bin", "projectstore.mjs")}" install --harness ${newerReader(proj, s.file, env)} --surface agents_block`,
|
|
689
|
+
s.file,
|
|
690
|
+
),
|
|
691
|
+
);
|
|
670
692
|
// Placement, held to the predicate install plans from (the install spec,
|
|
671
693
|
// contract 6 as amended after the rc.3 tag): one well-formed block, seen by
|
|
672
694
|
// every harness the project uses. rc.1 and rc.2 left a block in an
|
|
@@ -711,6 +733,19 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
711
733
|
return out;
|
|
712
734
|
}
|
|
713
735
|
|
|
736
|
+
// The harness a newer block's way out names: the one this run identified,
|
|
737
|
+
// else one the project uses whose files hold the block, else the source
|
|
738
|
+
// harness โ never Claude Code's by default in a Codex-only project, whose
|
|
739
|
+
// install would create CLAUDE.md and overwrite nothing.
|
|
740
|
+
function newerReader(proj, file, env) {
|
|
741
|
+
const identified = identifiedHarnessId(env);
|
|
742
|
+
if (identified) return identified;
|
|
743
|
+
const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
|
|
744
|
+
return (
|
|
745
|
+
used.find((id) => (loadHarness(id)?.surfaces?.agents_block?.files || []).includes(file)) || sourceHarness()?.id
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
714
749
|
// While the layout move is pending, the findings the move itself repairs fold
|
|
715
750
|
// into it instead of being counted beside it (the layout spec, contract 7 as
|
|
716
751
|
// amended after the rc.3 tag; the upgrade story's O1): the stale block, which
|
|
@@ -1597,7 +1632,7 @@ function hostIgnoreLineMissing(proj, env) {
|
|
|
1597
1632
|
.map((l) => l.trim());
|
|
1598
1633
|
} catch {}
|
|
1599
1634
|
if (lines.includes(".claude/") || lines.includes(".claude")) return [];
|
|
1600
|
-
return [relative(proj, hostSettingsPath(proj, env))].filter((w) => !lines.includes(w));
|
|
1635
|
+
return [relative(proj, hostSettingsPath(proj, env, sourceHarness()))].filter((w) => !lines.includes(w));
|
|
1601
1636
|
}
|
|
1602
1637
|
|
|
1603
1638
|
// Whether git ignores the host's machine-local settings file (the layout spec,
|
|
@@ -1611,7 +1646,7 @@ function hostIgnoreLineMissing(proj, env) {
|
|
|
1611
1646
|
// line read. Full report only: a subprocess, like checkTrackedRuntime.
|
|
1612
1647
|
export function checkHostIgnored(proj, { env = process.env } = {}) {
|
|
1613
1648
|
if (!existsSync(join(proj, ".git"))) return [];
|
|
1614
|
-
const file = relative(proj, hostSettingsPath(proj, env));
|
|
1649
|
+
const file = relative(proj, hostSettingsPath(proj, env, sourceHarness()));
|
|
1615
1650
|
const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
|
|
1616
1651
|
const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
|
|
1617
1652
|
if (!missing.length) return [];
|
|
@@ -503,10 +503,12 @@ export function speakingHarness(env = process.env, dir = MANIFEST_DIR) {
|
|
|
503
503
|
|
|
504
504
|
// โโโ Runtime names and paths โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
505
505
|
|
|
506
|
-
// The three branded names
|
|
507
|
-
// variable it is talking about without
|
|
508
|
-
|
|
509
|
-
|
|
506
|
+
// The three branded names a harness uses โ the active one unless the caller
|
|
507
|
+
// names it โ so a caller can say which variable it is talking about without
|
|
508
|
+
// spelling it. A surface rendered FOR a harness (the status-line launcher is
|
|
509
|
+
// Claude Code's) names that harness, whichever process renders it.
|
|
510
|
+
export function runtimeEnvNames(env = process.env, harness = activeHarness(env)) {
|
|
511
|
+
const r = harness?.runtime || {};
|
|
510
512
|
return { projectDir: r.project_dir_env || null, pluginRoot: r.plugin_root_env || null, home: r.home_env || null };
|
|
511
513
|
}
|
|
512
514
|
|
|
@@ -816,17 +818,20 @@ export function pickExisting(current, legacy) {
|
|
|
816
818
|
|
|
817
819
|
// The per-project directory the HARNESS discovers (".claude" for the source
|
|
818
820
|
// layout) โ how a harness is detected in a project and where its own settings
|
|
819
|
-
// live. Not where our config is: that is layoutPaths() (2026-09-06).
|
|
820
|
-
|
|
821
|
-
|
|
821
|
+
// live. Not where our config is: that is layoutPaths() (2026-09-06). A
|
|
822
|
+
// caller reading ONE harness's settings names it: which harness launched the
|
|
823
|
+
// process is not whose directory that is (the install spec, contract 6, as
|
|
824
|
+
// amended 2026-10-14).
|
|
825
|
+
export function projectConfigDir(env = process.env, harness = activeHarness(env)) {
|
|
826
|
+
return harness?.runtime?.harness_dir || ".claude";
|
|
822
827
|
}
|
|
823
828
|
|
|
824
829
|
// The host's own machine-local settings file in a project (the statusline
|
|
825
830
|
// surface's file in the manifest โ `.claude/settings.local.json` for Claude
|
|
826
831
|
// Code). A harness surface, not ours: the one `.claude` path the core may
|
|
827
832
|
// build, and it builds it from the manifest (2026-09-06).
|
|
828
|
-
export function hostSettingsPath(projectDir, env = process.env) {
|
|
829
|
-
const h =
|
|
833
|
+
export function hostSettingsPath(projectDir, env = process.env, harness = activeHarness(env)) {
|
|
834
|
+
const h = harness;
|
|
830
835
|
const file = h?.surfaces?.statusline?.file || join(h?.runtime?.harness_dir || ".claude", "settings.local.json");
|
|
831
836
|
return join(projectDir, file);
|
|
832
837
|
}
|
|
@@ -193,6 +193,9 @@ import {
|
|
|
193
193
|
npmRowBeside,
|
|
194
194
|
pinFor,
|
|
195
195
|
coreCommand,
|
|
196
|
+
terminalRun,
|
|
197
|
+
copyBehind,
|
|
198
|
+
behindAdvice,
|
|
196
199
|
DEFAULT_REGISTRY,
|
|
197
200
|
NOTICE_BUDGET_MS,
|
|
198
201
|
READ_TIMEOUT_MS,
|
|
@@ -235,6 +238,7 @@ import {
|
|
|
235
238
|
removeTreeUnder,
|
|
236
239
|
projectDirRefusal,
|
|
237
240
|
fromTerminalIn,
|
|
241
|
+
newerBlockWords,
|
|
238
242
|
registrationProvenance,
|
|
239
243
|
envFlag,
|
|
240
244
|
selectCopy,
|
|
@@ -461,12 +465,27 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
461
465
|
return items;
|
|
462
466
|
}
|
|
463
467
|
|
|
464
|
-
const entry = `projectstore:agents v${a.version}`;
|
|
465
468
|
const current = a.current;
|
|
469
|
+
// A block marked above this copy's template is never rendered down (the
|
|
470
|
+
// install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
|
|
471
|
+
// migration or a dedupe carries its own bytes, stamped with its own marker.
|
|
472
|
+
const newer = a.newer || null;
|
|
473
|
+
const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
|
|
474
|
+
const body = newer ? newer.block.block : a.desired;
|
|
475
|
+
const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
|
|
466
476
|
// Two files, one block each: resolve in favour of the preferred file โ
|
|
467
477
|
// register migrates, never duplicates (ADR-002 decision 3).
|
|
468
478
|
for (const e of a.duplicates || [])
|
|
469
|
-
items.push(
|
|
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,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
projectRoot as harnessProjectRoot,
|
|
31
31
|
pluginRoot as harnessPluginRoot,
|
|
32
32
|
agentHome as harnessAgentHome,
|
|
33
|
+
harnessHome,
|
|
33
34
|
configPath as harnessConfigPath,
|
|
34
35
|
projectConfigDir as harnessProjectConfigDir,
|
|
35
36
|
detectHarnessId,
|
|
@@ -545,12 +546,18 @@ export function pidAlive(pid) {
|
|
|
545
546
|
|
|
546
547
|
// โโโ Installed-plugin resolution โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
547
548
|
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
549
|
+
// Claude Code's config directory. Almost always ~/.claude, but its home
|
|
550
|
+
// variable relocates it โ and a consumer that hardcodes the default silently
|
|
551
|
+
// resolves nothing for those users instead of failing loudly. The variable
|
|
552
|
+
// comes from the source harness's manifest, never the active harness's: with
|
|
553
|
+
// another harness's home variable exported (CODEX_HOME), detection makes that
|
|
554
|
+
// harness active in a directory with no harness folder, and every Claude Code
|
|
555
|
+
// path used to follow it into the other home (the install spec, contract 6,
|
|
556
|
+
// as amended 2026-10-14). The host this process runs in is harnessAgentHome's
|
|
557
|
+
// question, asked by its own callers; it stands in here only if a manifest
|
|
558
|
+
// ever named no home at all.
|
|
552
559
|
export function claudeHome(home = homedir()) {
|
|
553
|
-
return harnessAgentHome(process.env, home);
|
|
560
|
+
return harnessHome(sourceHarness(), { env: process.env, home }) || harnessAgentHome(process.env, home);
|
|
554
561
|
}
|
|
555
562
|
|
|
556
563
|
// The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
|
|
@@ -633,9 +640,11 @@ export function pluginEnabled(key, home = homedir(), projectDir = null) {
|
|
|
633
640
|
enabled = user.enabledPlugins[key] !== false;
|
|
634
641
|
if (projectDir) {
|
|
635
642
|
// The committed project file, then the checkout's local one over it โ the
|
|
636
|
-
// host's own precedence (measured 2026-09-05, --scope local).
|
|
643
|
+
// host's own precedence (measured 2026-09-05, --scope local). Claude Code's
|
|
644
|
+
// project directory, whichever harness this process runs as.
|
|
645
|
+
const dir = harnessProjectConfigDir(process.env, sourceHarness());
|
|
637
646
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
638
|
-
const proj = read(join(projectDir,
|
|
647
|
+
const proj = read(join(projectDir, dir, f));
|
|
639
648
|
if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
|
|
640
649
|
enabled = proj.enabledPlugins[key] !== false;
|
|
641
650
|
}
|
|
@@ -758,13 +767,17 @@ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarne
|
|
|
758
767
|
return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
|
|
759
768
|
}
|
|
760
769
|
|
|
770
|
+
// How a git-marketplace copy is updated, in its own channel: the one phrase
|
|
771
|
+
// every advice about a kept or older copy quotes.
|
|
772
|
+
export const COPY_UPDATE_STEPS = "in Claude Code: /plugin marketplace update, then /plugin update, then restart";
|
|
773
|
+
|
|
761
774
|
// The advice for a marketplace copy that cannot run what is named for it, in
|
|
762
775
|
// that copy's own channel: the host's update, then a restart. `cannot` says
|
|
763
776
|
// what it cannot do, `then` what follows the update. One text for doctor's
|
|
764
777
|
// layout remedy and the keep's skip row and DONE line.
|
|
765
778
|
export function copyUpdateAdvice(copy, { cannot, then }) {
|
|
766
779
|
const v = copy?.version ? ` (${copy.version})` : "";
|
|
767
|
-
return `This project's projectstore plugin${v} ${cannot}: update it first (
|
|
780
|
+
return `This project's projectstore plugin${v} ${cannot}: update it first (${COPY_UPDATE_STEPS}), ${then}`;
|
|
768
781
|
}
|
|
769
782
|
|
|
770
783
|
// The mark a channel switch leaves in this checkout's machine-local binding
|
|
@@ -1236,8 +1249,11 @@ export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root =
|
|
|
1236
1249
|
// into state/<harness>/ made wrong; it is named instead, as plan() resolved
|
|
1237
1250
|
// it โ the same string the provenance line records, so an npx render and a
|
|
1238
1251
|
// cache-install render stay byte-identical.
|
|
1239
|
-
|
|
1240
|
-
|
|
1252
|
+
// `harness` is the one the launcher is FOR (the plan's harness), so a render
|
|
1253
|
+
// from a process another harness launched still bakes in Claude Code's names;
|
|
1254
|
+
// the SessionStart refresh passes none and renders for its own host.
|
|
1255
|
+
export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env, harness) {
|
|
1256
|
+
const names = harness ? runtimeEnvNames(env, harness) : runtimeEnvNames(env);
|
|
1241
1257
|
const subs = [
|
|
1242
1258
|
['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
|
|
1243
1259
|
['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
|
|
@@ -1281,13 +1297,20 @@ export function writeStatusLineLauncher(projectDir, root) {
|
|
|
1281
1297
|
// The command our entry should carry for this installation: the version-free
|
|
1282
1298
|
// launcher for a marketplace-cache install, the plugin's own script for a dev
|
|
1283
1299
|
// checkout (its path has no version to go stale). One resolver, shared by the
|
|
1284
|
-
// SessionStart refresh and the installer, so the two cannot disagree.
|
|
1285
|
-
|
|
1300
|
+
// SessionStart refresh and the installer, so the two cannot disagree. The
|
|
1301
|
+
// installer names the harness it plans (its launcher's state directory); the
|
|
1302
|
+
// refresh names none and means its own host.
|
|
1303
|
+
export function desiredStatusLineCommand(
|
|
1304
|
+
projectDir,
|
|
1305
|
+
root = pluginRoot(),
|
|
1306
|
+
home = homedir(),
|
|
1307
|
+
harnessId = activeHarnessId(),
|
|
1308
|
+
) {
|
|
1286
1309
|
const launcher = isPluginCacheRoot(root, home);
|
|
1287
1310
|
return {
|
|
1288
1311
|
launcher,
|
|
1289
1312
|
command: launcher
|
|
1290
|
-
? `node "${statusLineLauncherPath(projectDir)}"`
|
|
1313
|
+
? `node "${statusLineLauncherPath(projectDir, harnessId)}"`
|
|
1291
1314
|
: `node "${join(root, "scripts", "statusline.mjs")}"`,
|
|
1292
1315
|
};
|
|
1293
1316
|
}
|
|
@@ -1416,6 +1439,19 @@ export function agentsBlockVersion(tmpl = null) {
|
|
|
1416
1439
|
return m ? Number(m[1]) : null;
|
|
1417
1440
|
}
|
|
1418
1441
|
|
|
1442
|
+
// A block whose marker is above this copy's template (the install spec,
|
|
1443
|
+
// contract 6, as amended 2026-10-14): the words every surface says it with.
|
|
1444
|
+
// Never "written by a newer projectstore" โ a hand-edited marker reads the same.
|
|
1445
|
+
export function newerBlockWords(v, version) {
|
|
1446
|
+
return `marked v${v}, newer than this copy's v${version}`;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Whether a marker ranks above the template's. A template with no marker
|
|
1450
|
+
// (or a marker that is not a number) is never outranked.
|
|
1451
|
+
export function isNewerBlock(v, version) {
|
|
1452
|
+
return Number.isInteger(v) && Number.isInteger(version) && v > version;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1419
1455
|
// The first block in `text`: its version, its span, whether it closes, and how
|
|
1420
1456
|
// many open markers the file carries (more than one is a duplicate).
|
|
1421
1457
|
export function findAgentsBlock(text) {
|
|
@@ -3871,7 +3907,7 @@ export function writeAnchorOffer(projectDir, sessionId, rec) {
|
|
|
3871
3907
|
// within moments of each other both pass this check and both offer the same
|
|
3872
3908
|
// name โ the race is narrowed, not closed, and the ADR says so.
|
|
3873
3909
|
export function liveSessionNames(selfSessionId, dir = null) {
|
|
3874
|
-
const root = dir || join(
|
|
3910
|
+
const root = dir || join(harnessAgentHome(process.env), "sessions");
|
|
3875
3911
|
const out = [];
|
|
3876
3912
|
let files;
|
|
3877
3913
|
try {
|
|
@@ -3895,7 +3931,7 @@ export function liveSessionNames(selfSessionId, dir = null) {
|
|
|
3895
3931
|
|
|
3896
3932
|
// What this session is currently called, if the harness has recorded a name.
|
|
3897
3933
|
export function ownSessionName(selfSessionId, dir = null) {
|
|
3898
|
-
const root = dir || join(
|
|
3934
|
+
const root = dir || join(harnessAgentHome(process.env), "sessions");
|
|
3899
3935
|
let files;
|
|
3900
3936
|
try {
|
|
3901
3937
|
files = readdirSync(root);
|
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
languageNames,
|
|
65
65
|
} from "./binding.mjs";
|
|
66
66
|
import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
|
|
67
|
-
import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
|
|
67
|
+
import { runningCopies, coreCommand, channelChoices, pinFor, terminalRun } from "./convergence.mjs";
|
|
68
68
|
import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
|
|
69
69
|
import { resolveBinding } from "./worktree.mjs";
|
|
70
70
|
import * as term from "./term.mjs";
|
|
@@ -328,7 +328,9 @@ function factsOf(project, { env, home, root, version, interactive }) {
|
|
|
328
328
|
sessionCopy = selectCopy(home, project, { harness: channelHarness });
|
|
329
329
|
}
|
|
330
330
|
const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
|
|
331
|
-
const slot = slotHarness
|
|
331
|
+
const slot = slotHarness
|
|
332
|
+
? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home, harness: slotHarness }).state
|
|
333
|
+
: null;
|
|
332
334
|
// A worktree's main checkout (worktree.mjs): only where it can be offered โ
|
|
333
335
|
// at a terminal, unbound, in a linked worktree. Read for the project
|
|
334
336
|
// answered, never from the git top, which --project leaves unread.
|
|
@@ -692,12 +694,12 @@ function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, unde
|
|
|
692
694
|
// Rule 13: `--channel npm` when that was the answer, and only for the
|
|
693
695
|
// harness whose channel it is โ setup refuses --channel in a run without
|
|
694
696
|
// that harness (exit 2). A keep is not repeated: once recorded, it is the
|
|
695
|
-
// default already (rule 6).
|
|
696
|
-
|
|
697
|
+
// default already (rule 6). The deferred row's reason names the same
|
|
698
|
+
// command, from the same builder.
|
|
697
699
|
if (left.length)
|
|
698
700
|
blocks.push({
|
|
699
701
|
lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
|
|
700
|
-
commands: [
|
|
702
|
+
commands: [terminalRun({ verb: "setup", harness: m, version, channel })],
|
|
701
703
|
after: `then restart ${m.display_name}.`,
|
|
702
704
|
});
|
|
703
705
|
}
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Normative: the install spec โ contract 0 (exclusive vs shared), 3โ4 (the
|
|
21
21
|
// four states and the ladder, rungs 1โฒ and 1โณ included), 6 (per-entry states
|
|
22
|
-
// for shared files: ours-current, ours-stale, ours-absent,
|
|
22
|
+
// for shared files: ours-current, ours-stale, ours-newer โ 2026-10-14 โ, ours-absent,
|
|
23
|
+
// unparseable),
|
|
23
24
|
// 12 (current, last written by), 14 (host-managed surfaces have no state to
|
|
24
25
|
// derive), 16 (a harness is in use when detected or when it has files of
|
|
25
26
|
// ours). Pure node, no external deps.
|
|
@@ -37,6 +38,8 @@ import {
|
|
|
37
38
|
desiredStatusLineCommand,
|
|
38
39
|
findAgentsBlock,
|
|
39
40
|
agentsBlockVersion,
|
|
41
|
+
newerBlockWords,
|
|
42
|
+
isNewerBlock,
|
|
40
43
|
agentsBlockTemplatePath,
|
|
41
44
|
renderAgentsBlock,
|
|
42
45
|
loadLayout,
|
|
@@ -185,6 +188,15 @@ export function analyseBlock(
|
|
|
185
188
|
a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
|
|
186
189
|
a.duplicates = withBlock.filter((e) => e !== a.current);
|
|
187
190
|
if (!a.current) return { ...a, state: "ours-absent" };
|
|
191
|
+
// A marker above this copy's template is never rendered down (the install
|
|
192
|
+
// spec, contracts 4 and 6, as amended 2026-10-14). Placement still decides
|
|
193
|
+
// first (rungs B4โB6), and a block that moves, migrates or is deduplicated
|
|
194
|
+
// carries the newest block's own text; one that stays is `ours-newer`
|
|
195
|
+
// (B7), before the render is compared, so its roster is never re-rendered.
|
|
196
|
+
const newest = withBlock.reduce((n, e) => (e.block.v > n.block.v ? e : n), a.current);
|
|
197
|
+
a.newer = isNewerBlock(newest.block.v, a.version) ? newest : null;
|
|
198
|
+
const words = a.newer ? newerBlockWords(a.newer.block.v, a.version) : null;
|
|
199
|
+
const carried = a.newer ? ` with its own text (${words})` : "";
|
|
188
200
|
// A block this harness CANNOT READ moves, and its target is created if it has
|
|
189
201
|
// to be: that is the whole difference between a second harness arriving and a
|
|
190
202
|
// preference. A block it can read moves only when the preferred file already
|
|
@@ -197,17 +209,25 @@ export function analyseBlock(
|
|
|
197
209
|
return {
|
|
198
210
|
...a,
|
|
199
211
|
state: "ours-stale",
|
|
200
|
-
reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
|
|
212
|
+
reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}${carried}`,
|
|
201
213
|
};
|
|
202
214
|
}
|
|
203
215
|
if (a.current.file !== preferred.file && preferred.present)
|
|
204
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
...a,
|
|
218
|
+
state: "ours-stale",
|
|
219
|
+
reason: `in ${a.current.file}; install migrates it to ${preferred.file}${carried}`,
|
|
220
|
+
};
|
|
205
221
|
if (a.duplicates.length)
|
|
206
222
|
return {
|
|
207
223
|
...a,
|
|
208
224
|
state: "ours-stale",
|
|
209
|
-
reason:
|
|
225
|
+
reason:
|
|
226
|
+
a.newer && a.newer !== a.current
|
|
227
|
+
? `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}, and ${a.newer.file}'s carries v${a.newer.block.v}, newer than this copy's v${a.version}: its text replaces the block in ${a.current.file}`
|
|
228
|
+
: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}${carried}`,
|
|
210
229
|
};
|
|
230
|
+
if (a.newer) return { ...a, state: "ours-newer", reason: `${words}: left as it is` };
|
|
211
231
|
if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
|
|
212
232
|
return {
|
|
213
233
|
...a,
|
|
@@ -223,8 +243,15 @@ export function analyseBlock(
|
|
|
223
243
|
|
|
224
244
|
// `renderRoot` (contract 4โฒ, two phases): the root the surface is planned
|
|
225
245
|
// AGAINST โ the host's install path a registration produces โ when it differs
|
|
226
|
-
// from the root the templates are read from. Defaults to `root`.
|
|
227
|
-
|
|
246
|
+
// from the root the templates are read from. Defaults to `root`. `harness` is
|
|
247
|
+
// the one the entry is FOR: its launcher lives under its own state directory,
|
|
248
|
+
// whichever harness launched this process (the install spec, contract 6, as
|
|
249
|
+
// amended 2026-10-14).
|
|
250
|
+
export function analyseJsonEntry(
|
|
251
|
+
projectDir,
|
|
252
|
+
s,
|
|
253
|
+
{ root = pluginRoot(), home = homedir(), renderRoot = root, harness = null } = {},
|
|
254
|
+
) {
|
|
228
255
|
const path = join(projectDir, s.file);
|
|
229
256
|
const pointer = s.marker?.pointer || "statusLine.command";
|
|
230
257
|
const entryKey = pointer.split(".")[0];
|
|
@@ -263,7 +290,7 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
|
|
|
263
290
|
a.ours =
|
|
264
291
|
statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
|
|
265
292
|
statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
|
|
266
|
-
a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
|
|
293
|
+
a.desired = desiredStatusLineCommand(projectDir, renderRoot, home, harness?.id).command;
|
|
267
294
|
if (!a.curEntry) return { ...a, state: "ours-absent" };
|
|
268
295
|
if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
|
|
269
296
|
if (a.curCmd === a.desired) return { ...a, state: "ours-current" };
|
|
@@ -317,7 +344,7 @@ export function analyseStampedFile(
|
|
|
317
344
|
const tpl = readText(src);
|
|
318
345
|
if (!tpl.present || tpl.text === null)
|
|
319
346
|
return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
|
|
320
|
-
const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
|
|
347
|
+
const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir, process.env, harness);
|
|
321
348
|
if (rendered === null)
|
|
322
349
|
return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
|
|
323
350
|
const pkg = pluginVersionAt(root, harness) || "0.0.0";
|
|
@@ -791,7 +818,7 @@ export function surfaceStates(
|
|
|
791
818
|
const hasOurs = states.some(
|
|
792
819
|
(x) =>
|
|
793
820
|
(x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
|
|
794
|
-
(x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
|
|
821
|
+
(x.kind === "shared" && ["ours-current", "ours-stale", "ours-newer"].includes(x.state)),
|
|
795
822
|
);
|
|
796
823
|
if (detected.includes(m.id) || hasOurs) {
|
|
797
824
|
out.used.push(m.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.4",
|
|
4
4
|
"description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"projectstore",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"projectstore": "=0.30.
|
|
40
|
+
"projectstore": "=0.30.4"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|