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
@@ -27,7 +27,7 @@
27
27
  import { readFileSync, existsSync, realpathSync, readdirSync } from "node:fs";
28
28
  import { join, resolve } from "node:path";
29
29
  import { homedir } from "node:os";
30
- import { loadHarnesses, loadHarness, harnessIds, detectHarnesses, sourceHarness, MANIFEST_DIR } from "./harness.mjs";
30
+ import { loadHarnesses, harnessIds, detectHarnesses, sourceHarness, MANIFEST_DIR } from "./harness.mjs";
31
31
  import { stamp, deriveState, parseProvenance, sourceHash, STALE, STALE_TEXT, FOREIGN_TEXT } from "./provenance.mjs";
32
32
  import {
33
33
  pluginRoot,
@@ -44,6 +44,7 @@ import {
44
44
  claudeHome,
45
45
  whichOnPath,
46
46
  installedPluginEntries,
47
+ projectCopiesByKey,
47
48
  pluginEnabled,
48
49
  treeDigest,
49
50
  packageDigest,
@@ -52,11 +53,17 @@ import {
52
53
  RUNTIME_GITIGNORE_HEADER,
53
54
  LAUNCHER_HEADER,
54
55
  commandForm,
56
+ registrationProvenance,
55
57
  } from "./lib.mjs";
56
- import { analysePortableRegistration, portableRegistrationPaths, portablePayloadRoot } from "./portable-registration.mjs";
58
+ import {
59
+ analysePortableRegistration,
60
+ portableRegistrationPaths,
61
+ portablePayloadRoot,
62
+ } from "./portable-registration.mjs";
57
63
 
58
64
  export const GENERATOR = "scripts/install-harness.mjs";
59
- export const INSTALLED_REMEDY = "projectstore doctor reports this file when it is stale; run install again to refresh it.";
65
+ export const INSTALLED_REMEDY =
66
+ "projectstore doctor reports this file when it is stale; run install again to refresh it.";
60
67
  // The line every launcher has carried since the template was written; the
61
68
  // recogniser for a pre-provenance launcher (contract 4, rung 1″). A substring
62
69
  // of the template's own header, so a reworded comment cannot turn every
@@ -89,39 +96,90 @@ export function isOurFile(text) {
89
96
 
90
97
  // ─── markdown-block: the ADR-002 block ─────────────────────────────────
91
98
 
92
- export function analyseBlock(projectDir, s, { root = pluginRoot(), manifestDir = MANIFEST_DIR } = {}) {
99
+ // `layout` names the roster the run leaves (a layout name; the binding's when
100
+ // null). `readers` is a run's harnesses as [{ display, files }], passed when a
101
+ // run names more than one: the migration reason then names the one whose
102
+ // files lack the block's file, rather than the harness whose surface `s` is
103
+ // (the story "One run plans the agents block once…", rules 1 and 3).
104
+ export function analyseBlock(
105
+ projectDir,
106
+ s,
107
+ { root = pluginRoot(), manifestDir = MANIFEST_DIR, layout = null, readers = null } = {},
108
+ ) {
93
109
  const files = s.files || ["AGENTS.md", "CLAUDE.md"];
94
- const PREFERRED = files[0], FALLBACK = files[files.length - 1];
110
+ const PREFERRED = files[0],
111
+ FALLBACK = files[files.length - 1];
95
112
  // LOOK across every file any manifest names; WRITE to one this harness can
96
113
  // read. The two are different questions and conflating them is what let the
97
114
  // block be created twice: a harness that scanned only its own list could not
98
115
  // see the copy another harness had already written, so it created a second.
99
116
  // Scanning is free and static — the union is manifest data, not project
100
117
  // state — while the write target stays this manifest's own preference.
101
- const scan = [...files, ...[...loadHarnesses(manifestDir).values()]
102
- .flatMap((m) => m.surfaces?.agents_block?.files || [])
103
- .filter((f) => !files.includes(f))];
104
- const found = scan.map((f) => ({ file: f, path: join(projectDir, f), ...readText(join(projectDir, f)) }))
118
+ const scan = [
119
+ ...files,
120
+ ...[...loadHarnesses(manifestDir).values()]
121
+ .flatMap((m) => m.surfaces?.agents_block?.files || [])
122
+ .filter((f) => !files.includes(f)),
123
+ ];
124
+ const found = scan
125
+ .map((f) => ({ file: f, path: join(projectDir, f), ...readText(join(projectDir, f)) }))
105
126
  .map((e) => ({ ...e, block: e.text !== null ? findAgentsBlock(e.text) : null, own: files.includes(e.file) }));
106
127
  const withBlock = found.filter((e) => e.block);
107
128
  const preferred = found.find((e) => e.file === PREFERRED && e.present) || found.find((e) => e.file === FALLBACK);
108
129
  const claude = found.find((e) => e.file === FALLBACK);
109
130
  const importLine = `@${PREFERRED}`;
110
- const a = { files: found, own: files, withBlock, preferred, claude, importLine, PREFERRED, FALLBACK, entryKey: "projectstore:agents", version: null, desired: null, current: null, state: "ours-absent", reason: null, refusal: null };
131
+ const a = {
132
+ files: found,
133
+ own: files,
134
+ withBlock,
135
+ preferred,
136
+ claude,
137
+ importLine,
138
+ PREFERRED,
139
+ FALLBACK,
140
+ entryKey: "projectstore:agents",
141
+ version: null,
142
+ desired: null,
143
+ current: null,
144
+ state: "ours-absent",
145
+ reason: null,
146
+ refusal: null,
147
+ };
111
148
 
112
149
  const unclosed = withBlock.find((e) => e.block.unclosed);
113
- if (unclosed && unclosed.block.wrapped) return { ...a, state: "unparseable", refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })}` };
114
- if (unclosed) return { ...a, state: "unparseable", refusal: `${unclosed.file}: the block opens and never closes — restore the closing marker or remove the block by hand` };
150
+ if (unclosed && unclosed.block.wrapped)
151
+ return {
152
+ ...a,
153
+ state: "unparseable",
154
+ refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })}`,
155
+ };
156
+ if (unclosed)
157
+ return {
158
+ ...a,
159
+ state: "unparseable",
160
+ refusal: `${unclosed.file}: the block opens and never closes — restore the closing marker or remove the block by hand`,
161
+ };
115
162
  const twice = withBlock.find((e) => e.block.count > 1);
116
- if (twice) return { ...a, state: "unparseable", refusal: `${twice.file} carries the block more than once — keep exactly one` };
163
+ if (twice)
164
+ return { ...a, state: "unparseable", refusal: `${twice.file} carries the block more than once — keep exactly one` };
117
165
 
118
166
  const tmpl = readText(agentsBlockTemplatePath(root));
119
- if (!tmpl.present || tmpl.text === null) return { ...a, state: "unparseable", refusal: `the block's source is missing from the plugin at ${agentsBlockTemplatePath(root)}` };
167
+ if (!tmpl.present || tmpl.text === null)
168
+ return {
169
+ ...a,
170
+ state: "unparseable",
171
+ refusal: `the block's source is missing from the plugin at ${agentsBlockTemplatePath(root)}`,
172
+ };
120
173
  a.version = agentsBlockVersion(tmpl.text);
121
174
  const cfg = readConfigAt(projectDir);
122
175
  let roster = null;
123
- if (cfg && typeof cfg.layout === "string") {
124
- try { roster = loadLayout(cfg.layout, root).agents || null; } catch { roster = null; }
176
+ const name = layout ?? cfg?.layout;
177
+ if (typeof name === "string") {
178
+ try {
179
+ roster = loadLayout(name, root).agents || null;
180
+ } catch {
181
+ roster = null;
182
+ }
125
183
  }
126
184
  a.desired = renderAgentsBlock(tmpl.text, roster);
127
185
  a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
@@ -134,11 +192,31 @@ export function analyseBlock(projectDir, s, { root = pluginRoot(), manifestDir =
134
192
  // never acquires an AGENTS.md it did not ask for, while installing Codex into
135
193
  // that same project moves the substance to the file Codex can read and leaves
136
194
  // CLAUDE.md importing it.
137
- if (!a.current.own) return { ...a, state: "ours-stale", reason: `in ${a.current.file}, which ${s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}` };
138
- if (a.current.file !== preferred.file && preferred.present) return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
139
- if (a.duplicates.length) return { ...a, state: "ours-stale", reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}` };
195
+ if (!a.current.own) {
196
+ const reader = readers?.find((r) => !(r.files || []).includes(a.current.file))?.display;
197
+ return {
198
+ ...a,
199
+ state: "ours-stale",
200
+ reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
201
+ };
202
+ }
203
+ if (a.current.file !== preferred.file && preferred.present)
204
+ return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
205
+ if (a.duplicates.length)
206
+ return {
207
+ ...a,
208
+ state: "ours-stale",
209
+ reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}`,
210
+ };
140
211
  if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
141
- return { ...a, state: "ours-stale", reason: a.current.block.v !== a.version ? `v${a.current.block.v} → v${a.version}` : "content differs from the current source" };
212
+ return {
213
+ ...a,
214
+ state: "ours-stale",
215
+ reason:
216
+ a.current.block.v !== a.version
217
+ ? `v${a.current.block.v} → v${a.version}`
218
+ : "content differs from the current source",
219
+ };
142
220
  }
143
221
 
144
222
  // ─── json-entry: one entry in a JSON file the user co-owns ─────────────
@@ -151,18 +229,40 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
151
229
  const pointer = s.marker?.pointer || "statusLine.command";
152
230
  const entryKey = pointer.split(".")[0];
153
231
  const cur = readText(path);
154
- const a = { path, entryKey, cur, settings: {}, curEntry: null, curCmd: null, ours: false, desired: null, state: "ours-absent", reason: null };
232
+ const a = {
233
+ path,
234
+ entryKey,
235
+ cur,
236
+ settings: {},
237
+ curEntry: null,
238
+ curCmd: null,
239
+ ours: false,
240
+ desired: null,
241
+ state: "ours-absent",
242
+ reason: null,
243
+ };
155
244
  if (cur.present) {
156
245
  if (cur.text === null) return { ...a, state: "unparseable", reason: "the file cannot be read" };
157
- try { a.settings = JSON.parse(cur.text); } catch { return { ...a, state: "unparseable", reason: "the file is not valid JSON — nothing outside a marked entry is touched, so nothing is written" }; }
158
- if (!a.settings || typeof a.settings !== "object" || Array.isArray(a.settings)) return { ...a, state: "unparseable", reason: "the file is not a JSON object" };
246
+ try {
247
+ a.settings = JSON.parse(cur.text);
248
+ } catch {
249
+ return {
250
+ ...a,
251
+ state: "unparseable",
252
+ reason: "the file is not valid JSON — nothing outside a marked entry is touched, so nothing is written",
253
+ };
254
+ }
255
+ if (!a.settings || typeof a.settings !== "object" || Array.isArray(a.settings))
256
+ return { ...a, state: "unparseable", reason: "the file is not a JSON object" };
159
257
  }
160
258
  a.curEntry = a.settings[entryKey] ?? null;
161
259
  a.curCmd = a.curEntry && typeof a.curEntry.command === "string" ? a.curEntry.command : null;
162
260
  // "Did we write this?" is asked of the EXISTING entry, written by whatever
163
261
  // root was current then: a dev checkout's direct wiring stays ours when the
164
262
  // plan now renders against a registration's install path (reviewer, 2026-09-05).
165
- a.ours = statusLineIsOurWiring(a.curCmd, projectDir, home, root) || statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
263
+ a.ours =
264
+ statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
265
+ statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
166
266
  a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
167
267
  if (!a.curEntry) return { ...a, state: "ours-absent" };
168
268
  if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
@@ -172,7 +272,11 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
172
272
 
173
273
  // ─── mjs: an exclusive, provenance-stamped file ────────────────────────
174
274
 
175
- export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home = homedir(), harness = null, renderRoot = root } = {}) {
275
+ export function analyseStampedFile(
276
+ projectDir,
277
+ s,
278
+ { root = pluginRoot(), home = homedir(), harness = null, renderRoot = root } = {},
279
+ ) {
176
280
  const path = join(projectDir, s.file);
177
281
  let file = readText(path);
178
282
  // The manifest may name where an earlier release wrote this file
@@ -181,28 +285,74 @@ export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home =
181
285
  const legacyPath = s.legacy_file ? join(projectDir, s.legacy_file) : null;
182
286
  const atLegacy = !file.present && legacyPath && existsSync(legacyPath);
183
287
  if (atLegacy) file = readText(legacyPath);
184
- const a = { path, file, produced: true, ours: null, refusal: null, stamped: null, state: "absent", reason: null, writtenBy: null, sameProject: false, legacy: false, renderRoot, legacyPath: atLegacy ? legacyPath : null };
288
+ const a = {
289
+ path,
290
+ file,
291
+ produced: true,
292
+ ours: null,
293
+ refusal: null,
294
+ stamped: null,
295
+ state: "absent",
296
+ reason: null,
297
+ writtenBy: null,
298
+ sameProject: false,
299
+ legacy: false,
300
+ renderRoot,
301
+ legacyPath: atLegacy ? legacyPath : null,
302
+ };
185
303
  if (s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home)) {
186
304
  // Not produced for this installation (a dev checkout is wired directly).
187
305
  a.produced = false;
188
306
  if (!file.present) return { ...a, state: "absent" };
189
307
  a.ours = isOurFile(file.text);
190
- return { ...a, state: a.ours ? "stale" : "foreign", reason: a.ours ? "not produced for this installation (a dev checkout is wired directly) — left in place; a prune of a launcher this root did not write needs the root that wrote it, or uninstall" : null };
308
+ return {
309
+ ...a,
310
+ state: a.ours ? "stale" : "foreign",
311
+ reason: a.ours
312
+ ? "not produced for this installation (a dev checkout is wired directly) — left in place; a prune of a launcher this root did not write needs the root that wrote it, or uninstall"
313
+ : null,
314
+ };
191
315
  }
192
316
  const src = join(root, s.source);
193
317
  const tpl = readText(src);
194
- if (!tpl.present || tpl.text === null) return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
318
+ if (!tpl.present || tpl.text === null)
319
+ return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
195
320
  const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
196
- if (rendered === null) return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
321
+ if (rendered === null)
322
+ return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
197
323
  const pkg = pluginVersionAt(root, harness) || "0.0.0";
198
324
  a.pkg = pkg;
199
- a.stamped = stamp(rendered, { format: s.format, src: s.source, srcHash: sourceHash(tpl.text), pkg, project: projectDir, harness: harness?.id || sourceHarness()?.id || "harness", generator: GENERATOR, remedy: INSTALLED_REMEDY });
200
- let st = deriveState({ file, sourceHash: sourceHash(tpl.text), pkg, renderNowHash: a.stamped.render, project: projectDir });
325
+ a.stamped = stamp(rendered, {
326
+ format: s.format,
327
+ src: s.source,
328
+ srcHash: sourceHash(tpl.text),
329
+ pkg,
330
+ project: projectDir,
331
+ harness: harness?.id || sourceHarness()?.id || "harness",
332
+ generator: GENERATOR,
333
+ remedy: INSTALLED_REMEDY,
334
+ });
335
+ let st = deriveState({
336
+ file,
337
+ sourceHash: sourceHash(tpl.text),
338
+ pkg,
339
+ renderNowHash: a.stamped.render,
340
+ project: projectDir,
341
+ });
201
342
  // Contract 4, rung 1″: a launcher written before provenance existed carries
202
343
  // no line but the template's own header. It is ours, stale, replaceable.
203
- if (st.state === "foreign" && typeof file.text === "string" && file.text.includes(LAUNCHER_HEADER)) { st = { ...st, state: "stale", reason: STALE.PLUGIN }; a.legacy = true; }
344
+ if (st.state === "foreign" && typeof file.text === "string" && file.text.includes(LAUNCHER_HEADER)) {
345
+ st = { ...st, state: "stale", reason: STALE.PLUGIN };
346
+ a.legacy = true;
347
+ }
204
348
  a.installedPkg = st.provenance?.pkg || null;
205
- return { ...a, state: st.state, reason: st.reason ? STALE_TEXT[st.reason] + (a.legacy ? " (pre-provenance file)" : "") : null, writtenBy: st.writtenBy, sameProject: st.sameProject };
349
+ return {
350
+ ...a,
351
+ state: st.state,
352
+ reason: st.reason ? STALE_TEXT[st.reason] + (a.legacy ? " (pre-provenance file)" : "") : null,
353
+ writtenBy: st.writtenBy,
354
+ sameProject: st.sameProject,
355
+ };
206
356
  }
207
357
 
208
358
  // ─── host-plugin-registration: our marketplace directory + the host's registry ──
@@ -214,12 +364,22 @@ export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home =
214
364
  // located, never run.
215
365
 
216
366
  function samePath(a, b) {
217
- const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
367
+ const real = (p) => {
368
+ try {
369
+ return realpathSync(p);
370
+ } catch {
371
+ return resolve(p);
372
+ }
373
+ };
218
374
  return Boolean(a && b) && real(a) === real(b);
219
375
  }
220
376
 
221
377
  function readJson(p) {
222
- try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
378
+ try {
379
+ return JSON.parse(readFileSync(p, "utf8"));
380
+ } catch {
381
+ return null;
382
+ }
223
383
  }
224
384
 
225
385
  export function registrationPaths(s, { home = homedir(), projectDir = null, harness = null } = {}) {
@@ -239,35 +399,68 @@ export function registrationPaths(s, { home = homedir(), projectDir = null, harn
239
399
  };
240
400
  }
241
401
 
242
- export function analyseRegistration(projectDir, s, { root = pluginRoot(), home = homedir(), harness = null, env = process.env } = {}) {
402
+ export function analyseRegistration(
403
+ projectDir,
404
+ s,
405
+ { root = pluginRoot(), home = homedir(), harness = null, env = process.env } = {},
406
+ ) {
243
407
  const id = `${s.plugin_name}@${s.marketplace_name}`;
244
408
  const paths = registrationPaths(s, { home, projectDir, harness });
245
409
  const pkg = pluginVersionAt(root, harness) || "0.0.0";
246
- const a = { id, paths, path: paths.dir, entryKey: id, pkg, bin: whichOnPath(s.cli?.bin || "claude", env), produced: !(s.condition === "npm_package_root" && isPluginCacheRoot(root, home)),
247
- dir: { present: existsSync(paths.dir), manifest: null, prov: null, pkg: null, disabled: [], digestOk: null }, known: null, registeredHere: null, installed: null, installedVersion: null, installPath: null,
248
- enabled: null, others: [], otherProjects: 0, writtenBy: null, newer: false, state: "absent", reason: null, refusal: null };
410
+ const a = {
411
+ id,
412
+ paths,
413
+ path: paths.dir,
414
+ entryKey: id,
415
+ pkg,
416
+ bin: whichOnPath(s.cli?.bin || "claude", env),
417
+ produced: !(s.condition === "npm_package_root" && isPluginCacheRoot(root, home)),
418
+ dir: { present: existsSync(paths.dir), manifest: null, prov: null, pkg: null, disabled: [], digestOk: null },
419
+ known: null,
420
+ registeredHere: null,
421
+ installed: null,
422
+ installedVersion: null,
423
+ installPath: null,
424
+ enabled: null,
425
+ others: [],
426
+ otherProjects: 0,
427
+ writtenBy: null,
428
+ newer: false,
429
+ state: "absent",
430
+ reason: null,
431
+ refusal: null,
432
+ };
249
433
 
250
434
  // Machine-global facts: our directory, the host's marketplace registry, every row of ours.
251
435
  if (a.dir.present) {
252
436
  a.dir.manifest = readJson(paths.manifest);
253
- const prov = a.dir.manifest && a.dir.manifest[s.provenance_key];
254
- if (prov && typeof prov === "object" && typeof prov.pkg === "string") {
255
- a.dir.prov = prov; a.dir.pkg = prov.pkg;
437
+ const prov = registrationProvenance(a.dir.manifest, s);
438
+ if (prov) {
439
+ a.dir.prov = prov;
440
+ a.dir.pkg = prov.pkg;
256
441
  a.dir.disabled = Array.isArray(prov.disabled) ? prov.disabled.filter((x) => typeof x === "string") : [];
257
442
  if (typeof prov.project === "string" && !samePath(prov.project, projectDir)) a.writtenBy = prov.project;
258
443
  // The payload digest: a copy that did not finish, or a hand edit, is not current.
259
444
  if (prov.digest && typeof prov.digest.sha256 === "string") {
260
- try { const d = treeDigest(paths.payload); a.dir.digestOk = d.count === prov.digest.count && d.sha256 === prov.digest.sha256; } catch { a.dir.digestOk = false; }
445
+ try {
446
+ const d = treeDigest(paths.payload);
447
+ a.dir.digestOk = d.count === prov.digest.count && d.sha256 === prov.digest.sha256;
448
+ } catch {
449
+ a.dir.digestOk = false;
450
+ }
261
451
  } else a.dir.digestOk = null; // no digest recorded: nothing to compare
262
452
  }
263
453
  }
264
454
  const known = readJson(paths.marketplaces);
265
455
  const mp = known && known[s.marketplace_name];
266
- a.known = mp ? { path: (mp.installLocation || mp.source?.path || null), source: mp.source?.source || null } : null;
456
+ a.known = mp ? { path: mp.installLocation || mp.source?.path || null, source: mp.source?.source || null } : null;
267
457
  const all = installedPluginEntries(home, projectDir).filter((e) => e.key === id);
268
458
  // Per-project facts: the checkout's own settings file and the registry row carrying its path.
269
459
  const local = readJson(paths.projectSettings);
270
- const here = local && local[s.registry?.known_pointer || "extraKnownMarketplaces"] && local[s.registry?.known_pointer || "extraKnownMarketplaces"][s.marketplace_name];
460
+ const here =
461
+ local &&
462
+ local[s.registry?.known_pointer || "extraKnownMarketplaces"] &&
463
+ local[s.registry?.known_pointer || "extraKnownMarketplaces"][s.marketplace_name];
271
464
  a.registeredHere = here ? { path: here.source?.path || null } : null;
272
465
  const enabledHere = (local && local[s.registry?.enabled_pointer || "enabledPlugins"]) || {};
273
466
  a.disabledHere = Object.keys(enabledHere).filter((k) => enabledHere[k] === false);
@@ -278,19 +471,25 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
278
471
  a.installPath = row ? row.path : null;
279
472
  a.installedVersion = row ? row.version : null;
280
473
  a.enabled = pluginEnabled(id, home, projectDir);
281
- // Competing registrations of the same plugin, enabled for this project.
282
- const seen = new Set();
283
- for (const e of installedPluginEntries(home, projectDir)) {
284
- if (e.key === id || seen.has(e.key) || !e.present || e.enabled === false) continue;
285
- if (e.projectPath && !samePath(e.projectPath, projectDir)) continue;
286
- seen.add(e.key); a.others.push({ key: e.key, path: e.path, version: e.version });
474
+ // Competing registrations of the same plugin, enabled for this project:
475
+ // contract 12's selection, one row per key — the row the host loads here,
476
+ // this checkout's own before a user-scope one. The convergence story's rule
477
+ // 6 reads the same rows (lib's projectCopiesByKey), so the deferral of a
478
+ // newer copy and the stop never disagree about its version.
479
+ for (const e of projectCopiesByKey(home, projectDir)) {
480
+ if (e.key !== id) a.others.push({ key: e.key, path: e.path, version: e.version });
287
481
  }
288
482
  a.newer = Boolean(a.dir.pkg) && cmpPrecedence(a.dir.pkg, pkg) > 0;
289
483
  // Same version, different payload: the directory's recorded digest against
290
484
  // this package's — contract 4's rung 3 (source changed) for a directory.
291
485
  a.contentDiffers = null;
292
486
  if (a.dir.prov && a.dir.pkg === pkg && a.dir.prov.digest && typeof a.dir.prov.digest.sha256 === "string") {
293
- try { const d = packageDigest(root); a.contentDiffers = d.sha256 !== a.dir.prov.digest.sha256 || d.count !== a.dir.prov.digest.count; } catch { a.contentDiffers = null; }
487
+ try {
488
+ const d = packageDigest(root);
489
+ a.contentDiffers = d.sha256 !== a.dir.prov.digest.sha256 || d.count !== a.dir.prov.digest.count;
490
+ } catch {
491
+ a.contentDiffers = null;
492
+ }
294
493
  }
295
494
  // The version the host would install from the directory as it will stand after this run.
296
495
  a.targetPkg = a.newer ? a.dir.pkg : pkg;
@@ -300,22 +499,100 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
300
499
  const bin = s.cli?.bin || "claude";
301
500
 
302
501
  // The ladder (contract 4′).
303
- if (!a.bin && nothingOfOurs) return { ...a, state: "unavailable", reason: `\`${bin}\` is not on PATH — the registration needs the host's CLI; the other surfaces still install` };
304
- if (a.dir.present && !a.dir.prov) return { ...a, state: "foreign", refusal: `${paths.dir} exists and its ${s.manifest} carries no \`${s.provenance_key}\` field of ours — a directory we did not write sits at our path; move it if it is yours, or delete it to let install take the name` };
305
- if (a.known && a.known.path && !samePath(a.known.path, paths.dir)) return { ...a, state: "foreign", refusal: `the host's registry names marketplace \`${s.marketplace_name}\` at ${a.known.path}, not at ${paths.dir} — a registration we did not make holds our name; \`${bin} plugin marketplace remove ${s.marketplace_name}\` lets install take it` };
306
- if (a.registeredHere && a.registeredHere.path && !samePath(a.registeredHere.path, paths.dir)) return { ...a, state: "foreign", refusal: `${paths.projectSettings} declares marketplace \`${s.marketplace_name}\` at ${a.registeredHere.path}, not at ${paths.dir} — remove that entry to let install take the name` };
502
+ if (!a.bin && nothingOfOurs)
503
+ return {
504
+ ...a,
505
+ state: "unavailable",
506
+ reason: `\`${bin}\` is not on PATH — the registration needs the host's CLI; the other surfaces still install`,
507
+ };
508
+ if (a.dir.present && !a.dir.prov)
509
+ return {
510
+ ...a,
511
+ state: "foreign",
512
+ refusal: `${paths.dir} exists and its ${s.manifest} carries no \`${s.provenance_key}\` field of ours — a directory we did not write sits at our path; move it if it is yours, or delete it to let install take the name`,
513
+ };
514
+ if (a.known && a.known.path && !samePath(a.known.path, paths.dir))
515
+ return {
516
+ ...a,
517
+ state: "foreign",
518
+ refusal: `the host's registry names marketplace \`${s.marketplace_name}\` at ${a.known.path}, not at ${paths.dir} — a registration we did not make holds our name; \`${bin} plugin marketplace remove ${s.marketplace_name}\` lets install take it`,
519
+ };
520
+ if (a.registeredHere && a.registeredHere.path && !samePath(a.registeredHere.path, paths.dir))
521
+ return {
522
+ ...a,
523
+ state: "foreign",
524
+ refusal: `${paths.projectSettings} declares marketplace \`${s.marketplace_name}\` at ${a.registeredHere.path}, not at ${paths.dir} — remove that entry to let install take the name`,
525
+ };
307
526
  if (nothingOfOurs) return { ...a, state: "absent" };
308
527
  // The directory is shared by every checkout on the machine; a project that never registered is absent, whatever the directory holds.
309
- if (nothingOfOursHere) return { ...a, state: "absent", reason: a.dir.present ? `the marketplace directory is present${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}; this checkout is not registered` : null };
528
+ if (nothingOfOursHere)
529
+ return {
530
+ ...a,
531
+ state: "absent",
532
+ reason: a.dir.present
533
+ ? `the marketplace directory is present${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}; this checkout is not registered`
534
+ : null,
535
+ };
310
536
  if (!a.dir.present) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + " (the directory is gone)" };
311
- if (cmpPrecedence(a.dir.pkg, pkg) < 0) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})` };
312
- if (a.dir.digestOk === false) return { ...a, state: "stale", reason: "edited by hand or half-written (the payload does not match the digest its manifest carries)" };
313
- if (a.contentDiffers === true) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (same version ${pkg}, different content — the directory's digest is not this package's)` };
314
- if (!a.known || !a.registeredHere) return { ...a, state: "stale", reason: STALE_TEXT[STALE.CONFIG] + (a.known ? " (this checkout does not declare the marketplace)" : " (the host does not know the marketplace)") };
315
- if (!a.installed || !a.installed.present) return { ...a, state: "stale", reason: "not installed" + (a.installed ? " (the host's install path is gone)" : " (registered, not installed for this checkout)") };
316
- if (a.installedVersion !== a.dir.pkg) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (installed ${a.installedVersion}, directory ${a.dir.pkg})` };
537
+ if (cmpPrecedence(a.dir.pkg, pkg) < 0)
538
+ return {
539
+ ...a,
540
+ state: "stale",
541
+ reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})`,
542
+ };
543
+ if (a.dir.digestOk === false)
544
+ return {
545
+ ...a,
546
+ state: "stale",
547
+ reason: "edited by hand or half-written (the payload does not match the digest its manifest carries)",
548
+ };
549
+ if (a.contentDiffers === true)
550
+ return {
551
+ ...a,
552
+ state: "stale",
553
+ reason:
554
+ STALE_TEXT[STALE.PLUGIN] +
555
+ ` (same version ${pkg}, different content — the directory's digest is not this package's)`,
556
+ };
557
+ if (!a.known || !a.registeredHere)
558
+ return {
559
+ ...a,
560
+ state: "stale",
561
+ reason:
562
+ STALE_TEXT[STALE.CONFIG] +
563
+ (a.known ? " (this checkout does not declare the marketplace)" : " (the host does not know the marketplace)"),
564
+ };
565
+ if (!a.installed || !a.installed.present)
566
+ return {
567
+ ...a,
568
+ state: "stale",
569
+ reason:
570
+ "not installed" +
571
+ (a.installed ? " (the host's install path is gone)" : " (registered, not installed for this checkout)"),
572
+ };
573
+ if (a.installedVersion !== a.dir.pkg)
574
+ return {
575
+ ...a,
576
+ state: "stale",
577
+ reason: STALE_TEXT[STALE.PLUGIN] + ` (installed ${a.installedVersion}, directory ${a.dir.pkg})`,
578
+ };
317
579
  if (!a.enabled) return { ...a, state: "stale", reason: "disabled for this checkout" };
318
- return { ...a, state: "current", reason: a.newer ? `the directory is at ${a.dir.pkg}, newer than this package (${pkg})${a.writtenBy ? `, written from ${a.writtenBy}` : ""} — not downgraded` : null };
580
+ return {
581
+ ...a,
582
+ state: "current",
583
+ reason: a.newer
584
+ ? `the directory is at ${a.dir.pkg}, newer than this package (${pkg})${a.writtenBy ? `, written from ${a.writtenBy}` : ""} — not downgraded`
585
+ : null,
586
+ };
587
+ }
588
+
589
+ // The root a registration renders the project's dependent surfaces against,
590
+ // read-only (contract 4′, two phases): the host's install path of this
591
+ // checkout's row, when that copy is on disk and enabled; else null. One rule
592
+ // for plan() (a run that leaves the registration out) and surfaceStates
593
+ // (doctor), so the two cannot disagree about which root a launcher is for.
594
+ export function registrationRenderRoot(a) {
595
+ return a && a.installed && (a.installed.present ?? true) && a.enabled ? a.installPath : null;
319
596
  }
