@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,270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * herdr-plugin-activate — the forward half of a Herdr-plugin activation (#116 M3-b2).
4
+ *
5
+ * WHAT IT TAKES, AND WHY THAT IS ALL. Exactly a subset of `{pi, claude-code}`, and nothing else: no
6
+ * path, no command, no backend name this lane has not agreed to support. The stable runtime address
7
+ * is DERIVED here from XDG and handed down; the Pi and Claude writers accept a named root only when
8
+ * it is that same derived one. A verb that took a directory would be a way to point every pi session
9
+ * and every Claude MCP call at an executable of the caller's choosing.
10
+ *
11
+ * WHICH BACKENDS is M3-b3's question. This verb does not read Herdr's integration status; it is
12
+ * handed the answer. That keeps `H ∩ P` in one place — the profile leaf — instead of two.
13
+ *
14
+ * ADD-ONLY. A reinstall reconciles what is already active and adds what is newly requested. A
15
+ * backend absent from THIS request is retained, because a harness leaving Herdr's integration list
16
+ * is not a request to tear down wiring the operator still uses. Removal is the explicit verb.
17
+ *
18
+ * THE LEDGER IS WRITTEN BEFORE THE MUTATION, NOT AFTER. Each selected component is recorded
19
+ * `pending` before anything is touched and checkpointed `active` the moment its writer returns, so a
20
+ * run that dies halfway leaves a record of exactly how far it got. A transaction that only writes
21
+ * its result at the end is not retryable — it is re-runnable until the first partial failure.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import {
29
+ ACTIVATABLE_BACKENDS,
30
+ ActivationError,
31
+ certifyArtifactForForward,
32
+ certifyPhaseForForward,
33
+ certifyRootsAgainstLedger,
34
+ componentStates,
35
+ ledgerBody,
36
+ planActivation,
37
+ readCertifiedLedger,
38
+ resolveActivationLayout,
39
+ resolveComponentRoots,
40
+ resolveEntwurfDataRoot,
41
+ writeLedger,
42
+ } from "./herdr-activation.mjs";
43
+ import {
44
+ artifactCompleteness,
45
+ readCertifiedJournal,
46
+ resolveRuntimeLayout,
47
+ verifyInstalledRuntime,
48
+ } from "./herdr-runtime.mjs";
49
+
50
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
51
+
52
+ function run(command, args, env) {
53
+ return spawnSync(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
54
+ }
55
+
56
+ /** The only input: a subset of P, parsed from argv and refused if it is anything else. */
57
+ export function parseBackends(argv) {
58
+ const requested = [];
59
+ for (const arg of argv) {
60
+ if (!ACTIVATABLE_BACKENDS.includes(arg)) {
61
+ throw new ActivationError(
62
+ "activation-backend-outside-p",
63
+ `${JSON.stringify(arg)} — this verb takes a subset of ${JSON.stringify(ACTIVATABLE_BACKENDS)} and nothing else`,
64
+ );
65
+ }
66
+ if (!requested.includes(arg)) requested.push(arg);
67
+ }
68
+ if (requested.length === 0) {
69
+ throw new ActivationError("activation-backend-outside-p", "name at least one backend to activate");
70
+ }
71
+ return ACTIVATABLE_BACKENDS.filter((b) => requested.includes(b));
72
+ }
73
+
74
+ /** One line naming an artifact by its own anchor — the commit for a checkout, the spec for npm. */
75
+ export function describeArtifact(identity) {
76
+ return identity.kind === "npm"
77
+ ? `npm ${identity.name}@${identity.version}`
78
+ : `${identity.repository}@${identity.commit.slice(0, 12)} (${identity.packageName}@${identity.packageVersion})`;
79
+ }
80
+
81
+ export function activate(env, requested) {
82
+ const activation = resolveActivationLayout(env);
83
+ const runtime = resolveRuntimeLayout(env);
84
+ const roots = resolveComponentRoots(env);
85
+
86
+ // 1. the runtime must be certified ready AND actually be a runtime on disk.
87
+ const journal = readCertifiedJournal(runtime);
88
+ if (journal === null || journal.phase !== "runtime-ready") {
89
+ throw new ActivationError(
90
+ "activation-runtime-not-ready",
91
+ `${runtime.activeDir} has no certified runtime-ready journal behind it`,
92
+ );
93
+ }
94
+ // The EXACT name AND version the journal certifies, not merely "a usable tree of that name": a
95
+ // runtime the journal says is 0.21.0 while the disk holds something else is two different answers
96
+ // to the question the wiring is about to be pointed at.
97
+ try {
98
+ verifyInstalledRuntime(runtime.activeDir, artifactCompleteness(journal.artifactIdentity));
99
+ } catch (err) {
100
+ throw new ActivationError("activation-runtime-not-ready", `${err.code ?? "unknown"}: ${err.detail ?? err.message}`);
101
+ }
102
+ const current = journal.artifactIdentity;
103
+
104
+ // 2. the existing ledger, and the roots it was recorded against.
105
+ const ledger = readCertifiedLedger(activation);
106
+ certifyRootsAgainstLedger(ledger, roots, runtime.activeDir);
107
+ certifyPhaseForForward(ledger, requested);
108
+ // 2b. WHICH ARTIFACT this ledger is about. The root is stable and the bytes under it are not, so
109
+ // a reinstall can legally replace the artifact — but only from a settled ledger, with every
110
+ // component active and every activated backend still in the request. Everything up to the single
111
+ // checkpoint below is zero-write, so a refusal here leaves the previous ledger byte-identical.
112
+ const disposition = certifyArtifactForForward(ledger, current, requested);
113
+ const plan = planActivation({ ledger, requested });
114
+ const selected = [...plan.reconcile, ...plan.add];
115
+
116
+ // 3. preflight EVERY selected component before the first byte.
117
+ const preflights = [];
118
+ if (selected.includes("claude-code")) {
119
+ preflights.push([
120
+ "claude-code",
121
+ // The mode rides the ARGV, because that is what the state owner reads. The env var is the
122
+ // installer script's channel; passing only that preflights the DEFAULT mode and then
123
+ // installs the plugin one — a green check of work nobody is going to do.
124
+ run(
125
+ "python3",
126
+ [
127
+ path.join(PACKAGE_ROOT, "scripts", "meta-bridge-state.py"),
128
+ "preflight-install",
129
+ "--repo",
130
+ PACKAGE_ROOT,
131
+ "--plugin-runtime",
132
+ runtime.activeDir,
133
+ ],
134
+ { ...env, ENTWURF_PLUGIN_RUNTIME: runtime.activeDir },
135
+ ),
136
+ ]);
137
+ }
138
+ if (selected.includes("pi")) {
139
+ const agentSettings = path.join(roots.piAgentDir.path, "settings.json");
140
+ const dataRoot = resolveEntwurfDataRoot(env);
141
+ // BOTH halves. The user-scope citizen is a package registration and a provider key with
142
+ // SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
143
+ // preflighting only the provider would let the first ledger write land on a host the package
144
+ // writer was always going to refuse.
145
+ preflights.push([
146
+ "pi-package",
147
+ run(
148
+ "python3",
149
+ [
150
+ path.join(PACKAGE_ROOT, "scripts", "register-pi-package.py"),
151
+ agentSettings,
152
+ PACKAGE_ROOT,
153
+ "--scope",
154
+ "user",
155
+ "--state",
156
+ path.join(dataRoot, "pi-package", "install-state.json"),
157
+ "--preflight",
158
+ ],
159
+ env,
160
+ ),
161
+ ]);
162
+ preflights.push([
163
+ "pi-provider",
164
+ run(
165
+ "python3",
166
+ [
167
+ path.join(PACKAGE_ROOT, "scripts", "register-pi-provider.py"),
168
+ "install",
169
+ agentSettings,
170
+ PACKAGE_ROOT,
171
+ "--scope",
172
+ "user",
173
+ "--state",
174
+ path.join(
175
+ env.XDG_DATA_HOME ?? path.join(env.HOME, ".local", "share"),
176
+ "entwurf",
177
+ "pi-provider",
178
+ "install-state.json",
179
+ ),
180
+ "--plugin-runtime",
181
+ runtime.activeDir,
182
+ "--preflight",
183
+ ],
184
+ env,
185
+ ),
186
+ ]);
187
+ }
188
+ const refused = preflights.filter(([, r]) => r.status !== 0);
189
+ if (refused.length > 0) {
190
+ for (const [name, r] of refused) {
191
+ process.stderr.write(` ${name}: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}\n`);
192
+ }
193
+ throw new ActivationError(
194
+ "activation-preflight-refused",
195
+ `${refused.map(([n]) => n).join(", ")} — zero bytes changed`,
196
+ );
197
+ }
198
+
199
+ // 4. record the intention, THEN mutate, checkpointing each component as it lands.
200
+ //
201
+ // ON A REBIND this write is the whole checkpoint, and it is ONE `writeLedger`: the new artifact
202
+ // identity and the `activating` phase land together. A crash before it leaves the old ledger
203
+ // intact — old identity, phase `active`, nothing half-said. A crash after it leaves a ledger that
204
+ // already names the new artifact with its components `pending`, which the SAME verb resumes.
205
+ // Splitting the identity and the phase across two writes would open a window in which the ledger
206
+ // claims the new artifact while still saying the old transaction finished.
207
+ //
208
+ // Every selected component is `pending` in that case, including ones already active: they are
209
+ // being re-pointed at a different artifact, and a run that left them labelled `active` would be
210
+ // claiming work it has not redone.
211
+ const states = componentStates(ledger);
212
+ for (const backend of selected) {
213
+ if (disposition === "rebind" || states[backend] !== "active") states[backend] = "pending";
214
+ }
215
+ const checkpoint = (phase) =>
216
+ writeLedger(
217
+ activation,
218
+ ledgerBody({ phase, runtimeRoot: runtime.activeDir, artifactIdentity: current, roots, states }),
219
+ );
220
+ checkpoint("activating");
221
+
222
+ for (const backend of selected) {
223
+ const r =
224
+ backend === "pi"
225
+ ? run(
226
+ "bash",
227
+ [path.join(PACKAGE_ROOT, "run.sh"), "install-user-scope", "--plugin-runtime", runtime.activeDir],
228
+ env,
229
+ )
230
+ : run("bash", [path.join(PACKAGE_ROOT, "scripts", "meta-bridge-install.sh")], {
231
+ ...env,
232
+ ENTWURF_PLUGIN_RUNTIME: runtime.activeDir,
233
+ });
234
+ if (r.status !== 0) {
235
+ process.stderr.write(`${(r.stderr || r.stdout || "").trim()}\n`);
236
+ throw new ActivationError(
237
+ "activation-component-failed",
238
+ `${backend} — the ledger records how far this got and is the retry authority`,
239
+ );
240
+ }
241
+ states[backend] = "active";
242
+ checkpoint("activating");
243
+ }
244
+ checkpoint("active");
245
+
246
+ process.stdout.write(
247
+ `[herdr-plugin-activate] active: ${Object.keys(states).join(", ")} (runtime ${runtime.activeDir}, ` +
248
+ `artifact ${describeArtifact(current)}, ${disposition}, ` +
249
+ `pi=${roots.piAgentDir.source}, claude=${roots.claudeConfigDir.source}` +
250
+ `${plan.retained.length > 0 ? `, retained ${plan.retained.join(",")}` : ""})\n`,
251
+ );
252
+ return 0;
253
+ }
254
+
255
+ function sameFile(a, b) {
256
+ try {
257
+ return fs.realpathSync(a) === fs.realpathSync(b);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ if (process.argv[1] && sameFile(process.argv[1], fileURLToPath(import.meta.url))) {
264
+ try {
265
+ process.exit(activate(process.env, parseBackends(process.argv.slice(2))));
266
+ } catch (err) {
267
+ process.stderr.write(`[herdr-plugin-activate] ${err.code ?? "activation-failed"}: ${err.detail ?? err.message}\n`);
268
+ process.exit(1);
269
+ }
270
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * herdr-plugin-deactivate — the explicit teardown of a Herdr-plugin activation (#116 M3-b2).
4
+ *
5
+ * This is the verb Herdr will never call. `herdr plugin uninstall` removes its managed checkout and
6
+ * has no cleanup hook, so the harness wiring and the stable runtime are RETAINED, not cleaned — and
7
+ * the operator needs one command, shipped in the package rather than in the deleted checkout, that
8
+ * takes them back.
9
+ *
10
+ * EVERY IMPORT IS STATIC AND AT THE TOP. This process deletes the runtime it is executing from.
11
+ * Measured on Linux: already-loaded modules and open descriptors survive that, but a lazy `import()`
12
+ * afterwards fails with ERR_MODULE_NOT_FOUND — which would strand a host with its runtime gone, its
13
+ * ledger unretired, and nothing left to run the rest. So nothing here is loaded late, and the gate
14
+ * asserts there is no dynamic import to defer.
15
+ *
16
+ * PREFLIGHT EVERY AUTHORITY, THEN MUTATE. The ledger, the resolved roots, and BOTH component
17
+ * inverses are asked read-only first. Only when all of them are green does the first byte change.
18
+ * A component that then fails stops the run with the runtime and the ledger intact, because the
19
+ * ledger is the retry authority and a teardown that deletes its own record of what is left is one
20
+ * nobody can resume.
21
+ */
22
+
23
+ import { spawnSync } from "node:child_process";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import {
28
+ certifyPhaseForInverse,
29
+ certifyRootsAgainstLedger,
30
+ componentStates,
31
+ ledgerBody,
32
+ piStatePaths,
33
+ planDeactivation,
34
+ readCertifiedLedger,
35
+ resolveActivationLayout,
36
+ resolveComponentRoots,
37
+ writeLedger,
38
+ } from "./herdr-activation.mjs";
39
+ import { removeOwnedRuntime, resolveRuntimeLayout } from "./herdr-runtime.mjs";
40
+
41
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
42
+
43
+ function run(command, args, env) {
44
+ return spawnSync(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
45
+ }
46
+
47
+ function fail(code, detail) {
48
+ process.stderr.write(`[herdr-plugin-deactivate] ${code}: ${detail}\n`);
49
+ process.exit(1);
50
+ }
51
+
52
+ export function deactivate(env) {
53
+ const activation = resolveActivationLayout(env);
54
+ const runtime = resolveRuntimeLayout(env);
55
+ const roots = resolveComponentRoots(env);
56
+
57
+ const ledger = readCertifiedLedger(activation);
58
+ if (ledger === null) {
59
+ process.stdout.write("[herdr-plugin-deactivate] no certified activation ledger — nothing to undo\n");
60
+ return 0;
61
+ }
62
+ certifyRootsAgainstLedger(ledger, roots, runtime.activeDir);
63
+ certifyPhaseForInverse(ledger);
64
+ // On a RETRY the plan is what is LEFT: a component already recorded `removed` is skipped, because
65
+ // its inverse has no ownership state to be admitted by any more and re-running it is a permanent
66
+ // refusal — which is exactly how a half-finished teardown used to become unfinishable.
67
+ const steps = planDeactivation(ledger);
68
+ const states = componentStates(ledger);
69
+ const piState = piStatePaths(env);
70
+
71
+ // ── PREFLIGHT: every remaining authority, zero writes ────────────────────
72
+ const preflights = [];
73
+ if (steps.includes("claude-code")) {
74
+ preflights.push([
75
+ "claude-code",
76
+ run(
77
+ "python3",
78
+ [path.join(PACKAGE_ROOT, "scripts", "meta-bridge-state.py"), "preflight-uninstall", "--repo", PACKAGE_ROOT],
79
+ env,
80
+ ),
81
+ ]);
82
+ }
83
+ if (steps.includes("pi")) {
84
+ const agentSettings = path.join(roots.piAgentDir.path, "settings.json");
85
+ preflights.push([
86
+ "pi-package",
87
+ run(
88
+ "python3",
89
+ [
90
+ path.join(PACKAGE_ROOT, "scripts", "register-pi-package.py"),
91
+ agentSettings,
92
+ PACKAGE_ROOT,
93
+ "--scope",
94
+ "user",
95
+ "--state",
96
+ piState.packageState,
97
+ "--remove",
98
+ "--preflight",
99
+ ],
100
+ env,
101
+ ),
102
+ ]);
103
+ preflights.push([
104
+ "pi-provider",
105
+ run(
106
+ "python3",
107
+ [
108
+ path.join(PACKAGE_ROOT, "scripts", "register-pi-provider.py"),
109
+ "remove",
110
+ agentSettings,
111
+ PACKAGE_ROOT,
112
+ "--scope",
113
+ "user",
114
+ "--state",
115
+ piState.providerState,
116
+ "--preflight",
117
+ ],
118
+ env,
119
+ ),
120
+ ]);
121
+ }
122
+ const refused = preflights.filter(([, r]) => r.status !== 0);
123
+ if (refused.length > 0) {
124
+ for (const [name, r] of refused) {
125
+ process.stderr.write(` ${name}: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}\n`);
126
+ }
127
+ fail("deactivate-preflight-refused", `${refused.map(([n]) => n).join(", ")} — zero bytes changed`);
128
+ }
129
+ process.stdout.write(
130
+ `[herdr-plugin-deactivate] preflight-all ok (${preflights.length === 0 ? "nothing left to undo" : preflights.map(([n]) => n).join(", ")})\n`,
131
+ );
132
+
133
+ // ── MUTATE: retry authority is written BEFORE each step, checkpointed after ──
134
+ // The identity is carried through the teardown unchanged, from the ledger's own record. A
135
+ // teardown does not re-decide which artifact it was: it removes what the ledger says was
136
+ // activated, so rewriting that field mid-inverse could only ever make the record less true.
137
+ const checkpoint = () =>
138
+ writeLedger(
139
+ activation,
140
+ ledgerBody({
141
+ phase: "deactivating",
142
+ runtimeRoot: runtime.activeDir,
143
+ artifactIdentity: ledger.artifactIdentity,
144
+ roots,
145
+ states,
146
+ }),
147
+ );
148
+ checkpoint();
149
+
150
+ const done = [];
151
+ for (const step of steps) {
152
+ if (step === "claude-code" || step === "pi") {
153
+ const verb = step === "pi" ? "remove-user-scope" : "uninstall-meta-bridge";
154
+ const r = run("bash", [path.join(PACKAGE_ROOT, "run.sh"), verb], env);
155
+ if (r.status !== 0) {
156
+ process.stderr.write(`${(r.stderr || r.stdout || "").trim()}\n`);
157
+ fail(
158
+ "deactivate-component-failed",
159
+ `${step} inverse failed — runtime and ledger preserved as retry authority (${done.length} component(s) already removed and recorded)`,
160
+ );
161
+ }
162
+ states[step] = "removed";
163
+ checkpoint();
164
+ done.push(step);
165
+ } else if (step === "runtime") {
166
+ // Past this line the code that wrote this file is gone from disk. Everything below is
167
+ // already loaded; nothing is imported.
168
+ removeOwnedRuntime({ env });
169
+ done.push(step);
170
+ } else if (step === "ledger") {
171
+ fs.rmSync(activation.ledgerPath, { force: true });
172
+ done.push(step);
173
+ }
174
+ }
175
+ process.stdout.write(`[herdr-plugin-deactivate] done: ${done.join(" -> ")}\n`);
176
+ return 0;
177
+ }
178
+
179
+ function sameFile(a, b) {
180
+ try {
181
+ return fs.realpathSync(a) === fs.realpathSync(b);
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ if (process.argv[1] && sameFile(process.argv[1], fileURLToPath(import.meta.url))) {
188
+ try {
189
+ process.exit(deactivate(process.env));
190
+ } catch (err) {
191
+ fail(err.code ?? "deactivate-failed", err.detail ?? err.message);
192
+ }
193
+ }