projectstore-claude 0.29.3 → 0.30.0

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 +13 -12
  2. package/bin/projectstore-claude.mjs +2 -2
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +153 -77
  6. package/node_modules/projectstore/commands/bind.md +6 -17
  7. package/node_modules/projectstore/commands/doctor.md +12 -7
  8. package/node_modules/projectstore/commands/statusline.md +1 -1
  9. package/node_modules/projectstore/commands/story.md +15 -2
  10. package/node_modules/projectstore/docs/extending.md +11 -6
  11. package/node_modules/projectstore/docs/getting-started.md +21 -1
  12. package/node_modules/projectstore/docs/harnesses.md +13 -10
  13. package/node_modules/projectstore/docs/how-it-works.md +6 -4
  14. package/node_modules/projectstore/harnesses/claude-code.json +16 -15
  15. package/node_modules/projectstore/harnesses/codex.json +12 -13
  16. package/node_modules/projectstore/hooks/band.tsx +48 -20
  17. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  18. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  19. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  20. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  21. package/node_modules/projectstore/package.json +11 -2
  22. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  23. package/node_modules/projectstore/scripts/band.mjs +72 -21
  24. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  25. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  26. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  27. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  28. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  29. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  30. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  31. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  32. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  33. package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
  34. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  35. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  36. package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
  37. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  38. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  39. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  40. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  41. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  42. package/node_modules/projectstore/scripts/query.mjs +378 -80
  43. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  44. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  45. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  46. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  47. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  48. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  49. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  50. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  51. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  52. package/node_modules/projectstore/scripts/term.mjs +382 -97
  53. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  54. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  55. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  56. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  57. package/package.json +3 -3
@@ -79,19 +79,34 @@ function main() {
79
79
  lines.push(`| [[${folder.path}/${e.id}/epic\\|${e.id}]] | ${e.title} | ${e.status} | ${refs} |`);
80
80
  }
81
81
  if (storyRows.length) {
82
- lines.push("", "## Story-level refs (files each story touched)", "",
83
- "| Epic | Story | code_refs |", "|------|-------|-----------|");
82
+ lines.push(
83
+ "",
84
+ "## Story-level refs (files each story touched)",
85
+ "",
86
+ "| Epic | Story | code_refs |",
87
+ "|------|-------|-----------|",
88
+ );
84
89
  for (const s of storyRows) {
85
90
  lines.push(`| ${s.epic} | ${s.title} | ${s.refs.map((r) => `\`${r}\``).join(", ")} |`);
86
91
  }
87
92
  }
88
93
  lines.push("");
89
94
 
90
- process.stdout.write(JSON.stringify({
91
- path: join(cfg.vault_path, "code-map.md"),
92
- content: lines.join("\n"),
93
- stats: { epics: epics.length, epics_with_refs: epics.filter((e) => e.refs.length).length, story_rows: storyRows.length },
94
- }, null, 2) + "\n");
95
+ process.stdout.write(
96
+ JSON.stringify(
97
+ {
98
+ path: join(cfg.vault_path, "code-map.md"),
99
+ content: lines.join("\n"),
100
+ stats: {
101
+ epics: epics.length,
102
+ epics_with_refs: epics.filter((e) => e.refs.length).length,
103
+ story_rows: storyRows.length,
104
+ },
105
+ },
106
+ null,
107
+ 2,
108
+ ) + "\n",
109
+ );
95
110
  }
96
111
 
97
112
  if (isMain(import.meta.url)) {
@@ -12,12 +12,14 @@
12
12
  // decide({ copies, version, read, skipped, verb, args })
13
13
  // → stop or proceed, with the stop's texts
14
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 })
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 })
19
21
  // → a newer copy not switched off: { reason, advice }
20
- // switchConsequence(harness) → a switch row's consequence line (rule 2)
22
+ // switchConsequence() → a switch row's consequence line (rule 2)
21
23
  // switchReason({ copy, running, runs, decision, unbound })
22
24
  // → what a switch row adds under the channel "npm"
23
25
  // noCopyToKeep({ harness, surface, off }) → the channel "git" with nothing to keep
@@ -39,15 +41,23 @@
39
41
  //
40
42
  // Imports lib, harness, portable-registration and surfaces, never the fetch
41
43
  // 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.
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.
45
48
 
46
- import { readFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
49
+ import { readFileSync, mkdirSync, realpathSync } from "node:fs";
47
50
  import { dirname, join, resolve, sep } from "node:path";
48
51
  import { homedir } from "node:os";
49
- import { cmpPrecedence, installChannel, projectCopiesByKey, writeFileAtomic, envFlag } from "./lib.mjs";
50
- import { loadHarness, loadHarnesses, packageCommand } from "./harness.mjs";
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";
51
61
  import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
52
62
  import { registrationPaths } from "./surfaces.mjs";
53
63
 
@@ -65,6 +75,11 @@ export const ACCEPT = "application/vnd.npm.install-v1+json";
65
75
  // resolver's own 10 s (REGISTRY_BUDGET_MS, fetch-shell.mjs) before it.
66
76
  export const READ_TIMEOUT_MS = 2000;
67
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;
68
83
  export const FRESH_MS = 24 * 60 * 60 * 1000;
69
84
  export const NO_CHECK_ENV = "PROJECTSTORE_NO_UPDATE_CHECK";
70
85
 
@@ -72,13 +87,25 @@ export const NO_CHECK_ENV = "PROJECTSTORE_NO_UPDATE_CHECK";
72
87
  // anything else counts as missing — never printed, never compared.
73
88
  const NUM = "(?:0|[1-9]\\d*)";
74
89
  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-]+)*)?$`);
