projectstore-codex 0.0.1 → 0.28.1

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 (185) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +163 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +36 -14
  155. package/plugin.json +53 -0
  156. package/skills/projectstore-adr/SKILL.md +76 -0
  157. package/skills/projectstore-agents/SKILL.md +50 -0
  158. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  159. package/skills/projectstore-bind/SKILL.md +44 -0
  160. package/skills/projectstore-clerk/SKILL.md +126 -0
  161. package/skills/projectstore-codemap/SKILL.md +69 -0
  162. package/skills/projectstore-concept/SKILL.md +36 -0
  163. package/skills/projectstore-critic/SKILL.md +127 -0
  164. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  165. package/skills/projectstore-doctor/SKILL.md +33 -0
  166. package/skills/projectstore-epic/SKILL.md +59 -0
  167. package/skills/projectstore-graph/SKILL.md +75 -0
  168. package/skills/projectstore-kanban/SKILL.md +60 -0
  169. package/skills/projectstore-librarian/SKILL.md +114 -0
  170. package/skills/projectstore-meeting/SKILL.md +36 -0
  171. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  172. package/skills/projectstore-planner/SKILL.md +113 -0
  173. package/skills/projectstore-reconcile/SKILL.md +92 -0
  174. package/skills/projectstore-research/SKILL.md +36 -0
  175. package/skills/projectstore-review/SKILL.md +108 -0
  176. package/skills/projectstore-reviewer/SKILL.md +131 -0
  177. package/skills/projectstore-runbook/SKILL.md +36 -0
  178. package/skills/projectstore-scaffold/SKILL.md +42 -0
  179. package/skills/projectstore-search/SKILL.md +41 -0
  180. package/skills/projectstore-spec/SKILL.md +110 -0
  181. package/skills/projectstore-status/SKILL.md +47 -0
  182. package/skills/projectstore-statusline/SKILL.md +29 -0
  183. package/skills/projectstore-story/SKILL.md +132 -0
  184. package/skills/projectstore-story-completion/SKILL.md +69 -0
  185. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,1387 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — install-harness.mjs
