@junghanacs/entwurf 0.22.0 → 0.23.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 (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -0,0 +1,1203 @@
1
+ /**
2
+ * herdr-runtime — the Entwurf-owned runtime the Herdr plugin installs against (#116 M3-b1/b2).
3
+ *
4
+ * THIS FILE IS THE ONE OWNER, AND IT SHIPS. Herdr's `plugin uninstall` deletes its managed checkout
5
+ * and calls no cleanup hook, so anything that has to run AFTER that — retiring this runtime, reading
6
+ * its journal, undoing what was activated — cannot live in `plugins/`. It lives in `scripts/`, which
7
+ * `package.json.files` ships, and `plugins/herdr/lib/runtime-bootstrap.mjs` is a thin re-export of
8
+ * this module. One schema, one certified reader, one inverse: a second copy under `plugins/` would
9
+ * be the fork that lets an uninstall disagree with the install that preceded it.
10
+ *
11
+ * A Herdr user who has never heard of npm types one command. What has to exist afterwards is an
12
+ * Entwurf runtime at an address that does not move, because Pi records the owner ROOT it was wired
13
+ * with and a root that changes on every upgrade is a takeover, not an update. That address is
14
+ * `$XDG_DATA_HOME/entwurf/herdr-plugin/runtime/active`, and it is a REAL DIRECTORY — never a
15
+ * symlink whose canonicalisation would turn each version into a different owner root.
16
+ *
17
+ * WHAT IT DELIBERATELY DOES NOT DO: PUT ANYTHING ON PATH. An earlier cut exposed bare `entwurf` /
18
+ * `entwurf-bridge` through an owned bin directory. There is no reason a fresh
19
+ * `$XDG_DATA_HOME/…/bin` would be on a clean host's PATH, this module may not edit a shell profile,
20
+ * and it may not write into a standard bin directory it does not own — so that exposure was
21
+ * load-bearing on a condition nothing here can establish. It is retired. The scoped wiring one
22
+ * slice above records ABSOLUTE commands under the stable active root instead; this module's whole
23
+ * job is to make sure those commands exist and run.
24
+ *
25
+ * WHY THE TRANSACTION LOOKS LIKE THIS. Herdr runs `[[build]]` in a TEMPORARY checkout and only
26
+ * afterwards re-reads the manifest, swaps the checkout and registers the plugin (measured
27
+ * 2026-09-16 against herdr 0.9.0 at `c77af189`, `src/cli/plugin.rs:193-230`). So our work happens
28
+ * BEFORE Herdr has committed anything, and Herdr's own rollback covers only its checkout. This
29
+ * module therefore owns exactly one all-or-nothing step — replacing the active runtime — and
30
+ * refuses to describe its success in Herdr's words. It writes `runtime-ready`. It never writes
31
+ * `herdr-installed`, because at the moment it finishes, nobody knows whether Herdr will commit.
32
+ *
33
+ * OWNERSHIP IS PROVEN, NOT ASSUMED. The journal is a CERTIFIED reader's output: exact keys, exact
34
+ * schema version, a phase from the closed set, and a `runtimeRoot` that is this host's stable
35
+ * address. A journal that fails any of those grants no authority to delete or replace anything, and
36
+ * neither does a missing one — a directory sitting at our address with no journal behind it belongs
37
+ * to somebody else until proven otherwise, and this module refuses rather than renaming it away.
38
+ *
39
+ * DISK FACTS COME FROM lstat, AND THEY COME FIRST. `existsSync` follows links, so a dangling symlink
40
+ * at our address reads as "absent" and a symlink to somebody else's tree reads as a directory we may
41
+ * rename. Every one of the three runtime paths is therefore classified as `absent` / `real-dir` /
42
+ * `symlink` / `other`, anything that is not absent-or-a-real-directory is a named refusal, and the
43
+ * whole assessment happens BEFORE the first `mkdir` — a module that creates its own root and then
44
+ * asks whether the root was its own has already answered the question with its own footprint.
45
+ *
46
+ * THE LAST GOOD RUNTIME IS NEVER THROWN AWAY. `active` beside `previous` does not mean `previous` is
47
+ * garbage: it means a swap did not finish, and which of the two is usable is a question only
48
+ * inspection answers. So `previous` is kept until `active` has been inspected as an installed
49
+ * runtime, and when `active` turns out to be corrupt the backup is RESTORED over it. Deleting the
50
+ * backup first and then failing to acquire a candidate is how a host ends a run with nothing usable
51
+ * at all — measured on the first cut of this module, which did exactly that.
52
+ *
53
+ * A TORN SWAP IS A STATE, NOT A MYSTERY. `active → previous`, then `staging → active` is two
54
+ * renames, and a process can die between them. So entry does not blindly clear leftovers: it reads
55
+ * the combination of `active`/`staging`/`previous` against a certified journal and names what it
56
+ * found. The one that matters most is `active` absent with a good `previous` — the crash window —
57
+ * where the previous runtime is RESTORED first, so a candidate that then fails still leaves the
58
+ * host with the runtime it had.
59
+ *
60
+ * WHY THE PACKAGE IS LOCKED TWICE, ON THE npm SOURCE. The exact `name@version` comes from the
61
+ * checkout's own `package.json`; a sibling `runtime-lock.json` repeats it and adds the integrity npm
62
+ * published. The two must agree, and the tarball's OWN sha512 must equal that integrity before
63
+ * anything is installed. The journal records the expected integrity and the observed digest as
64
+ * separate fields, because a digest computed from whatever arrived is a record of what happened —
65
+ * calling it a pin would claim a check that only the comparison performs.
66
+ *
67
+ * WHY THERE IS A SECOND SOURCE, AND WHAT IT MAY NOT BECOME (#116 M3-b3). Requiring a published npm
68
+ * version before a candidate can be installed makes every candidate a release. So the same lock
69
+ * file carries a CLOSED discriminant, and its other branch packs the exact commit Herdr itself
70
+ * checked out — `git+https://github.com/junghan0611/entwurf.git#<full sha>`, a literal remote and a
71
+ * commit read from the checkout, with no URL, ref, env var or caller parameter anywhere in the path.
72
+ * That branch is VERIFICATION-ONLY and it does not weaken the npm branch: there is no fallback
73
+ * between them, the npm integrity comparison is untouched, and a source switch is refused rather
74
+ * than inferred. What replaces the missing registry integrity is named where it happens — the
75
+ * commit pins the tree, the digest records the bytes, and the installed-runtime verifier decides.
76
+ *
77
+ * THE PACK FORM IS PART OF THE CONTRACT. Measured 2026-09-16 (npm 11.16.0): `npm pack <git spec>`
78
+ * runs `prepare` and NOT `prepack`, so the bridge is compiled and no global pnpm is needed, and the
79
+ * tarball is byte-identical across three independent sandboxes including a `--depth 1` clone. `npm
80
+ * pack <directory>` runs `prepack`, which calls `pnpm` and exits 127 on a clean host. Only the git
81
+ * spec is built here; the directory form is structurally absent, not merely avoided.
82
+ *
83
+ * WHY npm's CACHE IS OURS. The acquisition names `npm_config_cache` explicitly, under the user's
84
+ * XDG cache root, so a plugin install never writes into the operator's default npm cache. It also
85
+ * installs with `--ignore-scripts`: a package we are placing on behalf of someone who did not ask
86
+ * for npm must not run arbitrary install hooks in their HOME. The installed artifact therefore has
87
+ * to work with its own scripts never run, which is why completeness is verified rather than assumed.
88
+ *
89
+ * WHAT THIS SLICE STILL DOES NOT DO. It does not touch the manifest, does not read Herdr
90
+ * integration status, and does not activate or deactivate Pi or Claude Code — that is M3-b2. The
91
+ * inverse here removes only what a certified journal proves, takes the runtime LAST among the
92
+ * artifacts, and retires the journal after them because the ledger has to outlive what it
93
+ * authorised.
94
+ */
95
+
96
+ import { spawnSync } from "node:child_process";
97
+ import { createHash } from "node:crypto";
98
+ import fs from "node:fs";
99
+ import path from "node:path";
100
+ import { fileURLToPath } from "node:url";
101
+
102
+ /** Journal format. Bump only with a reader that understands both. */
103
+ export const RUNTIME_SCHEMA_VERSION = 2;
104
+
105
+ /**
106
+ * The two ways a runtime may be ACQUIRED, and they are a closed set (#116 M3-b3).
107
+ *
108
+ * `npm` is the production authority and its semantics are untouched: the lock names an exact
109
+ * `name@version` plus the integrity npm published for it, and the tarball's own sha512 must equal
110
+ * that integrity before anything is installed.
111
+ *
112
+ * `herdr-checkout` is the VERIFICATION-ONLY candidate carrier. A published npm version cannot be a
113
+ * precondition for iterating on a candidate — that is a release step per candidate, which is the
114
+ * churn this carrier exists to remove. It packs the exact commit Herdr itself just checked out, so
115
+ * there is no URL, ref, env var or caller parameter anywhere in it: the identity is already on
116
+ * disk. No registry integrity exists for such an artifact, so the anchor is the COMMIT, and the
117
+ * safety is carried entirely by the final installed-runtime verifier and named refusals.
118
+ */
119
+ export const ARTIFACT_KINDS = Object.freeze(["npm", "herdr-checkout"]);
120
+
121
+ /**
122
+ * The ONE repository a checkout-sourced runtime may come from, as a literal. Herdr's own remote is
123
+ * hardcoded `https://github.com/{owner}/{repo}.git` with no env override (measured 2026-09-16,
124
+ * herdr `src/cli/plugin.rs:763` @ c77af189), so binding this side to the same literal keeps the
125
+ * pair closed: nothing a caller says can point the acquisition at another tree.
126
+ */
127
+ export const CHECKOUT_REPOSITORY = "junghan0611/entwurf";
128
+
129
+ /**
130
+ * The exact key set an `artifactIdentity` carries, per kind AND per stage. Both halves are
131
+ * load-bearing.
132
+ *
133
+ * PER KIND, because the two acquisitions are anchored by different facts and a union that merged
134
+ * them would have to accept a null integrity or a null commit — a shape in which "we have not
135
+ * observed this yet" and "this source has no such fact" are the same value.
136
+ *
137
+ * PER STAGE, because `installing` is written BEFORE an artifact exists. The requested shape has no
138
+ * digest KEY at all rather than a null one: an absent key cannot be mistaken for an observation,
139
+ * and each phase's certifier below names exactly which shape it will accept.
140
+ *
141
+ * `packageName`/`packageVersion` appear ONLY in the checkout-ready shape, and only as COMPLETENESS
142
+ * evidence read off the artifact npm actually produced — what must still be sitting on disk for the
143
+ * tree to be a usable runtime. They are never identity: two different commits can both call
144
+ * themselves `0.21.0`, so identity for this kind is the commit and nothing else.
145
+ */
146
+ export const ARTIFACT_KEYS = Object.freeze({
147
+ npm: Object.freeze({
148
+ requested: Object.freeze(["kind", "name", "version", "expectedIntegrity"]),
149
+ ready: Object.freeze(["kind", "name", "version", "expectedIntegrity", "observedDigest"]),
150
+ }),
151
+ "herdr-checkout": Object.freeze({
152
+ requested: Object.freeze(["kind", "repository", "commit"]),
153
+ ready: Object.freeze(["kind", "repository", "commit", "packageName", "packageVersion", "observedDigest"]),
154
+ }),
155
+ });
156
+
157
+ /**
158
+ * The bins an installed runtime must carry for the scoped wiring above to have anything to name.
159
+ * Measured need, not the package's whole `bin` map: `entwurf` is the operator command,
160
+ * `entwurf-bridge` is what Pi and Claude wiring invoke, and `entwurf-statusline` is demanded by
161
+ * `doctor-meta-bridge` (measured 2026-09-16: `FAIL statusline bin not on PATH: entwurf-statusline`).
162
+ * The agy and Copilot helpers in the same `bin` map belong to harnesses outside this plugin's set P.
163
+ * These are VERIFIED to exist and be executable; none of them is placed on PATH.
164
+ */
165
+ export const REQUIRED_BINS = Object.freeze(["entwurf", "entwurf-bridge", "entwurf-statusline"]);
166
+
167
+ /**
168
+ * The compiled entry an installed consumer must have. Measured in the 0.21.0 tarball; its presence
169
+ * is what separates "npm put a directory there" from "the prebuilt server is installed".
170
+ */
171
+ export const COMPILED_ENTRY = path.join("mcp", "entwurf-bridge", "dist", "mcp", "entwurf-bridge", "src", "index.js");
172
+
173
+ /**
174
+ * Phases a journal may record. `herdr-installed` is DELIBERATELY absent — see the header.
175
+ * `installing` is the incomplete-but-recoverable state entry reconciles.
176
+ */
177
+ export const JOURNAL_PHASES = Object.freeze(["installing", "runtime-ready", "removing"]);
178
+
179
+ /**
180
+ * The exact key set a certified journal carries. Extra or missing keys grant no authority.
181
+ *
182
+ * There is ONE identity field, and every consumer — this journal, `previousRuntime`, the torn-swap
183
+ * carry, and the activation ledger one slice above — uses that same union through the same
184
+ * certifier. A second string beside it ("source", "commit") would be a second authority that could
185
+ * disagree with the first, on exactly the question the whole transaction is about.
186
+ */
187
+ export const JOURNAL_KEYS = Object.freeze([
188
+ "schemaVersion",
189
+ "phase",
190
+ "runtimeRoot",
191
+ "artifactIdentity",
192
+ "previousRuntime",
193
+ ]);
194
+
195
+ /**
196
+ * `previousRuntime` exists because writing the `installing` entry would otherwise OVERWRITE the only
197
+ * record of what was running. A host that dies mid-install would then hold a backup directory and no
198
+ * statement of what it contains — recoverable bytes with unrecoverable provenance. So the prior
199
+ * ready entry's identity is carried forward for exactly as long as a backup can exist, and a
200
+ * finished install sets it back to null because there is no longer a previous runtime to describe.
201
+ * It is a READY identity or null: a backup that was never observed is not a backup anyone can name.
202
+ */
203
+
204
+ /** How a path at one of our three addresses may look. Anything else is refused by name. */
205
+ export const PATH_KINDS = Object.freeze(["absent", "real-dir", "symlink", "other"]);
206
+
207
+ /** The lock file that sits beside the manifest. */
208
+ export const RUNTIME_LOCK_BASENAME = "runtime-lock.json";
209
+
210
+ /** A named refusal. Never a warning, never a silent default (AGENTS.md Hard Rule 15). */
211
+ export class RuntimeBootstrapError extends Error {
212
+ constructor(code, detail) {
213
+ super(`${code}: ${detail}`);
214
+ this.name = "RuntimeBootstrapError";
215
+ this.code = code;
216
+ this.detail = detail;
217
+ }
218
+ }
219
+
220
+ /** Every path this module may touch, derived from XDG only. No defaults invented elsewhere. */
221
+ export function resolveRuntimeLayout(env) {
222
+ const home = env.HOME;
223
+ const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local", "share") : null);
224
+ const cacheHome = env.XDG_CACHE_HOME || (home ? path.join(home, ".cache") : null);
225
+ if (!dataHome || !cacheHome) {
226
+ throw new RuntimeBootstrapError(
227
+ "runtime-xdg-root-unresolvable",
228
+ "neither XDG_DATA_HOME/XDG_CACHE_HOME nor HOME is set, so there is no user root to own",
229
+ );
230
+ }
231
+ const pluginRoot = path.join(dataHome, "entwurf", "herdr-plugin");
232
+ const runtimeRoot = path.join(pluginRoot, "runtime");
233
+ return Object.freeze({
234
+ pluginRoot,
235
+ runtimeRoot,
236
+ activeDir: path.join(runtimeRoot, "active"),
237
+ stagingDir: path.join(runtimeRoot, "staging"),
238
+ previousDir: path.join(runtimeRoot, "previous"),
239
+ journalPath: path.join(pluginRoot, "journal.json"),
240
+ cacheDir: path.join(cacheHome, "entwurf", "herdr-plugin", "npm"),
241
+ });
242
+ }
243
+
244
+ /** The package root this module ships in: `<root>/scripts/` is where it lives. */
245
+ export function defaultCheckoutRoot() {
246
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
247
+ }
248
+
249
+ /** `<root>/plugins/herdr` — where the manifest and the lock file live in a CHECKOUT. */
250
+ export function defaultPluginDir() {
251
+ return path.join(defaultCheckoutRoot(), "plugins", "herdr");
252
+ }
253
+
254
+ /**
255
+ * The ONE production package spec: the exact `name@version` of the checkout that carries this
256
+ * plugin. Nothing here consults the environment or a flag.
257
+ */
258
+ export function readCheckoutPackageSpec(checkoutRoot) {
259
+ const manifestPath = path.join(checkoutRoot, "package.json");
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
263
+ } catch (err) {
264
+ throw new RuntimeBootstrapError("runtime-package-manifest-unreadable", `${manifestPath}: ${err.message}`);
265
+ }
266
+ const { name, version } = parsed;
267
+ if (typeof name !== "string" || name.length === 0 || typeof version !== "string" || version.length === 0) {
268
+ throw new RuntimeBootstrapError(
269
+ "runtime-package-manifest-unreadable",
270
+ `${manifestPath} carries no exact name/version pair`,
271
+ );
272
+ }
273
+ return Object.freeze({ name, version });
274
+ }
275
+
276
+ /**
277
+ * The plugin-owned lock, and it is a DISCRIMINATED UNION whose discriminant is committed.
278
+ *
279
+ * Which source a host acquires from may not be decided by an environment variable, a caller
280
+ * argument or a fallback — all three would let the acquisition authority be chosen at runtime by
281
+ * whoever is running, and a fallback in particular would turn "the registry is unreachable" into
282
+ * "install something else instead" (AGENTS.md Hard Rule 5). So the selector is a field in a file
283
+ * that travels in the checkout, and an unknown or absent `source` is a named refusal rather than a
284
+ * default.
285
+ *
286
+ * `comment` is the one tolerated extra key: a lock is read by people too.
287
+ */
288
+ export function readRuntimeLock(pluginDir) {
289
+ const lockPath = path.join(pluginDir, RUNTIME_LOCK_BASENAME);
290
+ let parsed;
291
+ try {
292
+ parsed = JSON.parse(fs.readFileSync(lockPath, "utf8"));
293
+ } catch (err) {
294
+ throw new RuntimeBootstrapError("runtime-lock-unreadable", `${lockPath}: ${err.message}`);
295
+ }
296
+ // Shape before fields. `JSON.parse` happily returns null, an array or a scalar, and reaching for
297
+ // `.schemaVersion` on any of those leaks a native TypeError with no code — an unnamed crash where
298
+ // the contract promises a named refusal, on the one file that says which package we install.
299
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
300
+ throw new RuntimeBootstrapError(
301
+ "runtime-lock-unreadable",
302
+ `${lockPath} parsed to ${Array.isArray(parsed) ? "an array" : JSON.stringify(parsed)}, not an object`,
303
+ );
304
+ }
305
+ if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION) {
306
+ throw new RuntimeBootstrapError(
307
+ "runtime-lock-unreadable",
308
+ `${lockPath} carries schemaVersion ${JSON.stringify(parsed.schemaVersion)}, not ${RUNTIME_SCHEMA_VERSION}`,
309
+ );
310
+ }
311
+ if (!ARTIFACT_KINDS.includes(parsed.source)) {
312
+ throw new RuntimeBootstrapError(
313
+ "runtime-lock-source-unknown",
314
+ `${lockPath} names source ${JSON.stringify(parsed.source)}; the closed set is ${JSON.stringify(ARTIFACT_KINDS)}`,
315
+ );
316
+ }
317
+ const expected =
318
+ parsed.source === "npm"
319
+ ? ["comment", "integrity", "name", "schemaVersion", "source", "version"]
320
+ : ["comment", "repository", "schemaVersion", "source"];
321
+ const keys = Object.keys(parsed)
322
+ .filter((k) => k !== "comment")
323
+ .sort();
324
+ if (keys.join(",") !== expected.filter((k) => k !== "comment").join(",")) {
325
+ throw new RuntimeBootstrapError(
326
+ "runtime-lock-unreadable",
327
+ `${lockPath} is a ${parsed.source} lock with key set ${keys.join(",")}`,
328
+ );
329
+ }
330
+ if (parsed.source === "npm") {
331
+ if (
332
+ typeof parsed.name !== "string" ||
333
+ typeof parsed.version !== "string" ||
334
+ typeof parsed.integrity !== "string" ||
335
+ !parsed.integrity.startsWith("sha512-")
336
+ ) {
337
+ throw new RuntimeBootstrapError(
338
+ "runtime-lock-unreadable",
339
+ `${lockPath} is not an npm lock with an sha512 integrity`,
340
+ );
341
+ }
342
+ return Object.freeze({ source: "npm", name: parsed.name, version: parsed.version, integrity: parsed.integrity });
343
+ }
344
+ if (parsed.repository !== CHECKOUT_REPOSITORY) {
345
+ throw new RuntimeBootstrapError(
346
+ "runtime-checkout-repository-foreign",
347
+ `${lockPath} names repository ${JSON.stringify(parsed.repository)}; this plugin installs ${CHECKOUT_REPOSITORY} and nothing else`,
348
+ );
349
+ }
350
+ return Object.freeze({ source: "herdr-checkout", repository: parsed.repository });
351
+ }
352
+
353
+ /** The npm lock and the checkout must name the SAME package, exactly. */
354
+ export function certifyLockCoherence(lock, checkoutSpec) {
355
+ if (lock.name !== checkoutSpec.name || lock.version !== checkoutSpec.version) {
356
+ throw new RuntimeBootstrapError(
357
+ "runtime-lock-incoherent",
358
+ `the lock names ${lock.name}@${lock.version} but this checkout is ${checkoutSpec.name}@${checkoutSpec.version}`,
359
+ );
360
+ }
361
+ return Object.freeze({ name: lock.name, version: lock.version, integrity: lock.integrity });
362
+ }
363
+
364
+ /** A commit is forty lowercase hex characters. Anything else is not an anchor. */
365
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
366
+
367
+ /**
368
+ * The canonical full SHA of the checkout this build is running inside.
369
+ *
370
+ * `HEAD^{commit}` is asked for deliberately: it resolves a tag or an annotated object down to the
371
+ * commit, and `--verify` refuses an ambiguous or missing revision instead of echoing the argument
372
+ * back. A shallow clone answers this exactly as a full one does (measured 2026-09-16 on a real
373
+ * `--depth 1` checkout), which matters because Herdr's managed checkout IS shallow.
374
+ */
375
+ export function resolveCheckoutCommit(checkoutRoot, { gitBin = "git", spawn = spawnSync } = {}) {
376
+ const run = spawn(gitBin, ["-C", checkoutRoot, "rev-parse", "--verify", "HEAD^{commit}"], { encoding: "utf8" });
377
+ if (run.error || run.status !== 0) {
378
+ throw new RuntimeBootstrapError(
379
+ "runtime-checkout-commit-unresolvable",
380
+ `${gitBin} -C ${checkoutRoot} rev-parse: ${run.error ? run.error.message : `exit ${run.status}: ${(run.stderr || "").trim().slice(0, 200)}`}`,
381
+ );
382
+ }
383
+ const commit = (run.stdout || "").trim();
384
+ if (!COMMIT_SHA.test(commit)) {
385
+ throw new RuntimeBootstrapError(
386
+ "runtime-checkout-commit-unresolvable",
387
+ `${checkoutRoot} resolved HEAD to ${JSON.stringify(commit)}, which is not a full 40-hex commit`,
388
+ );
389
+ }
390
+ return commit;
391
+ }
392
+
393
+ /**
394
+ * The identity a bootstrap is being ASKED for, derived from the committed lock and — for the
395
+ * checkout source — from the checkout itself. Nothing consults the environment or a flag.
396
+ */
397
+ export function requestedArtifactIdentity({ lock, checkoutRoot, resolveCommit = resolveCheckoutCommit }) {
398
+ if (lock.source === "npm") {
399
+ const locked = certifyLockCoherence(lock, readCheckoutPackageSpec(checkoutRoot));
400
+ return certifyArtifactIdentity("requested artifact", {
401
+ kind: "npm",
402
+ name: locked.name,
403
+ version: locked.version,
404
+ expectedIntegrity: locked.integrity,
405
+ });
406
+ }
407
+ return certifyArtifactIdentity("requested artifact", {
408
+ kind: "herdr-checkout",
409
+ repository: lock.repository,
410
+ commit: resolveCommit(checkoutRoot),
411
+ });
412
+ }
413
+
414
+ /** The fixed product remote. There is no other spelling, and no input reaches it. */
415
+ export function buildCheckoutRemote(repository) {
416
+ return `git+https://github.com/${repository}.git`;
417
+ }
418
+
419
+ /**
420
+ * `npm pack` argv for a checkout-sourced artifact, and the FORM is the contract.
421
+ *
422
+ * Measured 2026-09-16 on npm 11.16.0: packing a git spec runs `prepare` and NOT `prepack`, so the
423
+ * compiled bridge is built and no global pnpm is needed. Packing the DIRECTORY instead runs
424
+ * `prepack`, which calls `pnpm` and exits 127 on a clean host. Those are two different transactions,
425
+ * and only this one may be the product's — a directory pack is structurally absent from this module.
426
+ *
427
+ * `--ignore-scripts` is deliberately NOT here. It belongs to the local-tarball install below; on a
428
+ * pack it would skip `prepare` and produce a tarball with no compiled entry, which the installed
429
+ * verifier then refuses by name (measured: `runtime-compiled-dist-missing`).
430
+ */
431
+ export function buildCheckoutPackArgv(identity, packDestination) {
432
+ return Object.freeze([
433
+ "pack",
434
+ `${buildCheckoutRemote(identity.repository)}#${identity.commit}`,
435
+ "--json",
436
+ "--pack-destination",
437
+ packDestination,
438
+ "--no-audit",
439
+ "--no-fund",
440
+ ]);
441
+ }
442
+
443
+ /** `npm pack` argv for the exact spec, landing the artifact in OUR cache root. */
444
+ export function buildPackArgv(spec, packDestination) {
445
+ return Object.freeze([
446
+ "pack",
447
+ `${spec.name}@${spec.version}`,
448
+ "--json",
449
+ "--pack-destination",
450
+ packDestination,
451
+ "--no-audit",
452
+ "--no-fund",
453
+ ]);
454
+ }
455
+
456
+ /** `npm install` argv for a LOCAL tarball. `--ignore-scripts` is the load-bearing flag. */
457
+ export function buildInstallArgv(tarballPath, prefix) {
458
+ return Object.freeze([
459
+ "install",
460
+ tarballPath,
461
+ "--prefix",
462
+ prefix,
463
+ "--ignore-scripts",
464
+ "--no-audit",
465
+ "--no-fund",
466
+ "--no-save",
467
+ ]);
468
+ }
469
+
470
+ /** The environment npm runs under: the operator's default cache is never the one we fill. */
471
+ export function npmEnvironment(env, cacheDir) {
472
+ return Object.freeze({
473
+ ...env,
474
+ npm_config_cache: cacheDir,
475
+ npm_config_update_notifier: "false",
476
+ });
477
+ }
478
+
479
+ function removeTree(target) {
480
+ fs.rmSync(target, { recursive: true, force: true });
481
+ }
482
+
483
+ /** Journals are replaced, never patched in place: write beside, then rename. */
484
+ export function writeJournal(layout, entry) {
485
+ if (!JOURNAL_PHASES.includes(entry.phase)) {
486
+ throw new RuntimeBootstrapError("runtime-journal-phase-unknown", `phase ${JSON.stringify(entry.phase)}`);
487
+ }
488
+ // Certified on the way OUT as well as in. A journal only the writer can read is a journal that
489
+ // strands the next process — including the teardown, which is the one that has no checkout left
490
+ // to fall back on.
491
+ certifyArtifactIdentity(
492
+ "artifactIdentity",
493
+ entry.artifactIdentity,
494
+ entry.phase === "installing" ? "requested" : entry.phase === "runtime-ready" ? "ready" : "either",
495
+ );
496
+ if (entry.previousRuntime !== null) certifyArtifactIdentity("previousRuntime", entry.previousRuntime, "ready");
497
+ fs.mkdirSync(layout.pluginRoot, { recursive: true });
498
+ const body = `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, ...entry }, null, 2)}\n`;
499
+ const tmp = `${layout.journalPath}.tmp`;
500
+ fs.writeFileSync(tmp, body);
501
+ fs.renameSync(tmp, layout.journalPath);
502
+ }
503
+
504
+ const SHA512 = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
505
+ const SHA256 = /^sha256-[0-9a-f]{64}$/;
506
+
507
+ function isExactIdentity(value) {
508
+ return typeof value === "string" && value.trim().length > 0 && value === value.trim();
509
+ }
510
+
511
+ /**
512
+ * THE one certifier for an `artifactIdentity`, wherever it appears — journal entry, carried
513
+ * `previousRuntime`, or activation ledger. Exact keys for the kind AND the stage; a shape that
514
+ * belongs to the other kind, or to the other stage of its own kind, is a named refusal.
515
+ *
516
+ * @param stage `"requested"` | `"ready"` | `"either"` — which shapes the CALLER's position accepts.
517
+ * @returns the frozen identity, so a certified value cannot be mutated after it was certified.
518
+ */
519
+ export function certifyArtifactIdentity(where, value, stage = "either") {
520
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
521
+ throw new RuntimeBootstrapError(
522
+ "runtime-artifact-identity-uncertified",
523
+ `${where} is ${Array.isArray(value) ? "an array" : JSON.stringify(value)}, not an identity object`,
524
+ );
525
+ }
526
+ if (!ARTIFACT_KINDS.includes(value.kind)) {
527
+ throw new RuntimeBootstrapError(
528
+ "runtime-artifact-identity-uncertified",
529
+ `${where}.kind ${JSON.stringify(value.kind)} is outside ${JSON.stringify(ARTIFACT_KINDS)}`,
530
+ );
531
+ }
532
+ const shapes = stage === "either" ? ["requested", "ready"] : [stage];
533
+ const keys = Object.keys(value).sort().join(",");
534
+ const matched = shapes.find((s) => keys === [...ARTIFACT_KEYS[value.kind][s]].sort().join(","));
535
+ if (matched === undefined) {
536
+ throw new RuntimeBootstrapError(
537
+ "runtime-artifact-identity-uncertified",
538
+ `${where} has key set ${keys}, which is not ${value.kind}'s ${shapes.join(" or ")} shape`,
539
+ );
540
+ }
541
+ if (value.kind === "npm") {
542
+ if (!isExactIdentity(value.name) || !isExactIdentity(value.version)) {
543
+ throw new RuntimeBootstrapError(
544
+ "runtime-artifact-identity-uncertified",
545
+ `${where} carries no exact package identity`,
546
+ );
547
+ }
548
+ if (!SHA512.test(value.expectedIntegrity)) {
549
+ throw new RuntimeBootstrapError(
550
+ "runtime-artifact-identity-uncertified",
551
+ `${where}.expectedIntegrity ${JSON.stringify(value.expectedIntegrity)}`,
552
+ );
553
+ }
554
+ } else {
555
+ // The repo literal travels WITH the identity, and is re-checked wherever it is read: a journal
556
+ // or ledger that names another repository describes an artifact this plugin may not have put
557
+ // there, and inheriting it would make a foreign tree our own by transcription.
558
+ if (value.repository !== CHECKOUT_REPOSITORY) {
559
+ throw new RuntimeBootstrapError(
560
+ "runtime-checkout-repository-foreign",
561
+ `${where}.repository ${JSON.stringify(value.repository)} is not ${CHECKOUT_REPOSITORY}`,
562
+ );
563
+ }
564
+ if (!COMMIT_SHA.test(value.commit)) {
565
+ throw new RuntimeBootstrapError(
566
+ "runtime-artifact-identity-uncertified",
567
+ `${where}.commit ${JSON.stringify(value.commit)} is not a full 40-hex commit`,
568
+ );
569
+ }
570
+ if (matched === "ready" && (!isExactIdentity(value.packageName) || !isExactIdentity(value.packageVersion))) {
571
+ throw new RuntimeBootstrapError(
572
+ "runtime-artifact-identity-uncertified",
573
+ `${where} carries no exact package completeness pair`,
574
+ );
575
+ }
576
+ }
577
+ if (matched === "ready" && !SHA256.test(value.observedDigest)) {
578
+ throw new RuntimeBootstrapError(
579
+ "runtime-artifact-identity-uncertified",
580
+ `${where}.observedDigest ${JSON.stringify(value.observedDigest)}`,
581
+ );
582
+ }
583
+ return Object.freeze({ ...value });
584
+ }
585
+
586
+ /** Which stage a certified identity is at. Derived from its own key set, never stored twice. */
587
+ export function artifactStage(identity) {
588
+ const keys = Object.keys(identity).sort().join(",");
589
+ return keys === [...ARTIFACT_KEYS[identity.kind].ready].sort().join(",") ? "ready" : "requested";
590
+ }
591
+
592
+ /**
593
+ * The ONE place that answers "what name@version must be sitting on disk for this identity". For npm
594
+ * that is the identity itself; for a checkout it is the completeness pair observed off the artifact.
595
+ * Every disk verification — bootstrap idempotence, recovery, activation — asks here, so there is no
596
+ * second opinion to drift from.
597
+ */
598
+ export function artifactCompleteness(identity) {
599
+ if (artifactStage(identity) !== "ready") {
600
+ throw new RuntimeBootstrapError(
601
+ "runtime-artifact-identity-uncertified",
602
+ `a requested ${identity.kind} identity has not observed a package yet, so it cannot say what is on disk`,
603
+ );
604
+ }
605
+ return identity.kind === "npm"
606
+ ? Object.freeze({ name: identity.name, version: identity.version })
607
+ : Object.freeze({ name: identity.packageName, version: identity.packageVersion });
608
+ }
609
+
610
+ /**
611
+ * What the staging tree must hold, given what was asked for and what the acquisition observed.
612
+ *
613
+ * For npm the request itself is the answer — the registry spec IS the name@version. For a checkout
614
+ * the artifact says what it is, and this pair is exactly what the next line checks against the
615
+ * installed tree: an artifact whose metadata and whose installed tree disagree is refused there, by
616
+ * `verifyInstalledRuntime`, and not smoothed over here.
617
+ */
618
+ export function completenessOf(requested, acquired) {
619
+ if (requested.kind === "npm") return Object.freeze({ name: requested.name, version: requested.version });
620
+ if (!isExactIdentity(acquired?.packageName) || !isExactIdentity(acquired?.packageVersion)) {
621
+ throw new RuntimeBootstrapError(
622
+ "runtime-artifact-identity-uncertified",
623
+ `a ${requested.kind} acquisition must report the packed package's exact name and version`,
624
+ );
625
+ }
626
+ return Object.freeze({ name: acquired.packageName, version: acquired.packageVersion });
627
+ }
628
+
629
+ /** The READY identity of what just landed: the request, plus exactly what was observed about it. */
630
+ export function readyIdentity(requested, acquired) {
631
+ const observed =
632
+ requested.kind === "npm"
633
+ ? { ...requested, observedDigest: acquired.observedDigest }
634
+ : {
635
+ ...requested,
636
+ packageName: acquired.packageName,
637
+ packageVersion: acquired.packageVersion,
638
+ observedDigest: acquired.observedDigest,
639
+ };
640
+ return certifyArtifactIdentity("observed artifact", observed, "ready");
641
+ }
642
+
643
+ /**
644
+ * Is a READY identity the artifact a REQUESTED identity is asking for?
645
+ *
646
+ * For a checkout the answer is the commit and ONLY the commit. A version string is not an identity
647
+ * here: two commits can both call themselves `0.21.0`, so believing a version would let a stale
648
+ * runtime satisfy a request for a new candidate — the exact silence this comparison exists to make
649
+ * impossible.
650
+ */
651
+ export function sameArtifactRequest(ready, requested) {
652
+ if (ready.kind !== requested.kind) return false;
653
+ if (ready.kind === "npm") {
654
+ return (
655
+ ready.name === requested.name &&
656
+ ready.version === requested.version &&
657
+ ready.expectedIntegrity === requested.expectedIntegrity
658
+ );
659
+ }
660
+ return ready.repository === requested.repository && ready.commit === requested.commit;
661
+ }
662
+
663
+ /**
664
+ * The CERTIFIED journal, or null when there is none. Nothing else in this module may read the
665
+ * journal: an entry that cannot pass here — including a bare `null`, an array, a scalar, or a shape
666
+ * that contradicts the writer state its own phase implies — grants no authority to delete, rename or
667
+ * replace anything.
668
+ */
669
+ export function readCertifiedJournal(layout) {
670
+ let raw;
671
+ try {
672
+ raw = fs.readFileSync(layout.journalPath, "utf8");
673
+ } catch (err) {
674
+ if (err.code === "ENOENT") return null;
675
+ throw new RuntimeBootstrapError("runtime-journal-uncertified", `${layout.journalPath}: ${err.message}`);
676
+ }
677
+ let parsed;
678
+ try {
679
+ parsed = JSON.parse(raw);
680
+ } catch (err) {
681
+ throw new RuntimeBootstrapError("runtime-journal-uncertified", `${layout.journalPath}: ${err.message}`);
682
+ }
683
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
684
+ throw new RuntimeBootstrapError(
685
+ "runtime-journal-uncertified",
686
+ `${layout.journalPath} parsed to ${Array.isArray(parsed) ? "an array" : JSON.stringify(parsed)}, not an object`,
687
+ );
688
+ }
689
+ const keys = Object.keys(parsed).sort().join(",");
690
+ if (keys !== [...JOURNAL_KEYS].sort().join(",")) {
691
+ throw new RuntimeBootstrapError("runtime-journal-uncertified", `unexpected key set: ${keys}`);
692
+ }
693
+ if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION) {
694
+ throw new RuntimeBootstrapError("runtime-journal-uncertified", `schemaVersion ${parsed.schemaVersion}`);
695
+ }
696
+ if (!JOURNAL_PHASES.includes(parsed.phase)) {
697
+ throw new RuntimeBootstrapError("runtime-journal-uncertified", `phase ${JSON.stringify(parsed.phase)}`);
698
+ }
699
+ // The identity shape a phase may carry IS the writer state that phase describes, and each phase
700
+ // names its own: `installing` has asked for an artifact and not seen one, `runtime-ready` has
701
+ // observed exactly one, and `removing` inherits whichever entry it left. That is why the stage is
702
+ // passed in here rather than accepted loosely — a ready-shaped `installing` entry would claim an
703
+ // observation that never happened, and a requested-shaped ready entry would name a runtime whose
704
+ // bytes nobody looked at.
705
+ certifyArtifactIdentity(
706
+ "artifactIdentity",
707
+ parsed.artifactIdentity,
708
+ parsed.phase === "installing" ? "requested" : parsed.phase === "runtime-ready" ? "ready" : "either",
709
+ );
710
+ if (parsed.previousRuntime !== null) certifyArtifactIdentity("previousRuntime", parsed.previousRuntime, "ready");
711
+ if (parsed.phase === "runtime-ready" && parsed.previousRuntime !== null) {
712
+ throw new RuntimeBootstrapError(
713
+ "runtime-journal-uncertified",
714
+ "a ready entry describes a finished swap, so there is no previous runtime left to name",
715
+ );
716
+ }
717
+ if (parsed.runtimeRoot !== layout.activeDir) {
718
+ throw new RuntimeBootstrapError(
719
+ "runtime-journal-foreign-root",
720
+ `the journal names ${parsed.runtimeRoot}, which is not this host's stable ${layout.activeDir}`,
721
+ );
722
+ }
723
+ return Object.freeze(parsed);
724
+ }
725
+
726
+ /**
727
+ * The identity of a FINISHED runtime, for carrying across an install that may not finish. Only a
728
+ * `runtime-ready` entry has one: an install that never observed an artifact has nothing to hand to
729
+ * the next transaction, and inventing a shape for it would be a provenance claim nobody made.
730
+ */
731
+ export function provenanceOf(journal) {
732
+ if (journal === null || journal.phase !== "runtime-ready") return null;
733
+ return journal.artifactIdentity;
734
+ }
735
+
736
+ /**
737
+ * What a path at one of our addresses IS. `existsSync` follows links, so it answers "absent" for a
738
+ * dangling symlink and "there" for a link into somebody else's tree — neither is a fact this module
739
+ * can act on.
740
+ */
741
+ export function classifyPath(target) {
742
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
743
+ if (stat === undefined) return "absent";
744
+ if (stat.isSymbolicLink()) return "symlink";
745
+ if (stat.isDirectory()) return "real-dir";
746
+ return "other";
747
+ }
748
+
749
+ /**
750
+ * What the three runtime directories mean, given a certified journal. Reading only — this names a
751
+ * state, it does not act on one. All eight presence combinations have a name.
752
+ */
753
+ export function assessRuntimeState(layout, certified) {
754
+ // The root itself is classified too: a symlinked `runtime/` makes `mkdir -p` fail with a bare
755
+ // ENOENT, and an unnamed errno is not a refusal anyone can act on.
756
+ const kinds = {
757
+ runtimeRoot: classifyPath(layout.runtimeRoot),
758
+ active: classifyPath(layout.activeDir),
759
+ staging: classifyPath(layout.stagingDir),
760
+ previous: classifyPath(layout.previousDir),
761
+ // The cache is classified with the rest, because it is the fourth thing this module deletes
762
+ // and a transaction that reclaims it has to have proven it was ours BEFORE the first removal.
763
+ cache: classifyPath(layout.cacheDir),
764
+ };
765
+ const facts = {
766
+ kinds: Object.freeze(kinds),
767
+ active: kinds.active === "real-dir",
768
+ staging: kinds.staging === "real-dir",
769
+ previous: kinds.previous === "real-dir",
770
+ };
771
+ for (const [name, kind] of Object.entries(kinds)) {
772
+ if (kind !== "absent" && kind !== "real-dir") {
773
+ return Object.freeze({ state: "path-kind-refused", refused: `${name}=${kind}`, ...facts });
774
+ }
775
+ }
776
+ const { active, staging, previous } = facts;
777
+ if (certified === null) {
778
+ if (active || staging || previous) return Object.freeze({ state: "unowned-residue", ...facts });
779
+ // CACHE-ONLY residue gets its own name. A cache directory at our address with no journal behind
780
+ // it is somebody else's — or the remains of a generation this host cut — and adopting it would
781
+ // mean this module's first act on a strange host is deleting a tree it cannot prove it wrote.
782
+ if (kinds.cache !== "absent") return Object.freeze({ state: "unowned-cache-residue", ...facts });
783
+ return Object.freeze({ state: "clean", ...facts });
784
+ }
785
+ if (!active && !staging && !previous) return Object.freeze({ state: "owned-empty", ...facts });
786
+ if (!active && !staging && previous) return Object.freeze({ state: "torn-swap", ...facts });
787
+ if (!active && staging && !previous) return Object.freeze({ state: "abandoned-candidate", ...facts });
788
+ if (!active && staging && previous) return Object.freeze({ state: "torn-swap-with-candidate", ...facts });
789
+ if (active && !staging && !previous) return Object.freeze({ state: "settled", ...facts });
790
+ if (active && !staging && previous) return Object.freeze({ state: "backup-pending", ...facts });
791
+ if (active && staging && !previous) return Object.freeze({ state: "stale-candidate", ...facts });
792
+ return Object.freeze({ state: "backup-pending-with-candidate", ...facts });
793
+ }
794
+
795
+ /**
796
+ * INSPECT a tree as an installed package consumer, without demanding a particular version: the
797
+ * package is there under the name we expect, the compiled entry is present, every required bin is
798
+ * present and executable, and the shipped operator command runs. This is the question a recovery has
799
+ * to answer about a directory whose version it does not yet know — "is what is sitting at the active
800
+ * address a usable runtime at all?" — and it is why a backup is never deleted on the strength of a
801
+ * sibling's mere existence.
802
+ *
803
+ * @returns `{ok: true, version}` or `{ok: false, code, detail}` — a verdict, not a throw.
804
+ */
805
+ export function inspectInstalledRuntime(prefix, packageName) {
806
+ const installedRoot = path.join(prefix, "node_modules", packageName);
807
+ let installed;
808
+ try {
809
+ installed = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
810
+ } catch (err) {
811
+ return {
812
+ ok: false,
813
+ code: "runtime-package-spec-mismatch",
814
+ detail: `${installedRoot}/package.json: ${err.message}`,
815
+ };
816
+ }
817
+ if (installed.name !== packageName || !isExactIdentity(installed.version)) {
818
+ return {
819
+ ok: false,
820
+ code: "runtime-package-spec-mismatch",
821
+ detail: `${installedRoot} holds ${JSON.stringify(installed.name)}@${JSON.stringify(installed.version)}`,
822
+ };
823
+ }
824
+ if (!fs.existsSync(path.join(installedRoot, COMPILED_ENTRY))) {
825
+ return { ok: false, code: "runtime-compiled-dist-missing", detail: path.join(installedRoot, COMPILED_ENTRY) };
826
+ }
827
+ for (const name of REQUIRED_BINS) {
828
+ const bin = path.join(prefix, "node_modules", ".bin", name);
829
+ try {
830
+ fs.accessSync(bin, fs.constants.X_OK);
831
+ } catch (err) {
832
+ return { ok: false, code: "runtime-required-bin-missing", detail: `${bin}: ${err.message}` };
833
+ }
834
+ }
835
+ const entwurf = path.join(prefix, "node_modules", ".bin", "entwurf");
836
+ const run = spawnSync(entwurf, ["check-bridge"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
837
+ if (run.status !== 0) {
838
+ return {
839
+ ok: false,
840
+ code: "runtime-check-bridge-failed",
841
+ detail: `${entwurf} check-bridge exited ${run.status}: ${(run.stderr || run.stdout || "").trim().slice(0, 400)}`,
842
+ };
843
+ }
844
+ return { ok: true, version: installed.version };
845
+ }
846
+
847
+ /**
848
+ * Verify a tree as the EXACT runtime asked for: everything `inspectInstalledRuntime` checks, plus
849
+ * the version. Refusals are named and thrown, because this is the gate a candidate must pass before
850
+ * it is allowed to become the active runtime.
851
+ */
852
+ export function verifyInstalledRuntime(prefix, spec) {
853
+ const seen = inspectInstalledRuntime(prefix, spec.name);
854
+ if (!seen.ok) throw new RuntimeBootstrapError(seen.code, seen.detail);
855
+ if (seen.version !== spec.version) {
856
+ throw new RuntimeBootstrapError(
857
+ "runtime-package-spec-mismatch",
858
+ `asked for ${spec.name}@${spec.version}, the tree holds ${spec.name}@${seen.version}`,
859
+ );
860
+ }
861
+ return Object.freeze({ name: spec.name, version: seen.version });
862
+ }
863
+
864
+ /** npm's own integrity format, computed from the bytes that actually arrived. */
865
+ export function integrityOfFile(file) {
866
+ return `sha512-${createHash("sha512").update(fs.readFileSync(file)).digest("base64")}`;
867
+ }
868
+
869
+ /** A second, shorter record of the same bytes — what we observed, never a claim of a check. */
870
+ export function digestFile(file) {
871
+ return `sha256-${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
872
+ }
873
+
874
+ /** `npm pack --json`'s one row, or a named refusal. The metadata is the ARTIFACT's own claim. */
875
+ function readPackedArtifact(packed, cacheDir, failureCode) {
876
+ let row;
877
+ try {
878
+ row = JSON.parse(packed.stdout)[0];
879
+ } catch (err) {
880
+ throw new RuntimeBootstrapError(failureCode, `npm pack --json was unreadable: ${err.message}`);
881
+ }
882
+ if (
883
+ row === undefined ||
884
+ typeof row.filename !== "string" ||
885
+ !isExactIdentity(row.name) ||
886
+ !isExactIdentity(row.version)
887
+ ) {
888
+ throw new RuntimeBootstrapError(
889
+ failureCode,
890
+ `npm pack --json carried no name/version/filename: ${packed.stdout.slice(0, 200)}`,
891
+ );
892
+ }
893
+ return Object.freeze({
894
+ tarball: path.join(cacheDir, path.basename(row.filename)),
895
+ name: row.name,
896
+ version: row.version,
897
+ });
898
+ }
899
+
900
+ /** Install a local tarball into the staging prefix. `--ignore-scripts` is this call's flag. */
901
+ function installLocalTarball(tarball, prefix, npmEnv, npmBin, failureCode) {
902
+ const installed = spawnSync(npmBin, buildInstallArgv(tarball, prefix), { encoding: "utf8", env: npmEnv });
903
+ if (installed.status !== 0) {
904
+ throw new RuntimeBootstrapError(
905
+ failureCode,
906
+ `npm install exited ${installed.status}: ${(installed.stderr || "").trim().slice(0, 400)}`,
907
+ );
908
+ }
909
+ }
910
+
911
+ /** npm acquisition: pack the exact spec into our cache, verify its bytes against the published integrity, install. */
912
+ export function npmAcquire({ identity, prefix, cacheDir, env, npmBin = "npm" }) {
913
+ fs.mkdirSync(cacheDir, { recursive: true });
914
+ const npmEnv = npmEnvironment(env, cacheDir);
915
+ const spec = { name: identity.name, version: identity.version, integrity: identity.expectedIntegrity };
916
+ const packed = spawnSync(npmBin, buildPackArgv(spec, cacheDir), { encoding: "utf8", env: npmEnv });
917
+ if (packed.status !== 0) {
918
+ throw new RuntimeBootstrapError(
919
+ "runtime-acquire-failed",
920
+ `npm pack exited ${packed.status}: ${(packed.stderr || "").trim().slice(0, 400)}`,
921
+ );
922
+ }
923
+ const artifact = readPackedArtifact(packed, cacheDir, "runtime-acquire-failed");
924
+ const observedIntegrity = integrityOfFile(artifact.tarball);
925
+ if (observedIntegrity !== spec.integrity) {
926
+ throw new RuntimeBootstrapError(
927
+ "runtime-artifact-integrity-mismatch",
928
+ `the lock expects ${spec.integrity} for ${spec.name}@${spec.version}, the tarball hashes to ${observedIntegrity}`,
929
+ );
930
+ }
931
+ installLocalTarball(artifact.tarball, prefix, npmEnv, npmBin, "runtime-acquire-failed");
932
+ return Object.freeze({ observedDigest: digestFile(artifact.tarball) });
933
+ }
934
+
935
+ /**
936
+ * Checkout acquisition: pack the EXACT commit from the fixed remote, then install that tarball.
937
+ *
938
+ * There is no integrity to compare against, and this function does not pretend otherwise — a
939
+ * registry publishes an integrity, a commit does not. What stands in its place is spelled out
940
+ * rather than implied: the commit pins WHICH source tree was packed, the digest RECORDS the bytes
941
+ * that arrived, and the caller's `verifyInstalledRuntime` decides whether what landed is a runtime
942
+ * at all. A remote that cannot serve the commit, or a `prepare` that cannot build it, is
943
+ * `runtime-checkout-source-unavailable` — never a fallback to some other source.
944
+ */
945
+ export function checkoutAcquire({ identity, prefix, cacheDir, env, npmBin = "npm" }) {
946
+ fs.mkdirSync(cacheDir, { recursive: true });
947
+ const npmEnv = npmEnvironment(env, cacheDir);
948
+ const packed = spawnSync(npmBin, buildCheckoutPackArgv(identity, cacheDir), { encoding: "utf8", env: npmEnv });
949
+ if (packed.status !== 0) {
950
+ throw new RuntimeBootstrapError(
951
+ "runtime-checkout-source-unavailable",
952
+ `npm pack ${buildCheckoutRemote(identity.repository)}#${identity.commit} exited ${packed.status}: ${(packed.stderr || "").trim().slice(0, 400)}`,
953
+ );
954
+ }
955
+ const artifact = readPackedArtifact(packed, cacheDir, "runtime-checkout-source-unavailable");
956
+ installLocalTarball(artifact.tarball, prefix, npmEnv, npmBin, "runtime-checkout-source-unavailable");
957
+ // COMPLETENESS, not identity: what the artifact says it is, to be checked against the tree.
958
+ return Object.freeze({
959
+ observedDigest: digestFile(artifact.tarball),
960
+ packageName: artifact.name,
961
+ packageVersion: artifact.version,
962
+ });
963
+ }
964
+
965
+ /** The dispatcher, on the identity's own discriminant. No environment, no fallback, no third branch. */
966
+ export function acquireArtifact(args) {
967
+ return args.identity.kind === "npm" ? npmAcquire(args) : checkoutAcquire(args);
968
+ }
969
+
970
+ /**
971
+ * The package name the tree at the ACTIVE address was installed as, according to the journal.
972
+ *
973
+ * An `installing` entry describes what we are reaching for, not what is standing there, so the
974
+ * answer comes from the carried ready identity in that case. It is deliberately NOT taken from the
975
+ * request: on a source switch the request may carry no package name at all, and answering with the
976
+ * candidate's name would inspect the running runtime as if it were already the new one.
977
+ */
978
+ function activePackageName(certified) {
979
+ const ready =
980
+ artifactStage(certified.artifactIdentity) === "ready" ? certified.artifactIdentity : certified.previousRuntime;
981
+ if (ready === null) {
982
+ throw new RuntimeBootstrapError(
983
+ "runtime-owner-state-missing",
984
+ `${certified.runtimeRoot} has a backup beside it but the journal carries no ready identity naming what that backup contains`,
985
+ );
986
+ }
987
+ return artifactCompleteness(ready).name;
988
+ }
989
+
990
+ /**
991
+ * Put the exact runtime at the stable address, or leave the last good one exactly as it was.
992
+ *
993
+ * @returns frozen `{phase, changed, recovered, journal}`
994
+ */
995
+ export function bootstrapRuntime({
996
+ env,
997
+ lock,
998
+ checkoutRoot = defaultCheckoutRoot(),
999
+ acquire = acquireArtifact,
1000
+ resolveCommit = resolveCheckoutCommit,
1001
+ }) {
1002
+ const requested = requestedArtifactIdentity({ lock, checkoutRoot, resolveCommit });
1003
+ const layout = resolveRuntimeLayout(env);
1004
+
1005
+ // PRE-MUTATION, and that word is literal: not one directory is created before ownership and the
1006
+ // disk state have been decided. A module that mkdirs its own root first has answered "is this
1007
+ // ours?" with its own footprint.
1008
+ const certified = readCertifiedJournal(layout);
1009
+ const assessed = assessRuntimeState(layout, certified);
1010
+ if (assessed.state === "path-kind-refused") {
1011
+ throw new RuntimeBootstrapError(
1012
+ "runtime-path-kind-refused",
1013
+ `${layout.runtimeRoot} holds ${assessed.refused}; only an absent path or a real directory may stand at these addresses`,
1014
+ );
1015
+ }
1016
+ if (assessed.state === "unowned-residue") {
1017
+ throw new RuntimeBootstrapError(
1018
+ "runtime-owner-state-missing",
1019
+ `${layout.runtimeRoot} holds ${JSON.stringify(assessed.kinds)} with no certified journal behind it; refusing to move what we cannot prove is ours`,
1020
+ );
1021
+ }
1022
+ if (assessed.state === "unowned-cache-residue") {
1023
+ throw new RuntimeBootstrapError(
1024
+ "runtime-cache-unowned-residue",
1025
+ `${layout.cacheDir} exists with no certified journal behind it; this transaction reclaims only a cache its own journal proves it wrote`,
1026
+ );
1027
+ }
1028
+
1029
+ fs.mkdirSync(layout.runtimeRoot, { recursive: true });
1030
+
1031
+ // Recovery. The backup is the last good runtime until something proves otherwise, so it is never
1032
+ // dropped on the strength of a sibling merely existing.
1033
+ let recovered = null;
1034
+ let restored = false;
1035
+ switch (assessed.state) {
1036
+ case "torn-swap":
1037
+ case "torn-swap-with-candidate":
1038
+ removeTree(layout.stagingDir);
1039
+ fs.renameSync(layout.previousDir, layout.activeDir);
1040
+ recovered = assessed.state;
1041
+ restored = true;
1042
+ break;
1043
+ case "abandoned-candidate":
1044
+ case "stale-candidate":
1045
+ removeTree(layout.stagingDir);
1046
+ recovered = assessed.state;
1047
+ break;
1048
+ case "backup-pending":
1049
+ case "backup-pending-with-candidate": {
1050
+ removeTree(layout.stagingDir);
1051
+ // "Is the active tree a usable runtime at all?" — asked with the name the PREVIOUS ready
1052
+ // identity claims, because that is what the backup beside it was installed as. On a source
1053
+ // switch the requested identity may not even carry a package name.
1054
+ if (inspectInstalledRuntime(layout.activeDir, activePackageName(certified)).ok) {
1055
+ removeTree(layout.previousDir);
1056
+ recovered = "stale-backup";
1057
+ } else {
1058
+ removeTree(layout.activeDir);
1059
+ fs.renameSync(layout.previousDir, layout.activeDir);
1060
+ recovered = "corrupt-active-restored";
1061
+ restored = true;
1062
+ }
1063
+ break;
1064
+ }
1065
+ default:
1066
+ break;
1067
+ }
1068
+
1069
+ // Reconcile journal against disk: only a `runtime-ready` journal for THIS exact spec, whose
1070
+ // active tree is still a REAL DIRECTORY that verifies, may be believed. A recovery just moved a
1071
+ // tree the journal does not describe, so a restore never takes this path.
1072
+ // IDENTITY, not version. `sameArtifactRequest` is the only comparison allowed here: for a
1073
+ // checkout-sourced runtime the anchor is the commit, so a journal that says `0.21.0` while the
1074
+ // request names a different commit is NOT this runtime, and skipping the install on the strength
1075
+ // of a matching version is how a host would keep serving the previous candidate while every
1076
+ // receipt above it claimed the new one.
1077
+ if (
1078
+ !restored &&
1079
+ certified !== null &&
1080
+ certified.phase === "runtime-ready" &&
1081
+ sameArtifactRequest(certified.artifactIdentity, requested) &&
1082
+ classifyPath(layout.activeDir) === "real-dir"
1083
+ ) {
1084
+ try {
1085
+ verifyInstalledRuntime(layout.activeDir, artifactCompleteness(certified.artifactIdentity));
1086
+ return Object.freeze({ phase: "runtime-ready", changed: false, recovered, journal: certified });
1087
+ } catch {
1088
+ // fall through: the journal claimed a runtime the disk does not have.
1089
+ }
1090
+ }
1091
+
1092
+ // Carry the last good provenance forward. Without this, writing `installing` would erase the only
1093
+ // statement of what the backup about to be created actually contains.
1094
+ const carried =
1095
+ certified === null
1096
+ ? null
1097
+ : certified.phase === "runtime-ready"
1098
+ ? provenanceOf(certified)
1099
+ : certified.previousRuntime;
1100
+ writeJournal(layout, {
1101
+ phase: "installing",
1102
+ runtimeRoot: layout.activeDir,
1103
+ artifactIdentity: requested,
1104
+ previousRuntime: carried,
1105
+ });
1106
+
1107
+ // From here the journal above is this transaction's proof of ownership over the staging tree AND
1108
+ // the cache — which is why the cleanup below may reclaim both, and why it could not before the
1109
+ // write. What it may NOT touch is the journal itself, the backup, or the active runtime: those
1110
+ // are the retry authority and the last good runtime, and a failed candidate is not a reason to
1111
+ // have less than we started with.
1112
+ let acquired;
1113
+ try {
1114
+ fs.mkdirSync(layout.stagingDir, { recursive: true });
1115
+ acquired = acquire({ identity: requested, prefix: layout.stagingDir, cacheDir: layout.cacheDir, env });
1116
+ verifyInstalledRuntime(layout.stagingDir, completenessOf(requested, acquired));
1117
+ } catch (err) {
1118
+ removeTree(layout.stagingDir);
1119
+ // A half-fetched tarball left in the cache is the thing a retry would trip over: npm's own
1120
+ // cache entry may be incomplete, and OUR cache is the only place this transaction wrote.
1121
+ removeTree(layout.cacheDir);
1122
+ throw err;
1123
+ }
1124
+
1125
+ let backedUp = false;
1126
+ try {
1127
+ if (classifyPath(layout.activeDir) === "real-dir") {
1128
+ fs.renameSync(layout.activeDir, layout.previousDir);
1129
+ backedUp = true;
1130
+ }
1131
+ fs.renameSync(layout.stagingDir, layout.activeDir);
1132
+ } catch (err) {
1133
+ removeTree(layout.stagingDir);
1134
+ if (backedUp) {
1135
+ removeTree(layout.activeDir);
1136
+ fs.renameSync(layout.previousDir, layout.activeDir);
1137
+ }
1138
+ throw new RuntimeBootstrapError("runtime-swap-failed", err.message);
1139
+ }
1140
+ removeTree(layout.previousDir);
1141
+
1142
+ const journal = {
1143
+ phase: "runtime-ready",
1144
+ runtimeRoot: layout.activeDir,
1145
+ artifactIdentity: readyIdentity(requested, acquired),
1146
+ previousRuntime: null,
1147
+ };
1148
+ writeJournal(layout, journal);
1149
+ return Object.freeze({ phase: "runtime-ready", changed: true, recovered, journal });
1150
+ }
1151
+
1152
+ /**
1153
+ * The inverse, provable-only. Every deletion authority is established BEFORE the first deletion, so
1154
+ * a refusal leaves the host — including the journal's bytes — exactly as it found it. The runtime
1155
+ * goes LAST among the artifacts, and the journal after them, because the ledger has to outlive what
1156
+ * it authorised. No public verb reaches this yet — that orchestration is M3-b2.
1157
+ */
1158
+ export function removeOwnedRuntime({ env }) {
1159
+ const layout = resolveRuntimeLayout(env);
1160
+ const certified = readCertifiedJournal(layout);
1161
+ const assessed = assessRuntimeState(layout, certified);
1162
+ if (assessed.state === "path-kind-refused") {
1163
+ throw new RuntimeBootstrapError(
1164
+ "runtime-inverse-foreign-refused",
1165
+ `${layout.runtimeRoot} holds ${assessed.refused}; only an absent path or a real directory may stand at these addresses`,
1166
+ );
1167
+ }
1168
+ if (certified === null) {
1169
+ if (assessed.state === "unowned-residue") {
1170
+ throw new RuntimeBootstrapError(
1171
+ "runtime-inverse-foreign-refused",
1172
+ `${layout.runtimeRoot} holds ${JSON.stringify(assessed)} with no certified journal behind it`,
1173
+ );
1174
+ }
1175
+ // A CACHE with no journal behind it is refused here for the same reason the forward
1176
+ // transaction refuses it: the inverse's whole plan begins with that directory, and the two
1177
+ // sides of one ownership fact may not read it differently. An inverse that treated it as
1178
+ // "nothing of ours, report success" would be the half that quietly deletes it next time the
1179
+ // forward path is taught to be less careful.
1180
+ if (assessed.state === "unowned-cache-residue") {
1181
+ throw new RuntimeBootstrapError(
1182
+ "runtime-inverse-foreign-refused",
1183
+ `${layout.cacheDir} exists with no certified journal behind it; this inverse removes only what a journal proves it wrote`,
1184
+ );
1185
+ }
1186
+ return Object.freeze({ removed: Object.freeze([]), reason: "runtime-journal-absent" });
1187
+ }
1188
+
1189
+ // PREFLIGHT: everything we are about to delete, decided before anything is deleted.
1190
+ const plan = [];
1191
+ if (fs.existsSync(layout.cacheDir)) plan.push({ what: "cache", target: layout.cacheDir });
1192
+ plan.push({ what: "runtime", target: layout.runtimeRoot });
1193
+
1194
+ writeJournal(layout, { ...certified, phase: "removing" });
1195
+ const removed = [];
1196
+ for (const step of plan) {
1197
+ removeTree(step.target);
1198
+ removed.push(step.what);
1199
+ }
1200
+ fs.rmSync(layout.journalPath, { force: true });
1201
+ removed.push("journal");
1202
+ return Object.freeze({ removed: Object.freeze(removed), reason: null });
1203
+ }