90
+ const SEMVER = new RegExp(
91
+ `^${NUM}\\.${NUM}\\.${NUM}(?:-${PRE}(?:\\.${PRE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
92
+ );
76
93
  export const isSemver = (v) => typeof v === "string" && SEMVER.test(v);
77
94
 
78
95
  // ─── Which roots check (rule 6) and show the notice (rule 8) ────────────
79
96
 
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); };
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
+ };
82
109
 
83
110
  // A copy a host runs, updated through its own channel (layout spec contract
84
111
  // 12): never stopped, never told to update. installChannel names the host's
@@ -130,7 +157,10 @@ export const updateCheckOff = (env) => envFlag(env, NO_CHECK_ENV);
130
157
  // - a portable registration: the global registration's recorded version,
131
158
  // when it is effectively enabled for this project.
132
159
  // `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 } = {}) {
160
+ export function runningCopies(
161
+ projectDir,
162
+ { harnesses = [], root, home = homedir(), env = process.env, channel = null, version = null, analyse = null } = {},
163
+ ) {
134
164
  const out = [];
135
165
  for (const id of harnesses) {
136
166
  const harness = loadHarness(id);
@@ -140,10 +170,13 @@ export function runningCopies(projectDir, { harnesses = [], root, home = homedir
140
170
  if (s.format === "host-plugin-registration") {
141
171
  if (channel === "git") continue;
142
172
  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 });
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 });
144
175
  }
145
176
  } else if (s.format === "portable-plugin-registration") {
146
- const a = analyse ? analyse(harness, s) : analysePortableRegistration(projectDir, s, { root, home, harness, env, version });
177
+ const a = analyse
178
+ ? analyse(harness, s)
179
+ : analysePortableRegistration(projectDir, s, { root, home, harness, env, version });
147
180
  const v = a?.ownership?.version;
148
181
  if (typeof v === "string" && v && a.enabled) out.push({ harness: id, key: a.id, version: v });
149
182
  }