3
+ //
4
+ // The verbs that put projectstore's surfaces where a harness reads them —
5
+ // install, uninstall, upgrade — and the one thing that makes them safe: every
6
+ // write is planned first, shown, and applied only on explicit confirmation.
7
+ //
8
+ // Two grains of ownership (install spec, contract 0). An EXCLUSIVE file is
9
+ // wholly ours and carries the provenance line; provenance.mjs derives its
10
+ // state — current, stale with a reason, absent, foreign — and a foreign file
11
+ // is refused by these verbs, never repaired (contract 5). A SHARED file is
12
+ // the user's; we own one entry in it, recognised by the marker the manifest
13
+ // names, and nothing outside that entry is read, rewritten or removed
14
+ // (contract 6). A JSON file cannot carry the line, so it is always shared.
15
+ //
16
+ // The STATE of each surface comes from surfaces.mjs, which doctor reads too,
17
+ // so the verbs and the report can never disagree about a file. This file
18
+ // adds only policy — what a mode does with a state — and the writes.
19
+ //
20
+ // plan() writes nothing and reads no terminal: it is a pure description of
21
+ // what install/uninstall would do, per surface, so the preview, the states and
22
+ // the refusals are unit-tested without a subprocess. renderPreview() is a
23
+ // pure string. confirm() takes its streams as parameters. apply() is the only
24
+ // function that writes, and it writes only through lib.mjs writeFileAtomic.
25
+ //
26
+ // The gate (contract 9, distribution ADR decision 6): an interactive call
27
+ // prints the plan and asks; a non-interactive call that NAMES its harness
28
+ // counts as the confirmation; a bare install in a non-TTY refuses. There is
29
+ // no --yes flag. --surface narrows the plan (by prefix, so `statusline`
30
+ // covers the launcher too); it confirms nothing — except that naming the
31
+ // statusline surface is how a user opts into it without the config flag.
32
+ //
33
+ // Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
34
+ // by a harness id: adding a harness is adding harnesses/<id>.json, and this
35
+ // file gains no branch. Claude Code's own plugin surfaces are host-managed
36
+ // (contract 14) — the plan reports the marketplace steps the manifest
37
+ // carries and writes none of them.
38
+ //
39
+ // A REGISTRATION (contract 0 as amended 2026-09-05, contract 4′) is the third
40
+ // kind: a directory of ours under the harness home — a local marketplace whose
41
+ // manifest carries our provenance field — that the host's own CLI is driven
42
+ // to register, install, update and silence a competitor of, at project scope.
43
+ // Its plan item carries steps[]: the directory write and one verbatim argv
44
+ // per host command, each a preview line (contract 9). apply() runs them in
45
+ // order with the harness home pinned in the child's environment, and stops at
46
+ // the first non-zero exit. The registration is planned FIRST, and every other
47
+ // surface is then planned against the install path it produces — from npx,
48
+ // the package's own root is a directory the package manager collects.
49
+ //
50
+ // Direction: installer → surfaces ← doctor; installer → provenance ← doctor.
51
+ // Doctor never imports this file.
52
+ //
53
+ // Normative: the spec "Installing, refreshing and disowning a harness
54
+ // surface", contracts 0, 5–10, 13–15. The plan/apply split, the refusal
55
+ // without a detected harness and the four-state model are Ivan Morozov's
56
+ // (MultiProjectStore); the host-managed report shape is Maxim
57
+ // Podreshetnikov's (PR #13, installElsewhere). Pure node, no external deps.
58
+
59
+ import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync } from "node:fs";
60
+ import { join, resolve, dirname, relative, isAbsolute } from "node:path";
61
+ import { homedir } from "node:os";
62
+ import { randomUUID } from "node:crypto";
63
+ import { fileURLToPath } from "node:url";
64
+ import { createInterface } from "node:readline/promises";
65
+ import { spawnSync } from "node:child_process";
66
+ import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
67
+ import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
68
+ import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
69
+ import { payloadFiles, renderPortableCatalog } from "./portable-registration.mjs";
70
+ import { pluginRoot, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpPrecedence, importsLine, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder } from "./lib.mjs";
71
+
72
+ import { GENERATOR } from "./surfaces.mjs";
73
+ export { GENERATOR };
74
+
75
+ function rel(projectDir, p) {
76
+ const r = relative(projectDir, p);
77
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
78
+ }
79
+
80
+ // isEphemeralRoot lives in lib.mjs beside the channel classifier that uses it;
81
+ // re-exported here under the name the installer's callers already import.
82
+ export { isEphemeralRoot };
83
+
84
+ function inside(dir, parent) {
85
+ const r = relative(parent, dir);
86
+ return r !== "" && !r.startsWith("..") && !isAbsolute(r);
87
+ }
88
+
89
+ // ─── Surface handlers, keyed by format ─────────────────────────────────
90
+ //
91
+ // Each handler answers plan(ctx) → PlanItem[] for one surface row, in one
92
+ // mode, from the state surfaces.mjs derived. A PlanItem carries everything
93
+ // the preview and apply need; apply never re-derives a state.
94
+
95
+ const HANDLERS = {
96
+ "markdown-block": planAgentsBlock,
97
+ "json-entry": planJsonEntry,
98
+ "mjs": planStampedFile,
99
+ "host-plugin-registration": planRegistration,
100
+ "portable-plugin-registration": planPortableRegistration,
101
+ };
102
+
103
+ // The order plan() visits kinds in (contract 4′, two phases): the registration
104
+ // decides the root the rest is planned against; a shared entry (the status
105
+ // line slot) decides whether the exclusive launcher is written at all.
106
+ const KIND_ORDER = { registration: 0, shared: 1, exclusive: 2 };
107
+
108
+ function planAgentsBlock(ctx, key, s) {
109
+ const { projectDir, mode, root } = ctx;
110
+ const a = analyseBlock(projectDir, s, { root });
111
+ const { withBlock, preferred, claude, importLine, PREFERRED, FALLBACK } = a;
112
+ const items = [];
113
+ if (a.refusal) return [{ surface: key, kind: "shared", path: a.files[0].path, entry: "projectstore:agents", state: "refused", action: "refuse", reason: a.refusal }];
114
+
115
+ const hasImport = (text) => importsLine(text, importLine);
116
+ // A CLAUDE.md that is nothing but the import registration added is ours to
117
+ // delete when the block goes (ADR-002 decision 4); anything else stays.
118
+ const onlyImport = (text) => String(text ?? "").split("\n").every((l) => !l.trim() || l.trim() === importLine);
119
+ const removal = (e, extra = {}) => ({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "remove", reason: null,
120
+ before: e.text, after: removeAgentsBlock(e.text), deleteIfEmpty: e.file === FALLBACK, ...extra });
121
+
122
+ if (mode === "uninstall") {
123
+ if (!withBlock.length) return [{ surface: key, kind: "shared", path: preferred.path, entry: "projectstore:agents", state: "ours-absent", action: "skip", reason: "no block to remove" }];
124
+ // The agents block is the one surface the PROJECT owns rather than a
125
+ // harness: several harnesses read the same file. So disowning one harness
126
+ // may not remove it, and measurably did — `uninstall --harness <other>`
127
+ // deleted AGENTS.md while the source harness was still reading it through
128
+ // an @AGENTS.md import, and the reverse stripped the block Codex reads
129
+ // (both measured 2026-09-09). Either way, disowning one harness silently
130
+ // disabled the other.
131
+ //
132
+ // The test is static manifest data, not detection: is the file the block
133
+ // lives in one that ANOTHER manifest also names? No project state is read,
134
+ // so there is no circularity — and the single-harness case is untouched,
135
+ // because a file only one manifest names is that harness's to clear.
136
+ // Over-conservative on purpose: a block left behind is a marked,
137
+ // self-describing region a user can delete, where a deleted file another
138
+ // harness reads is silent breakage. `--surface agents_block` still removes
139
+ // it, which is the confirmation the gate asks for everywhere else.
140
+ const alsoRead = (file) => [...loadHarnesses().values()]
141
+ .filter((m) => m.id !== ctx.harness?.id)
142
+ .some((m) => (m.surfaces?.agents_block?.files || []).includes(file));
143
+ for (const e of withBlock) {
144
+ // Naming the surface IS the confirmation, as it is for every other
145
+ // write this bin makes: `--surface agents_block` removes it regardless.
146
+ if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
147
+ items.push({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "skip",
148
+ reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}` });
149
+ continue;
150
+ }
151
+ items.push(removal(e));
152
+ }
153
+ // An import that points at a file whose block has just gone is a pointer to
154
+ // nothing. Look across every file any manifest names, not just this one's:
155
+ // with a single-entry list PREFERRED and FALLBACK are the same file, which
156
+ // made the old condition — block in PREFERRED and not in FALLBACK —
157
+ // impossible to satisfy, so the import was never cleaned up at all.
158
+ // An import is dangling only when the file it names will be GONE — not
159
+ // merely when the block left it. A user's own AGENTS.md keeps its prose and
160
+ // survives, and importing it stays meaningful (install contract 13 /
161
+ // ADR-002 decision 4). The condition is therefore apply's own: marked for
162
+ // deletion and left holding nothing (`install-harness.mjs:760`).
163
+ const vanishing = new Set(items
164
+ .filter((i) => i.action === "remove" && i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
165
+ .map((i) => rel(projectDir, i.path)));
166
+ // Two independent reasons to take an import out, and the first version of
167
+ // this replaced one with the other:
168
+ // (1) it is OURS and nothing else is there — the registration we added to
169
+ // a file that held nothing else goes with the block (ADR-002
170
+ // decision 4), whether or not its target survives;
171
+ // (2) it DANGLES — the file it names will be gone, so it points at
172
+ // nothing even though the reader's own prose keeps the file alive.
173
+ // A user's AGENTS.md that keeps its prose satisfies neither, and its import
174
+ // stays: that is install contract 13, and it is what caught the mistake.
175
+ //
176
+ // Over the UNION of every manifest's list, not this one's: the file holding
177
+ // the import need not be one this harness can read. Codex's list is a
178
+ // single entry, which also made the old condition — block in PREFERRED and
179
+ // not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
180
+ const blockGone = new Set(items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)));
181
+ const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
182
+ for (const file of union) {
183
+ if (blockGone.has(file)) continue;
184
+ const e = readText(join(projectDir, file));
185
+ if (!e.present || e.text === null || !hasImport(e.text)) continue;
186
+ const ours = onlyImport(e.text);
187
+ const dangles = vanishing.has(importLine.slice(1));
188
+ if (!ours && !dangles) continue;
189
+ const after = e.text.split("\n").filter((l) => l.trim() !== importLine).join("\n").replace(/^\n+/, "");
190
+ items.push({ surface: `${key}_import`, kind: "shared", path: join(projectDir, file), entry: importLine, state: "ours-current", action: "remove",
191
+ reason: ours ? `${file} holds only the import registration added` : `${importLine} points at a file this uninstall removes`,
192
+ before: e.text, after, deleteIfEmpty: ours });
193
+ }
194
+ return items;
195
+ }
196
+
197
+ const entry = `projectstore:agents v${a.version}`;
198
+ const current = a.current;
199
+ // Two files, one block each: resolve in favour of the preferred file —
200
+ // register migrates, never duplicates (ADR-002 decision 3).
201
+ for (const e of a.duplicates || []) items.push(removal(e, { state: "ours-stale", reason: `duplicate of the block in ${current.file}` }));
202
+
203
+ if (!current) {
204
+ const target = preferred;
205
+ items.push({ surface: key, kind: "shared", path: target.path, entry, state: "ours-absent", action: target.present ? "add" : "create", reason: null,
206
+ before: target.present ? target.text : null, after: replaceAgentsBlock(target.present ? target.text : "", a.desired) });
207
+ } else if (!current.own || (current.file !== preferred.file && preferred.present)) {
208
+ // Two reasons to move, and they are not the same reason:
209
+ // - the block sits in a file this harness cannot read (`!own`). It must
210
+ // move, and its target is CREATED if it does not exist — this is a
211
+ // second harness arriving in a project that had one.
212
+ // - it sits in a readable but non-preferred file while the preferred one
213
+ // already exists. A preference, satisfied because the file is there.
214
+ // The distinction is what keeps a Claude-Code-only project from acquiring
215
+ // an AGENTS.md it never asked for, while still moving the substance the
216
+ // moment a harness that can only read AGENTS.md is installed.
217
+ items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
218
+ items.push({ surface: key, kind: "shared", path: preferred.path, entry, state: "ours-absent", action: preferred.present ? "add" : "create", reason: `migrated from ${current.file}`,
219
+ before: preferred.present ? preferred.text : null, after: replaceAgentsBlock(preferred.present ? preferred.text : "", a.desired) });
220
+ } else if (current.block.v === a.version && current.block.block === a.desired) {
221
+ items.push({ surface: key, kind: "shared", path: current.path, entry, state: "ours-current", action: "skip", reason: null });
222
+ } else {
223
+ items.push({ surface: key, kind: "shared", path: current.path, entry, state: "ours-stale", action: "replace-entry",
224
+ reason: current.block.v !== a.version ? `v${current.block.v} → v${a.version}` : "content differs from the current source",
225
+ before: current.text, after: replaceAgentsBlock(current.text, a.desired) });
226
+ }
227
+
228
+ // The @AGENTS.md import in CLAUDE.md (ADR-002 decision 3), only when the
229
+ // block lives in AGENTS.md and CLAUDE.md exists without it. A removal
230
+ // already rewriting CLAUDE.md in this plan takes the import onto its own
231
+ // text, or the two items would race.
232
+ const written = items.find((i) => ["add", "create", "replace-entry", "skip"].includes(i.action) && i.surface === key);
233
+ const blockFile = written ? rel(projectDir, written.path) : null;
234
+ // Every OTHER file any manifest names, and only if it already exists: a
235
+ // reader whose file no longer holds the substance is pointed at the one that
236
+ // does. Never a file we would have to create — a project with no CLAUDE.md
237
+ // does not acquire one because Codex was installed.
238
+ //
239
+ // It used to key on `a.files.length > 1`, which is a property of the
240
+ // INSTALLING harness's list: Codex's has one entry, so installing Codex left
241
+ // a CLAUDE.md that still exists, still reads as authoritative, and no longer
242
+ // holds anything. The block's file is what decides, not the list's length.
243
+ // Installing FOR a harness means the file that harness reads by itself ends
244
+ // up present. It holds the block when the block lands there; it holds the
245
+ // import when the block lands elsewhere. Without this, a project that already
246
+ // had an AGENTS.md took the block into it and created no bridge — so Claude
247
+ // Code, the harness that ran the install, could not see what it had just
248
+ // installed. `reads_natively` is the manifest's, so this is a file list, not
249
+ // a harness name.
250
+ const native = s.reads_natively;
251
+ const nativeEntry = native ? a.files.find((e) => e.file === native) : null;
252
+ for (const e of a.files) {
253
+ const mustExist = nativeEntry && e.file === native;
254
+ if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
255
+ const line = `@${blockFile}`;
256
+ const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
257
+ // An absent native file is empty text, not a reason to skip: it is created.
258
+ const text = rewrite ? rewrite.after : (e.present ? e.text : "");
259
+ if (typeof text !== "string" || importsLine(text, line)) continue;
260
+ const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
261
+ if (rewrite) { rewrite.after = after; rewrite.deleteIfEmpty = false; rewrite.reason += `; ${line} import added`; }
262
+ else items.push({ surface: `${key}_import`, kind: "shared", path: e.path, entry: line, state: "ours-absent", action: e.present ? "add" : "create",
263
+ reason: e.present ? `${blockFile} carries the block; ${e.file} must import it` : `${e.file} is what this harness reads by itself; it is created to import ${blockFile}`,
264
+ before: e.present ? text : null, after });
265
+ }
266
+ return items;
267
+ }
268
+
269
+ // ─── host-plugin-registration ──────────────────────────────────────────
270
+
271
+ // One host command as a plan step: the verbatim argv (the manifest's
272
+ // subcommand with its placeholders filled), why it runs, and the host-owned
273
+ // files it is known to touch (measured 2026-09-05 — the manifest's cli.verified).
274
+ // A portable registration keeps its marketplace and enablement stanzas in one
275
+ // global config (registry.global_config; Codex's config.toml, measured
276
+ // 2026-09-07), so the preview names that file wherever a step rewrites it.
277
+ function hostStep(a, s, name, fill, why) {
278
+ const template = s.cli.commands[name];
279
+ if (!Array.isArray(template) || !template.length) throw new Error(`${s.format}: host operation ${name} is not declared`);
280
+ const argv = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
281
+ const p = a.paths;
282
+ const touches = {
283
+ validate: [], marketplace_add: [p.marketplaces, p.globalConfig, p.projectSettings], marketplace_update: [p.marketplaces], marketplace_remove: [p.marketplaces, p.globalConfig, p.projectSettings],
284
+ install: [p.installed, p.globalConfig, p.projectSettings, p.cacheDir], update: [p.installed, p.cacheDir], uninstall: [p.installed, p.globalConfig, p.projectSettings], disable: [p.projectSettings], enable: [p.projectSettings],
285
+ }[name] || [];
286
+ return { kind: "host", name, bin: s.cli.bin, argv, why, touches: touches.filter(Boolean) };
287
+ }
288
+
289
+ function portableListFacts(stdout) {
290
+ let parsed;
291
+ try { parsed = JSON.parse(String(stdout || "")); } catch { return null; }
292
+ if (!Array.isArray(parsed?.installed)) return null;
293
+ return parsed.installed;
294
+ }
295
+
296
+ function portableListFact(stdout, id) {
297
+ const rows = portableListFacts(stdout);
298
+ if (!rows) return null;
299
+ const row = rows.find((entry) => entry?.pluginId === id);
300
+ if (!row) return null;
301
+ return {
302
+ id: row.pluginId,
303
+ version: typeof row.version === "string" ? row.version : null,
304
+ installed: row.installed === true,
305
+ enabled: row.enabled === true,
306
+ marketplaceSource: row.marketplaceSource?.source || null,
307
+ };
308
+ }
309
+
310
+ function planPortableRegistration(ctx, key, s) {
311
+ const { projectDir, mode, root, home, env, harness, globalRemoval } = ctx;
312
+ const a = analysePortableRegistration(projectDir, s, { root, home, harness, env });
313
+ const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id: a.id };
314
+ const base = {
315
+ surface: key,
316
+ kind: "registration",
317
+ path: a.paths.dir,
318
+ entry: a.id,
319
+ state: a.state,
320
+ reason: a.reason || a.refusal || null,
321
+ root: a.installPath,
322
+ home: a.paths.home,
323
+ scope: s.scope,
324
+ ownership: s.ownership,
325
+ observed: {
326
+ dir_exists: existsSync(a.paths.dir),
327
+ ownership_version: a.ownership?.version || null,
328
+ ownership_digest: a.ownership?.digest || null,
329
+ marketplace_source: a.market?.source || null,
330
+ global_plugin: a.globalPlugin || null,
331
+ project_plugin: a.projectPlugin || null,
332
+ installed_version: a.installedVersion || null,
333
+ installed_digest: a.installed?.digest || null,
334
+ enabled: a.enabled,
335
+ },
336
+ };
337
+ // The same fact the analyser computed: no portable payload in this run. A
338
+ // shell that named a root which is not a plugin root is broken, and says so
339
+ // through `incomplete`; the core run alone simply defers.
340
+ if (!a.payloadRoot) {
341
+ if (env.PROJECTSTORE_DISTRIBUTION_ROOT) ctx.incomplete = true;
342
+ return [{ ...base, action: "skip", deferred: true, reason: env.PROJECTSTORE_DISTRIBUTION_ROOT
343
+ ? `${env.PROJECTSTORE_DISTRIBUTION_ROOT} is not a portable plugin root; ${harness.display_name}'s registration runs only from its built distribution shell`
344
+ : `the core package is not ${harness.display_name}'s plugin root; registration runs only from its built distribution shell` }];
345
+ }
346
+ if (mode === "uninstall" && !globalRemoval) {
347
+ return [{ ...base, action: "skip", reason: "the plugin package and cache are global; project uninstall removes only project-owned surfaces. Use uninstall --global with an explicit harness to preview global removal" }];
348
+ }
349
+ if (["foreign", "conflict"].includes(a.state)) return [{ ...base, action: "refuse", reason: a.refusal }];
350
+ if (a.state === "unavailable") { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true }]; }
351
+ if (mode === "uninstall") {
352
+ if (a.state === "absent") return [{ ...base, action: "skip", reason: "no global registration of ours" }];
353
+ if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: `\`${s.cli.bin}\` is not on PATH; global registration is left untouched` }]; }
354
+ const steps = [];
355
+ if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `explicit global removal forgets ${a.id}`));
356
+ if (a.market && s.cli.commands.marketplace_remove) steps.push(hostStep(a, s, "marketplace_remove", fill, `explicit global removal forgets marketplace ${s.marketplace_name}`));
357
+ if (a.ownership) steps.push({ kind: "portable-remove", path: a.paths.dir, homeBase: a.paths.home, why: "the stable marketplace source is owned by this installer" });
358
+ return [{ ...base, action: "remove", steps, reason: "explicit global removal; project overrides are preserved" }];
359
+ }
360
+ if (a.state === "current") return [{ ...base, action: "skip", reason: a.reason }];
361
+ if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `\`${s.cli.bin}\` is not on PATH; no registration mutation was attempted` }]; }
362
+ const steps = [];
363
+ const digest = a.desiredDigest;
364
+ const mustWrite = !a.ownership || a.ownership.version !== a.desiredVersion || a.contentDiffers || a.state === "stale";
365
+ if (mustWrite) {
366
+ const files = payloadFiles(a.payloadRoot);
367
+ const ownership = { [s.provenance_key]: { grammar: GRAMMAR_VERSION, version: a.desiredVersion, generator: GENERATOR, digest } };
368
+ steps.push({ kind: "portable-write", path: a.paths.dir, from: a.payloadRoot, files, subdir: s.plugin_subdir, catalogRel: s.manifest, catalog: renderPortableCatalog(s), ownershipRel: s.ownership_manifest, ownership, why: a.ownership ? `stage ${a.desiredVersion} over ${a.ownership.version}` : `stage ${a.desiredVersion}` });
369
+ }
370
+ if (!a.market) steps.push(hostStep(a, s, "marketplace_add", fill, "register the stable local marketplace source globally"));
371
+ if (!a.installed || a.installedVersion !== a.desiredVersion || mustWrite) steps.push(hostStep(a, s, "install", fill, `materialise and enable ${a.id} from the staged source`));
372
+ steps.push(hostStep(a, s, "list", fill, "read back host-reported installation and effective enablement"));
373
+ return [{ ...base, action: a.state === "absent" ? "create" : "update", steps, verify: { version: a.desiredVersion, digest }, reason: a.reason }];
374
+ }
375
+
376
+ // The marketplace manifest we write: the host's catalogue shape (measured), plus
377
+ // our provenance field — contract 2 for a JSON file that is wholly ours — with
378
+ // the payload digest the state ladder checks (contract 4′).
379
+ export function registrationManifest(s, { pkg, projectDir, disabled = [], digest = null }) {
380
+ return {
381
+ name: s.marketplace_name,
382
+ description: "projectstore, installed from the npm package on this machine (written by projectstore install; do not edit)",
383
+ owner: { name: "SmartAndPoint", email: "ekonev@smartandpoint.com" },
384
+ plugins: [{ name: s.plugin_name, description: "Agent-first project memory: a vault-native workflow for ADRs, specs, epics and stories.", version: pkg, source: `./${s.plugin_subdir}` }],
385
+ [s.provenance_key]: { grammar: GRAMMAR_VERSION, pkg, project: projectDir, generator: GENERATOR, disabled, digest },
386
+ };
387
+ }
388
+
389
+ // Inside a live session of the host, its CLI and the session both rewrite the
390
+ // same settings files on their own schedules; the registration is planned
391
+ // only from a terminal outside one. The host marks its sessions in the
392
+ // environment (manifest runtime.detect_env).
393
+ function insideHostSession(env, harness) {
394
+ // runtime.session_env, not detect_env: a Bash tool inside a session carries
395
+ // the session marker, not the plugin-root variables a hook receives
396
+ // (measured 2026-09-05; the critic's third pass caught the first draft
397
+ // keying on detect_env, which never fired in a session).
398
+ return (harness?.runtime?.session_env || []).some((k) => env && env[k]);
399
+ }
400
+
401
+ function planRegistration(ctx, key, s) {
402
+ const { projectDir, mode, root, home, env, harness } = ctx;
403
+ const a = analyseRegistration(projectDir, s, { root, home, harness, env });
404
+ const id = a.id;
405
+ const bin = a.bin;
406
+ const binName = s.cli?.bin || "claude";
407
+ const base = { surface: key, kind: "registration", path: a.paths.dir, entry: id, state: a.state, reason: a.reason, root: a.installPath || a.predictedInstallPath, home: claudeHome(home), scope: s.scope || null, writtenBy: a.writtenBy };
408
+ const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id, other: id };
409
+ const notOnPath = `\`${binName}\` is not on PATH — the registration is left as it is; put the host's CLI on PATH and run install again`;
410
+ const named = (ctx.surfaces || []).includes(key);
411
+ const inSession = `this runs inside a ${harness.display_name} session, whose exit rewrites the same settings files the host's CLI writes — run it from a terminal outside the session`;
412
+ const other = (o) => ({ surface: `${key}_others`, kind: "registration", path: a.paths.projectSettings, entry: o.key, state: "enabled", home: base.home, scope: base.scope });
413
+
414
+ if (mode === "uninstall") {
415
+ if (a.state === "unavailable" || a.state === "absent") return [{ ...base, action: "skip", reason: a.state === "absent" ? a.reason : a.reason }];
416
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: a.refusal }];
417
+ if (!bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: notOnPath }]; }
418
+ if (insideHostSession(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: inSession }]; }
419
+ const steps = [];
420
+ if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `the host forgets ${id} for this checkout (its row and enablement; other checkouts keep theirs)`));
421
+ const last = a.otherProjects === 0;
422
+ // The host's `marketplace remove` drops EVERY checkout's rows for the
423
+ // marketplace (measured), so it runs only from the last checkout — and
424
+ // before anything else touches the declaration it requires. Otherwise our
425
+ // one entry in the checkout's local settings is removed by hand (contract 6).
426
+ if (last && a.known && a.registeredHere) steps.push(hostStep(a, s, "marketplace_remove", fill, `no other checkout uses marketplace ${s.marketplace_name}; the host forgets it and this checkout's declaration of it`));
427
+ else if (a.registeredHere) steps.push({ kind: "unregister", path: a.paths.projectSettings, pointer: s.registry?.known_pointer || "extraKnownMarketplaces", name: s.marketplace_name, why: `this checkout stops declaring the marketplace — our entry only; \`${binName} plugin marketplace remove\` would drop every checkout's rows (measured), so it runs only from the last checkout` });
428
+ // Re-enable only what THIS checkout holds disabled: the record in the shared
429
+ // manifest says what install silenced somewhere; a copy the user disabled by
430
+ // hand in another checkout is not ours to turn on (reviewer, 2026-09-05).
431
+ for (const o of a.dir.disabled.filter((k) => a.disabledHere.includes(k))) steps.push(hostStep(a, s, "enable", { ...fill, other: o }, `${o} was silenced for this checkout by install; it is turned back on`));
432
+ if (last && a.dir.present) steps.push({ kind: "remove", path: a.paths.dir, why: "our marketplace directory, removed whole (its manifest carries our provenance field); no other checkout is installed from it" });
433
+ return [{ ...base, action: "remove", steps, reason: a.otherProjects ? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay` : a.reason }];
434
+ }
435
+
436
+ // install / upgrade
437
+ if (!a.produced) {
438
+ // A cache install never registers a second copy of itself (condition npm_package_root).
439
+ if (a.state === "absent" || a.state === "unavailable") return named ? [{ ...base, action: "skip", deferred: true, reason: `this root is the host's own install of the plugin; it does not register a second copy of itself — the npm package does, from a terminal: ${packageCommand(harness, "install", { args: `--project "${projectDir}"` })}` }] : [];
440
+ if (a.state === "current") return [{ ...base, action: "skip" }];
441
+ return [{ ...base, action: "skip", deferred: true, reason: `${a.reason} — this root is the host's own install; refresh the registration from the package, outside a session: ${packageCommand(harness, "upgrade", { version: "<version>", args: `--surface ${key} --project "${projectDir}"` })}` }];
442
+ }
443
+ if (a.state === "unavailable") { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: a.reason }]; }
444
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: a.refusal }];
445
+ const items = [];
446
+ const needsHost = a.state !== "current" || a.others.length > 0;
447
+ if (needsHost && !bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${notOnPath}` }]; }
448
+ if (needsHost && insideHostSession(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }]; }
449
+
450
+ if (a.state === "current") {
451
+ items.push({ ...base, action: "skip" });
452
+ } else {
453
+ const steps = [];
454
+ // The directory is rewritten when it is missing, older than this package or
455
+ // damaged — never when a newer package wrote it (contract 12: reported, not
456
+ // downgraded); then this checkout registers against what stands.
457
+ // …or holds a different payload at the SAME version — the maintainer's
458
+ // pack → install → fix → pack loop never bumps it. The host will not
459
+ // re-copy at an equal version (measured), so that refresh is uninstall + install.
460
+ const sameVersionDiffers = a.contentDiffers === true;
461
+ const rewrite = !a.dir.present || (cmpPrecedence(a.dir.pkg, a.pkg) < 0) || a.dir.digestOk === false || sameVersionDiffers;
462
+ const disabled = [...new Set([...a.dir.disabled, ...a.others.map((o) => o.key)])];
463
+ const digest = rewrite ? packageDigest(root) : (a.dir.prov?.digest || null);
464
+ const files = digest ? digest.count : 0;
465
+ if (rewrite) steps.push({ kind: "write", path: a.paths.dir, files, manifest: registrationManifest(s, { pkg: a.pkg, projectDir, disabled, digest }), why: a.dir.present ? `the directory is rewritten from this package (${a.dir.pkg} → ${a.pkg}${a.dir.digestOk === false ? ", the payload did not match its digest" : sameVersionDiffers ? ", same version, different content" : ""})` : `the marketplace directory is written from this package's ${files} shipped files, staged and renamed into place` });
466
+ else if (a.newer) steps.push({ kind: "note", why: `the directory holds ${a.dir.pkg}${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}, newer than this package (${a.pkg}); this checkout registers ${a.dir.pkg} — not downgraded` });
467
+ else if (a.others.some((o) => !a.dir.disabled.includes(o.key))) steps.push({ kind: "write", path: a.paths.dir, files: 0, manifestOnly: true, manifest: { ...a.dir.manifest, [s.provenance_key]: { ...a.dir.prov, disabled } }, why: "our manifest records what install silences, so uninstall can turn it back on" });
468
+ steps.push(hostStep(a, s, "validate", fill, "the host checks the marketplace before it is registered (it warns about our provenance field and exits 0 — measured)"));
469
+ if (!a.known) steps.push(hostStep(a, s, "marketplace_add", fill, "the host learns our marketplace, declared in this checkout's local settings"));
470
+ else if (!a.registeredHere) steps.push(hostStep(a, s, "marketplace_add", fill, "the host already knows our marketplace; this checkout declares it (measured: a re-add is idempotent)"));
471
+ if (!a.installed || !a.installed.present) steps.push(hostStep(a, s, "install", fill, `the host copies the plugin into its cache and enables it for this checkout (-y accepts a marketplace-declared command; ours declares none)`));
472
+ else if (sameVersionDiffers && a.installedVersion === a.targetPkg) { steps.push(hostStep(a, s, "uninstall", fill, `the host forgets this checkout's row: at an unchanged version \`update\` copies nothing (measured), so the refresh is uninstall + install`)); steps.push(hostStep(a, s, "install", fill, `the host copies the rewritten ${a.targetPkg} into its cache and enables it for this checkout again`)); }
473
+ else if (a.installedVersion !== a.targetPkg) steps.push(hostStep(a, s, "update", fill, `the host swaps this checkout's cached copy for ${a.targetPkg} (measured: --scope names the row; no uninstall)`));
474
+ if (a.installed && a.installed.present && !a.enabled) steps.push(hostStep(a, s, "enable", fill, "it is disabled for this checkout; install turns it on"));
475
+ items.push({ ...base, action: a.state === "absent" ? "create" : "update", steps, root: a.predictedInstallPath, verify: { installPath: a.predictedInstallPath, version: a.targetPkg } });
476
+ }
477
+ // A competing enabled registration of the same plugin is silenced for THIS
478
+ // checkout only, as a precaution the preview names (whether two enabled copies
479
+ // load twice is the live test's row). Our manifest records it for uninstall.
480
+ for (const o of a.others) {
481
+ const steps = [];
482
+ if (a.state === "current" && !a.dir.disabled.includes(o.key)) steps.push({ kind: "write", path: a.paths.dir, files: 0, manifestOnly: true, manifest: { ...a.dir.manifest, [s.provenance_key]: { ...a.dir.prov, disabled: [...a.dir.disabled, o.key] } }, why: "our manifest records what install silences, so uninstall can turn it back on" });
483
+ steps.push(hostStep(a, s, "disable", { ...fill, other: o.key }, `${o.key} (${o.version}) is enabled for this checkout too; two enabled copies of one plugin would load twice — silenced in this checkout's local settings only, never globally`));
484
+ items.push({ ...other(o), action: "disable", steps });
485
+ }
486
+ return items;
487
+ }
488
+
489
+ function planJsonEntry(ctx, key, s) {
490
+ const { projectDir, mode, root, home } = ctx;
491
+ if (s.supported === false) {
492
+ return [{ surface: key, kind: "shared", path: join(projectDir, s.file), entry: s.marker?.pointer || null, state: "unsupported", action: "skip", reason: s.why_unsupported || "not supported for this harness yet" }];
493
+ }
494
+ const path = join(projectDir, s.file);
495
+ const entryKey = (s.marker?.pointer || "statusLine.command").split(".")[0];
496
+ // The status line is opt-in (projectstore.json → statusline.enabled), as
497
+ // the SessionStart refresh already honours; naming the surface explicitly
498
+ // is the other way to opt in.
499
+ if (mode === "install" && !ctx.optIn.has(key)) {
500
+ return [{ surface: key, kind: "shared", path, entry: entryKey, state: "opt-out", action: "skip", reason: "statusline.enabled is not true in projectstore.json — name --surface statusline to wire it anyway" }];
501
+ }
502
+ const renderRoot = ctx.renderRoot || root;
503
+ // A root the package manager will collect (npx's cache, a node_modules) is
504
+ // never wired directly: the entry would name a path that disappears. It is
505
+ // wired against a registration's install path — or not at all (contract 4′).
506
+ if (mode === "install" && !isPluginCacheRoot(renderRoot, home) && isEphemeralRoot(renderRoot)) {
507
+ return [{ surface: key, kind: "shared", path, entry: entryKey, state: "absent-or-present", action: "skip", reason: "this package root is a package-manager cache; the status line is wired only against a registered install (see the registration above)" }];
508
+ }
509
+ const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot });
510
+ if (a.state === "unparseable") return [{ surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason }];
511
+
512
+ if (mode === "uninstall") {
513
+ if (!a.curEntry) return [{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-absent", action: "skip", reason: null }];
514
+ if (!a.ours) return [{ surface: key, kind: "shared", path, entry: entryKey, state: "theirs", action: "skip", reason: "the entry is not ours — left in place" }];
515
+ const after = { ...a.settings }; delete after[entryKey];
516
+ return [{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "remove", reason: null, before: a.settings, after }];
517
+ }
518
+ if (a.state === "theirs") {
519
+ ctx.slotForeign.add(key);
520
+ return [{ surface: key, kind: "shared", path, entry: entryKey, state: "theirs", action: "skip", reason: "a status line we did not write owns the slot — left to its owner, and nothing else is wired for it" }];
521
+ }
522
+ if (a.state === "ours-current") return [{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "skip", reason: null }];
523
+ const after = { ...a.settings, [entryKey]: { ...(a.curEntry && typeof a.curEntry === "object" ? a.curEntry : {}), type: "command", command: a.desired } };
524
+ return [{ surface: key, kind: "shared", path, entry: entryKey, state: a.state, action: a.curEntry ? "replace-entry" : (a.cur.present ? "add" : "create"),
525
+ reason: a.curEntry ? a.reason : null, before: a.cur.present ? a.settings : null, after }];
526
+ }
527
+
528
+ function planStampedFile(ctx, key, s) {
529
+ const { projectDir, mode, root, home, harness } = ctx;
530
+ const renderRoot = ctx.renderRoot || root;
531
+ const path = join(projectDir, s.file);
532
+ const produced = !(s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home));
533
+ // The policy early-outs come before the render-and-hash, which they make
534
+ // unnecessary.
535
+ if (produced && mode === "install" && !ctx.optIn.has(s.condition ? "statusline" : key) && !ctx.optIn.has(key)) {
536
+ return [{ surface: key, kind: "exclusive", path, entry: null, state: "opt-out", action: "skip", reason: "statusline.enabled is not true in projectstore.json" }];
537
+ }
538
+ if (produced && mode === "install" && ctx.slotForeign.size) {
539
+ return [{ surface: key, kind: "exclusive", path, entry: null, state: "absent-or-present", action: "skip", reason: "the status line slot is foreign; a launcher nothing points at is not written" }];
540
+ }
541
+ const a = analyseStampedFile(projectDir, s, { root, home, harness, renderRoot });
542
+ // Not produced for this installation (a dev checkout is wired directly):
543
+ // a root that cannot produce a file has, by construction, never written it,
544
+ // so install and upgrade REPORT it and leave it (contract 13's wording;
545
+ // contract 7 as amended 2026-09-05 — a dev checkout's plan used to prune a
546
+ // cache install's launcher, the maintainer's habitual loop). Only uninstall
547
+ // removes it: the user asked to disown, and the file is recognisably ours.
548
+ // `prune` stays an action for the day a surface leaves the roster.
549
+ // A file found at its legacy path (the layout ADR): classified from there,
550
+ // removed from there, created at the new path — the legacy copy is then
551
+ // the layout cleanup's to delete once nothing names it.
552
+ const at = a.legacyPath || path;
553
+ if (!a.produced) {
554
+ if (!a.file.present) return [];
555
+ if (!a.ours) return [{ surface: key, kind: "exclusive", path: at, entry: null, state: "foreign", action: "skip", reason: "not produced for a dev checkout, and not ours — left in place" }];
556
+ return [{ surface: key, kind: "exclusive", path: at, entry: null, state: "stale", action: mode === "uninstall" ? "remove" : "skip", reason: a.reason }];
557
+ }
558
+ if (a.refusal) return [{ surface: key, kind: "exclusive", path: at, state: "refused", action: "refuse", reason: a.refusal }];
559
+ const base = { surface: key, kind: "exclusive", path: at, entry: null, state: a.state, reason: a.reason, writtenBy: a.writtenBy, sameProject: a.sameProject };
560
+ if (mode === "uninstall") {
561
+ if (a.state === "absent") return [{ ...base, action: "skip" }];
562
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
563
+ return [{ ...base, action: "remove" }];
564
+ }
565
+ if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
566
+ // Current, but written for another project (a copied or moved checkout):
567
+ // the file lives inside THIS project and its render names its project since
568
+ // 2026-09-06, so it is re-rendered here — doctor still reports who wrote it
569
+ // (contract 12); only the installer acts on it.
570
+ if (a.state === "current" && !a.legacyPath && a.writtenBy && !a.sameProject) return [{ ...base, path, action: "update", reason: `current, written for ${a.writtenBy} — re-rendered for this project`, after: a.stamped.text }];
571
+ if (a.state === "current" && !a.legacyPath) return [{ ...base, action: "skip" }];
572
+ // Written at the NEW path; a legacy file's state is why (stale, or current-but-moving).
573
+ return [{ ...base, path, action: a.state === "absent" || a.legacyPath ? "create" : "update", reason: a.legacyPath ? `${a.reason ? a.reason + "; " : ""}moving from ${rel(projectDir, a.legacyPath)} (the layout ADR)` : a.reason, after: a.stamped.text, legacyPath: a.legacyPath }];
574
+ }
575
+
576
+ // ─── plan ──────────────────────────────────────────────────────────────
577
+
578
+ export function plan(projectDir, { harnesses = [], mode = "install", env = process.env, home = homedir(), root = pluginRoot(), surfaces = null, globalRemoval = false, register = true } = {}) {
579
+ projectDir = resolve(projectDir);
580
+ const detected = detectHarnesses(projectDir);
581
+ const named = harnesses.filter(Boolean);
582
+ const ids = named.length ? named : detected.map((d) => d.id);
583
+ const out = { projectDir, mode, named: named.length > 0, detected, harnesses: [], reports: [], items: [], refusals: [], ok: true, incomplete: false, root, plannedAgainst: {} };
584
+ const unknown = named.filter((id) => !harnessIds().includes(id));
585
+ if (unknown.length) {
586
+ out.refusals.push(`unknown harness: ${unknown.join(", ")} — known: ${harnessIds().join(", ")}`);
587
+ out.ok = false;
588
+ return out;
589
+ }
590
+ if (!ids.length) {
591
+ out.refusals.push(harnessRefusal(projectDir));
592
+ out.ok = false;
593
+ return out;
594
+ }
595
+ const cfg = readConfigAt(projectDir);
596
+ const optIn = new Set(surfaces || []);
597
+ if (cfg?.statusline?.enabled === true) optIn.add("statusline");
598
+ // The project-level layout (the layout ADR): one harness-neutral item, planned
599
+ // once whatever --surface names — planned first, so every surface below is
600
+ // planned against the new paths; its cleanup is planned last (below).
601
+ const layoutHarness = loadHarness(ids.find((id) => { const p = layoutPaths(projectDir, { harnessDir: loadHarness(id).runtime?.harness_dir || null }); return existsSync(p.legacy.binding) || existsSync(p.legacy.runtime); }) || ids[0]);
602
+ const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false, surfaces };
603
+ const layout = planLayout(layoutCtx);
604
+ for (const item of layout.first) out.items.push({ harness: layoutHarness.id, ...item });
605
+ if (layoutCtx.incomplete) out.incomplete = true;
606
+ for (const id of ids) {
607
+ const harness = loadHarness(id);
608
+ out.harnesses.push(id);
609
+ const ctx = { projectDir, mode, env, home, root, harness, optIn, slotForeign: new Set(), incomplete: false, renderRoot: root, surfaces: surfaces || [], globalRemoval };
610
+ const hostRows = [];
611
+ const unsupportedHost = [];
612
+ let registration = null;
613
+ const rows = Object.entries(harness.surfaces || {}).filter(([key]) => !key.startsWith("_"));
614
+ rows.sort(([, x], [, y]) => (KIND_ORDER[x.kind] ?? 3) - (KIND_ORDER[y.kind] ?? 3));
615
+ for (const [key, s] of rows) {
616
+ // --no-register: this run changes the project's files and nothing of the
617
+ // host's (the layout move run from an installed copy; the layout spec,
618
+ // contract 12 as amended 2026-10-03).
619
+ const excluded = (surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) || (register === false && s.kind === "registration");
620
+ if (excluded) {
621
+ // A registration this run leaves out still decides the render root,
622
+ // read-only — HERE, before the surfaces that render against it (the
623
+ // registration sorts first). Read after the loop, as it was until
624
+ // 2026-10-03, it moved only the preview's "planned against" line and
625
+ // never an item: a --no-register run from a checkout re-pointed the
626
+ // status line at the checkout (the second review of the 2026-10-03
627
+ // fixes, S1).
628
+ if (s.kind === "registration" && !isPluginCacheRoot(root, home)) {
629
+ const analyser = s.format === "portable-plugin-registration" ? analysePortableRegistration : analyseRegistration;
630
+ const a = analyser(projectDir, s, { root, home, harness, env });
631
+ if (a.installed && (a.installed.present ?? true) && a.enabled) ctx.renderRoot = a.installPath;
632
+ }
633
+ continue;
634
+ }
635
+ // `kind: host` and `supported: false` are different facts and the report
636
+ // must not merge them: the first says the host installs this surface, the
637
+ // second says the harness has no such surface at all. Reporting both as
638
+ // "installed by the host" told a Codex user that its commands, agents,
639
+ // MCP and status line — four rows the manifest declares absent — were
640
+ // waiting for it somewhere. An unsupported surface is named below by its
641
+ // own row, with the reason the manifest gives.
642
+ if (s.kind === "host") { if (s.supported !== false) hostRows.push(key); else unsupportedHost.push([key, s]); continue; }
643
+ const handler = HANDLERS[s.format];
644
+ if (!handler) { out.refusals.push(`${id}: surface ${key} has format ${s.format}, which this installer cannot handle`); continue; }
645
+ const items = handler(ctx, key, s);
646
+ const dependent = s.kind !== "registration" && ctx.renderRoot !== root && ["json-entry", "mjs"].includes(s.format);
647
+ for (const item of items) out.items.push({ harness: id, ...item, ...(dependent ? { plannedAgainst: ctx.renderRoot } : {}) });
648
+ if (s.kind === "registration") {
649
+ // Phase two (contract 4′): the rest is planned against the root the
650
+ // registration produces — when it produces one on this run or has.
651
+ const own = items.find((i) => i.surface === key);
652
+ registration = own || null;
653
+ if (own && mode !== "uninstall" && ["create", "update", "skip"].includes(own.action) && own.root && !own.deferred) ctx.renderRoot = own.root;
654
+ }
655
+ }
656
+ if (ctx.renderRoot !== root) {
657
+ out.plannedAgainst[id] = ctx.renderRoot;
658
+ // Items planned before the render root was known (none today: the registration sorts first) are not re-planned.
659
+ }
660
+ if (ctx.incomplete) out.incomplete = true;
661
+ if (hostRows.length && !surfaces) out.reports.push(hostManagedReport(harness, hostRows, registration));
662
+ // An unsupported host surface gets the same row shape a shared one does
663
+ // (planJsonEntry's unsupported branch): state "unsupported", action "skip",
664
+ // and the manifest's own reason. One treatment for one fact, so a reader —
665
+ // and a test — meets "this harness does not have that" in one form rather
666
+ // than two.
667
+ for (const [key, s] of unsupportedHost) {
668
+ out.items.push({ harness: id, surface: key, kind: "host", path: null, entry: null, state: "unsupported", action: "skip", reason: s.why_unsupported || "not supported for this harness yet" });
669
+ }
670
+ }
671
+ for (const item of layout.last) out.items.push({ harness: layoutHarness.id, ...item });
672
+ if (out.items.some((i) => i.action === "refuse")) out.ok = false;
673
+ if (out.refusals.length) out.ok = false;
674
+ return out;
675
+ }
676
+
677
+ // ─── the layout migration (the layout ADR; layout spec contract 6) ──────
678
+ //
679
+ // Two items, kind `layout`. The FIRST moves what only we read — the legacy
680
+ // state directory (per-file collision policy), the entry log (merged), the
681
+ // two legacy markers — and, last of its steps, the binding, whose `agents`
682
+ // block becomes the harness's overlay. It never touches the launcher: the
683
+ // settings entry names it, and the entry is re-pointed by the statusline item
684
+ // only after the launcher item has written the new one. The LAST item, planned
685
+ // after every surface, removes the legacy launcher once nothing names it and
686
+ // prunes the emptied legacy runtime directory. Two config files is the one
687
+ // refusal (a merge is the user's); uninstall is never blocked by it.
688
+ function planLayout(ctx) {
689
+ const { projectDir, mode, env, harness } = ctx;
690
+ const a = analyseLayout(projectDir, { harness });
691
+ const P = a.paths;
692
+ const none = { first: [], last: [] };
693
+ if (!a.pending) return none;
694
+ const base = { surface: "layout", kind: "layout", path: P.legacy.binding, entry: null, state: a.state, reason: null };
695
+ if (mode === "uninstall") {
696
+ // Disowning: the legacy runtime directory goes with the new one when it is
697
+ // ours (its header); the legacy binding is bind's, never uninstall's. A
698
+ // narrowed uninstall leaves it alone: the way back from an npm switch is
699
+ // `uninstall --surface plugin`, and it deleted a not-yet-moved project's
700
+ // sessions, log and the launcher its status line still ran (the critic's
701
+ // fourth pass, 2026-10-04). `surfaces` is null when nothing was named.
702
+ if (ctx.surfaces) return none;
703
+ if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
704
+ return { first: [], last: [{ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "remove", reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state", steps: [
705
+ { kind: "remove-legacy-runtime", path: P.legacy.runtime, why: "the pre-0.28 state directory, removed whole" },
706
+ { kind: "note", why: ".projectstore/state/ (sessions, the entry log, the welcome marker) and the binding stay: the hooks' records and bind's file, not install's — delete .projectstore/ by hand to disown fully" },
707
+ ] }] };
708
+ }
709
+ if (a.twoConfigs && !a.resumable) return { first: [{ ...base, action: "refuse", reason: `two bindings: ${P.legacy.binding} (legacy) and ${P.binding} — keep one and delete the other (usually the legacy one), then run install again; nothing is written while both exist` }], last: [] };
710
+ if (insideHostSession(env, harness)) { ctx.incomplete = true; return { first: [{ ...base, action: "skip", deferred: true, reason: `the layout migration moves files this ${harness.display_name} session reads and writes — run it from a terminal outside the session` }], last: [] }; }
711
+ const steps = [
712
+ { kind: "note", why: `close other ${harness.display_name} sessions in this project first and restart afterwards: the migration moves files a running session reads and writes, and the status line does not reload mid-session` },
713
+ { kind: "ensure", path: P.root, why: ".projectstore/ with its .gitignore (projectstore.json, state/)" },
714
+ ];
715
+ if (a.legacy.state) steps.push({ kind: "move-state", from: P.legacy.state, to: P.sessions, files: a.legacy.stateFiles.length, why: "session files move into state/sessions/; one present on both sides keeps the newer, a per-session directory keeps the new side" });
716
+ if (a.legacy.entryLog) steps.push({ kind: "merge-log", from: P.legacy.entryLog, to: P.entryLog, why: "the legacy entry log's lines go before the new log's" });
717
+ if (a.legacy.welcomed) steps.push({ kind: "move-marker", from: P.legacy.welcomed, to: P.welcomed(harness.id), why: "the welcome marker moves under state/<harness>/ — a migrated project is not welcomed twice" });
718
+ if (a.legacy.sessionId) steps.push({ kind: "delete", path: P.legacy.sessionId, why: "the 0.6-era session-id file" });
719
+ if (a.legacy.binding && a.resumable) steps.push({ kind: "delete", path: P.legacy.binding, why: "the binding was already moved (an interrupted run left the legacy copy, byte-equal but for its agents block)" });
720
+ else if (a.legacy.binding) steps.push({ kind: "move-binding", from: P.legacy.binding, to: P.binding, overlay: P.overlay(harness.id), why: `the binding moves; its agents block becomes harness/${harness.id}.json` });
721
+ const first = [{ ...base, action: "migrate", steps, reason: `${a.legacy.binding ? "binding" : "state"} in the pre-0.28 layout under ${a.legacy.dir}/` }];
722
+ const last = [];
723
+ if (a.legacy.launcher || a.legacy.runtime) {
724
+ const cleanup = [];
725
+ if (a.legacy.launcher) cleanup.push({ kind: "remove-legacy-launcher", path: P.legacy.launcher, why: "removed once the new launcher is written and the settings entry names it — or at once when the status-line slot is not ours (foreign, or empty because the status line is off); kept while a settings file the host reads (this project's local or committed settings, or the user's) still runs it as the status line — a script that calls it indirectly is not seen" });
726
+ if (a.legacy.runtime) cleanup.push({ kind: "rmdir-legacy", path: P.legacy.runtime, why: "the emptied pre-0.28 runtime directory" });
727
+ last.push({ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "cleanup", reason: null, steps: cleanup });
728
+ }
729
+ return { first, last };
730
+ }
731
+
732
+ function applyLayout(p, i, { failed, home = homedir() }) {
733
+ const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
734
+ const within = p.projectDir;
735
+ if (i.action === "cleanup" && failed) { out.action = "skipped"; out.reason = "an earlier item failed; the legacy files stay until the next run"; return out; }
736
+ const fail = (step, message) => { out.failed = { step, status: null, stderr: message }; return out; };
737
+ for (const st of i.steps || []) {
738
+ try {
739
+ if (st.kind === "ensure") { ensureRuntimeDir(within); out.steps.push({ kind: st.kind, ok: true }); }
740
+ else if (st.kind === "move-state") { const r = moveStateDir(st.from, st.to, within); ensureStateDir(within); out.steps.push({ kind: st.kind, ok: true, ...r }); }
741
+ else if (st.kind === "merge-log") { const r = mergeEntryLog(st.from, st.to, within); out.steps.push({ kind: st.kind, ok: true, result: r }); }
742
+ else if (st.kind === "delete") { out.steps.push({ kind: st.kind, path: st.path, ok: true, removed: removeInside(st.path, within) }); }
743
+ else if (st.kind === "move-marker") { const r = movePath(st.from, st.to, within); if (r === "target-exists") removeInside(st.from, within); out.steps.push({ kind: st.kind, ok: true, result: r }); }
744
+ else if (st.kind === "move-binding") {
745
+ let cfg; try { cfg = JSON.parse(readFileSync(st.from, "utf8")); } catch (e) { return fail(st.kind, `${st.from} is not valid JSON; the binding was not moved`); }
746
+ if (existsSync(st.to)) return fail(st.kind, `${st.to} appeared under the plan; two bindings are a refusal`);
747
+ const { agents, ...binding } = cfg && typeof cfg === "object" ? cfg : {};
748
+ if (agents && typeof agents === "object") {
749
+ let overlay = {}; try { overlay = JSON.parse(readFileSync(st.overlay, "utf8")); } catch {}
750
+ mkdirSync(dirname(st.overlay), { recursive: true });
751
+ writeFileAtomic(st.overlay, JSON.stringify({ ...overlay, agents }, null, 2) + "\n", { sweep: false });
752
+ }
753
+ mkdirSync(dirname(st.to), { recursive: true });
754
+ writeFileAtomic(st.to, JSON.stringify(binding, null, 2) + "\n", { sweep: false });
755
+ removeInside(st.from, within);
756
+ out.steps.push({ kind: st.kind, ok: true, overlay: Boolean(agents) });
757
+ }
758
+ else if (st.kind === "remove-legacy-launcher") {
759
+ // Only ours, and only when no settings entry names it any more.
760
+ let text = null; try { text = readFileSync(st.path, "utf8"); } catch {}
761
+ // "Still in use" is decided by the FILE, not the spelling: an entry
762
+ // written through a symlinked or differently-cased path, or living in
763
+ // the committed or the user's settings, still runs it (the third review
764
+ // of the 2026-10-03 fixes — a string comparison of the local file alone
765
+ // deleted a launcher the status line was running).
766
+ const named = entryNames(p.projectDir, i.harness, st.path) || settingsRunFile(p.projectDir, i.harness, st.path, home);
767
+ // Moved, never just deleted: the legacy launcher goes only once the new
768
+ // one exists (a dev root produces none — contract 7 leaves it in place)
769
+ // — unless the status-line slot is not ours (any wiring of ours counts).
770
+ // Under a foreign or empty slot the new launcher is never made, and
771
+ // keeping the old one kept layout-legacy alive forever (the critic of
772
+ // the layout spec's 2026-10-03 amendment, case S1).
773
+ const moved = existsSync(layoutPaths(p.projectDir).launcher(i.harness));
774
+ const slotOurs = slotIsOurs(p.projectDir, i.harness, { home, root: p.root });
775
+ if (text === null) out.steps.push({ kind: st.kind, ok: true, removed: false });
776
+ else if (!moved && slotOurs) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "no launcher at the new path yet (this root does not produce one) — left in place" });
777
+ else if (!isOurFile(text)) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "not ours — left in place" });
778
+ else if (named) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "a settings file the host reads still runs it as the status line — left in place" });
779
+ else out.steps.push({ kind: st.kind, ok: true, removed: removeInside(st.path, within) });
780
+ }
781
+ else if (st.kind === "rmdir-legacy") {
782
+ // Prune the legacy runtime dir when only its own .gitignore (and an empty state/) remain.
783
+ let left = []; try { left = readdirSync(st.path).filter((n) => n !== ".gitignore"); } catch { out.steps.push({ kind: st.kind, ok: true, removed: false }); continue; }
784
+ if (left.length === 1 && left[0] === "state") { try { if (readdirSync(join(st.path, "state")).length === 0) { rmdirSync(join(st.path, "state")); left = []; } } catch {} }
785
+ if (left.length) { out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` }); continue; }
786
+ removeInside(st.path, within, { recursive: true });
787
+ out.steps.push({ kind: st.kind, ok: true, removed: true });
788
+ }
789
+ else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
790
+ } catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
791
+ }
792
+ return out;
793
+ }
794
+
795
+ // Does any settings file the host reads run this file as its status line?
796
+ // Compared by identity (device and inode), so a path spelled through a symlink
797
+ // or in another case still counts; the project-directory variable a command
798
+ // may carry is substituted first.
799
+ function settingsRunFile(projectDir, harnessId, file, home) {
800
+ const id = (f) => { try { const s = statSync(f); return `${s.dev}:${s.ino}`; } catch { return null; } };
801
+ const want = id(file);
802
+ if (!want) return false;
803
+ const h = loadHarness(harnessId);
804
+ const dir = h.runtime?.harness_dir || ".claude", v = h.runtime?.project_dir_env;
805
+ const files = [join(projectDir, h.surfaces?.statusline?.file || join(dir, "settings.local.json")), join(projectDir, dir, "settings.json"), join(claudeHome(home), "settings.json")];
806
+ return files.some((f) => {
807
+ try {
808
+ let cmd = JSON.parse(readFileSync(f, "utf8"))?.statusLine?.command;
809
+ if (typeof cmd !== "string") return false;
810
+ if (v) cmd = cmd.split("${" + v + "}").join(projectDir).split("$" + v).join(projectDir);
811
+ const sp = statusLineScriptPath(cmd);
812
+ return Boolean(sp) && id(isAbsolute(sp) ? sp : join(projectDir, sp)) === want;
813
+ } catch { return false; }
814
+ });
815
+ }
816
+
817
+ // Is the harness's status-line slot wired to anything of ours (any wiring)?
818
+ function slotIsOurs(projectDir, harnessId, { home, root }) {
819
+ try {
820
+ const s = loadHarness(harnessId).surfaces?.statusline;
821
+ if (!s || !s.file) return false;
822
+ const cmd = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"))?.statusLine?.command;
823
+ return typeof cmd === "string" && statusLineIsOurWiring(cmd, projectDir, home, root);
824
+ } catch { return false; }
825
+ }
826
+
827
+ // Does the harness's settings entry still name this launcher path?
828
+ function entryNames(projectDir, harnessId, launcherPath) {
829
+ try {
830
+ const h = loadHarness(harnessId);
831
+ const s = h.surfaces?.statusline;
832
+ if (!s || !s.file) return false;
833
+ const settings = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"));
834
+ const cmd = settings?.statusLine?.command;
835
+ const named = statusLineScriptPath(typeof cmd === "string" ? cmd : null);
836
+ return Boolean(named) && resolve(named) === resolve(launcherPath);
837
+ } catch { return false; }
838
+ }
839
+
840
+ // Contract 14: the host installs and updates these itself; say how, from the
841
+ // manifest, and write nothing. (PR #13's installElsewhere, as a plan line.)
842
+ function hostManagedReport(m, rows, registration = null) {
843
+ const inst = m.install || {};
844
+ const lines = [`${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`];
845
+ if (registration && registration.entry) {
846
+ const how = registration.action === "skip" && registration.state === "current" ? "registered here" : registration.action === "refuse" ? "refused, see below" : registration.action === "skip" ? "not registered on this run, see below" : `registered by this run (${registration.action})`;
847
+ lines.push(` They come from the registration ${registration.entry}, ${how}${registration.root ? ` — the host loads them from ${registration.root}` : ""}.`);
848
+ }
849
+ if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
850
+ for (const s of inst.steps || []) lines.push(` ${s}`);
851
+ for (const n of inst.notes || []) lines.push(` ${n}`);
852
+ if (inst.docs) lines.push(` ${inst.docs}`);
853
+ return lines.join("\n");
854
+ }
855
+
856
+ const isWrite = (i) => !["skip", "refuse"].includes(i.action);
857
+
858
+ // ─── preview ───────────────────────────────────────────────────────────
859
+
860
+ export function renderPreview(p) {
861
+ const lines = [`projectstore ${p.mode} — ${p.harnesses.join(", ") || "(no harness)"} — ${p.projectDir}`, ""];
862
+ for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(` ${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`, "");
863
+ for (const r of p.reports) lines.push(...r.split("\n").map((l) => " " + l), "");
864
+ const writes = p.items.filter(isWrite);
865
+ for (const i of p.items) {
866
+ const target = i.path === null
867
+ ? `harness=${i.harness} surface=${i.surface} [no filesystem path]`
868
+ : rel(p.projectDir, i.path);
869
+ const where = target + (i.entry ? ` [${i.entry}]` : "");
870
+ let state = i.state;
871
+ if (i.state === "current" && i.writtenBy && !i.sameProject) state = `current, last written by ${i.writtenBy}`;
872
+ if (i.reason && i.action !== "refuse") state += ` (${i.reason})`;
873
+ lines.push(` ${i.kind.padEnd(9)} ${where}`);
874
+ lines.push(` ${state.padEnd(44)} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`);
875
+ for (const st of i.steps || []) {
876
+ if (st.kind === "host") lines.push(` $ ${[st.bin, ...st.argv].join(" ")}`, ` ${st.why}${st.touches.length ? `; touches ${st.touches.map((t) => rel(p.projectDir, t)).join(", ")}` : ""}`);
877
+ else if (st.kind === "write") lines.push(` write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ` ${st.why}`);
878
+ else if (st.kind === "portable-write") lines.push(` stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ` ${st.why}`);
879
+ else if (st.kind === "portable-remove") lines.push(` remove ${st.path}`, ` ${st.why}`);
880
+ else if (st.kind === "remove") lines.push(` remove ${st.path}`, ` ${st.why}`);
881
+ else if (st.kind === "unregister") lines.push(` edit ${rel(p.projectDir, st.path)} [${st.pointer}.${st.name}] → removed`, ` ${st.why}`);
882
+ else if (st.kind === "note") lines.push(` note: ${st.why}`);
883
+ else if (st.kind === "ensure") lines.push(` ensure ${rel(p.projectDir, st.path)}/`, ` ${st.why}`);
884
+ else if (st.kind === "move-state") lines.push(` move ${rel(p.projectDir, st.from)}/ → ${rel(p.projectDir, st.to)}/ (${st.files} entries)`, ` ${st.why}`);
885
+ else if (st.kind === "merge-log") lines.push(` merge ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
886
+ else if (st.kind === "delete") lines.push(` delete ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
887
+ else if (st.kind === "move-marker") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
888
+ else if (st.kind === "move-binding") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)} (agents → ${rel(p.projectDir, st.overlay)})`, ` ${st.why}`);
889
+ else if (st.kind === "remove-legacy-launcher" || st.kind === "rmdir-legacy" || st.kind === "remove-legacy-runtime") lines.push(` remove ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
890
+ }
891
+ if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` (harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`);
892
+ if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` (the file would hold nothing else and is removed)`);
893
+ }
894
+ const exclusiveRemoval = p.items.find((i) => i.action === "remove" && i.kind === "exclusive");
895
+ if (exclusiveRemoval) lines.push(` (an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`);
896
+ for (const r of p.refusals) lines.push(` refused ${r}`);
897
+ lines.push("", " Nothing outside a marked entry is read, rewritten or removed.");
898
+ if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(" Each $ line runs the host's own CLI, which writes the host-owned files named after it.");
899
+ if (!p.ok) lines.push("", " Nothing will be written: resolve the refusals above first.");
900
+ else if (!writes.length) lines.push("", " Nothing to change." + (p.incomplete ? " One surface could not be planned (see above)." : ""));
901
+ else lines.push("", ` ${writes.length} change(s) to apply.${p.incomplete ? " One surface could not be planned (see above); the rest proceeds." : ""}`);
902
+ return lines.join("\n") + "\n";
903
+ }
904
+
905
+ // ─── gate ──────────────────────────────────────────────────────────────
906
+
907
+ // A named harness is the explicit confirmation (contract 9). Otherwise ask on
908
+ // a TTY, and refuse without one. Streams are parameters so the TTY branch is
909
+ // testable without a pseudo-terminal.
910
+ export async function confirm(p, { stdin = process.stdin, stdout = process.stdout, ask = null } = {}) {
911
+ if (!p.ok) return { confirmed: false, why: "refused" };
912
+ const writes = p.items.filter(isWrite);
913
+ if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
914
+ if (p.named) return { confirmed: true, why: "named" };
915
+ const interactive = Boolean(stdin && stdin.isTTY && stdout && stdout.isTTY);
916
+ if (!interactive && !ask) return { confirmed: false, why: "non-tty" };
917
+ const answer = ask ? await ask(`Apply these ${writes.length} change(s)? [y/N] `) : await (async () => {
918
+ const rl = createInterface({ input: stdin, output: stdout });
919
+ try { return await rl.question(`Apply these ${writes.length} change(s)? [y/N] `); } finally { rl.close(); }
920
+ })();
921
+ return /^y(es)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
922
+ }
923
+
924
+ // ─── apply ─────────────────────────────────────────────────────────────
925
+
926
+ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir() } = {}) {
927
+ if (!p.ok) throw new Error("apply: the plan carries refusals; nothing is written");
928
+ const done = [];
929
+ let registrationFailed = false;
930
+ let layoutFailed = false;
931
+ for (const i of p.items) {
932
+ if (!isWrite(i)) continue;
933
+ if (i.kind === "layout") {
934
+ const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home });
935
+ done.push(r);
936
+ if (r.failed) { done.failed = r.failed; layoutFailed = true; }
937
+ continue;
938
+ }
939
+ if (i.kind === "registration") {
940
+ const r = applyRegistration(p, i, { env, spawn, home });
941
+ done.push(r);
942
+ // A registration that did not complete leaves the surfaces planned against
943
+ // its install path unwritten: a launcher pointing at nothing is worse than
944
+ // none. Surfaces rendered from the package root (the block) still apply.
945
+ if (r.failed) { done.failed = r.failed; registrationFailed = true; }
946
+ continue;
947
+ }
948
+ if (registrationFailed && i.plannedAgainst) { done.push({ path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" }); continue; }
949
+ if (i.kind === "shared" && typeof i.after === "object" && i.after !== null && !Array.isArray(i.after)) {
950
+ mkdirSync(dirname(i.path), { recursive: true });
951
+ // Re-read at write time: a host command run earlier in this apply (the
952
+ // registration's) may have added sibling keys since plan() read the file.
953
+ // Our entry is set or deleted on the file as it stands; nothing else moves.
954
+ let now = null;
955
+ try { now = JSON.parse(readFileSync(i.path, "utf8")); } catch { now = null; }
956
+ let merged = i.after;
957
+ if (now && typeof now === "object" && !Array.isArray(now) && i.entry) {
958
+ merged = { ...now };
959
+ if (i.action === "remove") delete merged[i.entry]; else merged[i.entry] = i.after[i.entry];
960
+ }
961
+ writeFileAtomic(i.path, JSON.stringify(merged, null, 2) + "\n", { sweep: false });
962
+ } else if ((i.action === "remove" || i.action === "prune") && i.kind === "exclusive") {
963
+ try { unlinkSync(i.path); } catch {}
964
+ pruneEmptyDir(dirname(i.path), p.projectDir);
965
+ } else if (i.action === "remove" && i.kind === "shared" && typeof i.after === "string") {
966
+ if (i.deleteIfEmpty && !i.after.trim()) { try { unlinkSync(i.path); } catch {} }
967
+ else writeFileAtomic(i.path, i.after, { sweep: false });
968
+ } else if (typeof i.after === "string") {
969
+ if (i.kind === "exclusive") ensureStateDir(p.projectDir); // carries the nested .gitignore
970
+ mkdirSync(dirname(i.path), { recursive: true });
971
+ writeFileAtomic(i.path, i.after, { sweep: false });
972
+ }
973
+ done.push({ path: i.path, action: i.action, surface: i.surface });
974
+ }
975
+ return done;
976
+ }
977
+
978
+ // The registration's steps, in order, each leaving a state plan() can read.
979
+ // The host binary runs with the harness home pinned in its environment — the
980
+ // same home the plan was read from — and with the project as its cwd, which is
981
+ // how the host resolves `--scope local`. A non-zero exit stops the item and
982
+ // is recorded, never retried, never masked.
983
+ function applyRegistration(p, i, { env, spawn, home }) {
984
+ const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
985
+ const harness = loadHarness(i.harness);
986
+ const childEnv = { ...env, [homeEnvName(i.harness)]: i.home || claudeHome(home) };
987
+ const s = harness.surfaces[i.surface.replace(/_others$/, "")];
988
+ const portable = s.format === "portable-plugin-registration";
989
+ let staged = null;
990
+ const completedHost = [];
991
+ let hostList = null;
992
+ let lock = null;
993
+ let lockPath = null;
994
+ const journalPath = portable ? join(i.home, "projectstore", `${s.marketplace_name}.journal.json`) : null;
995
+ const releaseLock = () => {
996
+ if (lock !== null) { try { closeSync(lock); } catch {} lock = null; }
997
+ if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; }
998
+ };
999
+ const readHostList = () => {
1000
+ const argv = s.cli.commands.list || [];
1001
+ const bin = whichOnPathFromLib(s.cli.bin, env);
1002
+ const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1003
+ const rows = !r.error && r.status === 0 ? portableListFacts(r.stdout) : null;
1004
+ out.steps.push({ kind: "portable-recovery-list", argv: [s.cli.bin, ...argv], status: r.status ?? null, ok: Boolean(rows) });
1005
+ return { rows, stderr: String((r.stderr || "") + (r.error ? r.error.message : "")).trim() };
1006
+ };
1007
+ const observedPortable = (a) => ({
1008
+ dir_exists: existsSync(a.paths.dir),
1009
+ ownership_version: a.ownership?.version || null,
1010
+ ownership_digest: a.ownership?.digest || null,
1011
+ marketplace_source: a.market?.source || null,
1012
+ global_plugin: a.globalPlugin || null,
1013
+ project_plugin: a.projectPlugin || null,
1014
+ installed_version: a.installedVersion || null,
1015
+ installed_digest: a.installed?.digest || null,
1016
+ enabled: a.enabled,
1017
+ });
1018
+ const sameObserved = (a, b) => JSON.stringify(a || null) === JSON.stringify(b || null);
1019
+ const ownedTargetMatches = (target, expected) => {
1020
+ if (!expected?.version || !expected?.digest) return false;
1021
+ let owned = null;
1022
+ try { owned = JSON.parse(readFileSync(join(target, s.ownership_manifest), "utf8"))?.[s.provenance_key] || null; } catch {}
1023
+ return owned?.version === expected.version
1024
+ && owned?.digest?.sha256 === expected.digest.sha256
1025
+ && owned?.digest?.count === expected.digest.count;
1026
+ };
1027
+ const verifyPrevious = (previous) => {
1028
+ const listed = readHostList();
1029
+ if (!listed.rows) return { ok: false, why: listed.stderr || "Codex plugin list did not return JSON" };
1030
+ const row = listed.rows.find((entry) => entry?.pluginId === i.entry) || null;
1031
+ if (!previous) {
1032
+ const a = analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true });
1033
+ const cacheRoot = join(i.home, ...(s.registry.cache_dir || ["plugins", "cache"]), s.marketplace_name, s.plugin_name);
1034
+ let cached = [];
1035
+ try { cached = readdirSync(cacheRoot); } catch {}
1036
+ if (existsSync(i.path)) return { ok: false, why: `first-install rollback left the stable marketplace source at ${i.path}` };
1037
+ if (a.market) return { ok: false, why: `first-install rollback left marketplace ${s.marketplace_name} in ${a.paths.globalConfig}` };
1038
+ if (a.globalPlugin || a.projectPlugin) return { ok: false, why: `first-install rollback left plugin enablement for ${i.entry}` };
1039
+ if (row) return { ok: false, why: `${i.entry} remains host-reported after first-install rollback` };
1040
+ if (cached.length) return { ok: false, why: `first-install rollback left ${cached.length} materialised cache version(s) under ${cacheRoot}` };
1041
+ return { ok: true };
1042
+ }
1043
+ const a = analysePortableRegistration(p.projectDir, s, {
1044
+ root: p.root,
1045
+ payloadRoot: join(i.path, s.plugin_subdir),
1046
+ home,
1047
+ harness,
1048
+ env: childEnv,
1049
+ ignoreJournal: true,
1050
+ });
1051
+ const digest = previous.digest || {};
1052
+ const sourceOk = a.sourceDigest?.sha256 === digest.sha256 && a.sourceDigest?.count === digest.count;
1053
+ const cacheOk = a.installed?.digest?.sha256 === digest.sha256 && a.installed?.digest?.count === digest.count;
1054
+ const hostOk = row && row.version === previous.version && row.installed === true && row.enabled === previous.enabled && resolve(row.marketplaceSource?.source || "") === resolve(i.path);
1055
+ const ok = a.state === "current" && a.installedVersion === previous.version && a.enabled === previous.enabled && sourceOk && cacheOk && hostOk;
1056
+ return ok ? { ok: true } : { ok: false, why: `restored source/cache/enablement could not be proven (state=${a.state}, version=${a.installedVersion || "?"}, enabled=${String(a.enabled)})` };
1057
+ };
1058
+ const requireRecovery = (journal, why) => {
1059
+ writeFileAtomic(journalPath, JSON.stringify({ ...journal, phase: "recovery-required", error: why }, null, 2) + "\n", { sweep: false });
1060
+ releaseLock();
1061
+ out.failed = { step: "recovery-required", status: null, stderr: `${why}; ${journalPath} was retained and blocks automatic mutation` };
1062
+ return out;
1063
+ };
1064
+ if (portable) {
1065
+ const lockDir = join(i.home, "projectstore");
1066
+ mkdirSync(lockDir, { recursive: true });
1067
+ lockPath = join(lockDir, `${s.marketplace_name}.lock`);
1068
+ const acquire = () => {
1069
+ try { lock = openSync(lockPath, "wx", 0o600); return true; }
1070
+ catch (e) {
1071
+ if (e?.code !== "EEXIST") throw e;
1072
+ let owner = null;
1073
+ try { owner = JSON.parse(readFileSync(lockPath, "utf8")); } catch {}
1074
+ let dead = false;
1075
+ if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
1076
+ try { process.kill(owner.pid, 0); }
1077
+ catch (probe) { dead = probe?.code === "ESRCH"; }
1078
+ }
1079
+ if (!dead) return false;
1080
+ unlinkSync(lockPath);
1081
+ out.steps.push({ kind: "portable-stale-lock", pid: owner.pid, ok: true });
1082
+ lock = openSync(lockPath, "wx", 0o600);
1083
+ return true;
1084
+ }
1085
+ };
1086
+ try {
1087
+ if (!acquire()) {
1088
+ out.failed = { step: "lock", status: null, stderr: `${lockPath} is held by a live or unidentifiable owner; another ProjectStore registration may be running. Inspect the lock before removing it` };
1089
+ return out;
1090
+ }
1091
+ writeExclusiveMetadata(lock, { pid: process.pid, started_at: new Date().toISOString() });
1092
+ } catch (e) {
1093
+ releaseLock();
1094
+ out.failed = { step: "lock", status: null, stderr: e && e.message ? e.message : String(e) };
1095
+ return out;
1096
+ }
1097
+ if (existsSync(journalPath)) {
1098
+ let journal;
1099
+ try { journal = JSON.parse(readFileSync(journalPath, "utf8")); }
1100
+ catch { releaseLock(); out.failed = { step: "recovery", status: null, stderr: `${journalPath} is not valid JSON; inspect it before retrying` }; return out; }
1101
+ const ownedPath = (value) => typeof value === "string" && (resolve(value) === resolve(i.path) || inside(resolve(value), resolve(i.home)));
1102
+ if (!journal || resolve(journal.target || "") !== resolve(i.path) || !ownedPath(journal.stage) || (journal.backup && !ownedPath(journal.backup))) {
1103
+ releaseLock(); out.failed = { step: "recovery", status: null, stderr: `${journalPath} does not describe this owned marketplace; inspect it before retrying` }; return out;
1104
+ }
1105
+ try {
1106
+ if (journal.phase === "recovery-required") return requireRecovery(journal, journal.error || "a prior registration could not prove restoration");
1107
+ if (journal.phase === "verified") finishPortableMarketplace(journal.target, journal.backup || null);
1108
+ else if (journal.phase === "swapped" || (journal.phase === "prepare" && journal.backup && existsSync(journal.backup))) rollbackPortableMarketplace(journal.target, journal.backup || null);
1109
+ else if (journal.phase === "prepare" && !journal.previous && existsSync(journal.target)) {
1110
+ if (!ownedTargetMatches(journal.target, journal.next)) return requireRecovery(journal, `first-install recovery found an unrecognised target at ${journal.target}`);
1111
+ rollbackPortableMarketplace(journal.target, null);
1112
+ }
1113
+ if (existsSync(journal.stage)) removeTreeUnder(journal.stage, i.home);
1114
+ if (journal.phase !== "verified") {
1115
+ const restored = verifyPrevious(journal.previous || null);
1116
+ if (!restored.ok) return requireRecovery(journal, restored.why);
1117
+ }
1118
+ unlinkSync(journalPath);
1119
+ out.steps.push({ kind: "portable-recover", phase: journal.phase || "unknown", ok: true });
1120
+ } catch (e) {
1121
+ releaseLock(); out.failed = { step: "recovery", status: null, stderr: e && e.message ? e.message : String(e) }; return out;
1122
+ }
1123
+ }
1124
+ const current = analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true });
1125
+ const desiredCurrent = current.state === "current"
1126
+ && current.desiredVersion === i.verify?.version
1127
+ && current.desiredDigest?.sha256 === i.verify?.digest?.sha256
1128
+ && current.desiredDigest?.count === i.verify?.digest?.count;
1129
+ if (i.action !== "remove" && desiredCurrent) {
1130
+ out.action = "skip";
1131
+ out.state = "current";
1132
+ out.reason = "another completed registration while this plan waited for the lock";
1133
+ out.steps.push({ kind: "portable-recheck", state: current.state, action: "skip", ok: true });
1134
+ releaseLock();
1135
+ return out;
1136
+ }
1137
+ if (i.action === "remove" && current.state === "absent") {
1138
+ out.action = "skip";
1139
+ out.state = "absent";
1140
+ out.reason = "another removal completed while this plan waited for the lock";
1141
+ out.steps.push({ kind: "portable-recheck", state: current.state, action: "skip", ok: true });
1142
+ releaseLock();
1143
+ return out;
1144
+ }
1145
+ const desiredChanged = i.action !== "remove" && (current.desiredVersion !== i.verify?.version
1146
+ || current.desiredDigest?.sha256 !== i.verify?.digest?.sha256
1147
+ || current.desiredDigest?.count !== i.verify?.digest?.count);
1148
+ if (["foreign", "conflict", "unavailable"].includes(current.state) || desiredChanged || !sameObserved(observedPortable(current), i.observed)) {
1149
+ releaseLock();
1150
+ out.failed = { step: "recheck", status: null, stderr: current.refusal || current.reason || "the portable registration changed after preview; re-run the command to produce a fresh plan" };
1151
+ return out;
1152
+ }
1153
+ }
1154
+ const fail = (step, status, stderr, argv = null) => {
1155
+ if (staged) {
1156
+ // A first install can fail after Codex has accepted the marketplace but
1157
+ // before verification. Best-effort compensation happens while the staged
1158
+ // source still exists, then the filesystem swap is rolled back. A refresh
1159
+ // keeps its existing registration and cache; the prior source is restored.
1160
+ if (!staged.backup && (completedHost.includes("marketplace_add") || step === "marketplace_add")) {
1161
+ const fill = { dir: i.path, marketplace: s.marketplace_name, id: i.entry };
1162
+ for (const name of ["uninstall", "marketplace_remove"]) {
1163
+ const template = s.cli.commands[name];
1164
+ if (!Array.isArray(template)) continue;
1165
+ const args = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
1166
+ const bin = whichOnPathFromLib(s.cli.bin, env);
1167
+ const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1168
+ out.steps.push({ kind: "portable-compensate", name, argv: [s.cli.bin, ...args], status: r.status ?? null, ok: !r.error && r.status === 0 });
1169
+ }
1170
+ }
1171
+ try {
1172
+ rollbackPortableMarketplace(staged.dir, staged.backup);
1173
+ out.steps.push({ kind: "portable-rollback", path: staged.dir, ok: true });
1174
+ const restored = verifyPrevious(staged.previous || null);
1175
+ if (!restored.ok) {
1176
+ return requireRecovery({ version: 1, target: staged.dir, stage: `${staged.dir}.none`, backup: null, previous: staged.previous || null }, restored.why);
1177
+ }
1178
+ if (staged.journal) { try { unlinkSync(staged.journal); } catch {} }
1179
+ } catch (e) {
1180
+ out.steps.push({ kind: "portable-rollback", path: staged.dir, ok: false, stderr: e.message });
1181
+ return requireRecovery({ version: 1, target: staged.dir, stage: `${staged.dir}.none`, backup: staged.backup, previous: staged.previous || null }, `rollback failed: ${e.message}`);
1182
+ }
1183
+ staged = null;
1184
+ }
1185
+ out.failed = { step, status, stderr, ...(argv ? { argv } : {}) };
1186
+ releaseLock();
1187
+ return out;
1188
+ };
1189
+ for (const st of i.steps || []) {
1190
+ try {
1191
+ if (st.kind === "portable-write") {
1192
+ const token = `${process.pid}-${randomUUID()}`;
1193
+ const stage = `${st.path}.staging-${token}`;
1194
+ const backup = existsSync(st.path) ? `${st.path}.previous-${token}` : null;
1195
+ let previous = null;
1196
+ if (backup) {
1197
+ const before = analysePortableRegistration(p.projectDir, s, { root: p.root, payloadRoot: join(st.path, st.subdir), home, harness, env: childEnv, ignoreJournal: true });
1198
+ if (before.ownership?.version && before.ownership?.digest) previous = { version: before.ownership.version, digest: before.ownership.digest, enabled: before.enabled };
1199
+ }
1200
+ const next = { version: i.verify?.version || null, digest: i.verify?.digest || null };
1201
+ writeFileAtomic(journalPath, JSON.stringify({ version: 1, phase: "prepare", target: st.path, stage, backup, previous, next }, null, 2) + "\n", { sweep: false });
1202
+ const result = stagePortableMarketplace(st.path, { from: st.from, files: st.files, subdir: st.subdir, catalogRel: st.catalogRel, catalog: st.catalog, ownershipRel: st.ownershipRel, ownership: st.ownership, homeBase: i.home, token });
1203
+ writeFileAtomic(journalPath, JSON.stringify({ version: 1, phase: "swapped", target: st.path, stage: result.stage, backup: result.backup, previous, next }, null, 2) + "\n", { sweep: false });
1204
+ staged = { dir: st.path, backup: result.backup, journal: journalPath, previous };
1205
+ out.steps.push({ kind: "portable-write", path: st.path, ok: true });
1206
+ } else if (st.kind === "portable-remove") {
1207
+ removeTreeUnder(st.path, st.homeBase);
1208
+ out.steps.push({ kind: "portable-remove", path: st.path, ok: true });
1209
+ } else if (st.kind === "write") {
1210
+ const manifestPath = join(st.path, s.manifest);
1211
+ // Re-check at apply time what plan() proved: the directory is absent or ours.
1212
+ if (existsSync(st.path)) {
1213
+ let ours = false;
1214
+ try { ours = Boolean(JSON.parse(readFileSync(manifestPath, "utf8"))[s.provenance_key]); } catch {}
1215
+ if (!ours) { out.steps.push({ kind: "write", ok: false }); return fail("write", null, `${st.path} changed under the plan: its manifest is no longer ours; nothing is written`); }
1216
+ }
1217
+ if (st.manifestOnly) writeFileAtomic(manifestPath, JSON.stringify(st.manifest, null, 2) + "\n", { sweep: false });
1218
+ else writeOwnTree(st.path, { from: p.root, subdir: s.plugin_subdir, manifestRel: s.manifest, manifest: st.manifest, home });
1219
+ out.steps.push({ kind: "write", path: st.path, ok: true });
1220
+ } else if (st.kind === "unregister") {
1221
+ // Our one entry in the checkout's settings file (contract 6): the key is deleted, nothing else is touched.
1222
+ let settings = {};
1223
+ try { settings = JSON.parse(readFileSync(st.path, "utf8")); } catch { settings = null; }
1224
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) { out.steps.push({ kind: "unregister", ok: false }); return fail("unregister", null, `${st.path} is not a JSON object; the marketplace entry was not removed`); }
1225
+ if (settings[st.pointer] && typeof settings[st.pointer] === "object") { delete settings[st.pointer][st.name]; writeFileAtomic(st.path, JSON.stringify(settings, null, 2) + "\n", { sweep: false }); }
1226
+ out.steps.push({ kind: "unregister", path: st.path, ok: true });
1227
+ } else if (st.kind === "remove") {
1228
+ removeOwnTree(st.path, home);
1229
+ out.steps.push({ kind: "remove", path: st.path, ok: true });
1230
+ } else if (st.kind === "host") {
1231
+ const bin = whichOnPathFromLib(st.bin, env);
1232
+ const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1233
+ const ok = !r.error && r.status === 0;
1234
+ const said = String((r.stderr || "") + (ok ? "" : r.stdout || "") + (r.error ? r.error.message : "")).trim();
1235
+ out.steps.push({ kind: "host", argv: [st.bin, ...st.argv], status: r.status ?? null, ok, ...(ok ? {} : { stderr: said }) });
1236
+ if (!ok) return fail(st.name, r.status ?? null, said, [st.bin, ...st.argv]);
1237
+ if (portable && st.name === "list") {
1238
+ hostList = portableListFact(r.stdout, i.entry);
1239
+ if (!hostList) return fail("list", null, `${st.bin} ${st.argv.join(" ")} did not report ${i.entry} in JSON output`, [st.bin, ...st.argv]);
1240
+ }
1241
+ completedHost.push(st.name);
1242
+ }
1243
+ } catch (e) {
1244
+ return fail(st.kind, null, e && e.message ? e.message : String(e));
1245
+ }
1246
+ }
1247
+ // The host's registry is read back: the install path the rest of the plan
1248
+ // was rendered against must be the one the host recorded for this checkout.
1249
+ if (i.verify) {
1250
+ const a = portable
1251
+ ? analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true })
1252
+ : analyseRegistration(p.projectDir, s, { root: p.root, home, harness, env });
1253
+ if (portable) {
1254
+ const sourceDigestOk = a.sourceDigest?.sha256 === i.verify.digest?.sha256 && a.sourceDigest?.count === i.verify.digest?.count;
1255
+ const cacheDigestOk = a.installed?.digest?.sha256 === i.verify.digest?.sha256 && a.installed?.digest?.count === i.verify.digest?.count;
1256
+ const listed = hostList && hostList.version === i.verify.version && hostList.installed && hostList.enabled && resolve(hostList.marketplaceSource || "") === resolve(i.path);
1257
+ if (a.state !== "current" || a.installedVersion !== i.verify.version || !a.enabled || !sourceDigestOk || !cacheDigestOk || !listed) {
1258
+ return fail("verify", null, `after Codex ran, registration is ${a.state} at ${a.installedVersion || "?"}; expected current ${i.verify.version} with digest ${i.verify.digest?.sha256 || "?"}`);
1259
+ }
1260
+ out.verified = { installPath: a.installPath, version: a.installedVersion, sourceDigest: a.sourceDigest, cacheDigest: a.installed.digest, host: hostList };
1261
+ } else if (!a.installPath || resolve(a.installPath) !== resolve(i.verify.installPath) || a.installedVersion !== i.verify.version) {
1262
+ return fail("verify", null, `after the host ran, its registry records ${a.installPath || "no install"} at ${a.installedVersion || "?"} for this checkout; the plan rendered the other surfaces against ${i.verify.installPath} at ${i.verify.version} — they are not written`);
1263
+ } else {
1264
+ out.verified = { installPath: a.installPath, version: a.installedVersion };
1265
+ }
1266
+ }
1267
+ try {
1268
+ if (staged) {
1269
+ writeFileAtomic(staged.journal, JSON.stringify({ version: 1, phase: "verified", target: staged.dir, stage: `${staged.dir}.none`, backup: staged.backup, previous: staged.previous || null }, null, 2) + "\n", { sweep: false });
1270
+ finishPortableMarketplace(staged.dir, staged.backup);
1271
+ unlinkSync(staged.journal);
1272
+ }
1273
+ }
1274
+ catch (e) { return fail("finish", null, e && e.message ? e.message : String(e)); }
1275
+ releaseLock();
1276
+ return out;
1277
+ }
1278
+
1279
+ function homeEnvName(harnessId) {
1280
+ try { return loadHarness(harnessId).runtime.home_env; } catch { return sourceHarness().runtime.home_env; }
1281
+ }
1282
+
1283
+ // rmdirSync refuses a non-empty directory — that refusal IS the guarantee
1284
+ // (contract 13): we prune only a directory we emptied. The runtime dir's own
1285
+ // .gitignore does not count as content.
1286
+ function pruneEmptyDir(dir, projectDir) {
1287
+ if (!inside(dir, projectDir)) return;
1288
+ try {
1289
+ const left = readdirSync(dir).filter((n) => n !== ".gitignore");
1290
+ if (left.length) return;
1291
+ try { unlinkSync(join(dir, ".gitignore")); } catch {}
1292
+ rmdirSync(dir);
1293
+ } catch {}
1294
+ }
1295
+
1296
+ // ─── verbs ─────────────────────────────────────────────────────────────
1297
+
1298
+ export async function runVerb(verb, projectDir, opts = {}) {
1299
+ const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
1300
+ const p = plan(projectDir, { ...opts, mode });
1301
+ const preview = renderPreview(p);
1302
+ const gate = await confirm(p, opts);
1303
+ const result = { verb, plan: p, preview, gate, applied: [], failed: null };
1304
+ if (gate.confirmed) {
1305
+ result.applied = apply(p, { env: opts.env || process.env, spawn: opts.spawn || spawnSync, home: opts.home || homedir() });
1306
+ result.failed = result.applied.failed || null;
1307
+ }
1308
+ return result;
1309
+ }
1310
+
1311
+ // ─── main ──────────────────────────────────────────────────────────────
1312
+
1313
+ function usage() {
1314
+ return [
1315
+ "usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--json]",
1316
+ ` harnesses: ${harnessIds().join(", ")}`,
1317
+ " --surface narrows the plan to a surface and the surfaces beneath it (statusline covers statusline_launcher)",
1318
+ " --harness names the harness — and, non-interactively, is the confirmation; there is no --yes",
1319
+ ].join("\n");
1320
+ }
1321
+
1322
+ // The JSON envelope carries states and actions, never file bodies: a model
1323
+ // reading a status report must not receive the whole of CLAUDE.md twice.
1324
+ // Per step kind, because one field name means different things in different
1325
+ // steps. A layout step's `from`/`to`/`files` ARE its preview, and a
1326
+ // registration `write` carries `files` as a count beside its `manifest` body.
1327
+ // Only `portable-write` carries bodies under other names (`catalog`,
1328
+ // `ownership`), its payload root (`from`) and its file list. Stripping every
1329
+ // name from every step took the layout migration's `from` out of
1330
+ // `plan --json` (S1 of the 2026-10-03 review in "The Codex shell's plugin root:
1331
+ // .codex-plugin, the rendered surfaces, and the first Codex install").
1332
+ const PRIVATE_STEP_FIELDS = { "portable-write": ["catalog", "ownership", "from"] };
1333
+ function publicStep({ manifest, ...s }) {
1334
+ for (const k of PRIVATE_STEP_FIELDS[s.kind] || []) delete s[k];
1335
+ if (s.kind === "portable-write" && Array.isArray(s.files)) s.files = s.files.length;
1336
+ return s;
1337
+ }
1338
+ export const publicItem = ({ before, after, steps, observed, ...rest }) => steps
1339
+ ? { ...rest, steps: steps.map(publicStep) }
1340
+ : rest;
1341
+
1342
+ async function main() {
1343
+ const argv = process.argv.slice(2);
1344
+ const verb = argv[0];
1345
+ if (!["install", "uninstall", "upgrade", "plan"].includes(verb)) { process.stderr.write(usage() + "\n"); process.exit(2); }
1346
+ const harnesses = [], surfaces = [];
1347
+ let projectDir = null, json = false, globalRemoval = false, register = true;
1348
+ for (let i = 1; i < argv.length; i++) {
1349
+ const a = argv[i];
1350
+ const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) { process.stderr.write(`${a} needs a value\n${usage()}\n`); process.exit(2); } return v; };
1351
+ if (a === "--harness") harnesses.push(value());
1352
+ else if (a === "--surface") surfaces.push(value());
1353
+ else if (a === "--project") projectDir = value();
1354
+ else if (a === "--global") globalRemoval = true;
1355
+ else if (a === "--no-register" && verb !== "uninstall") register = false;
1356
+ else if (a === "--json") json = true;
1357
+ else { process.stderr.write(`unknown argument ${a}\n${usage()}\n`); process.exit(2); }
1358
+ }
1359
+ const src = sourceHarness();
1360
+ projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
1361
+ const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register };
1362
+ if (verb === "plan") {
1363
+ const p = plan(projectDir, opts);
1364
+ process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p));
1365
+ process.exit(p.ok && !p.incomplete ? 0 : 1);
1366
+ }
1367
+ const r = await runVerb(verb, projectDir, opts);
1368
+ if (json) {
1369
+ process.stdout.write(JSON.stringify({ verb, ok: r.plan.ok && !r.plan.incomplete && !r.failed, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports }, null, 2) + "\n");
1370
+ } else {
1371
+ process.stdout.write(r.preview);
1372
+ if (r.gate.confirmed) process.stdout.write(appliedLine(r));
1373
+ else if (r.gate.why === "non-tty") process.stdout.write(`a bare ${verb} in a non-TTY refuses; name a harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
1374
+ else if (r.gate.why === "declined") process.stdout.write("nothing written.\n");
1375
+ }
1376
+ process.exit(r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do") ? 0 : 1);
1377
+ }
1378
+
1379
+ // What apply did, for a terminal: the count, and a failed host command with
1380
+ // its stderr — the user sees what the host said, verbatim.
1381
+ export function appliedLine(r) {
1382
+ let s = `applied ${r.applied.length} change(s).\n`;
1383
+ if (r.failed) s += `stopped: ${r.failed.argv ? "$ " + r.failed.argv.join(" ") : r.failed.step} exited ${r.failed.status ?? "without running"}${r.failed.stderr ? "\n " + r.failed.stderr.split("\n").join("\n ") : ""}\n the surfaces planned against its install path were not written; run the verb again once it succeeds.\n`;
1384
+ return s;
1385
+ }
1386
+
1387
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();