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