@@ -152,7 +185,8 @@ export function runningCopies(projectDir, { harnesses = [], root, home = homedir
152
185
  return out;
153
186
  }
154
187
 
155
- export const newerCopies = (copies, version) => (version ? copies.filter((c) => cmpPrecedence(c.version, version) > 0) : []);
188
+ export const newerCopies = (copies, version) =>
189
+ version ? copies.filter((c) => cmpPrecedence(c.version, version) > 0) : [];
156
190
  // The lowest of them: a fresh cache entry below it is read again (rule 8).
157
191
  export const lowestVersion = (copies) => copies.map((c) => c.version).sort(cmpPrecedence)[0] || null;
158
192
 
@@ -163,7 +197,9 @@ function readCache(cachePath) {
163
197
  try {
164
198
  const c = JSON.parse(readFileSync(cachePath, "utf8"));
165
199
  return c && typeof c === "object" && !Array.isArray(c) ? c : {};
166
- } catch { return {}; }
200
+ } catch {
201
+ return {};
202
+ }
167
203
  }
168
204
 
169
205
  // Fresh for a day; an `at` in the future is stale (a clock that moved back).
@@ -178,29 +214,44 @@ function fresh(entry, t) {
178
214
  // its connection is released.
179
215
  function request(url, { fetch, timers, timeoutMs }) {
180
216
  return new Promise((settle) => {
181
- let done = false, timer = null;
217
+ let done = false,
218
+ timer = null;
182
219
  const ac = typeof AbortController === "function" ? new AbortController() : null;
183
220
  const finish = (r) => {
184
221
  if (done) return;
185
222
  done = true;
186
- if (timer !== null) { try { timers.clearTimeout(timer); } catch {} }
223
+ if (timer !== null) {
224
+ try {
225
+ timers.clearTimeout(timer);
226
+ } catch {}
227
+ }
187
228
  settle(r);
188
229
  };
189
230
  timer = timers.setTimeout(() => {
190
231
  timer = null;
191
- try { ac?.abort(); } catch {}
232
+ try {
233
+ ac?.abort();
234
+ } catch {}
192
235
  finish({ error: `no answer within ${timeoutMs / 1000} s` });
193
236
  }, timeoutMs);
194
- try { timer?.unref?.(); } catch {}
237
+ try {
238
+ timer?.unref?.();
239
+ } catch {}
195
240
  Promise.resolve()
196
241
  .then(() => fetch(url, { headers: { accept: ACCEPT }, ...(ac ? { signal: ac.signal } : {}) }))
197
242
  .then(async (res) => {
198
243
  if (!res || !res.ok) {
199
- try { Promise.resolve(res?.body?.cancel?.()).catch(() => {}); } catch {}
244
+ try {
245
+ Promise.resolve(res?.body?.cancel?.()).catch(() => {});
246
+ } catch {}
200
247
  return finish({ error: `HTTP ${res?.status ?? "error"}` });
201
248
  }
202
249
  let body;
203
- try { body = await res.json(); } catch { return finish({ error: "the answer is not JSON" }); }
250
+ try {
251
+ body = await res.json();
252
+ } catch {
253
+ return finish({ error: "the answer is not JSON" });
254
+ }
204
255
  const latest = body && typeof body === "object" ? body["dist-tags"]?.latest : undefined;
205
256
  if (!isSemver(latest)) return finish({ error: "the answer names no dist-tags.latest" });
206
257
  finish({ latest });
@@ -217,7 +268,15 @@ function request(url, { fetch, timers, timeoutMs }) {
217
268
  function writeCache(cachePath, cache, registry, latest, at) {
218
269
  if (!cachePath) return;
219
270
  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 } };
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
+ };
221
280
  try {
222
281
  mkdirSync(dirname(cachePath), { recursive: true });
223
282
  writeFileAtomic(cachePath, JSON.stringify(next, null, 2) + "\n", { sweep: false });
@@ -230,7 +289,17 @@ function writeCache(cachePath, cache, registry, latest, at) {
230
289
  // `atLeast`, the lowest newer copy rule 6 asks about: a release may have come
231
290
  // out since it was written. The cache never supplies a URL: an entry is only
232
291
  // 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 } = {}) {
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 */
234
303
  try {
235
304
  const cache = readCache(cachePath);
236
305
  const entry = cache[registry];
@@ -261,13 +330,22 @@ export function coreCommand(verb, { version = null, args = "" } = {}) {
261
330
  // part on its own line — the envelope's form, which an agent relays as it
262
331
  // is; `advice` is what PLAN draws, the commands in their own blocks.
263
332
  export function advised(text, blocks = []) {
264
- return { reason: [text, ...blocks.flatMap((b) => [b.lead, b.command])].join("\n"), advice: { 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
+ };
265
339
  }
266
340
 
267
341
  // The run's own selection, repeated: --harness and --surface as given, and
268
342
  // --no-register; never --project (the ADR's decision 5).
269
343
  function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
270
- return [...harnesses.map((h) => `--harness ${h}`), ...(surfaces || []).map((s) => `--surface ${s}`), ...(register === false ? ["--no-register"] : [])].join(" ");
344
+ return [
345
+ ...harnesses.map((h) => `--harness ${h}`),
346
+ ...(surfaces || []).map((s) => `--surface ${s}`),
347
+ ...(register === false ? ["--no-register"] : []),
348
+ ].join(" ");
271
349
  }
272
350
 
273
351
  // Stop when the registry's latest is at least a newer copy this project runs
@@ -276,12 +354,21 @@ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
276
354
  // readLatest's answer, null when nothing was read; `skipped` says the read
277
355
  // was switched off by PROJECTSTORE_NO_UPDATE_CHECK. The refusal's text puts
278
356
  // 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 = {} } = {}) {
357
+ export function decide({
358
+ copies = [],
359
+ version = null,
360
+ read = null,
361
+ skipped = false,
362
+ verb = "upgrade",
363
+ args = {},
364
+ } = {}) {
280
365
  const newer = newerCopies(copies, version);
281
366
  const decision = { checks: true, version, newer, read: skipped ? { skipped: true } : read, stop: null };
282
367
  const latest = read && isSemver(read.latest) ? read.latest : null;
283
368
  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];
369
+ const top = newer
370
+ .filter((c) => cmpPrecedence(latest, c.version) >= 0)
371
+ .sort((a, b) => cmpPrecedence(b.version, a.version))[0];
285
372
  if (!top) return decision;
286
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:`;
287
374
  const commands = [coreCommand(verb, { version: "latest", args: runArgs(args) }), `npm install -g ${PACKAGE}@latest`];
@@ -294,7 +381,8 @@ export function decide({ copies = [], version = null, read = null, skipped = fal
294
381
  // `v`: the dry run's note when no decision was made (plan reads nothing), none
295
382
  // for a root that never checks, else what the read found.
296
383
  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`;
384
+ if (decision === null || decision === undefined)
385
+ return `install and upgrade read the registry first, and stop when ${v} is published`;
298
386
  if (!decision.checks) return null;
299
387
  const r = decision.read;
300
388
  if (!r) return null;
@@ -304,55 +392,79 @@ export function proceedCase(decision, v) {
304
392
  return `${v} is published (the registry's latest is ${r.latest})`;
305
393
  }
306
394
 
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 } = {}) {
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 } = {}) {
324
420
  if (gate) {
325
421
  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 || {}) }) }];
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
+ ];
327
435
  }
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;
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
+ ];
337
449
  }
