projectstore-claude 0.30.4 → 0.30.6

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 (50) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
  3. package/node_modules/projectstore/.mcp.json +1 -2
  4. package/node_modules/projectstore/README.md +65 -16
  5. package/node_modules/projectstore/agents/archaeologist.md +1 -1
  6. package/node_modules/projectstore/agents/librarian.md +1 -1
  7. package/node_modules/projectstore/agents/planner.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +14 -11
  9. package/node_modules/projectstore/commands/reconcile.md +1 -1
  10. package/node_modules/projectstore/commands/review.md +2 -2
  11. package/node_modules/projectstore/commands/story.md +1 -1
  12. package/node_modules/projectstore/docs/getting-started.md +3 -4
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/docs/images/icon.png +0 -0
  16. package/node_modules/projectstore/harnesses/claude-code.json +61 -11
  17. package/node_modules/projectstore/harnesses/codex.json +45 -15
  18. package/node_modules/projectstore/hooks/band.tsx +19 -8
  19. package/node_modules/projectstore/hooks/hooks.json +6 -6
  20. package/node_modules/projectstore/hooks/session-start.mjs +55 -24
  21. package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
  22. package/node_modules/projectstore/package.json +11 -12
  23. package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
  24. package/node_modules/projectstore/scripts/band.mjs +20 -2
  25. package/node_modules/projectstore/scripts/binding.mjs +1 -1
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
  27. package/node_modules/projectstore/scripts/cli.mjs +74 -1373
  28. package/node_modules/projectstore/scripts/convergence.mjs +59 -16
  29. package/node_modules/projectstore/scripts/doctor.mjs +199 -36
  30. package/node_modules/projectstore/scripts/harness.mjs +88 -6
  31. package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
  32. package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
  33. package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
  34. package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
  35. package/node_modules/projectstore/scripts/lib.mjs +387 -107
  36. package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
  37. package/node_modules/projectstore/scripts/mcp.mjs +60 -60
  38. package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
  39. package/node_modules/projectstore/scripts/models.mjs +6 -10
  40. package/node_modules/projectstore/scripts/query.mjs +7 -6
  41. package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
  42. package/node_modules/projectstore/scripts/setup.mjs +62 -12
  43. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
  44. package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
  45. package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
  46. package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
  47. package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
  48. package/package.json +3 -3
  49. package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
  50. package/node_modules/projectstore/docs/images/team-light.svg +0 -79
