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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -0,0 +1,468 @@
1
+ // projectstore — convergence.mjs: what install and upgrade decide, before
2
+ // the plan, about the copies of ours this project already runs (the story
3
+ // "Setup and upgrade converge this project onto the running version: the
4
+ // channel switch, no downgrade, and the update notice", rules 1, 2, 5, 6 and 8; the
5
+ // ADR "One front door: projectstore setup installs every harness it finds;
6
+ // shells are release artifacts, not user commands", decision 4).
7
+ //
8
+ // runningCopies(projectDir, { harnesses, root, home, env, channel, … })
9
+ // → rule 6's copies, read-only
10
+ // readLatest({ registry, cachePath, fetch, now, timers, timeoutMs, atLeast })
11
+ // → { latest, from, error }: one read, cached a day
12
+ // decide({ copies, version, read, skipped, verb, args })
13
+ // → stop or proceed, with the stop's texts
14
+ // proceedCase(decision, v) → the words a newer row adds when not stopped
15
+ // channelChoices({ harness, s, copy, running, gate }) → the ways this release
16
+ // offers to settle a project's channel (rule
17
+ // 2's helper: the gate's text, and a newer copy's)
18
+ // newerCopyDeferral({ copy, running, decision, harness, s })
19
+ // → a newer copy not switched off: { reason, advice }
20
+ // switchConsequence(harness) → a switch row's consequence line (rule 2)
21
+ // switchReason({ copy, running, runs, decision, unbound })
22
+ // → what a switch row adds under the channel "npm"
23
+ // noCopyToKeep({ harness, surface, off }) → the channel "git" with nothing to keep
24
+ // keepArgv(copy, { verb, harness, projectDir }) → the kept copy's bin, remedy form + --json
25
+ // readKeepPlan(copy, { harness, projectDir, env, run, budget })
26
+ // → the kept copy's own plan: writes, ok, incomplete, refusals
27
+ // keepResult(stdout) → a bin --json envelope's `result`, or null
28
+ // ownUpgrade(copy, harness), npmRowBeside({ harness, id, surface }) → the keep's next lines
29
+ // repairAdvice(harness, v) → a damaged newer registration's way out
30
+ // advised(text, blocks) → { reason, advice }: prose, then commands alone
31
+ // updateNotice({ latest, version, root, verb }) → the notice's text and command
32
+ // hostCopyRoot, checksRoot, noticeRoot (root, { home, env }), updateCheckOff(env)
33
+ //
34
+ // The one owner of the stop: the prepare step calls decide() and, on a stop,
35
+ // short-circuits before the keep read and the fetch; plan() only renders what
36
+ // it is handed and reads no registry. Every clock, timer, fetch and cache
37
+ // path is a parameter, so each rule is a unit test with plain values. The
38
+ // one file it touches is the registry cache, written after a successful read.
39
+ //
40
+ // Imports lib, harness, portable-registration and surfaces, never the fetch
41
+ // or the host runner: every spawn and resolver is the caller's. Only the
42
+ // installer imports this file; doctor, lib and the hooks never do, so the
43
+ // SessionStart graph never loads it (tests/convergence.test.mjs asserts
44
+ // both). Pure node, no external deps — the read is Node's own fetch.
45
+
46
+ import { readFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
47
+ import { dirname, join, resolve, sep } from "node:path";
48
+ import { homedir } from "node:os";
49
+ import { cmpPrecedence, installChannel, projectCopiesByKey, writeFileAtomic, envFlag } from "./lib.mjs";
50
+ import { loadHarness, loadHarnesses, packageCommand } from "./harness.mjs";
51
+ import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
52
+ import { registrationPaths } from "./surfaces.mjs";
53
+
54
+ // The package every literal command here names: the core, in the core form
55
+ // (rule 9's command-form rule). The shells bundle it; the notice and the stop
56
+ // name the package that runs, never a shell.
57
+ export const PACKAGE = "projectstore";
58
+ // What the read uses when the user's registry cannot be resolved.
59
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
60
+ // The abbreviated packument: what `npm install` itself reads (8 728 bytes
61
+ // against 50 896 for the full document, measured 2026-10-09).
62
+ export const ACCEPT = "application/vnd.npm.install-v1+json";
63
+ // The request's bound. On the notice-only path it is the whole bound — the
64
+ // user's registry resolver and the request share it; rule 6's read keeps the
65
+ // resolver's own 10 s (REGISTRY_BUDGET_MS, fetch-shell.mjs) before it.
66
+ export const READ_TIMEOUT_MS = 2000;
67
+ export const NOTICE_BUDGET_MS = 2000;
68
+ export const FRESH_MS = 24 * 60 * 60 * 1000;
69
+ export const NO_CHECK_ENV = "PROJECTSTORE_NO_UPDATE_CHECK";
70
+
71
+ // Strict semver 2.0 (semver.org's own grammar): a `dist-tags.latest` that is
72
+ // anything else counts as missing — never printed, never compared.
73
+ const NUM = "(?:0|[1-9]\\d*)";
74
+ const PRE = `(?:${NUM}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
75
+ const SEMVER = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}(?:-${PRE}(?:\\.${PRE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`);
76
+ export const isSemver = (v) => typeof v === "string" && SEMVER.test(v);
77
+
78
+ // ─── Which roots check (rule 6) and show the notice (rule 8) ────────────
79
+
80
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
81
+ const within = (root, dir) => { const r = real(root), d = real(dir); return r === d || r.startsWith(d + sep); };
82
+
83
+ // A copy a host runs, updated through its own channel (layout spec contract
84
+ // 12): never stopped, never told to update. installChannel names the host's
85
+ // cache copies; two more roots a session runs are recognised here, from the
86
+ // manifests' own paths (the review of PR1, F2):
87
+ // - our registration's marketplace directory: a host that loads it in place
88
+ // runs the plugin from <dir>/<plugin_subdir> (the npm registration's root);
89
+ // - anything under a portable registration's stable source or its host's
90
+ // plugin cache: the core a harness's shell bundles there (the Codex skills'
91
+ // root, <harness home>/plugins/cache/…/node_modules/projectstore).
92
+ // Doctor keeps installChannel's answers; only these two checks read this.
93
+ export function hostCopyRoot(root, { home = homedir(), env = process.env } = {}) {
94
+ if (!root) return false;
95
+ if (["registration", "marketplace"].includes(installChannel(root, { home }))) return true;
96
+ for (const m of loadHarnesses().values()) {
97
+ for (const s of Object.values(m.surfaces || {})) {
98
+ if (s?.kind !== "registration") continue;
99
+ if (s.format === "host-plugin-registration") {
100
+ const p = registrationPaths(s, { home, harness: m });
101
+ if (within(root, p.payload) || within(root, p.cacheDir)) return true;
102
+ } else if (s.format === "portable-plugin-registration") {
103
+ const p = portableRegistrationPaths(s, { home, projectDir: home, harness: m, env });
104
+ if (within(root, p.dir) || within(root, p.cacheDir)) return true;
105
+ }
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // Who checks (rule 6): a package root or a checkout that is not a host's
112
+ // copy. Who shows the notice (rule 8): a package root that is not one. A
113
+ // checkout is updated by git and shows no notice.
114
+ export function checksRoot(root, { home = homedir(), env = process.env } = {}) {
115
+ return ["package", "checkout"].includes(installChannel(root, { home })) && !hostCopyRoot(root, { home, env });
116
+ }
117
+ export function noticeRoot(root, { home = homedir(), env = process.env } = {}) {
118
+ return installChannel(root, { home }) === "package" && !hostCopyRoot(root, { home, env });
119
+ }
120
+ export const updateCheckOff = (env) => envFlag(env, NO_CHECK_ENV);
121
+
122
+ // ─── The copies this project runs (rule 6) ──────────────────────────────
123
+
124
+ // For each harness of the run:
125
+ // - a host plugin registration: contract 12's rows, one per key — the row the
126
+ // host loads here (lib's projectCopiesByKey, the same rows the registration's
127
+ // competing copies are read from) — of any marketplace: the npm
128
+ // registration's row and a git-marketplace copy alike. Under channel "git"
129
+ // none: that run renders nothing for it;
130
+ // - a portable registration: the global registration's recorded version,
131
+ // when it is effectively enabled for this project.
132
+ // `analyse(harness, s)` lets the caller share one analysis with the fetch.
133
+ export function runningCopies(projectDir, { harnesses = [], root, home = homedir(), env = process.env, channel = null, version = null, analyse = null } = {}) {
134
+ const out = [];
135
+ for (const id of harnesses) {
136
+ const harness = loadHarness(id);
137
+ if (!harness) continue;
138
+ for (const [key, s] of Object.entries(harness.surfaces || {})) {
139
+ if (key.startsWith("_") || s?.kind !== "registration") continue;
140
+ if (s.format === "host-plugin-registration") {
141
+ if (channel === "git") continue;
142
+ for (const e of projectCopiesByKey(home, projectDir)) {
143
+ if (typeof e.version === "string" && e.version) out.push({ harness: id, key: e.key, version: e.version, path: e.path, scope: e.scope, own: e.own });
144
+ }
145
+ } else if (s.format === "portable-plugin-registration") {
146
+ const a = analyse ? analyse(harness, s) : analysePortableRegistration(projectDir, s, { root, home, harness, env, version });
147
+ const v = a?.ownership?.version;
148
+ if (typeof v === "string" && v && a.enabled) out.push({ harness: id, key: a.id, version: v });
149
+ }
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ export const newerCopies = (copies, version) => (version ? copies.filter((c) => cmpPrecedence(c.version, version) > 0) : []);
156
+ // The lowest of them: a fresh cache entry below it is read again (rule 8).
157
+ export const lowestVersion = (copies) => copies.map((c) => c.version).sort(cmpPrecedence)[0] || null;
158
+
159
+ // ─── The registry read (rule 8) ──────────────────────────────────────────
160
+
161
+ function readCache(cachePath) {
162
+ if (!cachePath) return {};
163
+ try {
164
+ const c = JSON.parse(readFileSync(cachePath, "utf8"));
165
+ return c && typeof c === "object" && !Array.isArray(c) ? c : {};
166
+ } catch { return {}; }
167
+ }
168
+
169
+ // Fresh for a day; an `at` in the future is stale (a clock that moved back).
170
+ function fresh(entry, t) {
171
+ const at = Date.parse(entry?.at || "");
172
+ return Number.isFinite(at) && at <= t && t - at < FRESH_MS;
173
+ }
174
+
175
+ // One request, bounded: the timer aborts the request and settles the read,
176
+ // whatever the fetch does after — a fetch that never settles costs the bound.
177
+ // An answer that is not read (an error status) has its body cancelled, so
178
+ // its connection is released.
179
+ function request(url, { fetch, timers, timeoutMs }) {
180
+ return new Promise((settle) => {
181
+ let done = false, timer = null;
182
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
183
+ const finish = (r) => {
184
+ if (done) return;
185
+ done = true;
186
+ if (timer !== null) { try { timers.clearTimeout(timer); } catch {} }
187
+ settle(r);
188
+ };
189
+ timer = timers.setTimeout(() => {
190
+ timer = null;
191
+ try { ac?.abort(); } catch {}
192
+ finish({ error: `no answer within ${timeoutMs / 1000} s` });
193
+ }, timeoutMs);
194
+ try { timer?.unref?.(); } catch {}
195
+ Promise.resolve()
196
+ .then(() => fetch(url, { headers: { accept: ACCEPT }, ...(ac ? { signal: ac.signal } : {}) }))
197
+ .then(async (res) => {
198
+ if (!res || !res.ok) {
199
+ try { Promise.resolve(res?.body?.cancel?.()).catch(() => {}); } catch {}
200
+ return finish({ error: `HTTP ${res?.status ?? "error"}` });
201
+ }
202
+ let body;
203
+ try { body = await res.json(); } catch { return finish({ error: "the answer is not JSON" }); }
204
+ const latest = body && typeof body === "object" ? body["dist-tags"]?.latest : undefined;
205
+ if (!isSemver(latest)) return finish({ error: "the answer names no dist-tags.latest" });
206
+ finish({ latest });
207
+ })
208
+ .catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
209
+ });
210
+ }
211
+
212
+ // The cache's one write per answer (a failed read is never cached): the entry
213
+ // keyed by the registry this run resolved, and `user_latest`, which the
214
+ // self-update ADR's session line reads without a registry of its own. This
215
+ // read never learns a publish time: `published_at` stays as it was for the
216
+ // same latest, else null. A write that fails is ignored.
217
+ function writeCache(cachePath, cache, registry, latest, at) {
218
+ if (!cachePath) return;
219
+ const prev = cache.user_latest && typeof cache.user_latest === "object" ? cache.user_latest : null;
220
+ const next = { ...cache, [registry]: { latest, at }, user_latest: { latest, at, published_at: prev && prev.latest === latest && typeof prev.published_at === "string" ? prev.published_at : null } };
221
+ try {
222
+ mkdirSync(dirname(cachePath), { recursive: true });
223
+ writeFileAtomic(cachePath, JSON.stringify(next, null, 2) + "\n", { sweep: false });
224
+ } catch {}
225
+ }
226
+
227
+ // `GET <registry>/projectstore` with the abbreviated Accept header, no
228
+ // credentials, bounded by `timeoutMs`; the answer is `dist-tags.latest`. A
229
+ // fresh entry for this registry answers instead — unless it is below
230
+ // `atLeast`, the lowest newer copy rule 6 asks about: a release may have come
231
+ // out since it was written. The cache never supplies a URL: an entry is only
232
+ // looked up with the registry this run resolved. Never throws.
233
+ export async function readLatest({ registry, cachePath = null, fetch = globalThis.fetch, now = Date.now, timers = globalThis, timeoutMs = READ_TIMEOUT_MS, atLeast = null } = {}) {
234
+ try {
235
+ const cache = readCache(cachePath);
236
+ const entry = cache[registry];
237
+ const t = now();
238
+ if (entry && isSemver(entry.latest) && fresh(entry, t) && !(atLeast && cmpPrecedence(entry.latest, atLeast) < 0)) {
239
+ return { latest: entry.latest, from: "cache", error: null };
240
+ }
241
+ if (typeof fetch !== "function") return { latest: null, from: null, error: "no fetch in this Node" };
242
+ const url = `${String(registry).replace(/\/+$/, "")}/${PACKAGE}`;
243
+ const r = await request(url, { fetch, timers, timeoutMs });
244
+ if (!r.latest) return { latest: null, from: "network", error: r.error };
245
+ writeCache(cachePath, cache, registry, r.latest, new Date(now()).toISOString());
246
+ return { latest: r.latest, from: "network", error: null };
247
+ } catch (e) {
248
+ return { latest: null, from: null, error: String(e?.message || e) };
249
+ }
250
+ }
251
+
252
+ // ─── Stop or proceed (rule 6) ───────────────────────────────────────────
253
+
254
+ // A command in the core form: `npx projectstore[@<v>] <verb> <args>`.
255
+ export function coreCommand(verb, { version = null, args = "" } = {}) {
256
+ return `npx ${PACKAGE}${version ? `@${version}` : ""} ${verb}${args ? ` ${args}` : ""}`;
257
+ }
258
+
259
+ // Prose, then each command on a line of its own after its lead, nothing after
260
+ // it (presentation spec contract 6). `reason` is the whole of it with every
261
+ // part on its own line — the envelope's form, which an agent relays as it
262
+ // is; `advice` is what PLAN draws, the commands in their own blocks.
263
+ export function advised(text, blocks = []) {
264
+ return { reason: [text, ...blocks.flatMap((b) => [b.lead, b.command])].join("\n"), advice: { text, blocks } };
265
+ }
266
+
267
+ // The run's own selection, repeated: --harness and --surface as given, and
268
+ // --no-register; never --project (the ADR's decision 5).
269
+ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
270
+ return [...harnesses.map((h) => `--harness ${h}`), ...(surfaces || []).map((s) => `--surface ${s}`), ...(register === false ? ["--no-register"] : [])].join(" ");
271
+ }
272
+
273
+ // Stop when the registry's latest is at least a newer copy this project runs
274
+ // — the newest such copy is named. A copy the latest does not reach, or no
275
+ // answer, proceeds: the rows say which case it was (proceedCase). `read` is
276
+ // readLatest's answer, null when nothing was read; `skipped` says the read
277
+ // was switched off by PROJECTSTORE_NO_UPDATE_CHECK. The refusal's text puts
278
+ // each command on a line of its own, never followed by punctuation.
279
+ export function decide({ copies = [], version = null, read = null, skipped = false, verb = "upgrade", args = {} } = {}) {
280
+ const newer = newerCopies(copies, version);
281
+ const decision = { checks: true, version, newer, read: skipped ? { skipped: true } : read, stop: null };
282
+ const latest = read && isSemver(read.latest) ? read.latest : null;
283
+ if (skipped || !latest) return decision;
284
+ const top = newer.filter((c) => cmpPrecedence(latest, c.version) >= 0).sort((a, b) => cmpPrecedence(b.version, a.version))[0];
285
+ if (!top) return decision;
286
+ const lead = `this project runs ${PACKAGE} ${top.version} (${top.key}), newer than this package (${version}), and ${top.version} is published. Nothing is written. Run the newer one from a terminal in this project:`;
287
+ const commands = [coreCommand(verb, { version: "latest", args: runArgs(args) }), `npm install -g ${PACKAGE}@latest`];
288
+ const between = "or install it for the whole machine:";
289
+ decision.stop = { copy: top, lead, commands, between, text: [lead, commands[0], between, commands[1]].join("\n") };
290
+ return decision;
291
+ }
292
+
293
+ // The case a newer row names when the run proceeds (rule 6), for the copy at
294
+ // `v`: the dry run's note when no decision was made (plan reads nothing), none
295
+ // for a root that never checks, else what the read found.
296
+ export function proceedCase(decision, v) {
297
+ if (decision === null || decision === undefined) return `install and upgrade read the registry first, and stop when ${v} is published`;
298
+ if (!decision.checks) return null;
299
+ const r = decision.read;
300
+ if (!r) return null;
301
+ if (r.skipped) return `the registry was not read (${NO_CHECK_ENV})`;
302
+ if (!isSemver(r.latest)) return `the registry could not be read (${r.error || "no answer"})`;
303
+ if (cmpPrecedence(r.latest, v) < 0) return `the registry's latest (${r.latest}) does not reach ${v}`;
304
+ return `${v} is published (the registry's latest is ${r.latest})`;
305
+ }
306
+
307
+ // Rule 2's helper: the ways this release offers to settle which copy a
308
+ // project runs, when a copy from another marketplace is enabled for it. Only
309
+ // verbs this release has are named, never `setup`, which does not exist yet;
310
+ // the setup story replaces both branches below with its own commands
311
+ // (`npx projectstore setup`, and its `--channel npm` and `--channel git`),
312
+ // here and nowhere else.
313
+ // - `gate` ({ verb, args, keys }): the channel gate's text (rule 2). A switch
314
+ // planned under no channel answer is a choice made at a terminal, and this
315
+ // release has no verb that takes the answer without one, so it names the
316
+ // terminal run of the same verb with the run's own --harness and --surface,
317
+ // in the core form (rule 9).
318
+ // - otherwise, a newer copy that is not switched off (rule 6): the copy's own
319
+ // bin in the layout remedy's form (layout spec contract 12), which converges
320
+ // the project on that copy; and the host's own disable, built from the
321
+ // manifest as a plan step is, which leaves the project to this package's
322
+ // registration on the next run.
323
+ export function channelChoices({ harness = null, s = null, copy = null, running = null, gate = null } = {}) {
324
+ if (gate) {
325
+ const from = gate.keys?.length ? ` (${gate.keys.join(", ")})` : "";
326
+ return [{ lead: `Nothing written: this project loads ${PACKAGE} from the git marketplace${from}, and moving it to the npm install is a choice made at a terminal in this project:`, command: coreCommand(gate.verb || "upgrade", { args: runArgs(gate.args || {}) }) }];
327
+ }
328
+ const blocks = [];
329
+ const bin = copy?.path ? join(copy.path, "bin", "projectstore.mjs") : null;
330
+ if (bin && existsSync(bin)) blocks.push({ lead: "Run the newer copy instead, from a terminal in this project:", command: `node "${bin}" upgrade --harness ${harness.id} --no-register` });
331
+ const disable = s?.cli?.commands?.disable;
332
+ if (Array.isArray(disable) && disable.length && s.cli.bin) {
333
+ const argv = disable.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => (k === "other" || k === "id" ? copy.key : `{${k}}`)));
334
+ blocks.push({ lead: `Or move this project to this package's registration, the older ${running}: turn that copy off for this project, then repeat this run:`, command: [s.cli.bin, ...argv].join(" ") });
335
+ }
336
+ return blocks;
337
+ }
338
+
339
+ // A newer copy from another marketplace under channel null: not switched
340
+ // off, because switching it off would run the project on an older package.
341
+ // The rows name both versions and the case, then the ways out.
342
+ export function newerCopyDeferral({ copy, running, decision, harness, s }) {
343
+ const c = proceedCase(decision, copy.version);
344
+ const text = `this project runs a newer copy (${copy.key} ${copy.version}) than this package (${running}), so it is not switched off${c ? ` — ${c}` : ""}.`;
345
+ return advised(text, channelChoices({ harness, s, copy, running }));
346
+ }
347
+
348
+ // The consequence line of a switch (rule 2; presentation spec contract 7):
349
+ // the ADR's three facts — the git-marketplace copy is off for this project,
350
+ // the host's update no longer reaches it, and what updates it from now on —
351
+ // with no backticks, and no command followed by punctuation. The command is
352
+ // packageCommand's, so it keeps the shell form until the text-cleanup story
353
+ // moves it (rule 9's command-form rule).
354
+ export function switchConsequence(harness) {
355
+ return `this project stops loading the git-marketplace copy, and /plugin update no longer reaches it — from now on it is updated by ${packageCommand(harness, "upgrade")}`;
356
+ }
357
+
358
+ // The words a switch row adds under the channel "npm" (rules 2 and 6): over a
359
+ // copy newer than this package, both versions, the case the registry read
360
+ // found, and that the project will run the older one; for a project not bound
361
+ // yet, that the switch is recorded once it is.
362
+ export function switchReason({ copy, running, runs = running, decision, unbound = false }) {
363
+ const parts = [];
364
+ // Against what the project runs once the copy is off (`runs`: the shared
365
+ // directory's version when another checkout wrote a newer one), not against
366
+ // this package: a copy older than that is no downgrade, and saying so
367
+ // would be false.
368
+ if (copy && typeof copy.version === "string" && runs && cmpPrecedence(copy.version, runs) > 0) {
369
+ const c = proceedCase(decision, copy.version);
370
+ parts.push(`${copy.key} ${copy.version} is newer than this package (${running})${c ? ` — ${c}` : ""}; turning it off runs this project on the older ${runs}`);
371
+ }
372
+ if (unbound) parts.push("the project is not bound yet: the switch is recorded in its binding once it is");
373
+ return parts.length ? parts.join("; ") : null;
374
+ }
375
+
376
+ // The channel "git" with no copy from another marketplace enabled for this
377
+ // project (rule 1): there is nothing to keep, and nothing is planned. The way
378
+ // back from an earlier switch is the registration's uninstall (rule 4); a copy
379
+ // of ours this checkout's local settings hold off — what a hand removal of the
380
+ // npm row leaves — is turned back on in the host. Each command alone on its
381
+ // line (presentation spec contract 6).
382
+ export function noCopyToKeep({ harness, surface, off = [] }) {
383
+ const blocks = off.map((key) => ({ lead: `This checkout's local settings hold ${key} off. To load it again, run this in ${harness.display_name}:`, command: `/plugin enable ${key}` }));
384
+ blocks.push({ lead: "The way back from an earlier switch to the npm install is its uninstall, from a terminal in this project:", command: coreCommand("uninstall", { args: runArgs({ harnesses: [harness.id], surfaces: [surface] }) }) });
385
+ return advised(`no git-marketplace copy of ${PACKAGE} is enabled for this project, so there is none to keep. Nothing is written.`, blocks);
386
+ }
387
+
388
+ // ─── The keep (rule 5) ──────────────────────────────────────────────────
389
+
390
+ // The keep runs the kept copy's own bin in the layout remedy's form (layout
391
+ // spec contract 12): `--harness <id> --no-register --project <dir>`, plus
392
+ // `--json`, which lets the run read the child's result and keeps the child
393
+ // from asking — the run asks one question, its own. Never `--surface`: naming
394
+ // the status line would opt it in (rule 5). `process.execPath`, not `node`:
395
+ // the Node that runs us is sure to exist, and the PLAN shows the argv verbatim.
396
+ export function keepArgv(copy, { verb, harness, projectDir }) {
397
+ return [process.execPath, join(copy.path, "bin", "projectstore.mjs"), verb, "--harness", harness.id, "--no-register", "--json", "--project", projectDir];
398
+ }
399
+
400
+ // The bin's `--json` envelope, `{ schema_version, verb, project, ok, result }`:
401
+ // its `result`, or null when stdout holds none. The top-level `ok` is never
402
+ // read — for plan it is `ok && !incomplete`, for a write verb the gate too.
403
+ export function keepResult(stdout) {
404
+ let env = null;
405
+ try { env = JSON.parse(String(stdout || "").trim()); } catch { return null; }
406
+ const r = env && typeof env === "object" && !Array.isArray(env) ? env.result : null;
407
+ return r && typeof r === "object" && !Array.isArray(r) ? r : null;
408
+ }
409
+
410
+ // Rule 5's read of the kept copy's own plan, before plan(), in the prepare
411
+ // step: `plan --json` through its bin, with the project as the working
412
+ // directory and the caller's environment (the copy's root as the plugin root,
413
+ // the harness home pinned), stdin ignored, bounded at `budget`. `run` is the
414
+ // caller's asynchronous runner, runHost's shape: this module spawns nothing
415
+ // itself. `plan` is a dry run in every version the capability read passes, so
416
+ // the read writes nothing. Never throws. Returns:
417
+ // { argv, envelope: false, status, stderr, error } no result to read
418
+ // { argv, envelope: true, ok, incomplete, refusals, writes, deferred, status }
419
+ // `writes` are the plan's rows that change something, as { surface, action,
420
+ // path }; `refusals` its plan-level refusals, then each refused row's reason;
421
+ // `deferred` the rows it left for later, as { surface, reason }.
422
+ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget = 120000 } = {}) {
423
+ const argv = keepArgv(copy, { verb: "plan", harness, projectDir });
424
+ let r;
425
+ try { r = await run(argv[0], argv.slice(1), { env, cwd: projectDir, budget }); }
426
+ catch (e) { r = { status: null, stdout: "", stderr: "", error: e }; }
427
+ const status = r?.status ?? null;
428
+ const stderr = String(r?.stderr || "").trim();
429
+ const result = keepResult(r?.stdout);
430
+ if (!result) return { argv, envelope: false, status, stderr, error: r?.error ? String(r.error.message || r.error) : null };
431
+ const items = Array.isArray(result.items) ? result.items.filter((i) => i && typeof i === "object") : [];
432
+ const writes = items.filter((i) => !["skip", "refuse"].includes(i.action)).map((i) => ({ surface: String(i.surface ?? ""), action: String(i.action ?? ""), path: i.path ?? null }));
433
+ const refusals = [...(Array.isArray(result.refusals) ? result.refusals : []), ...items.filter((i) => i.action === "refuse").map((i) => i.reason)].filter((x) => typeof x === "string" && x);
434
+ const deferred = items.filter((i) => i.deferred === true).map((i) => ({ surface: String(i.surface ?? ""), reason: typeof i.reason === "string" ? i.reason : null }));
435
+ return { argv, envelope: true, ok: result.ok !== false, incomplete: result.incomplete === true, refusals, writes, deferred, status };
436
+ }
437
+
438
+ // The kept copy's own upgrade, in the form channelChoices names a copy's bin:
439
+ // for a copy whose plan printed no result — run by the person, with its own
440
+ // preview and its own question (we never run what we could not preview,
441
+ // install spec contract 9).
442
+ export function ownUpgrade(copy, harness) {
443
+ return { lead: "Run that copy's own upgrade from a terminal in this project; it shows its own plan and asks:", command: `node "${join(copy.path, "bin", "projectstore.mjs")}" upgrade --harness ${harness.id} --no-register` };
444
+ }
445
+
446
+ // This checkout's npm registration enabled beside the kept copy: the keep
447
+ // changes no registration, so it stays; its uninstall (rule 4) drops it.
448
+ export function npmRowBeside({ harness, id, surface }) {
449
+ return { lead: `This checkout's npm registration (${id}) stays enabled beside the kept copy. To drop it, from a terminal in this project:`, command: coreCommand("uninstall", { args: runArgs({ harnesses: [harness.id], surfaces: [surface] }) }) };
450
+ }
451
+
452
+ // A newer registration that is damaged: never rewritten or staged over from
453
+ // the older package; repaired from its own version.
454
+ export function repairAdvice(harness, v) {
455
+ return { lead: "not downgraded — repair it from its own version:", command: coreCommand("upgrade", { version: v, args: `--harness ${harness.id}` }) };
456
+ }
457
+
458
+ // ─── The update notice (rule 8) ─────────────────────────────────────────
459
+
460
+ // The notice, when the registry's latest ranks above the running version: its
461
+ // text, and the command on a line of its own (presentation spec contract 6).
462
+ // A root under npx's cache is told npx; anything else the global install,
463
+ // the ADR's form — npm install -g would add a global copy an npx user never had.
464
+ export function updateNotice({ latest, version, root, verb = "upgrade" } = {}) {
465
+ if (!isSemver(latest) || !version || cmpPrecedence(latest, version) <= 0) return null;
466
+ const npx = /[\\/]_npx[\\/]/.test(String(root || ""));
467
+ return { latest, text: `newer published: ${latest} (this is ${version}). To update:`, command: npx ? coreCommand(verb, { version: "latest" }) : `npm install -g ${PACKAGE}@latest` };
468
+ }