338
450
 
339
451
  // A newer copy from another marketplace under channel null: not switched
340
452
  // off, because switching it off would run the project on an older package.
341
453
  // The rows name both versions and the case, then the ways out.
342
- export function newerCopyDeferral({ copy, running, decision, harness, s }) {
454
+ export function newerCopyDeferral({ copy, running, decision, harness, pin = null }) {
343
455
  const c = proceedCase(decision, copy.version);
344
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}` : ""}.`;
345
- return advised(text, channelChoices({ harness, s, copy, running }));
457
+ return advised(text, channelChoices({ harness, copy, running, pin }));
346
458
  }
347
459
 
348
460
  // The consequence line of a switch (rule 2; presentation spec contract 7):
349
461
  // the ADR's three facts — the git-marketplace copy is off for this project,
350
462
  // the host's update no longer reaches it, and what updates it from now on —
351
463
  // 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")}`;
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")}`;
356
468
  }
357
469
 
358
470
  // The words a switch row adds under the channel "npm" (rules 2 and 6): over a
@@ -367,7 +479,9 @@ export function switchReason({ copy, running, runs = running, decision, unbound
367
479
  // would be false.
368
480
  if (copy && typeof copy.version === "string" && runs && cmpPrecedence(copy.version, runs) > 0) {
369
481
  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}`);
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
+ );
371
485
  }
372
486
  if (unbound) parts.push("the project is not bound yet: the switch is recorded in its binding once it is");
373
487
  return parts.length ? parts.join("; ") : null;
@@ -380,9 +494,18 @@ export function switchReason({ copy, running, runs = running, decision, unbound
380
494
  // npm row leaves — is turned back on in the host. Each command alone on its
381
495
  // line (presentation spec contract 6).
382
496
  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);
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
+ );
386
509
  }
387
510
 
388
511
  // ─── The keep (rule 5) ──────────────────────────────────────────────────
@@ -394,15 +517,29 @@ export function noCopyToKeep({ harness, surface, off = [] }) {
394
517
  // the status line would opt it in (rule 5). `process.execPath`, not `node`:
395
518
  // the Node that runs us is sure to exist, and the PLAN shows the argv verbatim.
396
519
  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];
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
+ ];
398
531
  }
399
532
 
400
533
  // The bin's `--json` envelope, `{ schema_version, verb, project, ok, result }`:
401
534
  // its `result`, or null when stdout holds none. The top-level `ok` is never
402
535
  // read — for plan it is `ok && !incomplete`, for a write verb the gate too.
403
536
  export function keepResult(stdout) {
404
- let env = null;
405
- try { env = JSON.parse(String(stdout || "").trim()); } catch { return null; }
537
+ let env;
538
+ try {
539
+ env = JSON.parse(String(stdout || "").trim());
540
+ } catch {
541
+ return null;
542
+ }
406
543
  const r = env && typeof env === "object" && !Array.isArray(env) ? env.result : null;
407
544
  return r && typeof r === "object" && !Array.isArray(r) ? r : null;
408
545
  }
@@ -422,17 +559,37 @@ export function keepResult(stdout) {
422
559
  export async function readKeepPlan(copy, { harness, projectDir, env, run, budget = 120000 } = {}) {
423
560
  const argv = keepArgv(copy, { verb: "plan", harness, projectDir });
424
561
  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 }; }
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
+ }
427
567
  const status = r?.status ?? null;
428
568
  const stderr = String(r?.stderr || "").trim();
429
569
  const result = keepResult(r?.stdout);
430
- if (!result) return { argv, envelope: false, status, stderr, error: r?.error ? String(r.error.message || r.error) : null };
570
+ if (!result)
571
+ return { argv, envelope: false, status, stderr, error: r?.error ? String(r.error.message || r.error) : null };
431
572
  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 };
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
+ };
436
593
  }
437
594
 
438
595
  // The kept copy's own upgrade, in the form channelChoices names a copy's bin:
@@ -440,19 +597,28 @@ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget
440
597
  // preview and its own question (we never run what we could not preview,
441
598
  // install spec contract 9).
442
599
  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` };
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
+ };
444
604
  }
445
605
 
446
606
  // This checkout's npm registration enabled beside the kept copy: the keep
447
607
  // changes no registration, so it stays; its uninstall (rule 4) drops it.
448
608
  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] }) }) };
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
+ };
450
613
  }
451
614
 
452
615
  // A newer registration that is damaged: never rewritten or staged over from
453
616
  // the older package; repaired from its own version.
454
617
  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}` }) };
618
+ return {
619
+ lead: "not downgraded — repair it from its own version:",
620
+ command: coreCommand("upgrade", { version: v, args: `--harness ${harness.id}` }),
621
+ };
456
622
  }
457
623
 
458
624
  // ─── The update notice (rule 8) ─────────────────────────────────────────
@@ -464,5 +630,9 @@ export function repairAdvice(harness, v) {
464
630
  export function updateNotice({ latest, version, root, verb = "upgrade" } = {}) {
465
631
  if (!isSemver(latest) || !version || cmpPrecedence(latest, version) <= 0) return null;
466
632
  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` };
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
+ };
468
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)) {