@@ -0,0 +1,925 @@
1
+ // projectstore — install-handlers.mjs: the installer's planners for the
2
+ // surfaces it writes itself, setup's items, and the plan helpers they share.
3
+ //
4
+ // Moved out of install-harness.mjs as it was (the story "The installer is
5
+ // split into modules under 200 KiB each, with every export and every test
6
+ // unchanged"). The installer's HANDLERS table keys these by the manifest's
7
+ // surfaces.<kind>.format, never by a harness id: planAgentsBlock (the agents
8
+ // block, planned once per run), planJsonEntry (our entry in a shared JSON
9
+ // file, such as the status-line slot) and planStampedFile (an exclusive file
10
+ // carrying the provenance line). Each answers (ctx, key, s) → PlanItem[] from
11
+ // the state surfaces.mjs derived, and writes nothing.
12
+ //
13
+ // Setup's items (the setup story, rule 10): planVault, planStateDir and the
14
+ // `binding` items — planStatuslineFlag, STAMP, stamped and bindingItem, which
15
+ // the installer's keep record, mark and binding apply build on. And the helpers
16
+ // plan(), apply() and the preview share: rel and inside, isWrite, the
17
+ // consequence note, surfaceExcluded, oneAddPerFile and the host-managed
18
+ // report (contract 14).
19
+ //
20
+ // A leaf: it imports neither the installer nor any module that reads the
21
+ // registry, fetches a shell or runs a host command. The registration's
22
+ // planners, which do, stay in install-harness.mjs.
23
+
24
+ import { existsSync } from "node:fs";
25
+ import { join, resolve, relative, isAbsolute } from "node:path";
26
+ import { loadHarnesses, packageCommand, agentsBlockRow } from "./harness.mjs";
27
+ import { FOREIGN_TEXT } from "./provenance.mjs";
28
+ import { analyseBlock, analyseJsonEntry, analyseStampedFile, readText } from "./surfaces.mjs";
29
+ import { bindPlanSteps } from "./binding.mjs";
30
+ import {
31
+ removeAgentsBlock,
32
+ replaceAgentsBlock,
33
+ isPluginCacheRoot,
34
+ isHostInstallRoot,
35
+ isEphemeralRoot,
36
+ importsLine,
37
+ layoutPaths,
38
+ newerBlockWords,
39
+ } from "./lib.mjs";
40
+
41
+ export function rel(projectDir, p) {
42
+ const r = relative(projectDir, p);
43
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
44
+ }
45
+
46
+ export function inside(dir, parent) {
47
+ const r = relative(parent, dir);
48
+ return r !== "" && !r.startsWith("..") && !isAbsolute(r);
49
+ }
50
+
51
+ // Planned once per RUN by plan(), never once per harness (the story "One run
52
+ // plans the agents block once, and a bare uninstall selects every harness the
53
+ // project uses", rule 1). `s` carries the run's common file list as `files`;
54
+ // `ctx.run` is every harness of the run that has a block, `ctx.harness` the
55
+ // first of them, `ctx.readers` set when the run names more than one, and
56
+ // `ctx.layout` the roster's layout name (rule 3).
57
+ export function planAgentsBlock(ctx, key, s) {
58
+ const { projectDir, mode, root } = ctx;
59
+ const run = ctx.run || [ctx.harness];
60
+ const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
61
+ const { withBlock, preferred, importLine, FALLBACK } = a;
62
+ const items = [];
63
+ if (a.refusal)
64
+ return [
65
+ {
66
+ surface: key,
67
+ kind: "shared",
68
+ path: a.files[0].path,
69
+ entry: "projectstore:agents",
70
+ state: "refused",
71
+ action: "refuse",
72
+ reason: a.refusal,
73
+ },
74
+ ];
75
+
76
+ const hasImport = (text) => importsLine(text, importLine);
77
+ // A CLAUDE.md that is nothing but the import registration added is ours to
78
+ // delete when the block goes (ADR-002 decision 4); anything else stays.
79
+ const onlyImport = (text) =>
80
+ String(text ?? "")
81
+ .split("\n")
82
+ .every((l) => !l.trim() || l.trim() === importLine);
83
+ // A file the removal leaves empty is deleted when it held only the block
84
+ // (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
85
+ // a run's common list (whose last entry is AGENTS.md) and Claude Code alone
86
+ // both left a 0-byte AGENTS.md (measured 2026-10-08).
87
+ const removal = (e, extra = {}) => {
88
+ const after = removeAgentsBlock(e.text);
89
+ return {
90
+ surface: key,
91
+ kind: "shared",
92
+ path: e.path,
93
+ entry: `projectstore:agents v${e.block.v}`,
94
+ state: "ours-current",
95
+ action: "remove",
96
+ reason: null,
97
+ before: e.text,
98
+ after,
99
+ deleteIfEmpty: e.file === FALLBACK || !after.trim(),
100
+ ...extra,
101
+ };
102
+ };
103
+
104
+ if (mode === "uninstall") {
105
+ if (!withBlock.length)
106
+ return [
107
+ {
108
+ surface: key,
109
+ kind: "shared",
110
+ path: preferred.path,
111
+ entry: "projectstore:agents",
112
+ state: "ours-absent",
113
+ action: "skip",
114
+ reason: "no block to remove",
115
+ },
116
+ ];
117
+ // The agents block is the one surface the PROJECT owns rather than a
118
+ // harness: several harnesses read the same file. So disowning one harness
119
+ // may not remove it, and measurably did — `uninstall --harness <other>`
120
+ // deleted AGENTS.md while the source harness was still reading it through
121
+ // an @AGENTS.md import, and the reverse stripped the block Codex reads
122
+ // (both measured 2026-09-09). Either way, disowning one harness silently
123
+ // disabled the other.
124
+ //
125
+ // The test is static manifest data, not detection: is the file the block
126
+ // lives in one that ANOTHER manifest also names? No project state is read,
127
+ // so there is no circularity — and the single-harness case is untouched,
128
+ // because a file only one manifest names is that harness's to clear.
129
+ // Over-conservative on purpose: a block left behind is a marked,
130
+ // self-describing region a user can delete, where a deleted file another
131
+ // harness reads is silent breakage. `--surface agents_block` still removes
132
+ // it, which is the confirmation the gate asks for everywhere else.
133
+ //
134
+ // "Another" means a manifest OUTSIDE this run (rule 4): a run that names
135
+ // every harness reading the file is the last one, and removes the block
136
+ // once. Excluding only the planning harness kept the block for a harness
137
+ // that was in the same run.
138
+ const inRun = new Set(run.map((m) => m?.id));
139
+ const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
140
+ const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
141
+ for (const e of withBlock) {
142
+ // Naming the surface removes it regardless: `--surface agents_block`
143
+ // is the confirmation without a terminal, and a terminal is asked
144
+ // (contract 9 as amended 2026-10-04).
145
+ if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
146
+ items.push({
147
+ surface: key,
148
+ kind: "shared",
149
+ path: e.path,
150
+ entry: `projectstore:agents v${e.block.v}`,
151
+ state: "ours-current",
152
+ action: "skip",
153
+ reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}`,
154
+ });
155
+ continue;
156
+ }
157
+ items.push(removal(e));
158
+ }
159
+ // An import that points at a file whose block has just gone is a pointer to
160
+ // nothing. Look across every file any manifest names, not just this one's:
161
+ // with a single-entry list PREFERRED and FALLBACK are the same file, which
162
+ // made the old condition — block in PREFERRED and not in FALLBACK —
163
+ // impossible to satisfy, so the import was never cleaned up at all.
164
+ // An import is dangling only when the file it names will be GONE — not
165
+ // merely when the block left it. A user's own AGENTS.md keeps its prose and
166
+ // survives, and importing it stays meaningful (install contract 13 /
167
+ // ADR-002 decision 4). The condition is therefore apply's own: marked for
168
+ // deletion and left holding nothing (apply's `deleteIfEmpty` branch).
169
+ const vanishing = new Set(
170
+ items
171
+ .filter((i) => i.action === "remove" && i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
172
+ .map((i) => rel(projectDir, i.path)),
173
+ );
174
+ // Two independent reasons to take an import out, and the first version of
175
+ // this replaced one with the other:
176
+ // (1) it is OURS and nothing else is there — the registration we added to
177
+ // a file that held nothing else goes with the block (ADR-002
178
+ // decision 4), whether or not its target survives;
179
+ // (2) it DANGLES — the file it names will be gone, so it points at
180
+ // nothing even though the reader's own prose keeps the file alive.
181
+ // A user's AGENTS.md that keeps its prose satisfies neither, and its import
182
+ // stays: that is install contract 13, and it is what caught the mistake.
183
+ //
184
+ // Over the UNION of every manifest's list, not this one's: the file holding
185
+ // the import need not be one this harness can read. Codex's list is a
186
+ // single entry, which also made the old condition — block in PREFERRED and
187
+ // not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
188
+ //
189
+ // Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
190
+ // import in place"): the import in the file a harness outside this run
191
+ // reads by itself, pointing at a block this run keeps for that harness.
192
+ // It is the only way that harness sees the block; taking it out because it
193
+ // is ours kept the block and blinded its reader. The source harness's
194
+ // own uninstall still removes its own file's import, as before.
195
+ const blockGone = new Set(
196
+ items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)),
197
+ );
198
+ const blockKept = new Set(
199
+ items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)),
200
+ );
201
+ const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
202
+ const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
203
+ for (const file of union) {
204
+ if (blockGone.has(file)) continue;
205
+ const e = readText(join(projectDir, file));
206
+ if (!e.present || e.text === null || !hasImport(e.text)) continue;
207
+ const ours = onlyImport(e.text);
208
+ const dangles = vanishing.has(importLine.slice(1));
209
+ const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
210
+ if ((!ours && !dangles) || bridge) continue;
211
+ const after = e.text
212
+ .split("\n")
213
+ .filter((l) => l.trim() !== importLine)
214
+ .join("\n")
215
+ .replace(/^\n+/, "");
216
+ items.push({
217
+ surface: `${key}_import`,
218
+ kind: "shared",
219
+ path: join(projectDir, file),
220
+ entry: importLine,
221
+ state: "ours-current",
222
+ action: "remove",
223
+ reason: ours
224
+ ? `${file} holds only the import registration added`
225
+ : `${importLine} points at a file this uninstall removes`,
226
+ before: e.text,
227
+ after,
228
+ deleteIfEmpty: ours,
229
+ });
230
+ }
231
+ return items;
232
+ }
233
+
234
+ const current = a.current;
235
+ // A block marked above this copy's template is never rendered down (the
236
+ // install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
237
+ // migration or a dedupe carries its own bytes, stamped with its own marker.
238
+ const newer = a.newer || null;
239
+ const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
240
+ const body = newer ? newer.block.block : a.desired;
241
+ const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
242
+ // Two files, one block each: resolve in favour of the preferred file —
243
+ // register migrates, never duplicates (ADR-002 decision 3).
244
+ for (const e of a.duplicates || [])
245
+ items.push(
246
+ removal(e, {
247
+ state: "ours-stale",
248
+ reason:
249
+ newer === e
250
+ ? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
251
+ : `duplicate of the block in ${current.file}`,
252
+ }),
253
+ );
254
+ let newerInPlace = null;
255
+
256
+ if (!current) {
257
+ const target = preferred;
258
+ items.push({
259
+ surface: key,
260
+ kind: "shared",
261
+ path: target.path,
262
+ entry,
263
+ state: "ours-absent",
264
+ action: target.present ? "add" : "create",
265
+ reason: null,
266
+ before: target.present ? target.text : null,
267
+ after: replaceAgentsBlock(target.present ? target.text : "", a.desired),
268
+ });
269
+ } else if (!current.own || (current.file !== preferred.file && preferred.present)) {
270
+ // Two reasons to move, and they are not the same reason:
271
+ // - the block sits in a file this harness cannot read (`!own`). It must
272
+ // move, and its target is CREATED if it does not exist — this is a
273
+ // second harness arriving in a project that had one.
274
+ // - it sits in a readable but non-preferred file while the preferred one
275
+ // already exists. A preference, satisfied because the file is there.
276
+ // The distinction is what keeps a Claude-Code-only project from acquiring
277
+ // an AGENTS.md it never asked for, while still moving the substance the
278
+ // moment a harness that can only read AGENTS.md is installed.
279
+ // A run of several harnesses says which of them cannot read the file the
280
+ // block leaves (rule 1); one harness keeps its text, byte for byte.
281
+ items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
282
+ items.push({
283
+ surface: key,
284
+ kind: "shared",
285
+ path: preferred.path,
286
+ entry,
287
+ state: "ours-absent",
288
+ action: preferred.present ? "add" : "create",
289
+ reason:
290
+ ctx.readers && !current.own
291
+ ? a.reason
292
+ : `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
293
+ before: preferred.present ? preferred.text : null,
294
+ after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
295
+ });
296
+ } else if (newer && newer !== current) {
297
+ // The duplicate carries the higher marker: its text replaces the stayer's.
298
+ items.push({
299
+ surface: key,
300
+ kind: "shared",
301
+ path: current.path,
302
+ entry,
303
+ state: "ours-stale",
304
+ action: "replace-entry",
305
+ reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
306
+ before: current.text,
307
+ after: replaceAgentsBlock(current.text, body),
308
+ });
309
+ } else if (newer) {
310
+ // It stays as it is; a named run with nothing else to place overwrites it,
311
+ // decided once the imports are known (below).
312
+ newerInPlace = {
313
+ surface: key,
314
+ kind: "shared",
315
+ path: current.path,
316
+ entry,
317
+ state: "ours-newer",
318
+ action: "skip",
319
+ reason: `${words}: left as it is — ${
320
+ ctx.takesSurface === false
321
+ ? `to write this copy's v${a.version} over it: ${packageCommand(ctx.harness, "install", { args: `--surface ${key}` })}`
322
+ : `name --surface ${key} to write this copy's v${a.version} over it`
323
+ }`,
324
+ };
325
+ items.push(newerInPlace);
326
+ } else if (current.block.v === a.version && current.block.block === a.desired) {
327
+ items.push({
328
+ surface: key,
329
+ kind: "shared",
330
+ path: current.path,
331
+ entry,
332
+ state: "ours-current",
333
+ action: "skip",
334
+ reason: null,
335
+ });
336
+ } else {
337
+ items.push({
338
+ surface: key,
339
+ kind: "shared",
340
+ path: current.path,
341
+ entry,
342
+ state: "ours-stale",
343
+ action: "replace-entry",
344
+ reason:
345
+ current.block.v !== a.version
346
+ ? `v${current.block.v} → v${a.version}`
347
+ : "content differs from the current source",
348
+ before: current.text,
349
+ after: replaceAgentsBlock(current.text, a.desired),
350
+ });
351
+ }
352
+
353
+ // The @AGENTS.md import in CLAUDE.md (ADR-002 decision 3), only when the
354
+ // block lives in AGENTS.md and CLAUDE.md exists without it. A removal
355
+ // already rewriting CLAUDE.md in this plan takes the import onto its own
356
+ // text, or the two items would race.
357
+ const written = items.find((i) => ["add", "create", "replace-entry", "skip"].includes(i.action) && i.surface === key);
358
+ const blockFile = written ? rel(projectDir, written.path) : null;
359
+ // Every OTHER file any manifest names, and only if it already exists: a
360
+ // reader whose file no longer holds the substance is pointed at the one that
361
+ // does. Never a file we would have to create — a project with no CLAUDE.md
362
+ // does not acquire one because Codex was installed.
363
+ //
364
+ // It used to key on `a.files.length > 1`, which is a property of the
365
+ // INSTALLING harness's list: Codex's has one entry, so installing Codex left
366
+ // a CLAUDE.md that still exists, still reads as authoritative, and no longer
367
+ // holds anything. The block's file is what decides, not the list's length.
368
+ // Installing FOR a harness means the file that harness reads by itself ends
369
+ // up present. It holds the block when the block lands there; it holds the
370
+ // import when the block lands elsewhere. Without this, a project that already
371
+ // had an AGENTS.md took the block into it and created no bridge — so Claude
372
+ // Code, the harness that ran the install, could not see what it had just
373
+ // installed. `reads_natively` is the manifest's, so this is a file list, not
374
+ // a harness name — and it is every RUN harness's (rule 1): one run for two
375
+ // harnesses leaves both native files present, each holding the block or the
376
+ // import.
377
+ const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
378
+ // The import item sits under the run's first harness, which need not be the
379
+ // one that reads the file: in a run of several, the reason names the reader.
380
+ // One harness is its own reader and keeps its text, byte for byte.
381
+ const reader = (file) =>
382
+ (ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
383
+ for (const e of a.files) {
384
+ const mustExist = natives.has(e.file);
385
+ if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
386
+ const line = `@${blockFile}`;
387
+ const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
388
+ // An absent native file is empty text, not a reason to skip: it is created.
389
+ const text = rewrite ? rewrite.after : e.present ? e.text : "";
390
+ if (typeof text !== "string" || importsLine(text, line)) continue;
391
+ const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
392
+ if (rewrite) {
393
+ rewrite.after = after;
394
+ rewrite.deleteIfEmpty = false;
395
+ rewrite.reason += `; ${line} import added`;
396
+ } else
397
+ items.push({
398
+ surface: `${key}_import`,
399
+ kind: "shared",
400
+ path: e.path,
401
+ entry: line,
402
+ state: "ours-absent",
403
+ action: e.present ? "add" : "create",
404
+ reason: e.present
405
+ ? `${blockFile} carries the block; ${e.file} must import it`
406
+ : `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
407
+ before: e.present ? text : null,
408
+ after,
409
+ });
410
+ }
411
+ // Naming the surface confirms the overwrite only when the run has nothing
412
+ // else to do for the block: it is the only block and every import it needs
413
+ // is in place. Doctor's placement remedies name the surface too, and they
414
+ // must never downgrade (contract 9 as amended 2026-10-14).
415
+ if (
416
+ newerInPlace &&
417
+ (ctx.surfaces || []).includes(key) &&
418
+ !(a.duplicates || []).length &&
419
+ !items.some((i) => i.surface === `${key}_import`)
420
+ )
421
+ Object.assign(newerInPlace, {
422
+ entry: `projectstore:agents v${a.version}`,
423
+ action: "replace-entry",
424
+ reason: `v${current.block.v} → v${a.version}: ${words}, and --surface ${key} names it`,
425
+ before: current.text,
426
+ after: replaceAgentsBlock(current.text, a.desired),
427
+ });
428
+ return items;
429
+ }
430
+
431
+ // A row's consequence (presentation spec contract 7) carried as a `note` step:
432
+ // the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
433
+ // beneath its row, in the explanation role, by the mark kept off the envelope.
434
+ export function consequenceStep(why) {
435
+ const st = { kind: "note", why };
436
+ Object.defineProperty(st, "consequence", { value: true, enumerable: false });
437
+ return st;
438
+ }
439
+ export const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
440
+
441
+ // The status line's flag (the setup story, rule 9; the configure spec,
442
+ // contract 6): `statusline.enabled`, from setup's answer or from a run that
443
+ // names the surface — install records on, uninstall off. This item is the
444
+ // flag's one writer; every other key of a `statusline` object is kept.
445
+ // Planned for the run's harness that has the status-line surface, when the
446
+ // binding the run leaves does not already say the same; only when there is a
447
+ // binding to record it in.
448
+ const STATUSLINE_FLAG = "statusline.enabled";
449
+ export function planStatuslineFlag(ctx, value, mode = "install") {
450
+ const b = ctx.binding;
451
+ if (!b || !b.bound || typeof value !== "boolean" || b.statusline === value) return null;
452
+ const off =
453
+ mode === "uninstall"
454
+ ? "the status line is removed in this run, and the SessionStart hook keeps it off"
455
+ : "the status line stays off, and setup does not ask again";
456
+ return bindingItem({
457
+ harness: ctx.harness.id,
458
+ surface: "statusline_flag",
459
+ entry: STATUSLINE_FLAG,
460
+ path: b.path,
461
+ bound: true,
462
+ set: { [STATUSLINE_FLAG]: value },
463
+ why: {
464
+ [STATUSLINE_FLAG]: value
465
+ ? "the status line is wired in this run, and the SessionStart hook keeps it current"
466
+ : off,
467
+ },
468
+ said: {
469
+ unbound: "the project is not bound; the status line's flag is recorded once it is",
470
+ done: "the status line's flag is already recorded",
471
+ object: "the status line's flag is not recorded",
472
+ },
473
+ });
474
+ }
475
+
476
+ // Setup's vault item (the story's rule 10; the front-door ADR's decision 7):
477
+ // one item of kind `vault`, first in the plan and under no harness — PLAN's
478
+ // VAULT group. Its steps are the binding plan's own (binding.mjs
479
+ // bindPlanSteps): the mkdir, the binding, `git init`, the scaffold. `create`
480
+ // for a vault this run makes (missing, or there and empty); `add` for a
481
+ // binding of one that exists, scaffolded or not, and for a worktree's
482
+ // inherited binding. The binding plan rides along off the envelope, for
483
+ // apply; with nothing to write there is no item.
484
+ export function planVault(bp) {
485
+ const steps = bindPlanSteps(bp);
486
+ if (!steps.length) return null;
487
+ const reason = bp.inherit
488
+ ? `the vault of ${bp.inherit.from}, with its settings`
489
+ : bp.buildsVault || !bp.vaultExists
490
+ ? null
491
+ : bp.scaffoldsExisting
492
+ ? "it already exists; it is bound as it is, and the layout's missing folders and indexes are added"
493
+ : "it already exists; it is bound as it is";
494
+ const item = {
495
+ harness: null,
496
+ surface: "vault",
497
+ kind: "vault",
498
+ path: bp.vault,
499
+ entry: null,
500
+ state: !bp.vaultExists ? "absent" : bp.buildsVault ? "empty" : "present",
501
+ action: bp.buildsVault ? "create" : "add",
502
+ reason,
503
+ steps,
504
+ };
505
+ Object.defineProperty(item, "bind", { value: bp, enumerable: false });
506
+ return item;
507
+ }
508
+
509
+ // Setup's state directory (the story's rule 10): an empty
510
+ // .projectstore/state/<id>/, the signal detection reads (detectHarnesses'
511
+ // `state`), so a harness setup installed with no directory of its own is used
512
+ // here before its first session. Never the welcome marker: that file means
513
+ // the welcome was shown, and the first session writes it into this
514
+ // directory. The first such item names state/ and its .gitignore when they
515
+ // are still to be made.
516
+ export function planStateDir(projectDir, harness, { first = false } = {}) {
517
+ const P = layoutPaths(projectDir);
518
+ const path = P.harnessState(harness.id);
519
+ if (existsSync(path)) return null;
520
+ const steps =
521
+ first && !existsSync(P.stateGitignore)
522
+ ? [
523
+ {
524
+ kind: "ensure",
525
+ path: P.state,
526
+ why: "state/ with its .gitignore, which keeps everything under it out of git",
527
+ },
528
+ ]
529
+ : [];
530
+ return {
531
+ harness: harness.id,
532
+ surface: "harness_state",
533
+ kind: "state",
534
+ path,
535
+ entry: null,
536
+ state: "absent",
537
+ action: "create",
538
+ reason: null,
539
+ ...(steps.length ? { steps } : {}),
540
+ };
541
+ }
542
+
543
+ // A key a `binding` item stamps at apply: `{ by: "setup", at: <the time of
544
+ // apply> }`, the mark's shape and the keep's.
545
+ export const STAMP = Object.freeze({ stamp: "setup" });
546
+ export const stamped = (v) =>
547
+ Boolean(v) && typeof v === "object" && !Array.isArray(v) && typeof v.by === "string" && typeof v.at === "string";
548
+
549
+ // A `binding` item (the converge story's kind, made generic by the setup
550
+ // story's rule 10): keys merged into the binding at apply time, after the
551
+ // layout item, every other key kept. `set` maps a key — dotted for a nested
552
+ // one — to its value, or to STAMP; `unset` lists the keys it removes. Both
553
+ // are apply's data, kept off the envelope, as `said` is: the item's own
554
+ // words for a binding that is not there, not an object, or already says it.
555
+ // The steps are what PLAN shows and the envelope carries, in that order:
556
+ // `mark` for a stamped key, `set` for a value, `unmark` for a removal. An
557
+ // item that only removes a key is an `update` of a binding whose key is
558
+ // `present`.
559
+ export function bindingItem({
560
+ harness,
561
+ surface,
562
+ entry,
563
+ path,
564
+ bound,
565
+ reason = null,
566
+ set = {},
567
+ unset = [],
568
+ why = {},
569
+ said = {},
570
+ action = "add",
571
+ state = "absent",
572
+ }) {
573
+ const step = (fields, key) => (why[key] ? { ...fields, why: why[key] } : fields);
574
+ const steps = [
575
+ ...Object.entries(set).map(([key, value]) =>
576
+ step(value === STAMP ? { kind: "mark", path, key } : { kind: "set", path, key, value }, key),
577
+ ),
578
+ ...unset.map((key) => step({ kind: "unmark", path, key }, key)),
579
+ ];
580
+ const item = {
581
+ harness,
582
+ surface,
583
+ kind: "binding",
584
+ path,
585
+ entry,
586
+ state: bound ? state : "unbound",
587
+ action,
588
+ reason,
589
+ steps,
590
+ };
591
+ for (const [k, v] of Object.entries({ set, unset, said }))
592
+ Object.defineProperty(item, k, { value: v, enumerable: false });
593
+ return item;
594
+ }
595
+
596
+ export function planJsonEntry(ctx, key, s) {
597
+ const { projectDir, mode, root, home } = ctx;
598
+ if (s.supported === false) {
599
+ return [
600
+ {
601
+ surface: key,
602
+ kind: "shared",
603
+ path: join(projectDir, s.file),
604
+ entry: s.marker?.pointer || null,
605
+ state: "unsupported",
606
+ action: "skip",
607
+ reason: s.why_unsupported || "not supported for this harness yet",
608
+ },
609
+ ];
610
+ }
611
+ const path = join(projectDir, s.file);
612
+ const entryKey = (s.marker?.pointer || "statusLine.command").split(".")[0];
613
+ // The status line is opt-in (projectstore.json → statusline.enabled), as
614
+ // the SessionStart refresh already honours; naming the surface explicitly
615
+ // is the other way to opt in. A verb with no --surface (setup, its rule 9)
616
+ // names install's command, which takes it, in the core form.
617
+ if (mode === "install" && !ctx.optIn.has(key)) {
618
+ const anyway =
619
+ ctx.takesSurface === false
620
+ ? `to wire it anyway: ${packageCommand(ctx.harness, "install", { args: `--surface ${key}` })}`
621
+ : "name --surface statusline to wire it anyway";
622
+ return [
623
+ {
624
+ surface: key,
625
+ kind: "shared",
626
+ path,
627
+ entry: entryKey,
628
+ state: "opt-out",
629
+ action: "skip",
630
+ reason: `statusline.enabled is not true in projectstore.json — ${anyway}`,
631
+ },
632
+ ];
633
+ }
634
+ const renderRoot = ctx.renderRoot || root;
635
+ // A root the package manager will collect (npx's cache, a node_modules) is
636
+ // never wired directly: the entry would name a path that disappears. It is
637
+ // wired against a registration's install path — or not at all (contract 4′).
638
+ if (mode === "install" && !isPluginCacheRoot(renderRoot, home) && isEphemeralRoot(renderRoot)) {
639
+ return [
640
+ {
641
+ surface: key,
642
+ kind: "shared",
643
+ path,
644
+ entry: entryKey,
645
+ state: "absent-or-present",
646
+ action: "skip",
647
+ reason:
648
+ "this package root is a package-manager cache; the status line is wired only against a registered install (see the registration above)",
649
+ },
650
+ ];
651
+ }
652
+ const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
653
+ if (a.state === "unparseable")
654
+ return [
655
+ { surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
656
+ ];
657
+
658
+ if (mode === "uninstall") {
659
+ if (!a.curEntry)
660
+ return [
661
+ { surface: key, kind: "shared", path, entry: entryKey, state: "ours-absent", action: "skip", reason: null },
662
+ ];
663
+ if (!a.ours)
664
+ return [
665
+ {
666
+ surface: key,
667
+ kind: "shared",
668
+ path,
669
+ entry: entryKey,
670
+ state: "theirs",
671
+ action: "skip",
672
+ reason: "the entry is not ours — left in place",
673
+ },
674
+ ];
675
+ const after = { ...a.settings };
676
+ delete after[entryKey];
677
+ return [
678
+ {
679
+ surface: key,
680
+ kind: "shared",
681
+ path,
682
+ entry: entryKey,
683
+ state: "ours-current",
684
+ action: "remove",
685
+ reason: null,
686
+ before: a.settings,
687
+ after,
688
+ },
689
+ ];
690
+ }
691
+ if (a.state === "theirs") {
692
+ ctx.slotForeign.add(key);
693
+ return [
694
+ {
695
+ surface: key,
696
+ kind: "shared",
697
+ path,
698
+ entry: entryKey,
699
+ state: "theirs",
700
+ action: "skip",
701
+ reason: "a status line we did not write owns the slot — left to its owner, and nothing else is wired for it",
702
+ },
703
+ ];
704
+ }
705
+ if (a.state === "ours-current")
706
+ return [
707
+ { surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "skip", reason: null },
708
+ ];
709
+ const after = {
710
+ ...a.settings,
711
+ [entryKey]: {
712
+ ...(a.curEntry && typeof a.curEntry === "object" ? a.curEntry : {}),
713
+ type: "command",
714
+ command: a.desired,
715
+ },
716
+ };
717
+ return [
718
+ {
719
+ surface: key,
720
+ kind: "shared",
721
+ path,
722
+ entry: entryKey,
723
+ state: a.state,
724
+ action: a.curEntry ? "replace-entry" : a.cur.present ? "add" : "create",
725
+ reason: a.curEntry ? a.reason : null,
726
+ before: a.cur.present ? a.settings : null,
727
+ after,
728
+ },
729
+ ];
730
+ }
731
+
732
+ export function planStampedFile(ctx, key, s) {
733
+ const { projectDir, mode, root, home, harness } = ctx;
734
+ const renderRoot = ctx.renderRoot || root;
735
+ const path = join(projectDir, s.file);
736
+ const produced = !(s.condition === "plugin_cache_install" && !isHostInstallRoot(renderRoot, home));
737
+ // The policy early-outs come before the render-and-hash, which they make
738
+ // unnecessary.
739
+ if (produced && mode === "install" && !ctx.optIn.has(s.condition ? "statusline" : key) && !ctx.optIn.has(key)) {
740
+ return [
741
+ {
742
+ surface: key,
743
+ kind: "exclusive",
744
+ path,
745
+ entry: null,
746
+ state: "opt-out",
747
+ action: "skip",
748
+ reason: "statusline.enabled is not true in projectstore.json",
749
+ },
750
+ ];
751
+ }
752
+ if (produced && mode === "install" && ctx.slotForeign.size) {
753
+ return [
754
+ {
755
+ surface: key,
756
+ kind: "exclusive",
757
+ path,
758
+ entry: null,
759
+ state: "absent-or-present",
760
+ action: "skip",
761
+ reason: "the status line slot is foreign; a launcher nothing points at is not written",
762
+ },
763
+ ];
764
+ }
765
+ const a = analyseStampedFile(projectDir, s, { root, home, harness, renderRoot });
766
+ // Not produced for this installation (a dev checkout is wired directly):
767
+ // a root that cannot produce a file has, by construction, never written it,
768
+ // so install and upgrade REPORT it and leave it (contract 13's wording;
769
+ // contract 7 as amended 2026-09-05 — a dev checkout's plan used to prune a
770
+ // cache install's launcher, the maintainer's habitual loop). Only uninstall
771
+ // removes it: the user asked to disown, and the file is recognisably ours.
772
+ // `prune` stays an action for the day a surface leaves the roster.
773
+ // A file found at its legacy path (the layout ADR): classified from there,
774
+ // removed from there, created at the new path — the legacy copy is then
775
+ // the layout cleanup's to delete once nothing names it.
776
+ const at = a.legacyPath || path;
777
+ if (!a.produced) {
778
+ if (!a.file.present) return [];
779
+ if (!a.ours)
780
+ return [
781
+ {
782
+ surface: key,
783
+ kind: "exclusive",
784
+ path: at,
785
+ entry: null,
786
+ state: "foreign",
787
+ action: "skip",
788
+ reason: "not produced for a dev checkout, and not ours — left in place",
789
+ },
790
+ ];
791
+ return [
792
+ {
793
+ surface: key,
794
+ kind: "exclusive",
795
+ path: at,
796
+ entry: null,
797
+ state: "stale",
798
+ action: mode === "uninstall" ? "remove" : "skip",
799
+ reason: a.reason,
800
+ },
801
+ ];
802
+ }
803
+ if (a.refusal)
804
+ return [{ surface: key, kind: "exclusive", path: at, state: "refused", action: "refuse", reason: a.refusal }];
805
+ const base = {
806
+ surface: key,
807
+ kind: "exclusive",
808
+ path: at,
809
+ entry: null,
810
+ state: a.state,
811
+ reason: a.reason,
812
+ writtenBy: a.writtenBy,
813
+ sameProject: a.sameProject,
814
+ };
815
+ if (mode === "uninstall") {
816
+ if (a.state === "absent") return [{ ...base, action: "skip" }];
817
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
818
+ return [{ ...base, action: "remove" }];
819
+ }
820
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
821
+ // Current, but written for another project (a copied or moved checkout):
822
+ // the file lives inside THIS project and its render names its project since
823
+ // 2026-09-06, so it is re-rendered here — doctor still reports who wrote it
824
+ // (contract 12); only the installer acts on it.
825
+ if (a.state === "current" && !a.legacyPath && a.writtenBy && !a.sameProject)
826
+ return [
827
+ {
828
+ ...base,
829
+ path,
830
+ action: "update",
831
+ reason: `current, written for ${a.writtenBy} — re-rendered for this project`,
832
+ after: a.stamped.text,
833
+ },
834
+ ];
835
+ if (a.state === "current" && !a.legacyPath) return [{ ...base, action: "skip" }];
836
+ // Written at the NEW path; a legacy file's state is why (stale, or current-but-moving).
837
+ return [
838
+ {
839
+ ...base,
840
+ path,
841
+ action: a.state === "absent" || a.legacyPath ? "create" : "update",
842
+ reason: a.legacyPath
843
+ ? `${a.reason ? a.reason + "; " : ""}moving from ${rel(projectDir, a.legacyPath)} (the layout ADR)`
844
+ : a.reason,
845
+ after: a.stamped.text,
846
+ legacyPath: a.legacyPath,
847
+ },
848
+ ];
849
+ }
850
+
851
+ // The surface rows a run leaves out: a --surface that does not name the key
852
+ // (by prefix, so `statusline` covers the launcher), or --no-register's
853
+ // registration. One predicate for plan() and the shell fetch's decision.
854
+ export function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
855
+ return (
856
+ Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) ||
857
+ (register === false && s.kind === "registration")
858
+ );
859
+ }
860
+
861
+ // One add per file (the presentation spec, contract 7 as amended 2026-10-14):
862
+ // within one plan, a file an earlier item creates is added once. A later
863
+ // `create` or `add` on that path changes the file the run has just made, so it
864
+ // reads `update`; its state, steps and detail stay. Each item is planned
865
+ // against the tree before the run, so the binding the vault item writes was
866
+ // "absent" to the status-line flag and the channel mark, and all three read
867
+ // "add". A creator is an earlier `create` or `add` item that is neither a
868
+ // registration (its host-owned files are the host CLI's) nor a binding row (it
869
+ // never makes the binding: apply skips it on an unbound project, and the vault
870
+ // item or the layout move makes the file), counted for the paths absent at
871
+ // plan time: its own and each step's `path` — never a move step's `to`, so a
872
+ // migrating project's binding rows keep `add`. An entry row on a file that was
873
+ // there keeps `add`.
874
+ export function oneAddPerFile(items) {
875
+ const created = new Set();
876
+ const writes = (i) => i.action === "create" || i.action === "add";
877
+ for (const item of items) {
878
+ if (!writes(item) || item.kind === "registration") continue;
879
+ if (item.path && created.has(resolve(item.path))) {
880
+ item.action = "update";
881
+ continue;
882
+ }
883
+ if (item.kind === "binding") continue;
884
+ for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
885
+ if (p && !existsSync(p)) created.add(resolve(p));
886
+ }
887
+ }
888
+
889
+ // Contract 14: the host installs and updates these itself; say how, from the
890
+ // manifest, and write nothing. (PR #13's installElsewhere, as a plan line.)
891
+ export function hostManagedReport(m, rows, registration = null) {
892
+ const inst = m.install || {};
893
+ const lines = [
894
+ `${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`,
895
+ ];
896
+ if (registration && registration.entry) {
897
+ const how =
898
+ registration.action === "skip" && registration.state === "current"
899
+ ? "registered here"
900
+ : registration.action === "refuse"
901
+ ? "refused, see below"
902
+ : registration.action === "skip"
903
+ ? "not registered on this run, see below"
904
+ : `registered by this run (${registration.action})`;
905
+ // A host plugin registration is a marketplace added from a local path, and
906
+ // the host loads its plugin in place, from the plugin's root inside that
907
+ // directory (`payload`, set by plan()); the install path is the registry's
908
+ // copy, which the launcher renders (the story "The SessionStart hook and
909
+ // install agree on the status line…", measured on host 2.1.293 and
910
+ // 2.1.296). A portable registration's host loads the copy.
911
+ const where = !registration.root
912
+ ? ""
913
+ : registration.payload
914
+ ? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}`
915
+ : ` — the host loads them from ${registration.root}`;
916
+ lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
917
+ }
918
+ if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
919
+ for (const s of inst.steps || []) lines.push(` ${s}`);
920
+ for (const n of inst.notes || []) lines.push(` ${n}`);
921
+ if (inst.docs) lines.push(` ${inst.docs}`);
922
+ return lines.join("\n");
923
+ }
924
+
925
+ export const isWrite = (i) => !["skip", "refuse"].includes(i.action);