projectstore-claude 0.29.2 → 0.29.3
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/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -24,17 +24,16 @@ import {
|
|
|
24
24
|
compareArtifactOrder, isMain,
|
|
25
25
|
commandForm,
|
|
26
26
|
} from "./lib.mjs";
|
|
27
|
+
// The status table lives in stages.mjs, Node-free, so the band above the
|
|
28
|
+
// prompt counts stories exactly as the board files them.
|
|
29
|
+
import { NOT_ACTIONABLE, statusToColumn } from "./stages.mjs";
|
|
30
|
+
export { NOT_ACTIONABLE, statusToColumn };
|
|
27
31
|
|
|
28
32
|
function die(msg) {
|
|
29
33
|
process.stderr.write(`projectstore/kanban: ${msg}\n`);
|
|
30
34
|
process.exit(1);
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
// Statuses that describe work which is not on the board by definition: it was
|
|
34
|
-
// replaced, postponed, or set aside. Putting them in a column would claim the
|
|
35
|
-
// work is queued, which is the opposite of what the status says.
|
|
36
|
-
const NOT_ACTIONABLE = new Set(["superseded", "deferred", "parked"]);
|
|
37
|
-
|
|
38
37
|
export function findStories(vault, epicsPath) {
|
|
39
38
|
const root = join(vault, epicsPath);
|
|
40
39
|
const stories = [];
|
|
@@ -92,21 +91,6 @@ export function findStories(vault, epicsPath) {
|
|
|
92
91
|
return { stories, skipped };
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
export function statusToColumn(status) {
|
|
96
|
-
const m = {
|
|
97
|
-
planned: "Backlog",
|
|
98
|
-
todo: "ToDo",
|
|
99
|
-
"to-do": "ToDo",
|
|
100
|
-
"in-progress": "In Progress",
|
|
101
|
-
in_progress: "In Progress",
|
|
102
|
-
"in progress": "In Progress",
|
|
103
|
-
review: "Review",
|
|
104
|
-
done: "Done",
|
|
105
|
-
closed: "Done",
|
|
106
|
-
};
|
|
107
|
-
return m[status] || "Backlog";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
94
|
function renderItem(story) {
|
|
111
95
|
const tags = [`#${story.priority}`];
|
|
112
96
|
if (story.status === "done") tags.push("#done");
|
|
@@ -8,6 +8,7 @@ import { join, dirname, basename, resolve, relative, isAbsolute } from "node:pat
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { hostname, homedir } from "node:os";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { parseFrontmatter } from "./stages.mjs";
|
|
11
12
|
import {
|
|
12
13
|
projectRoot as harnessProjectRoot,
|
|
13
14
|
pluginRoot as harnessPluginRoot,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
overlayId,
|
|
21
22
|
hostSettingsPath,
|
|
22
23
|
layoutPaths,
|
|
24
|
+
cachePaths,
|
|
23
25
|
pickExisting,
|
|
24
26
|
LAYOUT,
|
|
25
27
|
RUNTIME_GITIGNORE_HEADER,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
invocation,
|
|
32
34
|
speakingHarness,
|
|
33
35
|
packageCommand,
|
|
36
|
+
whichOnPath,
|
|
34
37
|
} from "./harness.mjs";
|
|
35
38
|
|
|
36
39
|
// ─── Paths ─────────────────────────────────────────────────────────────
|
|
@@ -103,10 +106,35 @@ export function speakingDisplayName({ env = process.env } = {}) {
|
|
|
103
106
|
return speakingHarness(env)?.display_name || "another";
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
// ─── doctor's last line (presentation spec, contract 11) ───────────────
|
|
110
|
+
//
|
|
111
|
+
// The Summary line ends every text report of doctor's, verbatim from its
|
|
112
|
+
// 0.29.2 report: the write ceremony captures it as the pre-state and compares
|
|
113
|
+
// it after (commands/story.md step 5a, agents/clerk.md), so its bytes are a
|
|
114
|
+
// contract. It lives here, beside commandForm, because every printer of the
|
|
115
|
+
// report needs it — doctor-report.mjs, and the fallback of each caller when
|
|
116
|
+
// that renderer cannot be loaded or throws — and this is the one module all
|
|
117
|
+
// of them already import. `env` decides the command forms.
|
|
118
|
+
export function doctorSummaryLine(findings, { env = process.env } = {}) {
|
|
119
|
+
const issues = findings.filter((f) => f.level === "issue").length;
|
|
120
|
+
const warns = findings.filter((f) => f.level === "warn").length;
|
|
121
|
+
return `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix", env })} (install), ${commandForm("kanban", { env })} / reconcile (vault).` : "Vault and wiring look healthy."}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What a reader gets when the renderer cannot draw the findings: each one
|
|
125
|
+
// per line, `level check message — file`, then the Summary line, still last.
|
|
126
|
+
export function doctorFallbackText(findings, { env = process.env } = {}) {
|
|
127
|
+
const lines = findings.map((f) => `${f.level} ${f.check} ${f.message}${f.file ? ` — ${f.file}` : ""}`);
|
|
128
|
+
return [...lines, ...(lines.length ? [""] : []), doctorSummaryLine(findings, { env })].join("\n") + "\n";
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
// The layout resolver and its constants, re-exported so hooks and scripts
|
|
107
132
|
// import one module (the layout ADR, 2026-09-06). The active harness's id,
|
|
108
|
-
// for the paths keyed by it (state/<id>/…).
|
|
109
|
-
|
|
133
|
+
// for the paths keyed by it (state/<id>/…). cachePaths is the user-level
|
|
134
|
+
// class beside it (layout spec contract 0): the shell fetch's scratch.
|
|
135
|
+
// whichOnPath, the PATH walk for a host binary, moved to harness.mjs beside
|
|
136
|
+
// hostPresence (the setup story, rule 5); its importers keep this name.
|
|
137
|
+
export { layoutPaths, cachePaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId, speakingHarness, whichOnPath };
|
|
110
138
|
|
|
111
139
|
// A hook's payload carries the `cwd` of the session that fired it, and on a
|
|
112
140
|
// harness that exports no project-dir variable it is the only answer better
|
|
@@ -354,16 +382,18 @@ function sweepOrphanTemps(dir) {
|
|
|
354
382
|
if (!m) continue;
|
|
355
383
|
const pid = parseInt(m[1], 10);
|
|
356
384
|
if (!pid || pid === process.pid) continue;
|
|
357
|
-
try {
|
|
358
|
-
process.kill(pid, 0); // returns ⇒ alive; EPERM ⇒ alive, not ours
|
|
359
|
-
} catch (e) {
|
|
360
|
-
if (e.code === "ESRCH") {
|
|
361
|
-
try { unlinkSync(join(dir, n)); } catch {}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
385
|
+
if (!pidAlive(pid)) { try { unlinkSync(join(dir, n)); } catch {} }
|
|
364
386
|
}
|
|
365
387
|
}
|
|
366
388
|
|
|
389
|
+
// The sweeps' one liveness rule: signal 0 returns ⇒ alive; EPERM ⇒ alive but
|
|
390
|
+
// not ours; only ESRCH is dead. Shared by the temp sweep above and the shell
|
|
391
|
+
// fetch's scratch sweep (fetch-shell.mjs), so the two cannot drift.
|
|
392
|
+
export function pidAlive(pid) {
|
|
393
|
+
try { process.kill(pid, 0); return true; }
|
|
394
|
+
catch (e) { return e?.code !== "ESRCH"; }
|
|
395
|
+
}
|
|
396
|
+
|
|
367
397
|
// ─── Installed-plugin resolution ───────────────────────────────────────
|
|
368
398
|
|
|
369
399
|
// The harness's config directory. Almost always ~/.claude, but the harness's
|
|
@@ -474,18 +504,104 @@ export function installedPluginEntries(home = homedir(), projectDir = null) {
|
|
|
474
504
|
return out;
|
|
475
505
|
}
|
|
476
506
|
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
507
|
+
// Contract 12's selection (the layout spec, as amended 2026-10-03): the copies
|
|
508
|
+
// of ours the host loads for this project — present, enabled for it, and this
|
|
509
|
+
// checkout's own row or a row with no project (user scope), never another
|
|
510
|
+
// checkout's. This checkout's row first, then the most recently updated. Real
|
|
511
|
+
// paths: the host records the project's cwd as one, and /var against
|
|
512
|
+
// /private/var would otherwise drop the project's own row. One filter for
|
|
513
|
+
// doctor's layout remedy and the convergence story's running copies (the
|
|
514
|
+
// converge story's rule 6), so the two cannot disagree about what a project
|
|
515
|
+
// runs. `own` marks this checkout's row.
|
|
516
|
+
export function projectCopies(home = homedir(), projectDir) {
|
|
517
|
+
const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
|
|
518
|
+
const here = real(projectDir);
|
|
519
|
+
const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
|
|
520
|
+
return installedPluginEntries(home, projectDir)
|
|
521
|
+
.filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
|
|
522
|
+
.map((e) => ({ ...e, own: mine(e) }))
|
|
523
|
+
.sort((a, b) => (Number(b.own) - Number(a.own)) || (b.at - a.at));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The same selection, one row per plugin key: the row the host loads for this
|
|
527
|
+
// project — this checkout's own row over a user-scope row of the same key,
|
|
528
|
+
// else the most recent. What "a copy enabled for this project" means for the
|
|
529
|
+
// competing copies the registration silences (surfaces.mjs, contract 13) and
|
|
530
|
+
// for the copies the convergence story's rule 6 compares, so the stop and the
|
|
531
|
+
// deferral of a newer copy always see the same (key, version) pairs.
|
|
532
|
+
export function projectCopiesByKey(home = homedir(), projectDir) {
|
|
533
|
+
const seen = new Set();
|
|
534
|
+
return projectCopies(home, projectDir).filter((e) => !seen.has(e.key) && seen.add(e.key));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Whether a registry copy can run the command named for it. `--no-register`
|
|
538
|
+
// arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
|
|
539
|
+
// exits 2 on the flag and writes nothing (the layout spec, contract 12 as
|
|
540
|
+
// amended after the rc.3 tag). Read from the copy's own parse table, never
|
|
541
|
+
// from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
|
|
542
|
+
// the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
|
|
543
|
+
// file read, because this runs in the SessionStart subset: never an import()
|
|
544
|
+
// of the copy, never a spawn. Add files to the read, never drop cli.mjs:
|
|
545
|
+
// released copies keep their parse there. Moved here from doctor.mjs (the
|
|
546
|
+
// converge story's rule 5), which re-exports it: doctor's layout remedy and
|
|
547
|
+
// the installer's keep read one function.
|
|
548
|
+
export function takesNoRegister(copy) {
|
|
549
|
+
try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
|
|
550
|
+
}
|
|
551
|
+
// …and whether its CLI takes --verbose, the same way: the flag arrived in
|
|
552
|
+
// 0.29.0, after --no-register, and a copy between them (rc.3 to 0.28.x)
|
|
553
|
+
// parses strictly, so a command naming it would exit 2. Read before DONE names
|
|
554
|
+
// a kept copy's own `plan --verbose`.
|
|
555
|
+
export function takesVerbose(copy) {
|
|
556
|
+
try { return /\bverbose\b/.test(readFileSync(join(copy, "scripts", "cli.mjs"), "utf8")); } catch { return false; }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Contract 12's selection with its capability read (the layout spec, contract
|
|
560
|
+
// 12 as amended 2026-10-03; the converge story's rule 5): the first of
|
|
561
|
+
// projectCopies — this checkout's own row, then user-scope rows by recency,
|
|
562
|
+
// never another checkout's — and whether its own bin can run the remedy form
|
|
563
|
+
// (`missing`: "bin" for a copy with no bin/projectstore.mjs, 0.27.x;
|
|
564
|
+
// "no-register" for one whose CLI predates the flag, rc.1 and rc.2; null when
|
|
565
|
+
// it can). `only` limits the rows to one install channel before the first is
|
|
566
|
+
// taken: the keep takes "marketplace", so the npm registration's copy is never
|
|
567
|
+
// the kept one. Doctor's layout remedy and the installer's keep call this one
|
|
568
|
+
// function, so the two never disagree about which copy a project runs.
|
|
569
|
+
export function selectCopy(home = homedir(), projectDir, { harness = sourceHarness(), only = null } = {}) {
|
|
570
|
+
const copy = projectCopies(home, projectDir).find((e) => !only || installChannel(e.path, { home, harness }) === only);
|
|
571
|
+
if (!copy) return null;
|
|
572
|
+
const bin = join(copy.path, "bin", "projectstore.mjs");
|
|
573
|
+
const missing = !existsSync(bin) ? "bin" : !takesNoRegister(copy.path) ? "no-register" : null;
|
|
574
|
+
return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The advice for a marketplace copy that cannot run what is named for it, in
|
|
578
|
+
// that copy's own channel: the host's update, then a restart. `cannot` says
|
|
579
|
+
// what it cannot do, `then` what follows the update. One text for doctor's
|
|
580
|
+
// layout remedy and the keep's skip row and DONE line.
|
|
581
|
+
export function copyUpdateAdvice(copy, { cannot, then }) {
|
|
582
|
+
const v = copy?.version ? ` (${copy.version})` : "";
|
|
583
|
+
return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The mark a channel switch leaves in this checkout's machine-local binding
|
|
587
|
+
// (the converge story's rules 2–4): `channel_switch: { by, at }`, two strings.
|
|
588
|
+
// It says this checkout chose the npm install; it lists no copies — the
|
|
589
|
+
// silenced set lives in our manifest and the checkout's local settings.
|
|
590
|
+
// Doctor drops contract 13's "if moving to npm was meant, ignore this" info
|
|
591
|
+
// for a marked checkout, the installer plans no second mark, and uninstall
|
|
592
|
+
// removes the key with the registration. `bind --inherit` never copies it.
|
|
593
|
+
export const CHANNEL_SWITCH_KEY = "channel_switch";
|
|
594
|
+
export function channelMark(cfg) {
|
|
595
|
+
const m = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg[CHANNEL_SWITCH_KEY] : null;
|
|
596
|
+
return m && typeof m === "object" && !Array.isArray(m) && typeof m.by === "string" && typeof m.at === "string" ? m : null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// A switch read from the environment the way contract 9 reads CI: set, and
|
|
600
|
+
// not empty, "0" or "false" (any case). One reading for CI and for
|
|
601
|
+
// PROJECTSTORE_NO_UPDATE_CHECK (the converge story's rule 8).
|
|
602
|
+
export function envFlag(env, name) {
|
|
603
|
+
const v = env ? env[name] : undefined;
|
|
604
|
+
return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
|
|
489
605
|
}
|
|
490
606
|
|
|
491
607
|
// The files a package ships, as the release gate defines them — package.json
|
|
@@ -615,10 +731,13 @@ export function rollbackPortableMarketplace(dir, backup = null) {
|
|
|
615
731
|
return dir;
|
|
616
732
|
}
|
|
617
733
|
|
|
618
|
-
|
|
734
|
+
// `maxRetries` is rmSync's own: a tree another process may still be writing
|
|
735
|
+
// into (an npm the fetch budget stopped) can fail with ENOTEMPTY or EBUSY
|
|
736
|
+
// once and succeed on the next pass. The path guard holds whatever it is.
|
|
737
|
+
export function removeTreeUnder(dir, homeBase, { maxRetries = 0 } = {}) {
|
|
619
738
|
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
620
739
|
if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`removeTreeUnder: ${dir} is not under ${homeBase}`);
|
|
621
|
-
rmSync(dir, { recursive: true, force: true });
|
|
740
|
+
rmSync(dir, { recursive: true, force: true, maxRetries });
|
|
622
741
|
}
|
|
623
742
|
|
|
624
743
|
export function installedPluginRoot(home = homedir(), preferFamily = null) {
|
|
@@ -714,7 +833,8 @@ export function projectDirRefusal(dir) {
|
|
|
714
833
|
// PREVIOUS session start, one restart behind every update. The launcher path
|
|
715
834
|
// never changes, and it resolves the installed plugin at render time, so
|
|
716
835
|
// `/plugin update` + `/reload-plugins` show up immediately. Dev checkouts and
|
|
717
|
-
// --plugin-dir roots carry no version, so
|
|
836
|
+
// --plugin-dir roots carry no version, so install wires those directly; a
|
|
837
|
+
// session run from one keeps whatever launcher entry install wrote.
|
|
718
838
|
//
|
|
719
839
|
// Idempotent (writes only when the value changes); never clobbers a foreign
|
|
720
840
|
// statusLine; bails on an unparseable settings file. Returns a status string,
|
|
@@ -751,21 +871,65 @@ export function statusLineScriptPath(cmd) {
|
|
|
751
871
|
return m ? m[1].replace(/\\/g, "/") : null;
|
|
752
872
|
}
|
|
753
873
|
|
|
874
|
+
// Is `p` a launcher path of ours in this project: any harness's launcher in
|
|
875
|
+
// its state directory, or the legacy one? A path test — nothing is read. The
|
|
876
|
+
// ownership check below and the SessionStart refresh both ask it.
|
|
877
|
+
function ourLauncherPath(p, projectDir) {
|
|
878
|
+
if (!p) return false;
|
|
879
|
+
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
880
|
+
const q = norm(p);
|
|
881
|
+
const lp = layoutPaths(projectDir);
|
|
882
|
+
return (q.startsWith(norm(lp.state) + "/") && isLauncherPath(q)) || q === norm(lp.legacy.launcher);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// The provenance field of a registration directory's manifest, or null — the
|
|
886
|
+
// install spec's contract 2 for a JSON directory: emitted by one function
|
|
887
|
+
// (registrationManifest, the installer) and read by this one. The registration
|
|
888
|
+
// analysis (surfaces.mjs) and statusLineIsOurWiring both call it; the key is
|
|
889
|
+
// the surface's provenance_key.
|
|
890
|
+
export function registrationProvenance(manifest, s) {
|
|
891
|
+
const prov = manifest && s && s.provenance_key ? manifest[s.provenance_key] : null;
|
|
892
|
+
return prov && typeof prov === "object" && typeof prov.pkg === "string" ? prov : null;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Is `p` the npm registration's own payload script —
|
|
896
|
+
// <registration dir>/<plugin_subdir>/scripts/statusline.mjs, whose directory's
|
|
897
|
+
// manifest carries our provenance field? The host loads that registration in
|
|
898
|
+
// place from its marketplace directory, so a session refreshed the entry to
|
|
899
|
+
// that script and a package root then read it as someone else's (the story
|
|
900
|
+
// "The SessionStart hook and install agree on the status line of an npm
|
|
901
|
+
// registration loaded from its marketplace directory"). A string test first;
|
|
902
|
+
// the manifest is read only when the path has the payload's shape. Every name
|
|
903
|
+
// comes from the source manifest's registration surface, and the directory
|
|
904
|
+
// from `p` itself — never a path built from the home.
|
|
905
|
+
function isRegistrationPayloadScript(p, harness = sourceHarness()) {
|
|
906
|
+
const s = Object.values(harness?.surfaces || {}).find((x) => x && x.kind === "registration" && x.format === "host-plugin-registration" && x.plugin_subdir && x.manifest);
|
|
907
|
+
if (!s) return false;
|
|
908
|
+
const tail = `/${s.plugin_subdir}/scripts/statusline.mjs`;
|
|
909
|
+
if (!p.endsWith(tail)) return false;
|
|
910
|
+
try {
|
|
911
|
+
return Boolean(registrationProvenance(JSON.parse(readFileSync(join(p.slice(0, -tail.length), s.manifest), "utf8")), s));
|
|
912
|
+
} catch {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
754
917
|
// Strict test — "did WE write this?". Required wherever we would overwrite or
|
|
755
918
|
// delete the entry: the loose shape above also matches a user's own
|
|
756
919
|
// ~/.claude/scripts/statusline.mjs, and clobbering that would take their HUD.
|
|
757
920
|
// Ours means one of: this project's launcher, the running plugin's own script
|
|
758
|
-
// (dev checkouts included),
|
|
921
|
+
// (dev checkouts included), any versioned install under the plugin cache, or
|
|
922
|
+
// the npm registration's payload script — the one rung that reads a file, so
|
|
923
|
+
// it runs last, after every cheaper test has failed.
|
|
759
924
|
export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root = pluginRoot()) {
|
|
760
925
|
const p = statusLineScriptPath(cmd);
|
|
761
926
|
if (!p) return false;
|
|
762
927
|
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
763
928
|
// Ours if it is any harness's launcher in this project's state, or the legacy launcher.
|
|
764
|
-
|
|
765
|
-
if (p.startsWith(norm(lp.state) + "/") && isLauncherPath(p)) return true;
|
|
766
|
-
if (p === norm(lp.legacy.launcher)) return true;
|
|
929
|
+
if (ourLauncherPath(p, projectDir)) return true;
|
|
767
930
|
if (p === norm(join(root, "scripts", "statusline.mjs"))) return true;
|
|
768
|
-
|
|
931
|
+
if (isPluginCacheRoot(dirname(dirname(p)), home)) return true;
|
|
932
|
+
return isRegistrationPayloadScript(p);
|
|
769
933
|
}
|
|
770
934
|
|
|
771
935
|
// The launcher template runs standalone, before it knows which plugin root to
|
|
@@ -867,14 +1031,26 @@ export function syncStatusLine(cfg, projectDir, home = homedir()) {
|
|
|
867
1031
|
// nothing into the project, since we are not wiring anything here.
|
|
868
1032
|
if (cur && !isOurs) return "foreign-present";
|
|
869
1033
|
if (!cur) return "needs-install";
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
1034
|
+
// Install alone decides the entry's shape; this hook never demotes a
|
|
1035
|
+
// launcher. An entry naming a launcher of ours that is on disk is left
|
|
1036
|
+
// byte-identical, whatever root runs the hook: the host loads an npm
|
|
1037
|
+
// registration in place from its marketplace directory, which is not the
|
|
1038
|
+
// cache path the launcher was planned against, and re-pointing the entry
|
|
1039
|
+
// at that root's script undid every install (the install spec, contract 0
|
|
1040
|
+
// as amended 2026-10-10). Recognised by path and existsSync only — the
|
|
1041
|
+
// launcher is not read here.
|
|
1042
|
+
const named = statusLineScriptPath(curCmd);
|
|
1043
|
+
const namesLauncher = ourLauncherPath(named, projectDir);
|
|
1044
|
+
if (namesLauncher && existsSync(named)) return "unchanged";
|
|
1045
|
+
// Refresh: a launcher of ours on disk when this root is a cache install or
|
|
1046
|
+
// the entry named a launcher, else the plugin's own script, so an entry
|
|
1047
|
+
// always names a renderer that exists. A missing launcher is install's to
|
|
1048
|
+
// create (stamped), never this path's.
|
|
873
1049
|
const { launcher } = desiredStatusLineCommand(projectDir, root, home);
|
|
874
1050
|
// A launcher on disk at either path — the new one, or the legacy one an
|
|
875
1051
|
// earlier release wrote (the layout ADR) — is kept; moving it is install's.
|
|
876
1052
|
const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) => existsSync(f));
|
|
877
|
-
const desired = launcher && onDisk
|
|
1053
|
+
const desired = (launcher || namesLauncher) && onDisk
|
|
878
1054
|
? `node "${onDisk}"`
|
|
879
1055
|
: `node "${join(root, "scripts", "statusline.mjs")}"`;
|
|
880
1056
|
if (curCmd !== desired) {
|
|
@@ -1065,14 +1241,33 @@ export function folderByKind(layout, kind) {
|
|
|
1065
1241
|
|
|
1066
1242
|
// ─── Templates ─────────────────────────────────────────────────────────
|
|
1067
1243
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1244
|
+
// `root` is the plugin root to read from — the bin passes its own package's,
|
|
1245
|
+
// as it does for layouts, so a scaffold plans against the copy that runs.
|
|
1246
|
+
export function loadTemplate(lang, name, root = pluginRoot()) {
|
|
1247
|
+
const p = join(root, "templates", lang, `${name}.md.tmpl`);
|
|
1070
1248
|
if (!existsSync(p)) {
|
|
1071
1249
|
throw new Error(`Template not found: templates/${lang}/${name}.md.tmpl`);
|
|
1072
1250
|
}
|
|
1073
1251
|
return readFileSync(p, "utf8");
|
|
1074
1252
|
}
|
|
1075
1253
|
|
|
1254
|
+
// templates/<lang>/strings.json, parsed — or null when the language has none
|
|
1255
|
+
// or it does not parse. No English fallback, unlike the status line's own
|
|
1256
|
+
// reader: the folder names and descriptions are written into a vault's
|
|
1257
|
+
// READMEs, and an English description in a Russian vault would read as the
|
|
1258
|
+
// vault's own prose forever after (the skeleton's Purpose column takes it
|
|
1259
|
+
// from there). A missing string is the caller's refusal to name.
|
|
1260
|
+
export function loadStrings(lang, root = pluginRoot()) {
|
|
1261
|
+
const p = join(root, "templates", String(lang), "strings.json");
|
|
1262
|
+
if (!existsSync(p)) return null;
|
|
1263
|
+
try {
|
|
1264
|
+
const s = JSON.parse(readFileSync(p, "utf8"));
|
|
1265
|
+
return s && typeof s === "object" && !Array.isArray(s) ? s : null;
|
|
1266
|
+
} catch {
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1076
1271
|
// {{x}} substitutes raw; {{x_json}} substitutes JSON.stringify(String(x)) — a
|
|
1077
1272
|
// valid YAML double-quoted scalar. Frontmatter lines in templates use the
|
|
1078
1273
|
// _json form so titles containing `"` or `:` cannot corrupt the YAML.
|
|
@@ -1100,7 +1295,9 @@ export function renderTemplate(template, vars) {
|
|
|
1100
1295
|
// language is the canonical form used when WRITING. Matching always accepts
|
|
1101
1296
|
// every registered form of every language — a ru-headed file in an en-bound
|
|
1102
1297
|
// vault must still lint. This is deliberately separate from
|
|
1103
|
-
// templates/<lang>/strings.json,
|
|
1298
|
+
// templates/<lang>/strings.json, a render-only map: the status line's strings
|
|
1299
|
+
// and the folder names and descriptions scaffold writes into READMEs, which
|
|
1300
|
+
// nothing ever matches against.
|
|
1104
1301
|
|
|
1105
1302
|
let _headingsCache = null;
|
|
1106
1303
|
|
|
@@ -2373,13 +2570,25 @@ export function ensureRuntimeDir(projectDir) {
|
|
|
2373
2570
|
// harness inside; its own ignore file so nothing in here can reach git,
|
|
2374
2571
|
// whichever writer gets there first.
|
|
2375
2572
|
export function ensureStateDir(projectDir) {
|
|
2376
|
-
|
|
2573
|
+
ensureStateRoot(projectDir);
|
|
2377
2574
|
const p = layoutPaths(projectDir);
|
|
2378
2575
|
mkdirSync(p.sessions, { recursive: true });
|
|
2379
|
-
if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
|
|
2380
2576
|
return p.sessions;
|
|
2381
2577
|
}
|
|
2382
2578
|
|
|
2579
|
+
// state/ and its .gitignore, header included, without sessions/: what the
|
|
2580
|
+
// welcome marker's writer needs in a project that is not bound, where no
|
|
2581
|
+
// session state may appear. The header is how a project uninstall
|
|
2582
|
+
// recognises the marker as ours (install spec contract 13, as amended by the
|
|
2583
|
+
// shell-fetch story).
|
|
2584
|
+
export function ensureStateRoot(projectDir) {
|
|
2585
|
+
ensureRuntimeDir(projectDir);
|
|
2586
|
+
const p = layoutPaths(projectDir);
|
|
2587
|
+
mkdirSync(p.state, { recursive: true });
|
|
2588
|
+
if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
|
|
2589
|
+
return p.state;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2383
2592
|
export function readSessionState(projectDir, sessionId) {
|
|
2384
2593
|
try {
|
|
2385
2594
|
const p = pickExisting(sessionStatePath(projectDir, sessionId), join(legacyStateDir(projectDir), `${sessionId}.json`));
|
|
@@ -2462,16 +2671,21 @@ export const SOURCE_IGNORE = [
|
|
|
2462
2671
|
|
|
2463
2672
|
// The entry-rule counter ignores strictly more than the base set, and the
|
|
2464
2673
|
// difference is deliberate rather than an oversight of one or the other.
|
|
2465
|
-
// /projectstore:bind writes these
|
|
2674
|
+
// /projectstore:bind writes these itself, in a session that by
|
|
2466
2675
|
// construction has no story open — counting them makes the plugin nag about its
|
|
2467
2676
|
// own setup. They must NOT join SOURCE_IGNORE: an edit to AGENTS.md is a real
|
|
2468
2677
|
// code reference (the PS-AGENTS epic already lists it), and folding these into
|
|
2469
2678
|
// the shared set would silently drop it from every proposed code_refs.
|
|
2470
2679
|
// Root-anchored, unlike the patterns above: a monorepo's nested AGENTS.md is
|
|
2471
|
-
// ordinary source and must still count.
|
|
2680
|
+
// ordinary source and must still count. The fourth is the binding's own
|
|
2681
|
+
// ignore file, which `bind` and `init` write through ensureRuntimeDir: the
|
|
2682
|
+
// layout move added it and this list missed it, so a fresh `init` on a clean
|
|
2683
|
+
// project read as one uncommitted source file (measured 2026-10-08 on 0.29.2).
|
|
2684
|
+
// Built from LAYOUT.root, which the resolver alone spells (layout contract 0).
|
|
2472
2685
|
export const ENTRY_IGNORE = [
|
|
2473
2686
|
...SOURCE_IGNORE,
|
|
2474
2687
|
/^AGENTS\.md$/, /^CLAUDE\.md$/, /^\.gitignore$/,
|
|
2688
|
+
new RegExp(`^${LAYOUT.root.replace(".", "\\.")}\\/\\.gitignore$`),
|
|
2475
2689
|
];
|
|
2476
2690
|
|
|
2477
2691
|
export function isSourcePath(absPath, projectDir, vaultPath) {
|
|
@@ -3138,21 +3352,6 @@ export function sessionNameOfferText(offer, { env = process.env } = {}) {
|
|
|
3138
3352
|
|
|
3139
3353
|
// ─── Frontmatter parsing (minimal) ─────────────────────────────────────
|
|
3140
3354
|
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
const data = {};
|
|
3145
|
-
for (const line of m[1].split("\n")) {
|
|
3146
|
-
const kv = line.match(/^(\w+):\s*(.*)$/);
|
|
3147
|
-
if (!kv) continue;
|
|
3148
|
-
let v = kv[2].trim();
|
|
3149
|
-
if (v === "null") v = null;
|
|
3150
|
-
else if (v.startsWith('"') && v.endsWith('"')) {
|
|
3151
|
-
// JSON.parse round-trips escaped scalars from renderTemplate's _json
|
|
3152
|
-
// form (titles containing quotes); fall back to the bare strip.
|
|
3153
|
-
try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
|
|
3154
|
-
}
|
|
3155
|
-
data[kv[1]] = v;
|
|
3156
|
-
}
|
|
3157
|
-
return { data, body: md.slice(m[0].length) };
|
|
3158
|
-
}
|
|
3355
|
+
// Moved to stages.mjs (Node-free) so the band module parses frontmatter by
|
|
3356
|
+
// the same rule; re-exported here for every existing caller.
|
|
3357
|
+
export { parseFrontmatter };
|
|
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { whichOnPath } from "./lib.mjs";
|
|
9
|
+
import { whichOnPath, cmpPrecedence } from "./lib.mjs";
|
|
10
10
|
|
|
11
11
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
|
|
12
12
|
const samePath = (a, b) => {
|
|
@@ -114,13 +114,35 @@ function pluginIds(sections) {
|
|
|
114
114
|
return ids;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// The manifests a portable plugin root may carry, in the order they are read:
|
|
118
|
+
// an Agent Plugins plugin.json at the root, then the legacy one Codex
|
|
119
|
+
// validates — from 0.28.2 the shell's one manifest, since Codex picks a root
|
|
120
|
+
// one first and loads no hooks from it (openai/codex#37027). The one place
|
|
121
|
+
// either is spelled; the shell fetch's verification names them by kind.
|
|
122
|
+
const PAYLOAD_MANIFESTS = { root: ["plugin.json"], legacy: [".codex-plugin", "plugin.json"] };
|
|
123
|
+
export function payloadManifestPaths(root) {
|
|
124
|
+
return Object.values(PAYLOAD_MANIFESTS).map((parts) => join(root, ...parts));
|
|
125
|
+
}
|
|
126
|
+
export function payloadManifestPath(root, kind) {
|
|
127
|
+
return join(root, ...PAYLOAD_MANIFESTS[kind]);
|
|
128
|
+
}
|
|
129
|
+
// Is `root` a portable plugin root: does it carry either manifest?
|
|
130
|
+
export function isPortablePluginRoot(root) {
|
|
131
|
+
return Boolean(root) && payloadManifestPaths(root).some((p) => existsSync(p));
|
|
132
|
+
}
|
|
133
|
+
// The first manifest that parses, or null — or, with `kind`, that one only.
|
|
134
|
+
export function payloadManifest(root, { kind = null } = {}) {
|
|
135
|
+
for (const p of kind ? [payloadManifestPath(root, kind)] : payloadManifestPaths(root)) { const m = readJson(p); if (m) return m; }
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
function cacheEntries(paths, s) {
|
|
118
140
|
const base = join(paths.cacheDir, s.marketplace_name, s.plugin_name);
|
|
119
141
|
let names;
|
|
120
142
|
try { names = readdirSync(base); } catch { return []; }
|
|
121
143
|
return names.map((name) => {
|
|
122
144
|
const path = join(base, name);
|
|
123
|
-
const manifest =
|
|
145
|
+
const manifest = payloadManifest(path);
|
|
124
146
|
let at = 0; try { at = statSync(path).mtimeMs; } catch {}
|
|
125
147
|
if (!manifest || typeof manifest.version !== "string") return null;
|
|
126
148
|
let digest = null;
|
|
@@ -139,18 +161,33 @@ function cacheEntries(paths, s) {
|
|
|
139
161
|
// the harness's directory (S2 of the 2026-10-03 review in "The Codex shell's
|
|
140
162
|
// plugin root: .codex-plugin, the rendered surfaces, and the first Codex
|
|
141
163
|
// install").
|
|
142
|
-
|
|
164
|
+
//
|
|
165
|
+
// `fetched` is the root a core run fetched for this harness (the shell fetch
|
|
166
|
+
// of install and upgrade). The variable wins: a set one is used as is, a root
|
|
167
|
+
// or not, and the fetched root is consulted only when it is unset — a shell
|
|
168
|
+
// that names its own root is never second-guessed by a fetch.
|
|
169
|
+
export function portablePayloadRoot(s, { root, env = process.env, fetched = null } = {}) {
|
|
143
170
|
const named = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
|
|
144
171
|
if (s.condition !== "distribution_root") return named || root;
|
|
145
|
-
|
|
146
|
-
return
|
|
172
|
+
const candidate = named || fetched || null;
|
|
173
|
+
return candidate && isPortablePluginRoot(candidate) ? candidate : null;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
|
|
176
|
+
// Two modes. With a payload, every rung is decided, the published digest
|
|
177
|
+
// against the recorded one included. With a `version` and no payload — plan's
|
|
178
|
+
// dry run, both uninstalls, a run whose fetch was skipped, and the recheck of
|
|
179
|
+
// an item planned that way — every rung that reads host facts is decided, and
|
|
180
|
+
// the one comparison that needs the payload's bytes is not: its digest, so
|
|
181
|
+
// `contentDiffers` and the same-version, different-content conflict. That is
|
|
182
|
+
// the one rung a local-tarball install at an unchanged version trips, and it
|
|
183
|
+
// is decided after a real fetch. `unavailable` for want of a payload fires
|
|
184
|
+
// only when there is neither. Doctor passes neither and reads as before.
|
|
185
|
+
export function analysePortableRegistration(projectDir, s, { root, payloadRoot: explicitPayloadRoot = null, version = null, fetched = null, home = homedir(), harness, env = process.env, ignoreJournal = false } = {}) {
|
|
150
186
|
const paths = portableRegistrationPaths(s, { home, projectDir, harness, env });
|
|
151
|
-
const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env });
|
|
152
|
-
const desiredManifest = payloadRoot ? (
|
|
153
|
-
const
|
|
187
|
+
const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env, fetched });
|
|
188
|
+
const desiredManifest = payloadRoot ? payloadManifest(payloadRoot) : null;
|
|
189
|
+
const versionOnly = !payloadRoot && typeof version === "string" && version !== "";
|
|
190
|
+
const desiredVersion = desiredManifest?.version || (versionOnly ? version : null);
|
|
154
191
|
const id = `${s.plugin_name}@${s.marketplace_name}`;
|
|
155
192
|
const bin = whichOnPath(s.cli.bin, env);
|
|
156
193
|
const ownership = readJson(paths.ownership)?.[s.provenance_key] || null;
|
|
@@ -168,15 +205,21 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
|
|
|
168
205
|
const g = global.get(pluginTable(other)) || null, p = project.get(pluginTable(other)) || null;
|
|
169
206
|
return { id: other, enabled: typeof p?.enabled === "boolean" ? p.enabled : g?.enabled === true };
|
|
170
207
|
});
|
|
208
|
+
// A registration newer than what this run offers (the converge story's rule
|
|
209
|
+
// 6): set before the ladder, so a damaged one keeps it beside its real
|
|
210
|
+
// reason, and the cache is then read at the version the host registered —
|
|
211
|
+
// keyed on the desired version, a newer registration's integrity rungs
|
|
212
|
+
// checked the wrong cache directory.
|
|
213
|
+
const newer = typeof ownership?.version === "string" && typeof desiredVersion === "string" && cmpPrecedence(ownership.version, desiredVersion) > 0;
|
|
171
214
|
const caches = cacheEntries(paths, s);
|
|
172
|
-
const installed = caches.find((e) => e.version === desiredVersion) || caches[0] || null;
|
|
173
|
-
const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null };
|
|
215
|
+
const installed = caches.find((e) => e.version === (newer ? ownership.version : desiredVersion)) || caches[0] || null;
|
|
216
|
+
const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, versionOnly, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null, newer };
|
|
174
217
|
const enabledOther = others.find((other) => other.enabled);
|
|
175
218
|
const nothing = !existsSync(paths.dir) && !market && !globalPlugin && !installed && !enabledOther;
|
|
176
|
-
if (!desiredManifest) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
|
|
219
|
+
if (!desiredManifest && !versionOnly) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
|
|
177
220
|
if (!bin && nothing) return { ...out, state: "unavailable", reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI` };
|
|
178
221
|
let desiredDigest = null;
|
|
179
|
-
try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; }
|
|
222
|
+
if (!versionOnly) { try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; } }
|
|
180
223
|
out.desiredDigest = desiredDigest;
|
|
181
224
|
if (enabledOther) return { ...out, state: "conflict", refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry` };
|
|
182
225
|
// A journal the rollback could not prove names the run that left it, when,
|
|
@@ -191,13 +234,17 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
|
|
|
191
234
|
let sourceDigest = null;
|
|
192
235
|
try { if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload); } catch {}
|
|
193
236
|
out.sourceDigest = sourceDigest;
|
|
194
|
-
out.contentDiffers = Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
|
|
237
|
+
out.contentDiffers = versionOnly ? null : Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
|
|
195
238
|
if (!ownership || !catalog) return { ...out, state: "stale", reason: "the owned marketplace source is incomplete" };
|
|
196
|
-
if (ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
|
|
239
|
+
if (!versionOnly && ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
|
|
197
240
|
if (!sourceDigest || sourceDigest.sha256 !== ownership.digest?.sha256 || sourceDigest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
|
|
198
241
|
if (!market) return { ...out, state: "stale", reason: "the host does not know the marketplace" };
|
|
199
242
|
if (!installed || installed.version !== ownership.version) return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
|
|
200
243
|
if (!installed.digest || installed.digest.sha256 !== ownership.digest?.sha256 || installed.digest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the materialised host cache does not match the registered payload digest" };
|
|
244
|
+
// The newer rung, after the integrity rungs: a sound registration newer
|
|
245
|
+
// than this run is current and never staged over (rule 6). A damaged one
|
|
246
|
+
// stayed stale above, with its real reason; the planner skips it.
|
|
247
|
+
if (newer) return { ...out, state: "current", reason: `the registration is at ${ownership.version}, newer than this package (${desiredVersion}) — not downgraded` };
|
|
201
248
|
if (ownership.version !== desiredVersion) return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
|
|
202
249
|
return { ...out, state: "current", reason: enabled ? null : "installed globally but disabled by effective configuration" };
|
|
203
250
|
}
|