@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.
- package/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- 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
|
+
}
|