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
@@ -6,11 +6,23 @@ import { createHash } from "node:crypto";
6
6
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { whichOnPath } from "./lib.mjs";
9
+ import { whichOnPath, cmpPrecedence } from "./lib.mjs";
10
10
 
11
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
11
+ const readJson = (path) => {
12
+ try {
13
+ return JSON.parse(readFileSync(path, "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
12
18
  const samePath = (a, b) => {
13
- const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
19
+ const real = (p) => {
20
+ try {
21
+ return realpathSync(p);
22
+ } catch {
23
+ return resolve(p);
24
+ }
25
+ };
14
26
  return Boolean(a && b) && real(a) === real(b);
15
27
  };
16
28
 
@@ -19,19 +31,31 @@ function parseValue(raw) {
19
31
  if (s === "true") return true;
20
32
  if (s === "false") return false;
21
33
  if (s.startsWith('"') && s.endsWith('"')) {
22
- try { return JSON.parse(s); } catch { return s.slice(1, -1); }
34
+ try {
35
+ return JSON.parse(s);
36
+ } catch {
37
+ return s.slice(1, -1);
38
+ }
23
39
  }
24
40
  return s;
25
41
  }
26
42
 
27
43
  export function readTomlSections(path) {
28
44
  let text;
29
- try { text = readFileSync(path, "utf8"); } catch { return new Map(); }
45
+ try {
46
+ text = readFileSync(path, "utf8");
47
+ } catch {
48
+ return new Map();
49
+ }
30
50
  const sections = new Map();
31
51
  let current = null;
32
52
  for (const line of text.split(/\r?\n/)) {
33
53
  const header = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/);
34
- if (header) { current = header[1]; if (!sections.has(current)) sections.set(current, {}); continue; }
54
+ if (header) {
55
+ current = header[1];
56
+ if (!sections.has(current)) sections.set(current, {});
57
+ continue;
58
+ }
35
59
  if (!current || /^\s*(?:#|$)/.test(line)) continue;
36
60
  const kv = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*(?:#.*)?$/);
37
61
  if (kv) sections.get(current)[kv[1]] = parseValue(kv[2]);
@@ -43,17 +67,23 @@ export function payloadFiles(root) {
43
67
  const out = [];
44
68
  const walk = (at, rel = "") => {
45
69
  let entries;
46
- try { entries = readdirSync(at, { withFileTypes: true }); } catch { return; }
70
+ try {
71
+ entries = readdirSync(at, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
47
75
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
48
76
  if (e.name.startsWith(".DS_Store")) continue;
49
- const abs = join(at, e.name), next = rel ? `${rel}/${e.name}` : e.name;
77
+ const abs = join(at, e.name),
78
+ next = rel ? `${rel}/${e.name}` : e.name;
50
79
  // npm creates command shims as symlinks here. The shell invokes the
51
80
  // bundled core by its real path, so these convenience links are neither
52
81
  // runtime input nor portable plugin payload.
53
82
  if (next === "node_modules/.bin" || next.startsWith("node_modules/.bin/")) continue;
54
83
  const st = lstatSync(abs);
55
84
  if (st.isSymbolicLink()) throw new Error(`plugin payload contains a symlink: ${next}`);
56
- if (st.isDirectory()) walk(abs, next); else if (st.isFile()) out.push(next);
85
+ if (st.isDirectory()) walk(abs, next);
86
+ else if (st.isFile()) out.push(next);
57
87
  }
58
88
  };
59
89
  walk(root);
@@ -65,7 +95,11 @@ export function payloadDigest(root) {
65
95
  const h = createHash("sha256");
66
96
  for (const rel of files) {
67
97
  h.update(rel + "\n");
68
- h.update(createHash("sha256").update(readFileSync(join(root, rel))).digest("hex") + "\n");
98
+ h.update(
99
+ createHash("sha256")
100
+ .update(readFileSync(join(root, rel)))
101
+ .digest("hex") + "\n",
102
+ );
69
103
  }
70
104
  return { count: files.length, sha256: h.digest("hex") };
71
105
  }
@@ -92,41 +126,86 @@ export function renderPortableCatalog(s) {
92
126
  return {
93
127
  name: s.marketplace_name,
94
128
  interface: { displayName: "ProjectStore (npm)" },
95
- plugins: [{
96
- name: s.plugin_name,
97
- source: { source: "local", path: `./${s.plugin_subdir}` },
98
- policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
99
- category: "Developer Tools",
100
- }],
129
+ plugins: [
130
+ {
131
+ name: s.plugin_name,
132
+ source: { source: "local", path: `./${s.plugin_subdir}` },
133
+ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
134
+ category: "Developer Tools",
135
+ },
136
+ ],
101
137
  };
102
138
  }
103
139
 
104
- function pluginTable(id) { return `plugins.${JSON.stringify(id)}`; }
105
- function marketplaceTable(name) { return `marketplaces.${name}`; }
140
+ function pluginTable(id) {
141
+ return `plugins.${JSON.stringify(id)}`;
142
+ }
143
+ function marketplaceTable(name) {
144
+ return `marketplaces.${name}`;
145
+ }
106
146
 
107
147
  function pluginIds(sections) {
108
148
  const ids = [];
109
149
  for (const key of sections.keys()) {
110
150
  const m = key.match(/^plugins\.(.+)$/);
111
151
  if (!m) continue;
112
- try { ids.push(m[1].startsWith('"') ? JSON.parse(m[1]) : m[1]); } catch {}
152
+ try {
153
+ ids.push(m[1].startsWith('"') ? JSON.parse(m[1]) : m[1]);
154
+ } catch {}
113
155
  }
114
156
  return ids;
115
157
  }
116
158
 
159
+ // The manifests a portable plugin root may carry, in the order they are read:
160
+ // an Agent Plugins plugin.json at the root, then the legacy one Codex
161
+ // validates — from 0.28.2 the shell's one manifest, since Codex picks a root
162
+ // one first and loads no hooks from it (openai/codex#37027). The one place
163
+ // either is spelled; the shell fetch's verification names them by kind.
164
+ const PAYLOAD_MANIFESTS = { root: ["plugin.json"], legacy: [".codex-plugin", "plugin.json"] };
165
+ export function payloadManifestPaths(root) {
166
+ return Object.values(PAYLOAD_MANIFESTS).map((parts) => join(root, ...parts));
167
+ }
168
+ export function payloadManifestPath(root, kind) {
169
+ return join(root, ...PAYLOAD_MANIFESTS[kind]);
170
+ }
171
+ // Is `root` a portable plugin root: does it carry either manifest?
172
+ export function isPortablePluginRoot(root) {
173
+ return Boolean(root) && payloadManifestPaths(root).some((p) => existsSync(p));
174
+ }
175
+ // The first manifest that parses, or null — or, with `kind`, that one only.
176
+ export function payloadManifest(root, { kind = null } = {}) {
177
+ for (const p of kind ? [payloadManifestPath(root, kind)] : payloadManifestPaths(root)) {
178
+ const m = readJson(p);
179
+ if (m) return m;
180
+ }
181
+ return null;
182
+ }
183
+
117
184
  function cacheEntries(paths, s) {
118
185
  const base = join(paths.cacheDir, s.marketplace_name, s.plugin_name);
119
186
  let names;
120
- try { names = readdirSync(base); } catch { return []; }
121
- return names.map((name) => {
122
- const path = join(base, name);
123
- const manifest = readJson(join(path, "plugin.json")) || readJson(join(path, ".codex-plugin", "plugin.json"));
124
- let at = 0; try { at = statSync(path).mtimeMs; } catch {}
125
- if (!manifest || typeof manifest.version !== "string") return null;
126
- let digest = null;
127
- try { digest = payloadDigest(path); } catch {}
128
- return { path, version: manifest.version, at, digest };
129
- }).filter(Boolean).sort((a, b) => b.at - a.at || b.version.localeCompare(a.version));
187
+ try {
188
+ names = readdirSync(base);
189
+ } catch {
190
+ return [];
191
+ }
192
+ return names
193
+ .map((name) => {
194
+ const path = join(base, name);
195
+ const manifest = payloadManifest(path);
196
+ let at = 0;
197
+ try {
198
+ at = statSync(path).mtimeMs;
199
+ } catch {}
200
+ if (!manifest || typeof manifest.version !== "string") return null;
201
+ let digest = null;
202
+ try {
203
+ digest = payloadDigest(path);
204
+ } catch {}
205
+ return { path, version: manifest.version, at, digest };
206
+ })
207
+ .filter(Boolean)
208
+ .sort((a, b) => b.at - a.at || b.version.localeCompare(a.version));
130
209
  }
131
210
 
132
211
  // Where the payload to register lives. A surface conditioned on a distribution
@@ -139,18 +218,46 @@ function cacheEntries(paths, s) {
139
218
  // the harness's directory (S2 of the 2026-10-03 review in "The Codex shell's
140
219
  // plugin root: .codex-plugin, the rendered surfaces, and the first Codex
141
220
  // install").
142
- export function portablePayloadRoot(s, { root, env = process.env } = {}) {
221
+ //
222
+ // `fetched` is the root a core run fetched for this harness (the shell fetch
223
+ // of install and upgrade). The variable wins: a set one is used as is, a root
224
+ // or not, and the fetched root is consulted only when it is unset — a shell
225
+ // that names its own root is never second-guessed by a fetch.
226
+ export function portablePayloadRoot(s, { root, env = process.env, fetched = null } = {}) {
143
227
  const named = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
144
228
  if (s.condition !== "distribution_root") return named || root;
145
- if (!named) return null;
146
- return existsSync(join(named, "plugin.json")) || existsSync(join(named, ".codex-plugin", "plugin.json")) ? named : null;
229
+ const candidate = named || fetched || null;
230
+ return candidate && isPortablePluginRoot(candidate) ? candidate : null;
147
231
  }
148
232
 
149
- export function analysePortableRegistration(projectDir, s, { root, payloadRoot: explicitPayloadRoot = null, home = homedir(), harness, env = process.env, ignoreJournal = false } = {}) {
233
+ // Two modes. With a payload, every rung is decided, the published digest
234
+ // against the recorded one included. With a `version` and no payload — plan's
235
+ // dry run, both uninstalls, a run whose fetch was skipped, and the recheck of
236
+ // an item planned that way — every rung that reads host facts is decided, and
237
+ // the one comparison that needs the payload's bytes is not: its digest, so
238
+ // `contentDiffers` and the same-version, different-content conflict. That is
239
+ // the one rung a local-tarball install at an unchanged version trips, and it
240
+ // is decided after a real fetch. `unavailable` for want of a payload fires
241
+ // only when there is neither. Doctor passes neither and reads as before.
242
+ export function analysePortableRegistration(
243
+ projectDir,
244
+ s,
245
+ {
246
+ root,
247
+ payloadRoot: explicitPayloadRoot = null,
248
+ version = null,
249
+ fetched = null,
250
+ home = homedir(),
251
+ harness,
252
+ env = process.env,
253
+ ignoreJournal = false,
254
+ } = {},
255
+ ) {
150
256
  const paths = portableRegistrationPaths(s, { home, projectDir, harness, env });
151
- const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env });
152
- const desiredManifest = payloadRoot ? (readJson(join(payloadRoot, "plugin.json")) || readJson(join(payloadRoot, ".codex-plugin", "plugin.json"))) : null;
153
- const desiredVersion = desiredManifest?.version || null;
257
+ const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env, fetched });
258
+ const desiredManifest = payloadRoot ? payloadManifest(payloadRoot) : null;
259
+ const versionOnly = !payloadRoot && typeof version === "string" && version !== "";
260
+ const desiredVersion = desiredManifest?.version || (versionOnly ? version : null);
154
261
  const id = `${s.plugin_name}@${s.marketplace_name}`;
155
262
  const bin = whichOnPath(s.cli.bin, env);
156
263
  const ownership = readJson(paths.ownership)?.[s.provenance_key] || null;
@@ -165,39 +272,152 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
165
272
  const others = [...new Set([...pluginIds(global), ...pluginIds(project)])]
166
273
  .filter((other) => other !== id && other.split("@")[0] === s.plugin_name)
167
274
  .map((other) => {
168
- const g = global.get(pluginTable(other)) || null, p = project.get(pluginTable(other)) || null;
275
+ const g = global.get(pluginTable(other)) || null,
276
+ p = project.get(pluginTable(other)) || null;
169
277
  return { id: other, enabled: typeof p?.enabled === "boolean" ? p.enabled : g?.enabled === true };
170
278
  });
279
+ // A registration newer than what this run offers (the converge story's rule
280
+ // 6): set before the ladder, so a damaged one keeps it beside its real
281
+ // reason, and the cache is then read at the version the host registered —
282
+ // keyed on the desired version, a newer registration's integrity rungs
283
+ // checked the wrong cache directory.
284
+ const newer =
285
+ typeof ownership?.version === "string" &&
286
+ typeof desiredVersion === "string" &&
287
+ cmpPrecedence(ownership.version, desiredVersion) > 0;
171
288
  const caches = cacheEntries(paths, s);
172
- const installed = caches.find((e) => e.version === desiredVersion) || caches[0] || null;
173
- const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null };
289
+ const installed = caches.find((e) => e.version === (newer ? ownership.version : desiredVersion)) || caches[0] || null;
290
+ const out = {
291
+ id,
292
+ paths,
293
+ path: paths.dir,
294
+ payloadRoot,
295
+ desiredVersion,
296
+ versionOnly,
297
+ bin,
298
+ ownership,
299
+ journal,
300
+ catalog,
301
+ market,
302
+ globalPlugin,
303
+ projectPlugin,
304
+ enabled,
305
+ others,
306
+ caches,
307
+ installed,
308
+ installPath: installed?.path || null,
309
+ installedVersion: installed?.version || null,
310
+ state: "absent",
311
+ reason: null,
312
+ produced: Boolean(desiredManifest),
313
+ contentDiffers: null,
314
+ newer,
315
+ };
174
316
  const enabledOther = others.find((other) => other.enabled);
175
317
  const nothing = !existsSync(paths.dir) && !market && !globalPlugin && !installed && !enabledOther;
176
- if (!desiredManifest) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
177
- if (!bin && nothing) return { ...out, state: "unavailable", reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI` };
318
+ if (!desiredManifest && !versionOnly)
319
+ return {
320
+ ...out,
321
+ state: "unavailable",
322
+ reason: payloadRoot
323
+ ? `${payloadRoot} is not a portable plugin root`
324
+ : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core`,
325
+ };
326
+ if (!bin && nothing)
327
+ return {
328
+ ...out,
329
+ state: "unavailable",
330
+ reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI`,
331
+ };
178
332
  let desiredDigest = null;
179
- try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; }
333
+ if (!versionOnly) {
334
+ try {
335
+ desiredDigest = payloadDigest(payloadRoot);
336
+ } catch (e) {
337
+ return { ...out, state: "unavailable", reason: e.message };
338
+ }
339
+ }
180
340
  out.desiredDigest = desiredDigest;
181
- if (enabledOther) return { ...out, state: "conflict", refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry` };
341
+ if (enabledOther)
342
+ return {
343
+ ...out,
344
+ state: "conflict",
345
+ refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry`,
346
+ };
182
347
  // A journal the rollback could not prove names the run that left it, when,
183
348
  // and its error, so the reader of the plan is not told recovery is assured
184
349
  // (issue #28). Every other phase reads as before.
185
- if (journal && !ignoreJournal) return { ...out, state: "stale", reason: journal.phase === "recovery-required"
186
- ? `a previous run${journal.failed_at ? ` (${journal.failed_at})` : ""} could not prove its restore: ${String(journal.error || "no error recorded").split("\n")[0]}; this run re-proves the previous state before applying this plan, and refuses if it cannot (${paths.journal})`
187
- : "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan" };
188
- if (existsSync(paths.dir) && !ownership) return { ...out, state: "foreign", refusal: `${paths.dir} exists without ${s.ownership_manifest}; a source this installer does not own holds the stable marketplace path` };
189
- if (market?.source && !samePath(market.source, paths.dir)) return { ...out, state: "foreign", refusal: `${paths.globalConfig} points marketplace ${s.marketplace_name} at ${market.source}, not ${paths.dir}` };
350
+ if (journal && !ignoreJournal)
351
+ return {
352
+ ...out,
353
+ state: "stale",
354
+ reason:
355
+ journal.phase === "recovery-required"
356
+ ? `a previous run${journal.failed_at ? ` (${journal.failed_at})` : ""} could not prove its restore: ${String(journal.error || "no error recorded").split("\n")[0]}; this run re-proves the previous state before applying this plan, and refuses if it cannot (${paths.journal})`
357
+ : "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan",
358
+ };
359
+ if (existsSync(paths.dir) && !ownership)
360
+ return {
361
+ ...out,
362
+ state: "foreign",
363
+ refusal: `${paths.dir} exists without ${s.ownership_manifest}; a source this installer does not own holds the stable marketplace path`,
364
+ };
365
+ if (market?.source && !samePath(market.source, paths.dir))
366
+ return {
367
+ ...out,
368
+ state: "foreign",
369
+ refusal: `${paths.globalConfig} points marketplace ${s.marketplace_name} at ${market.source}, not ${paths.dir}`,
370
+ };
190
371
  if (nothing) return out;
191
372
  let sourceDigest = null;
192
- try { if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload); } catch {}
373
+ try {
374
+ if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload);
375
+ } catch {}
193
376
  out.sourceDigest = sourceDigest;
194
- out.contentDiffers = Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
377
+ out.contentDiffers = versionOnly
378
+ ? null
379
+ : Boolean(ownership?.digest) &&
380
+ (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
195
381
  if (!ownership || !catalog) return { ...out, state: "stale", reason: "the owned marketplace source is incomplete" };
196
- if (ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
197
- if (!sourceDigest || sourceDigest.sha256 !== ownership.digest?.sha256 || sourceDigest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
382
+ if (!versionOnly && ownership.version === desiredVersion && out.contentDiffers)
383
+ return {
384
+ ...out,
385
+ state: "conflict",
386
+ refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest`,
387
+ };
388
+ if (
389
+ !sourceDigest ||
390
+ sourceDigest.sha256 !== ownership.digest?.sha256 ||
391
+ sourceDigest.count !== ownership.digest?.count
392
+ )
393
+ return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
198
394
  if (!market) return { ...out, state: "stale", reason: "the host does not know the marketplace" };
199
- if (!installed || installed.version !== ownership.version) return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
200
- if (!installed.digest || installed.digest.sha256 !== ownership.digest?.sha256 || installed.digest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the materialised host cache does not match the registered payload digest" };
201
- if (ownership.version !== desiredVersion) return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
202
- return { ...out, state: "current", reason: enabled ? null : "installed globally but disabled by effective configuration" };
395
+ if (!installed || installed.version !== ownership.version)
396
+ return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
397
+ if (
398
+ !installed.digest ||
399
+ installed.digest.sha256 !== ownership.digest?.sha256 ||
400
+ installed.digest.count !== ownership.digest?.count
401
+ )
402
+ return {
403
+ ...out,
404
+ state: "stale",
405
+ reason: "the materialised host cache does not match the registered payload digest",
406
+ };
407
+ // The newer rung, after the integrity rungs: a sound registration newer
408
+ // than this run is current and never staged over (rule 6). A damaged one
409
+ // stayed stale above, with its real reason; the planner skips it.
410
+ if (newer)
411
+ return {
412
+ ...out,
413
+ state: "current",
414
+ reason: `the registration is at ${ownership.version}, newer than this package (${desiredVersion}) — not downgraded`,
415
+ };
416
+ if (ownership.version !== desiredVersion)
417
+ return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
418
+ return {
419
+ ...out,
420
+ state: "current",
421
+ reason: enabled ? null : "installed globally but disabled by effective configuration",
422
+ };
203
423
  }
@@ -88,15 +88,18 @@ export const STALE_TEXT = Object.freeze({
88
88
 
89
89
  // Contract 5's resolution wording (Ivan Morozov's), in one place for the
90
90
  // verbs that refuse and the report that names.
91
- export const FOREIGN_TEXT = "a file we did not write sits at our path — rename it if it is yours, or delete it to let install take the name";
91
+ export const FOREIGN_TEXT =
92
+ "a file we did not write sits at our path — rename it if it is yours, or delete it to let install take the name";
92
93
 
93
94
  const normalizeEol = (s) => s.replace(/\r\n/g, "\n");
94
95
 
95
96
  function unsupportedFormat(format) {
96
97
  return new Error(
97
98
  `format "${format}" cannot carry a provenance line — a JSON surface is a ` +
98
- `SHARED surface (install spec contracts 2 and 6), owned per entry, not stamped; ` +
99
- `known stampable formats: ${Object.keys(DELIMITERS).filter((k) => DELIMITERS[k]).join(", ")}`,
99
+ `SHARED surface (install spec contracts 2 and 6), owned per entry, not stamped; ` +
100
+ `known stampable formats: ${Object.keys(DELIMITERS)
101
+ .filter((k) => DELIMITERS[k])
102
+ .join(", ")}`,
100
103
  );
101
104
  }
102
105
 
@@ -149,7 +152,8 @@ export function provenanceLine({ src, srcHash, pkg, project, render }) {
149
152
  // whitespace would be emitted, fail to parse back, derive `foreign`, and
150
153
  // make our own file immune to uninstall — refuse it here instead.
151
154
  for (const [k, v] of Object.entries({ src, pkg })) {
152
- if (/\s/.test(v)) throw new Error(`provenanceLine: field "${k}" may not contain whitespace (got ${JSON.stringify(v)})`);
155
+ if (/\s/.test(v))
156
+ throw new Error(`provenanceLine: field "${k}" may not contain whitespace (got ${JSON.stringify(v)})`);
153
157
  }
154
158
  if (!/^[0-9a-f]{12}$/.test(srcHash) || !/^[0-9a-f]{12}$/.test(render)) {
155
159
  throw new Error("provenanceLine: srcHash and render must be 12 lowercase hex characters");
@@ -198,7 +202,9 @@ function insertionOffset(body, format) {
198
202
  const m = /^---\n(?:[\s\S]*?\n)?---(?:\n|$)/.exec(body);
199
203
  if (m) return m[0].length;
200
204
  if (body.startsWith("---\n") || body === "---") {
201
- throw new Error("insertStamp: markdown body opens a frontmatter block that never closes — refusing to stamp before it");
205
+ throw new Error(
206
+ "insertStamp: markdown body opens a frontmatter block that never closes — refusing to stamp before it",
207
+ );
202
208
  }
203
209
  }
204
210
  if (format === "mjs" && body.startsWith("#!")) {
@@ -219,7 +225,9 @@ export function insertStamp(body, { format, harness, source, generator = DEFAULT
219
225
  delimitersFor(format);
220
226
  const text = normalizeEol(String(body));
221
227
  if (bareTokenRe().test(text)) {
222
- throw new Error(`insertStamp: the body already carries a bare ${PROVENANCE_TOKEN} line — it would survive into the installed file`);
228
+ throw new Error(
229
+ `insertStamp: the body already carries a bare ${PROVENANCE_TOKEN} line — it would survive into the installed file`,
230
+ );
223
231
  }
224
232
  const at = insertionOffset(text, format);
225
233
  const block = banner(format, { harness, source, generator, remedy }) + PROVENANCE_TOKEN + "\n";
@@ -250,7 +258,10 @@ export function substituteProvenance(tokenized, { format, src, srcHash, pkg, pro
250
258
  // read and be born "edited by hand" — a false accusation reinstalling cannot
251
259
  // clear. The cost: an edit confined to such a quoted line does not move
252
260
  // `render=`; strictly narrower harm than a permanent false positive.
253
- export function stamp(body, { format, src, srcHash, pkg, project, harness, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
261
+ export function stamp(
262
+ body,
263
+ { format, src, srcHash, pkg, project, harness, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY },
264
+ ) {
254
265
  const tokenized = insertStamp(body, { format, harness, source: src, generator, remedy });
255
266
  const render = renderHash(neutralizeProject(tokenizeProvenance(tokenized) ?? tokenized, project));
256
267
  const text = substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render });
@@ -336,7 +347,8 @@ export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, proj
336
347
  provenance,
337
348
  writtenBy: provenance ? provenance.project : null,
338
349
  sameProject: Boolean(
339
- provenance && typeof project === "string" &&
350
+ provenance &&
351
+ typeof project === "string" &&
340
352
  stripTrailingSlash(provenance.project) === stripTrailingSlash(project),
341
353
  ),
342
354
  });
@@ -348,8 +360,12 @@ export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, proj
348
360
  const parsed = parseProvenance(file.text);
349
361
  if (!parsed) return result("foreign");
350
362
  const provenance = {
351
- v: parsed.v, src: parsed.src, srcHash: parsed.srcHash,
352
- pkg: parsed.pkg, project: parsed.project, render: parsed.render,
363
+ v: parsed.v,
364
+ src: parsed.src,
365
+ srcHash: parsed.srcHash,
366
+ pkg: parsed.pkg,
367
+ project: parsed.project,
368
+ render: parsed.render,
353
369
  };
354
370
 
355
371
  // A newer grammar than this parser knows. The spec does not say; reporting