320
597
 
321
598
  // ─── the project-level layout: legacy / new / both (the layout ADR) ─────
@@ -329,13 +606,27 @@ export function analyseLayout(projectDir, { harness = null } = {}) {
329
606
  const has = (f) => existsSync(f);
330
607
  const legacy = {
331
608
  dir: p.legacy.dir,
332
- binding: has(p.legacy.binding), runtime: has(p.legacy.runtime), state: has(p.legacy.state), launcher: has(p.legacy.launcher),
333
- entryLog: has(p.legacy.entryLog), welcomed: has(p.legacy.welcomed), sessionId: has(p.legacy.sessionId),
334
- runtimeOurs: false, stateFiles: [],
609
+ binding: has(p.legacy.binding),
610
+ runtime: has(p.legacy.runtime),
611
+ state: has(p.legacy.state),
612
+ launcher: has(p.legacy.launcher),
613
+ entryLog: has(p.legacy.entryLog),
614
+ welcomed: has(p.legacy.welcomed),
615
+ sessionId: has(p.legacy.sessionId),
616
+ runtimeOurs: false,
617
+ stateFiles: [],
335
618
  };
336
619
  if (legacy.runtime) {
337
- try { legacy.runtimeOurs = readFileSync(p.legacy.gitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER); } catch { legacy.runtimeOurs = false; }
338
- if (legacy.state) { try { legacy.stateFiles = readdirSync(p.legacy.state).filter((n) => n !== ".gitignore"); } catch {} }
620
+ try {
621
+ legacy.runtimeOurs = readFileSync(p.legacy.gitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER);
622
+ } catch {
623
+ legacy.runtimeOurs = false;
624
+ }
625
+ if (legacy.state) {
626
+ try {
627
+ legacy.stateFiles = readdirSync(p.legacy.state).filter((n) => n !== ".gitignore");
628
+ } catch {}
629
+ }
339
630
  }
340
631
  const current = { root: has(p.root), binding: has(p.binding) };
341
632
  const any = legacy.binding || legacy.runtime || legacy.welcomed || legacy.sessionId;
@@ -344,28 +635,77 @@ export function analyseLayout(projectDir, { harness = null } = {}) {
344
635
  let resumable = false;
345
636
  if (legacy.binding && current.binding) {
346
637
  try {
347
- const { agents, ...oldRest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
638
+ const { agents: _agents, ...oldRest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
348
639
  const cur = JSON.parse(readFileSync(p.binding, "utf8"));
349
640
  resumable = JSON.stringify(oldRest) === JSON.stringify(cur);
350
- } catch { resumable = false; }
641
+ } catch {
642
+ resumable = false;
643
+ }
351
644
  }
352
645
  return {
353
- paths: p, legacy, current, resumable,
646
+ paths: p,
647
+ legacy,
648
+ current,
649
+ resumable,
354
650
  twoConfigs: legacy.binding && current.binding,
355
651
  pending: any,
356
- state: !any ? "current" : (legacy.binding && current.binding) ? "both" : legacy.binding ? "legacy" : "partial",
652
+ state: !any ? "current" : legacy.binding && current.binding ? "both" : legacy.binding ? "legacy" : "partial",
357
653
  };
358
654
  }
359
655
 
656
+ // ─── a harness's state directory: the welcome marker (contract 13) ──────
657
+ //
658
+ // What a project uninstall may take from .projectstore/state/<id>/: the
659
+ // welcome marker, and the directory once it is empty. Without that the
660
+ // directory outlives the uninstall and the next bare upgrade selects the
661
+ // removed harness again (layout spec contract 5). The marker carries no
662
+ // provenance line, so it is recognised three ways at once — the third
663
+ // recogniser contract 13 accepts beside the provenance line and rung 1″: its
664
+ // path, layoutPaths(p).welcomed(id); the state directory's .gitignore carrying
665
+ // RUNTIME_GITIGNORE_HEADER; and its content, one ISO timestamp line, exactly
666
+ // as the SessionStart hook writes it. Anything else in the directory — a
667
+ // launcher, a file of the user's — is named in `others` and never ours to
668
+ // take here: a launcher is its surface item's. Read-only, like everything in
669
+ // this module.
670
+ const WELCOME_STAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\n?$/;
671
+ export function analyseHarnessState(projectDir, id) {
672
+ const p = layoutPaths(projectDir);
673
+ const dir = p.harnessState(id);
674
+ let names;
675
+ try {
676
+ names = readdirSync(dir).sort();
677
+ } catch {
678
+ return { dir, present: false, names: [], headed: false, marker: null, others: [] };
679
+ }
680
+ let headed = false;
681
+ try {
682
+ headed = readFileSync(p.stateGitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER);
683
+ } catch {}
684
+ const markerPath = p.welcomed(id);
685
+ const markerName = markerPath.slice(dir.length + 1);
686
+ const t = names.includes(markerName) ? readText(markerPath) : { present: false, text: null };
687
+ const marker = headed && t.present && typeof t.text === "string" && WELCOME_STAMP.test(t.text) ? markerPath : null;
688
+ return { dir, present: true, names, headed, marker, others: names.filter((n) => !(marker && n === markerName)) };
689
+ }
690
+
360
691
  // ─── every surface, for doctor ─────────────────────────────────────────
361
692
 
362
693
  export { analysePortableRegistration, portableRegistrationPaths };
363
- const ANALYSERS = { "markdown-block": analyseBlock, "json-entry": analyseJsonEntry, "mjs": analyseStampedFile, "host-plugin-registration": analyseRegistration, "portable-plugin-registration": analysePortableRegistration };
694
+ const ANALYSERS = {
695
+ "markdown-block": analyseBlock,
696
+ "json-entry": analyseJsonEntry,
697
+ mjs: analyseStampedFile,
698
+ "host-plugin-registration": analyseRegistration,
699
+ "portable-plugin-registration": analysePortableRegistration,
700
+ };
364
701
 
365
702
  // The states of every non-host, supported surface of every harness this
366
703
  // project uses — detected by directory, or carrying a file of ours (contract
367
704
  // 16). Host-managed rows have nothing to derive (contract 14).
368
- export function surfaceStates(projectDir, { home = homedir(), root = pluginRoot(), manifestDir = MANIFEST_DIR, harnesses = null, env = process.env } = {}) {
705
+ export function surfaceStates(
706
+ projectDir,
707
+ { home = homedir(), root = pluginRoot(), manifestDir = MANIFEST_DIR, harnesses = null, env = process.env } = {},
708
+ ) {
369
709
  projectDir = resolve(projectDir);
370
710
  const detected = detectHarnesses(projectDir, { dir: manifestDir }).map((d) => d.id);
371
711
  const out = { projectDir, detected, used: [], installable: harnessIds(manifestDir), states: [] };
@@ -376,37 +716,83 @@ export function surfaceStates(projectDir, { home = homedir(), root = pluginRoot(
376
716
  // and so does this (S2, the 2026-10-03 review): reporting it from the
377
717
  // core printed "<core> is not a portable plugin root" into every project
378
718
  // that merely contained the harness's directory.
379
- const rows = Object.entries(m.surfaces || {}).filter(([k, s]) => !k.startsWith("_") && s.kind !== "host" && s.supported !== false && ANALYSERS[s.format]
380
- && !(s.condition === "distribution_root" && !portablePayloadRoot(s, { root, env })));
719
+ const rows = Object.entries(m.surfaces || {}).filter(
720
+ ([k, s]) =>
721
+ !k.startsWith("_") &&
722
+ s.kind !== "host" &&
723
+ s.supported !== false &&
724
+ ANALYSERS[s.format] &&
725
+ !(s.condition === "distribution_root" && !portablePayloadRoot(s, { root, env })),
726
+ );
727
+ // The render root, as plan() takes it (contract 0 as amended 2026-10-10):
728
+ // an enabled, produced registration's install path. The launcher and the
729
+ // status-line entry are analysed against it — from the marketplace
730
+ // directory the host loads an npm registration from, or from a package
731
+ // root, the running root alone read the launcher as "not produced here".
732
+ // Read once, before the rows (the registration sorts last in the
733
+ // manifest); its own row reuses the result, since the analysis digests
734
+ // the payload.
735
+ const reg = rows.find(([, s]) => s.kind === "registration");
736
+ const regA = reg ? ANALYSERS[reg[1].format](projectDir, reg[1], { root, home, harness: m, env }) : null;
737
+ const renderRoot = (regA && regA.produced !== false && registrationRenderRoot(regA)) || root;
381
738
  const states = [];
382
739
  for (const [key, s] of rows) {
383
- const a = ANALYSERS[s.format](projectDir, s, { root, home, harness: m, env });
384
- const entry = s.kind === "shared" ? (a.entryKey || s.marker?.pointer || null) : null;
385
- const path = a.legacyPath || a.path || (a.current ? a.current.path : (a.preferred ? a.preferred.path : join(projectDir, s.file || "")));
386
- const row = { harness: m.id, surface: key, kind: s.kind, path, entry, state: a.state, reason: a.reason || a.refusal || null, writtenBy: a.writtenBy || null, sameProject: Boolean(a.sameProject), produced: a.produced !== false, legacy: Boolean(a.legacy), installedPkg: a.installedPkg || null, present: a.file ? a.file.present : (a.current ? true : (a.curEntry ? true : false)) };
387
- if (s.kind === "registration") Object.assign(row, {
388
- entry: a.id,
389
- present: Boolean(a.dir?.present || a.ownership || a.known || a.market || a.installed),
390
- produced: a.produced,
391
- pkg: a.pkg || a.desiredVersion || null,
392
- dirPkg: a.dir?.pkg || a.ownership?.version || null,
393
- installedVersion: a.installedVersion,
394
- installPath: a.installPath,
395
- enabled: a.enabled,
396
- others: a.others || [],
397
- otherProjects: a.otherProjects || 0,
740
+ const dependent = renderRoot !== root && ["json-entry", "mjs"].includes(s.format);
741
+ const a =
742
+ reg && s === reg[1]
743
+ ? regA
744
+ : ANALYSERS[s.format](projectDir, s, { root, home, harness: m, env, ...(dependent ? { renderRoot } : {}) });
745
+ const entry = s.kind === "shared" ? a.entryKey || s.marker?.pointer || null : null;
746
+ const path =
747
+ a.legacyPath ||
748
+ a.path ||
749
+ (a.current ? a.current.path : a.preferred ? a.preferred.path : join(projectDir, s.file || ""));
750
+ const row = {
751
+ harness: m.id,
752
+ surface: key,
753
+ kind: s.kind,
754
+ path,
755
+ entry,
756
+ state: a.state,
757
+ reason: a.reason || a.refusal || null,
398
758
  writtenBy: a.writtenBy || null,
399
- newer: Boolean(a.newer),
400
- bin: a.bin,
401
- // What this registration silenced for the checkout and the checkout
402
- // still holds off: exactly the set uninstall re-enables (contract 13).
403
- silenced: (a.dir?.disabled || []).filter((k) => (a.disabledHere || []).includes(k)),
404
- });
759
+ sameProject: Boolean(a.sameProject),
760
+ produced: a.produced !== false,
761
+ legacy: Boolean(a.legacy),
762
+ installedPkg: a.installedPkg || null,
763
+ present: a.file ? a.file.present : a.current ? true : a.curEntry ? true : false,
764
+ };
765
+ if (s.kind === "registration")
766
+ Object.assign(row, {
767
+ entry: a.id,
768
+ present: Boolean(a.dir?.present || a.ownership || a.known || a.market || a.installed),
769
+ produced: a.produced,
770
+ pkg: a.pkg || a.desiredVersion || null,
771
+ dirPkg: a.dir?.pkg || a.ownership?.version || null,
772
+ installedVersion: a.installedVersion,
773
+ installPath: a.installPath,
774
+ // The plugin's root inside our directory (registrationPaths' payload):
775
+ // what a host that loads the registration in place runs from.
776
+ payload: a.paths?.payload || null,
777
+ enabled: a.enabled,
778
+ others: a.others || [],
779
+ otherProjects: a.otherProjects || 0,
780
+ writtenBy: a.writtenBy || null,
781
+ newer: Boolean(a.newer),
782
+ bin: a.bin,
783
+ // What this registration silenced for the checkout and the checkout
784
+ // still holds off: exactly the set uninstall re-enables (contract 13).
785
+ silenced: (a.dir?.disabled || []).filter((k) => (a.disabledHere || []).includes(k)),
786
+ });
405
787
  states.push(row);
406
788
  }
407
789
  // A registration is the host's to load and never counts as a file of ours
408
790
  // (contract 16): it decides nothing about whether the harness is in use.
409
- const hasOurs = states.some((x) => (x.kind === "exclusive" && ["current", "stale"].includes(x.state)) || (x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)));
791
+ const hasOurs = states.some(
792
+ (x) =>
793
+ (x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
794
+ (x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
795
+ );
410
796
  if (detected.includes(m.id) || hasOurs) {
411
797
  out.used.push(m.id);
412
798
  // The agents block is deliberately shared across harnesses. Its presence