@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,536 @@
1
+ /**
2
+ * herdr-activation — the ledger that records which harnesses this plugin activated, and the plan
3
+ * that undoes it (#116 M3-b2).
4
+ *
5
+ * THIS FILE SHIPS, AND THAT IS THE POINT. Herdr's `plugin uninstall` deletes its managed checkout
6
+ * and calls no cleanup hook, so everything needed to undo an activation has to be inside the npm
7
+ * package, beside `herdr-runtime.mjs` and using its journal API rather than a second copy of it.
8
+ *
9
+ * ACTIVATION IS ADD-ONLY. The plugin re-evaluates `H ∩ P` on every reinstall, so `{pi}` may become
10
+ * `{pi, claude-code}`. It may not become `{}` by the same route: a harness disappearing from Herdr's
11
+ * integration status is not a request to tear down wiring the operator still depends on. Removal is
12
+ * an explicit verb, and only the explicit verb.
13
+ *
14
+ * ROOTS ARE RESOLVED ONCE AND RECORDED. `PI_CODING_AGENT_DIR` and `CLAUDE_CONFIG_DIR` each decide
15
+ * WHERE a component's bytes live, and the same override has to hold for the status read, the
16
+ * install and the eventual removal. Recording the resolved value — and refusing when a later run
17
+ * resolves a different one — is what stops an activation from being undone against the wrong host.
18
+ *
19
+ * PREFLIGHT-ALL OR NOTHING. A multi-component teardown that starts mutating on a promise it never
20
+ * checked is how a Claude failure strands a Pi half. Every inverse here is asked read-only first;
21
+ * the first mutation happens only after all of them are green.
22
+ *
23
+ * NOTHING IS IMPORTED LATE. The deactivate path deletes the runtime it is executing from. Measured
24
+ * on Linux: already-loaded modules and open descriptors survive that, but a lazy `import()`
25
+ * afterwards fails with ERR_MODULE_NOT_FOUND. So every module this path needs is a top-level static
26
+ * import, and the gate proves there is no dynamic import to defer.
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { artifactStage, certifyArtifactIdentity, sameArtifactRequest } from "./herdr-runtime.mjs";
32
+
33
+ /** Ledger format. Bump only with a reader that understands both. */
34
+ export const ACTIVATION_SCHEMA_VERSION = 2;
35
+
36
+ /** Set P, frozen. The ledger may never name anything else. */
37
+ export const ACTIVATABLE_BACKENDS = Object.freeze(["pi", "claude-code"]);
38
+
39
+ /** The exact key set a certified ledger carries. */
40
+ export const LEDGER_KEYS = Object.freeze([
41
+ "schemaVersion",
42
+ "phase",
43
+ "runtimeRoot",
44
+ "artifactIdentity",
45
+ "piAgentDir",
46
+ "claudeConfigDir",
47
+ "claudeUserConfig",
48
+ "activatedBackends",
49
+ "components",
50
+ ]);
51
+
52
+ /** The exact key set a component record carries. `state` is a value from a closed set, never prose. */
53
+ export const COMPONENT_KEYS = Object.freeze(["backend", "state"]);
54
+
55
+ /**
56
+ * The transaction's own progress, written BEFORE each mutation and checkpointed after it.
57
+ *
58
+ * Without this the first cut had a hole with a receipt: a Claude inverse that succeeded and a Pi
59
+ * inverse that then failed left the ledger saying Claude was still active, so the retry ran Claude's
60
+ * preflight against a state file its own first run had already removed — and refused forever. A
61
+ * transaction that cannot say how far it got is not retryable, it is just re-runnable until the
62
+ * first partial failure.
63
+ */
64
+ export const LEDGER_PHASES = Object.freeze(["activating", "active", "deactivating"]);
65
+
66
+ /** What a component row may say about itself. A closed set, so a reader can reason about it. */
67
+ export const COMPONENT_STATES = Object.freeze(["pending", "active", "removed"]);
68
+
69
+ /** Which component states each phase permits. A phase that contradicts its rows is not certified. */
70
+ const PHASE_ALLOWS = Object.freeze({
71
+ activating: Object.freeze(["pending", "active"]),
72
+ active: Object.freeze(["active"]),
73
+ deactivating: Object.freeze(["active", "removed"]),
74
+ });
75
+
76
+ export class ActivationError extends Error {
77
+ constructor(code, detail) {
78
+ super(`${code}: ${detail}`);
79
+ this.name = "ActivationError";
80
+ this.code = code;
81
+ this.detail = detail;
82
+ }
83
+ }
84
+
85
+ /** Where the ledger lives: XDG state, beside nothing else of ours. */
86
+ export function resolveActivationLayout(env) {
87
+ const home = env.HOME;
88
+ const stateHome = env.XDG_STATE_HOME || (home ? path.join(home, ".local", "state") : null);
89
+ if (!stateHome) {
90
+ throw new ActivationError(
91
+ "activation-xdg-root-unresolvable",
92
+ "neither XDG_STATE_HOME nor HOME is set, so there is no user state root to own",
93
+ );
94
+ }
95
+ const stateRoot = path.join(stateHome, "entwurf", "herdr-plugin");
96
+ return Object.freeze({ stateRoot, ledgerPath: path.join(stateRoot, "activation.json") });
97
+ }
98
+
99
+ /**
100
+ * Where each harness's bytes actually go. Recorded so a later run cannot undo an activation against
101
+ * a different host, and carrying WHICH answer it was — an override or the default — because those
102
+ * two are indistinguishable once only the path survives.
103
+ */
104
+ export function resolveComponentRoots(env) {
105
+ const home = env.HOME;
106
+ if (!home) throw new ActivationError("activation-xdg-root-unresolvable", "HOME is unset");
107
+ const piOverride = env.PI_CODING_AGENT_DIR;
108
+ const claudeOverride = env.CLAUDE_CONFIG_DIR;
109
+ return Object.freeze({
110
+ piAgentDir: Object.freeze({
111
+ path: piOverride || path.join(home, ".pi", "agent"),
112
+ source: piOverride ? "PI_CODING_AGENT_DIR" : "default",
113
+ }),
114
+ claudeConfigDir: Object.freeze({
115
+ path: claudeOverride || path.join(home, ".claude"),
116
+ source: claudeOverride ? "CLAUDE_CONFIG_DIR" : "default",
117
+ }),
118
+ // The THIRD root, and the one a `CLAUDE_CONFIG_DIR`-only record silently misses: Claude's
119
+ // user-scope MCP lives in `$HOME/.claude.json`, which the state owner derives from HOME and
120
+ // NOT from the override (`meta-bridge-state.py` claude_root_config_path). Measured by Terra:
121
+ // same XDG, same CLAUDE_CONFIG_DIR, HOME moved — the preflight went green and the MCP entry
122
+ // stayed behind in the old HOME. A ledger that claims to know every component root has to
123
+ // record this one too, or its teardown is aimed at two thirds of the install.
124
+ claudeUserConfig: Object.freeze({ path: path.join(home, ".claude.json"), source: "HOME" }),
125
+ });
126
+ }
127
+
128
+ function certifyRoot(where, value) {
129
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
130
+ throw new ActivationError("activation-ledger-uncertified", `${where} is not an object`);
131
+ }
132
+ if (Object.keys(value).sort().join(",") !== "path,source") {
133
+ throw new ActivationError("activation-ledger-uncertified", `${where} key set: ${Object.keys(value).sort()}`);
134
+ }
135
+ if (typeof value.path !== "string" || !path.isAbsolute(value.path)) {
136
+ throw new ActivationError("activation-ledger-uncertified", `${where}.path is not an absolute path`);
137
+ }
138
+ if (typeof value.source !== "string" || value.source.length === 0) {
139
+ throw new ActivationError("activation-ledger-uncertified", `${where}.source is not a name`);
140
+ }
141
+ }
142
+
143
+ /** Ledgers are replaced, never patched: write beside, then rename. */
144
+ export function writeLedger(layout, entry) {
145
+ fs.mkdirSync(layout.stateRoot, { recursive: true });
146
+ const body = `${JSON.stringify({ schemaVersion: ACTIVATION_SCHEMA_VERSION, ...entry }, null, 2)}\n`;
147
+ const tmp = `${layout.ledgerPath}.tmp`;
148
+ fs.writeFileSync(tmp, body);
149
+ fs.renameSync(tmp, layout.ledgerPath);
150
+ }
151
+
152
+ /**
153
+ * The CERTIFIED ledger, or null. Nothing else may read it: an entry that cannot pass here grants no
154
+ * authority to undo anything, and "roughly the right shape" is how a teardown ends up aimed at a
155
+ * host it was never describing.
156
+ */
157
+ export function readCertifiedLedger(layout) {
158
+ let raw;
159
+ try {
160
+ raw = fs.readFileSync(layout.ledgerPath, "utf8");
161
+ } catch (err) {
162
+ if (err.code === "ENOENT") return null;
163
+ throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath}: ${err.message}`);
164
+ }
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(raw);
168
+ } catch (err) {
169
+ throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath}: ${err.message}`);
170
+ }
171
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
172
+ throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath} is not a JSON object`);
173
+ }
174
+ if (Object.keys(parsed).sort().join(",") !== [...LEDGER_KEYS].sort().join(",")) {
175
+ throw new ActivationError("activation-ledger-uncertified", `key set: ${Object.keys(parsed).sort()}`);
176
+ }
177
+ if (parsed.schemaVersion !== ACTIVATION_SCHEMA_VERSION) {
178
+ throw new ActivationError("activation-ledger-uncertified", `schemaVersion ${parsed.schemaVersion}`);
179
+ }
180
+ if (typeof parsed.runtimeRoot !== "string" || !path.isAbsolute(parsed.runtimeRoot)) {
181
+ throw new ActivationError("activation-ledger-uncertified", "runtimeRoot is not an absolute path");
182
+ }
183
+ // The SAME union and the SAME certifier the runtime journal uses — imported, not re-implemented.
184
+ // A ledger that recorded the artifact in its own words could disagree with the journal about
185
+ // which runtime the wiring below it names, and nothing would be able to say which one was right.
186
+ // A READY shape only: the ledger describes an activation against an artifact that was observed.
187
+ try {
188
+ certifyArtifactIdentity("artifactIdentity", parsed.artifactIdentity, "ready");
189
+ } catch (err) {
190
+ throw new ActivationError("activation-ledger-uncertified", `artifactIdentity: ${err.detail ?? err.message}`);
191
+ }
192
+ certifyRoot("piAgentDir", parsed.piAgentDir);
193
+ certifyRoot("claudeConfigDir", parsed.claudeConfigDir);
194
+ certifyRoot("claudeUserConfig", parsed.claudeUserConfig);
195
+ if (!LEDGER_PHASES.includes(parsed.phase)) {
196
+ throw new ActivationError("activation-ledger-uncertified", `phase ${JSON.stringify(parsed.phase)}`);
197
+ }
198
+ if (!Array.isArray(parsed.activatedBackends) || !Array.isArray(parsed.components)) {
199
+ throw new ActivationError("activation-ledger-uncertified", "activatedBackends/components are not arrays");
200
+ }
201
+ for (const backend of parsed.activatedBackends) {
202
+ if (!ACTIVATABLE_BACKENDS.includes(backend)) {
203
+ throw new ActivationError("activation-ledger-backend-outside-p", `${JSON.stringify(backend)}`);
204
+ }
205
+ }
206
+ // CANONICAL: P's own order, no duplicates. Two spellings of the same set would be two ledgers
207
+ // that mean the same thing and compare differently, and every later decision compares them.
208
+ const canonical = ACTIVATABLE_BACKENDS.filter((b) => parsed.activatedBackends.includes(b));
209
+ if (parsed.activatedBackends.join(",") !== canonical.join(",")) {
210
+ throw new ActivationError(
211
+ "activation-ledger-uncertified",
212
+ `activatedBackends is not canonical/unique: ${JSON.stringify(parsed.activatedBackends)}`,
213
+ );
214
+ }
215
+ const seen = new Set();
216
+ for (const component of parsed.components) {
217
+ if (typeof component !== "object" || component === null || Array.isArray(component)) {
218
+ throw new ActivationError("activation-ledger-uncertified", "a component is not an object");
219
+ }
220
+ if (Object.keys(component).sort().join(",") !== [...COMPONENT_KEYS].sort().join(",")) {
221
+ throw new ActivationError("activation-ledger-uncertified", `component key set: ${Object.keys(component).sort()}`);
222
+ }
223
+ if (!ACTIVATABLE_BACKENDS.includes(component.backend)) {
224
+ throw new ActivationError("activation-ledger-backend-outside-p", `${JSON.stringify(component.backend)}`);
225
+ }
226
+ if (seen.has(component.backend)) {
227
+ throw new ActivationError("activation-ledger-uncertified", `duplicate component ${component.backend}`);
228
+ }
229
+ seen.add(component.backend);
230
+ if (!COMPONENT_STATES.includes(component.state)) {
231
+ throw new ActivationError("activation-ledger-uncertified", `component state ${JSON.stringify(component.state)}`);
232
+ }
233
+ if (!PHASE_ALLOWS[parsed.phase].includes(component.state)) {
234
+ throw new ActivationError(
235
+ "activation-ledger-phase-contradiction",
236
+ `phase ${parsed.phase} does not allow component ${component.backend} in state ${component.state}`,
237
+ );
238
+ }
239
+ }
240
+ // EXACTLY the backends it claims: no component without a backend, no backend without a row.
241
+ if (canonical.join(",") !== ACTIVATABLE_BACKENDS.filter((b) => seen.has(b)).join(",")) {
242
+ throw new ActivationError(
243
+ "activation-ledger-uncertified",
244
+ `activatedBackends ${JSON.stringify(parsed.activatedBackends)} and components ${JSON.stringify([...seen])} disagree`,
245
+ );
246
+ }
247
+ return Object.freeze(parsed);
248
+ }
249
+
250
+ /**
251
+ * Refuse before the first byte when the ledger and this host's environment disagree about where a
252
+ * component's bytes live. An activation recorded under an override and re-run without it is not the
253
+ * same host, and treating it as one is how an inverse edits somebody else's config.
254
+ */
255
+ export function certifyRootsAgainstLedger(ledger, roots, runtimeRoot) {
256
+ if (ledger === null) return;
257
+ const mismatches = [];
258
+ if (ledger.runtimeRoot !== runtimeRoot) mismatches.push(`runtimeRoot ${ledger.runtimeRoot} != ${runtimeRoot}`);
259
+ // BOTH halves: an activation recorded under an explicit override and re-run where the same path
260
+ // happens to be the default is not the same host decision, and the next upgrade of either side
261
+ // moves only one of them.
262
+ for (const name of ["piAgentDir", "claudeConfigDir", "claudeUserConfig"]) {
263
+ for (const field of ["path", "source"]) {
264
+ if (ledger[name][field] !== roots[name][field]) {
265
+ mismatches.push(`${name}.${field} ${ledger[name][field]} != ${roots[name][field]}`);
266
+ }
267
+ }
268
+ }
269
+ if (mismatches.length > 0) {
270
+ throw new ActivationError("activation-roots-drifted", mismatches.join("; "));
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Which ledger phases each verb may act on, and why the other ones are refused.
276
+ *
277
+ * A forward run over a `deactivating` ledger would re-wire components a teardown is midway through
278
+ * removing, and the teardown's own record of where it stopped would be gone. A teardown over an
279
+ * `activating` ledger would skip the components still `pending` — they are not `active`, so the plan
280
+ * has no step for them — and then delete the runtime, leaving partial wiring that names an address
281
+ * with nothing at it. Both are refused by name and told which verb to run instead.
282
+ */
283
+ export function certifyPhaseForForward(ledger, requested) {
284
+ if (ledger === null) return;
285
+ if (ledger.phase === "deactivating") {
286
+ throw new ActivationError(
287
+ "activation-phase-refused",
288
+ "this ledger is mid-teardown; finish or retry `herdr-plugin-deactivate` before activating again",
289
+ );
290
+ }
291
+ if (ledger.phase === "activating") {
292
+ // A retry is welcome — that is what the phase is for — but only for the SAME work. A request
293
+ // that drops a backend left `pending` would strand it: half-wired, and no longer in any record.
294
+ const abandoned = ledger.components
295
+ .filter((c) => c.state === "pending" && !requested.includes(c.backend))
296
+ .map((c) => c.backend);
297
+ if (abandoned.length > 0) {
298
+ // The advice names a path that EXISTS. "deactivate first" would not: the inverse refuses an
299
+ // `activating` ledger for its own reason (it has no step for a pending component and would
300
+ // remove the runtime their half-written wiring names), so telling an operator to run it
301
+ // here would hand them a loop between two refusals.
302
+ throw new ActivationError(
303
+ "activation-phase-refused",
304
+ `a previous activation left ${abandoned.join(", ")} pending; retry this verb with every pending backend ` +
305
+ `included (${[...new Set([...requested, ...abandoned])].sort().join(" ")}), or repair that activation by hand first`,
306
+ );
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Does the ledger describe the runtime that is ACTUALLY standing at the root right now?
313
+ *
314
+ * This is the pre-change consistency question, and it is asked BEFORE anything is installed (#116
315
+ * M3-b3 B1). If the ledger names one artifact and the journal says a different one is at the
316
+ * address, then some earlier run replaced the bytes without rebinding the record — and a build that
317
+ * proceeded would be layering a third artifact on top of a disagreement it did not cause and cannot
318
+ * resolve. That refusal has to happen while the host is still exactly as it was found.
319
+ *
320
+ * IT MUST NOT BE ASKED AFTER A BOOTSTRAP. Once the swap has happened, "ledger names the old
321
+ * artifact, runtime is the new one" is the NORMAL mid-rebind state — the very state the single
322
+ * atomic checkpoint exists to close. So this function has exactly one caller position: the
323
+ * read-only authority check that runs before the runtime is touched.
324
+ *
325
+ * @param current the READY identity describing what is at the active root, or null when nothing does.
326
+ */
327
+ export function certifyLedgerDescribesRuntime(ledger, current) {
328
+ if (ledger === null) return;
329
+ if (current === null) {
330
+ throw new ActivationError(
331
+ "activation-runtime-ledger-mismatch",
332
+ `this ledger records an activation against ${JSON.stringify(ledger.artifactIdentity)} but no certified journal ` +
333
+ `describes what is standing at ${ledger.runtimeRoot}; run \`herdr-plugin-deactivate\` (or repair that runtime) ` +
334
+ "before installing over a state nothing accounts for",
335
+ );
336
+ }
337
+ if (!sameArtifactRequest(ledger.artifactIdentity, current)) {
338
+ throw new ActivationError(
339
+ "activation-runtime-ledger-mismatch",
340
+ `the ledger was recorded against ${JSON.stringify(ledger.artifactIdentity)} while the runtime at ` +
341
+ `${ledger.runtimeRoot} is ${JSON.stringify(current)} — an earlier run replaced the bytes without rebinding ` +
342
+ "the record, and this build may not stack a third artifact on that disagreement",
343
+ );
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The READY identity that describes what is at the active root, or null when nothing does.
349
+ *
350
+ * A `runtime-ready` journal describes itself. An `installing` or `removing` entry describes an
351
+ * intention, so what is actually standing there is the carried `previousRuntime` — which is exactly
352
+ * why that field is carried at all.
353
+ */
354
+ export function runtimeIdentityOnDisk(journal) {
355
+ if (journal === null) return null;
356
+ if (journal.phase === "runtime-ready") return journal.artifactIdentity;
357
+ if (artifactStage(journal.artifactIdentity) === "ready" && journal.previousRuntime === null) {
358
+ return journal.artifactIdentity;
359
+ }
360
+ return journal.previousRuntime;
361
+ }
362
+
363
+ /**
364
+ * Does the ledger already describe the artifact this activation is being pointed at — and if not,
365
+ * may this run REBIND it (#116 M3-b3 C)?
366
+ *
367
+ * The question exists because the runtime address is stable while the artifact at it is not. A
368
+ * reinstall can legitimately replace the bytes under the same root: same source, new commit. The
369
+ * wiring does not change (it names the root, not the version), but the ledger's claim about WHICH
370
+ * artifact it activated does, and a ledger that keeps naming the previous commit is a teardown and
371
+ * a doctor aimed at an artifact that is no longer there.
372
+ *
373
+ * ONE function, TWO call positions, and that is deliberate. Before a bootstrap the `target` is the
374
+ * identity this build INTENDS to install (a requested shape); after a bootstrap it is the identity
375
+ * now standing at the root (a ready shape). `sameArtifactRequest` reads only the anchor fields both
376
+ * shapes carry — kind plus commit, or kind plus name/version/integrity — so the same gate answers
377
+ * the same question at both points, and a build cannot be admitted by one and refused by the other.
378
+ *
379
+ * WHAT IS REFUSED, AND WHY EACH ONE:
380
+ * - a SOURCE change (npm ⇄ herdr-checkout) is not a reinstall, it is a different acquisition
381
+ * authority taking over an existing activation. It needs the operator's explicit teardown, not
382
+ * an inference made mid-build.
383
+ * - a ledger that is not `active` is a transaction somebody else is in the middle of. Rebinding
384
+ * over it would overwrite the only record of how far that run got.
385
+ * - a component that is not `active` is half-wired; rebinding would relabel it as belonging to the
386
+ * new artifact without anyone having pointed it there.
387
+ * - a request that DROPS a backend the ledger holds would leave that backend's wiring attached to
388
+ * an artifact no record names. Add-only is preserved by requiring a superset, not by silently
389
+ * rebinding the rest.
390
+ *
391
+ * @returns `"fresh"` (no ledger) | `"match"` (already this artifact) | `"rebind"` (legal, and the
392
+ * caller must perform it in ONE atomic ledger write before any mutation).
393
+ */
394
+ export function certifyArtifactForForward(ledger, target, requested) {
395
+ if (ledger === null) return "fresh";
396
+ if (sameArtifactRequest(ledger.artifactIdentity, target)) return "match";
397
+ if (ledger.artifactIdentity.kind !== target.kind) {
398
+ throw new ActivationError(
399
+ "activation-artifact-source-drifted",
400
+ `this activation was recorded against a ${ledger.artifactIdentity.kind} artifact and the one it is being ` +
401
+ `pointed at for ${ledger.runtimeRoot} is ${target.kind}; run \`herdr-plugin-deactivate\` and activate again ` +
402
+ "rather than letting one acquisition source inherit the other's activation",
403
+ );
404
+ }
405
+ if (ledger.phase !== "active") {
406
+ throw new ActivationError(
407
+ "activation-rebind-refused",
408
+ `the artifact this activation would name is changing but the ledger is ${ledger.phase}; finish or retry that ` +
409
+ "transaction first — rebinding over it would discard its record of how far it got",
410
+ );
411
+ }
412
+ const unsettled = ledger.components.filter((c) => c.state !== "active").map((c) => `${c.backend}=${c.state}`);
413
+ if (unsettled.length > 0) {
414
+ throw new ActivationError(
415
+ "activation-rebind-refused",
416
+ `the artifact this activation would name is changing but ${unsettled.join(", ")} is not active; a half-wired ` +
417
+ "component may not be relabelled as belonging to the new artifact",
418
+ );
419
+ }
420
+ const dropped = ledger.activatedBackends.filter((b) => !requested.includes(b));
421
+ if (dropped.length > 0) {
422
+ throw new ActivationError(
423
+ "activation-rebind-refused",
424
+ `the artifact this activation would name is changing but this request omits ${dropped.join(", ")}, whose wiring ` +
425
+ `names that same root; retry with every activated backend included (${[...new Set([...requested, ...ledger.activatedBackends])].sort().join(" ")})`,
426
+ );
427
+ }
428
+ return "rebind";
429
+ }
430
+
431
+ export function certifyPhaseForInverse(ledger) {
432
+ if (ledger === null) return;
433
+ if (ledger.phase === "activating") {
434
+ throw new ActivationError(
435
+ "activation-phase-refused",
436
+ "this ledger is mid-activation with components still pending; retry or repair the activation first — " +
437
+ "tearing down now would skip them and then remove the runtime their wiring names",
438
+ );
439
+ }
440
+ }
441
+
442
+ /**
443
+ * What an activation should do, given what is already recorded. ADD-ONLY: a backend the ledger
444
+ * already holds is reconciled, a new one in `H ∩ P` is added, and one that has DISAPPEARED from the
445
+ * request is left exactly where it is. Anything outside P never enters the plan at all.
446
+ */
447
+ export function planActivation({ ledger, requested }) {
448
+ const outside = requested.filter((b) => !ACTIVATABLE_BACKENDS.includes(b));
449
+ if (outside.length > 0) {
450
+ throw new ActivationError("activation-backend-outside-p", `${JSON.stringify(outside)}`);
451
+ }
452
+ const already = ledger === null ? [] : ledger.activatedBackends;
453
+ const reconcile = ACTIVATABLE_BACKENDS.filter((b) => already.includes(b) && requested.includes(b));
454
+ const add = ACTIVATABLE_BACKENDS.filter((b) => !already.includes(b) && requested.includes(b));
455
+ const retained = ACTIVATABLE_BACKENDS.filter((b) => already.includes(b) && !requested.includes(b));
456
+ return Object.freeze({
457
+ reconcile: Object.freeze(reconcile),
458
+ add: Object.freeze(add),
459
+ // NOT "remove". A harness that left H is not a request to tear down wiring.
460
+ retained: Object.freeze(retained),
461
+ resulting: Object.freeze(ACTIVATABLE_BACKENDS.filter((b) => already.includes(b) || requested.includes(b))),
462
+ });
463
+ }
464
+
465
+ /**
466
+ * `$XDG_DATA_HOME/entwurf`, resolved the SAME way `run.sh` and the runtime owner resolve it. The
467
+ * first cut used `env.XDG_DATA_HOME || ""`, which on a host with XDG unset built a path rooted at
468
+ * `/entwurf/...` while every writer used `$HOME/.local/share/entwurf` — a preflight that looked at
469
+ * a file nobody writes and passed for the wrong reason.
470
+ */
471
+ export function resolveEntwurfDataRoot(env) {
472
+ const home = env.HOME;
473
+ const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local", "share") : null);
474
+ if (!dataHome) {
475
+ throw new ActivationError("activation-xdg-root-unresolvable", "neither XDG_DATA_HOME nor HOME is set");
476
+ }
477
+ return path.join(dataHome, "entwurf");
478
+ }
479
+
480
+ /** The two Pi ownership records the inverse is admitted by. */
481
+ export function piStatePaths(env) {
482
+ const root = resolveEntwurfDataRoot(env);
483
+ return Object.freeze({
484
+ packageState: path.join(root, "pi-package", "install-state.json"),
485
+ providerState: path.join(root, "pi-provider", "install-state.json"),
486
+ });
487
+ }
488
+
489
+ /** A component row, in the one shape the certifier accepts. */
490
+ export function componentRow(backend, state) {
491
+ return Object.freeze({ backend, state });
492
+ }
493
+
494
+ /** A whole ledger body, canonical by construction. */
495
+ export function ledgerBody({ phase, runtimeRoot, artifactIdentity, roots, states }) {
496
+ const backends = ACTIVATABLE_BACKENDS.filter((b) => Object.hasOwn(states, b));
497
+ return {
498
+ phase,
499
+ runtimeRoot,
500
+ artifactIdentity,
501
+ piAgentDir: roots.piAgentDir,
502
+ claudeConfigDir: roots.claudeConfigDir,
503
+ claudeUserConfig: roots.claudeUserConfig,
504
+ activatedBackends: backends,
505
+ components: backends.map((b) => componentRow(b, states[b])),
506
+ };
507
+ }
508
+
509
+ /** The component states a ledger currently records, as a plain map. */
510
+ export function componentStates(ledger) {
511
+ const states = {};
512
+ if (ledger !== null) for (const c of ledger.components) states[c.backend] = c.state;
513
+ return states;
514
+ }
515
+
516
+ /**
517
+ * The order a teardown must follow, and — on a RETRY — what is left of it. A component already
518
+ * recorded `removed` is skipped rather than re-run: its inverse has no state left to be admitted by,
519
+ * so re-running it is a permanent refusal, which is exactly how the first cut got stuck.
520
+ */
521
+ export function planDeactivation(ledger) {
522
+ if (ledger === null) {
523
+ throw new ActivationError("activation-ledger-absent", "there is no certified activation to undo");
524
+ }
525
+ const states = componentStates(ledger);
526
+ const steps = [];
527
+ // Components first, most-dependent last: both harnesses' wiring NAMES the runtime, so they must
528
+ // be gone before the thing they name.
529
+ for (const backend of ["claude-code", "pi"]) {
530
+ if (states[backend] === "active") steps.push(backend);
531
+ }
532
+ steps.push("runtime");
533
+ // The ledger is the retry authority, so it is retired after everything it authorised.
534
+ steps.push("ledger");
535
+ return Object.freeze(steps);
536
+ }