@junghanacs/entwurf 0.22.0 → 0.23.0

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 (98) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +380 -0
  3. package/DELIVERY.md +10 -2
  4. package/README.md +43 -4
  5. package/VERIFY.md +6 -0
  6. package/docs/setup-clean-host.md +11 -1
  7. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  21. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  22. package/mcp/entwurf-bridge/src/index.ts +41 -41
  23. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  24. package/package.json +4 -4
  25. package/pi-extensions/entwurf-control.ts +91 -52
  26. package/pi-extensions/lib/codex-declaration.js +612 -0
  27. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  28. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  29. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  30. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  31. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  32. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  33. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  34. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  35. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  36. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  37. package/pi-extensions/lib/herdr-placement.ts +276 -0
  38. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  39. package/run.sh +396 -8
  40. package/scripts/check-codex-birth-hook.ts +187 -0
  41. package/scripts/check-entwurf-control-rpc.ts +138 -0
  42. package/scripts/check-entwurf-fact-provider.ts +4 -2
  43. package/scripts/check-entwurf-facts.ts +12 -6
  44. package/scripts/check-entwurf-peers-surface.ts +3 -1
  45. package/scripts/check-entwurf-self-address.ts +68 -0
  46. package/scripts/check-fresh-call-dispatch.ts +301 -0
  47. package/scripts/check-gate-qualification.ts +16 -4
  48. package/scripts/check-herdr-activation.ts +1293 -0
  49. package/scripts/check-herdr-fresh-call.ts +1250 -0
  50. package/scripts/check-herdr-placement.ts +383 -0
  51. package/scripts/check-herdr-plugin-build.ts +842 -0
  52. package/scripts/check-herdr-plugin-profile.ts +371 -0
  53. package/scripts/check-herdr-plugin.ts +523 -0
  54. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  55. package/scripts/check-herdr-sandbox.ts +456 -0
  56. package/scripts/check-herdr-supply.ts +184 -0
  57. package/scripts/check-mux-launcher-fence.ts +138 -0
  58. package/scripts/check-peer-facts.ts +331 -0
  59. package/scripts/check-release-gate-outcomes.ts +12 -0
  60. package/scripts/check-typing-call-fence.ts +296 -0
  61. package/scripts/codex-birth-doctor.sh +159 -16
  62. package/scripts/codex-birth-install.sh +178 -72
  63. package/scripts/codex-birth-uninstall.sh +162 -7
  64. package/scripts/fixtures/herdr-supply.json +29 -0
  65. package/scripts/herdr-activation.mjs +536 -0
  66. package/scripts/herdr-plugin-activate.mjs +270 -0
  67. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  68. package/scripts/herdr-runtime.mjs +1203 -0
  69. package/scripts/install-herdr-ci.sh +96 -0
  70. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  71. package/scripts/lib/tree-digest.ts +97 -0
  72. package/scripts/meta-bridge-install.sh +19 -2
  73. package/scripts/meta-bridge-state.py +167 -11
  74. package/scripts/mutants/codex-caller-seat.json +6 -8
  75. package/scripts/mutants/codex-native.json +195 -29
  76. package/scripts/mutants/control-socket-disconnect.json +57 -0
  77. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  78. package/scripts/mutants/herdr-activation.json +305 -0
  79. package/scripts/mutants/herdr-fresh-call.json +448 -0
  80. package/scripts/mutants/herdr-placement.json +161 -0
  81. package/scripts/mutants/herdr-plugin-build.json +160 -0
  82. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  83. package/scripts/mutants/herdr-plugin.json +133 -0
  84. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  85. package/scripts/mutants/herdr-supply.json +133 -0
  86. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  87. package/scripts/mutants/mux-fresh-call.json +104 -17
  88. package/scripts/mutants/mux-launcher-fence.json +13 -0
  89. package/scripts/mutants/omp-fresh.json +4 -4
  90. package/scripts/mutants/peer-facts.json +98 -0
  91. package/scripts/mutants/self-address.json +33 -0
  92. package/scripts/mutants/typing-call-fence.json +17 -0
  93. package/scripts/peer-facts.ts +120 -0
  94. package/scripts/register-pi-package.py +8 -0
  95. package/scripts/register-pi-provider.py +88 -12
  96. package/scripts/smoke-codex-birth.sh +281 -12
  97. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  98. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # install-herdr-ci — put the MEASURED herdr binary on PATH for one CI job, and nothing else.
3
+ #
4
+ # This is not an installer for people. `entwurf setup` does not call it, the package does not
5
+ # ship it as a command, and it never writes outside the runner's temp dir: Hard Rule 17 says
6
+ # Entwurf supplies Entwurf's bytes, never a harness or a placement owner's. A CI job that wants
7
+ # the herdr rail proven fetches the exact asset this repository has measured, checks its digest
8
+ # BEFORE the file is ever made executable, and exposes only that one directory to later steps.
9
+ #
10
+ # What is deliberately NOT here, each for a reason:
11
+ # - no `curl … | sh`. A pipe to a shell has no version and no digest to check; whatever the
12
+ # upstream script does today it can do something else tomorrow, and the whole point of this
13
+ # file is that CI runs a byte we have named.
14
+ # - no package manager (brew/mise/nix). Each adds a layer whose contents we would then be
15
+ # asserting about without measuring.
16
+ # - no `latest`. A mutable URL turns a green run into a claim about a binary nobody chose.
17
+ # - no `gh attestation verify`. `[측정 2026-09-14, gh 2.97.0]` it cannot close herdr's
18
+ # predicate (`in-toto release/v0.2`) — default is 404, explicit is "no attestations found",
19
+ # while the REST API returns the attestation. A check we cannot run is not a check.
20
+ # - no global install, no ~/.local/bin, no sudo. The binary lives and dies with the job.
21
+ #
22
+ # Every version and digest comes from scripts/fixtures/herdr-supply.json. This file contains
23
+ # none of its own, on purpose: two copies of a digest is how a pin drifts.
24
+ set -euo pipefail
25
+
26
+ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27
+ manifest="${repo_dir}/scripts/fixtures/herdr-supply.json"
28
+
29
+ fail() {
30
+ echo "[install-herdr-ci] FAIL — $1" >&2
31
+ exit 1
32
+ }
33
+
34
+ [ -f "$manifest" ] || fail "the supply manifest is missing: ${manifest}"
35
+
36
+ # The runner's temp dir in CI; a private mktemp when someone runs this by hand. Never a
37
+ # directory that outlives the job.
38
+ dest_dir="${RUNNER_TEMP:-}"
39
+ if [ -z "$dest_dir" ]; then
40
+ dest_dir="$(mktemp -d)"
41
+ fi
42
+ mkdir -p "$dest_dir"
43
+
44
+ read_manifest() {
45
+ node -e '
46
+ const fs = require("node:fs");
47
+ const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
48
+ const arch = process.argv[2];
49
+ const asset = m.assets[arch];
50
+ if (!asset) {
51
+ // Fail closed. A machine architecture we have never measured does not get a
52
+ // "closest" binary — there is no such thing.
53
+ process.stderr.write(`unsupported architecture ${arch}; measured: ${Object.keys(m.assets).join(", ")}\n`);
54
+ process.exit(3);
55
+ }
56
+ process.stdout.write([m.version, m.releaseUrlPrefix, m.tag, asset.name, asset.sha256].join("\n"));
57
+ ' "$manifest" "$1"
58
+ }
59
+
60
+ arch="$(uname -m)"
61
+ if ! fields="$(read_manifest "$arch")"; then
62
+ fail "no measured herdr asset for $(uname -m) — the supply manifest owns that set"
63
+ fi
64
+ version="$(echo "$fields" | sed -n 1p)"
65
+ url_prefix="$(echo "$fields" | sed -n 2p)"
66
+ tag="$(echo "$fields" | sed -n 3p)"
67
+ asset_name="$(echo "$fields" | sed -n 4p)"
68
+ expected_sha="$(echo "$fields" | sed -n 5p)"
69
+
70
+ url="${url_prefix}/${tag}/${asset_name}"
71
+ target="${dest_dir}/herdr"
72
+
73
+ echo "[install-herdr-ci] fetching ${asset_name} (${tag}) for ${arch}"
74
+ curl -fsSL --retry 3 --retry-delay 2 -o "$target" "$url" || fail "download failed: ${url}"
75
+
76
+ # ORDER IS THE SAFETY ARGUMENT: the digest is checked while the file is still inert data.
77
+ # chmod comes after, so a byte we did not choose never becomes a thing this job can run.
78
+ actual_sha="$(sha256sum "$target" | cut -d' ' -f1)"
79
+ if [ "$actual_sha" != "$expected_sha" ]; then
80
+ rm -f "$target"
81
+ fail "digest mismatch for ${asset_name}: expected ${expected_sha}, got ${actual_sha}"
82
+ fi
83
+ chmod +x "$target"
84
+
85
+ reported="$("$target" --version 2>/dev/null || true)"
86
+ case "$reported" in
87
+ *"herdr ${version}"*) ;;
88
+ *) fail "the verified binary reports '${reported}', not 'herdr ${version}' — the manifest and the artifact disagree" ;;
89
+ esac
90
+
91
+ # Only this directory reaches later steps. Nothing is copied into a shared bin dir.
92
+ if [ -n "${GITHUB_PATH:-}" ]; then
93
+ echo "$dest_dir" >>"$GITHUB_PATH"
94
+ fi
95
+ echo "[install-herdr-ci] ok — ${reported} at ${target} (sha256 ${expected_sha})"
96
+ echo "$dest_dir"
@@ -55,11 +55,11 @@ export function buildCodexInstruction(input: CodexInstructionInput): string {
55
55
  'NO cwd parameter and NO placement parameter, and task "After your automatic callback succeeds, answer ACK and ' +
56
56
  'stop. Do not open another sibling."\n' +
57
57
  "2. Stop and wait for the Pi callback delivered into this Codex thread. Accept only a callback whose body " +
58
- "repeats the exact nonce from your LAUNCH receipt and whose sender envelope names a garden id.\n" +
58
+ "repeats the exact nonce from your LAUNCH receipt and whose sender envelope carries a garden id on its `session:` line.\n" +
59
59
  `3. Only after that match, call public mcp__entwurf_bridge__entwurf_v2 once with target ${input.callerGid}, ` +
60
60
  "intent fire-and-forget, wants_reply false, and message:\n" +
61
61
  `${input.finalToken}\nPI_LAUNCH_NONCE=<exact launch nonce>\nPI_CALLBACK_NONCE=<same exact nonce>\n` +
62
- "PI_CALLBACK_FROM=<sender-envelope garden id>\nPI_SESSION_ID=<exact $session id from the LAUNCH receipt>\n" +
62
+ "PI_CALLBACK_FROM=<the garden id on the callback envelope's `session:` line — NOT the model name on its `from:` line>\nPI_SESSION_ID=<exact $session id from the LAUNCH receipt>\n" +
63
63
  "PI_WINDOW_ID=<exact @window id from the LAUNCH receipt>\nDo not claim completion in prose."
64
64
  );
65
65
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * tree-digest — one deterministic digest of a whole directory tree, for gates that claim a tree is
3
+ * UNCHANGED.
4
+ *
5
+ * WHY THIS EXISTS. Two #116 M3-b3 oracles compared a sorted list of relative paths (one of them with
6
+ * sizes) and called the result "byte-identical". A same-length content edit passes both — which is
7
+ * precisely the shape a refusal-must-not-mutate claim needs to catch, because a transaction that
8
+ * rewrites a file in place rarely changes its length. So the digest here reads the actual bytes.
9
+ *
10
+ * WHAT IS IN IT: every entry's relative path, its node TYPE, and for a regular file its permission
11
+ * bits, its size and its whole content; for a symlink its target; for a directory its own entry
12
+ * count, so an emptied directory and a removed one are different facts. That makes the honest claim
13
+ * "content + mode + topology", which is what the callers now say.
14
+ *
15
+ * FRAMING IS LENGTH-PREFIXED. A digest built by concatenating names cannot tell `a/b` + `c` from
16
+ * `a` + `b/c`; every field is therefore written as `label:<byteLength>:<bytes>\0`, so no boundary
17
+ * between two fields can be spelled by the contents of one.
18
+ *
19
+ * MEMORY IS BOUNDED. A runtime tree here is hundreds of megabytes, so files are hashed in 64 KiB
20
+ * chunks through one reused buffer rather than read whole. No `sha256sum`, no shell: a gate that
21
+ * shells out to hash acquires a dependency the hosts it runs on do not owe it.
22
+ */
23
+
24
+ import { createHash, type Hash } from "node:crypto";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+
28
+ const CHUNK_BYTES = 64 * 1024;
29
+
30
+ function field(hash: Hash, label: string, value: string | number): void {
31
+ const bytes = Buffer.from(String(value), "utf8");
32
+ hash.update(`${label}:${bytes.length}:`);
33
+ hash.update(bytes);
34
+ hash.update("\0");
35
+ }
36
+
37
+ function hashFileContent(hash: Hash, file: string): void {
38
+ const fd = fs.openSync(file, "r");
39
+ try {
40
+ const buf = Buffer.allocUnsafe(CHUNK_BYTES);
41
+ let read = fs.readSync(fd, buf, 0, CHUNK_BYTES, null);
42
+ while (read > 0) {
43
+ hash.update(buf.subarray(0, read));
44
+ read = fs.readSync(fd, buf, 0, CHUNK_BYTES, null);
45
+ }
46
+ } finally {
47
+ fs.closeSync(fd);
48
+ }
49
+ hash.update("\0");
50
+ }
51
+
52
+ /**
53
+ * The digest of `root`, or the literal `"absent"` when nothing is there.
54
+ *
55
+ * @returns `sha256-<hex>` over content + mode + topology, or `absent`.
56
+ */
57
+ export function treeDigest(root: string): string {
58
+ const top = fs.lstatSync(root, { throwIfNoEntry: false });
59
+ if (top === undefined) return "absent";
60
+ const hash = createHash("sha256");
61
+ if (!top.isDirectory()) {
62
+ field(hash, top.isSymbolicLink() ? "root-symlink" : "root-nondir", path.basename(root));
63
+ if (top.isSymbolicLink()) field(hash, "target", fs.readlinkSync(root));
64
+ else if (top.isFile()) hashFileContent(hash, root);
65
+ return `sha256-${hash.digest("hex")}`;
66
+ }
67
+ const walk = (dir: string): void => {
68
+ const entries = fs
69
+ .readdirSync(dir, { withFileTypes: true })
70
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
71
+ field(hash, "dir", path.relative(root, dir) || ".");
72
+ field(hash, "entries", entries.length);
73
+ for (const entry of entries) {
74
+ const abs = path.join(dir, entry.name);
75
+ const rel = path.relative(root, abs);
76
+ if (entry.isSymbolicLink()) {
77
+ field(hash, "symlink", rel);
78
+ field(hash, "target", fs.readlinkSync(abs));
79
+ } else if (entry.isDirectory()) {
80
+ field(hash, "subdir", rel);
81
+ } else if (entry.isFile()) {
82
+ const stat = fs.statSync(abs);
83
+ field(hash, "file", rel);
84
+ field(hash, "mode", (stat.mode & 0o7777).toString(8));
85
+ field(hash, "size", stat.size);
86
+ hashFileContent(hash, abs);
87
+ } else {
88
+ field(hash, "other", rel);
89
+ }
90
+ }
91
+ for (const entry of entries) {
92
+ if (entry.isDirectory() && !entry.isSymbolicLink()) walk(path.join(dir, entry.name));
93
+ }
94
+ };
95
+ walk(root);
96
+ return `sha256-${hash.digest("hex")}`;
97
+ }
@@ -170,7 +170,13 @@ echo "[meta-bridge-install] platform=$(uname -s) node=$NODE_VER ($NODE_BIN) clau
170
170
  # settings.json / ~/.claude.json. Re-runs preserve the first snapshot, so
171
171
  # uninstall restores the true pre-entwurf state rather than the last install
172
172
  # run's already-managed values.
173
- python3 "$REPO/scripts/meta-bridge-state.py" prepare --repo "$REPO" --asm "$ASM"
173
+ # #116 M3-b2 — decide before writing. An aggregate activation has to be able to say "nothing will
174
+ # fail once I start" before it starts, and that promise is only real if this half can be checked
175
+ # without mutating.
176
+ python3 "$REPO/scripts/meta-bridge-state.py" preflight-install --repo "$REPO" --asm "$ASM" \
177
+ ${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
178
+ python3 "$REPO/scripts/meta-bridge-state.py" prepare --repo "$REPO" --asm "$ASM" \
179
+ ${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
174
180
 
175
181
  # --- 1. assemble a self-contained, node-baked plugin ------------------------
176
182
  rm -rf "$ASM"
@@ -251,6 +257,15 @@ claude mcp remove pi-tools-bridge -s user >/dev/null 2>&1 || true
251
257
  # in node_modules/@junghanacs/entwurf) wires the STABLE `entwurf-bridge` bin shim; baking
252
258
  # the pnpm store path here would go stale on any peer/version bump. A dev clone pins to
253
259
  # this clone's start.sh. Both branches carry the same env desired_mcp() writes.
260
+ # #116 M3-b2 — the explicit plugin mode comes FIRST, and it is derived, not supplied. A Herdr
261
+ # plugin activates where nothing entwurf is on PATH, so the bare shim cannot resolve; the absolute
262
+ # bridge under the certified stable runtime can. `desired_mcp()` above is still the SSOT — this
263
+ # branch mirrors it, as the comment on the block already demanded.
264
+ if [ -n "${ENTWURF_PLUGIN_RUNTIME:-}" ]; then
265
+ claude mcp add -s user entwurf-bridge \
266
+ -e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
267
+ -- "$ENTWURF_PLUGIN_RUNTIME/node_modules/.bin/entwurf-bridge" >/dev/null
268
+ else
254
269
  case "$REPO" in
255
270
  */node_modules/@junghanacs/entwurf)
256
271
  claude mcp add -s user entwurf-bridge \
@@ -261,6 +276,7 @@ case "$REPO" in
261
276
  -e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
262
277
  -- bash "$REPO/mcp/entwurf-bridge/start.sh" >/dev/null ;;
263
278
  esac
279
+ fi
264
280
  # Capture, THEN match — and require BOTH the exit code and the content.
265
281
  # `<cli> | grep -q` under `set -o pipefail` is a race, not a test: grep exits at the
266
282
  # first match and closes the pipe, the still-writing CLI dies of SIGPIPE (141), and
@@ -283,7 +299,8 @@ echo "[meta-bridge-install] installed entwurf-bridge MCP (scope: user = global r
283
299
  # Re-assert the repo-owned keyset through our stateful manager. The Claude CLI
284
300
  # calls above are allowed to maintain their cache/registry files, but the
285
301
  # operator-facing JSON keys are owned here so uninstall can be honest.
286
- python3 "$REPO/scripts/meta-bridge-state.py" apply --repo "$REPO" --asm "$ASM"
302
+ python3 "$REPO/scripts/meta-bridge-state.py" apply --repo "$REPO" --asm "$ASM" \
303
+ ${ENTWURF_PLUGIN_RUNTIME:+--plugin-runtime "$ENTWURF_PLUGIN_RUNTIME"}
287
304
 
288
305
  # --- evidence ---------------------------------------------------------------
289
306
  echo "--- claude plugin list ---"
@@ -21,6 +21,9 @@ MARKETPLACE = "meta-bridge-local"
21
21
  PLUGIN_REF = f"{PLUGIN}@{MARKETPLACE}"
22
22
  STATE_VERSION = 1
23
23
  OWNER = "entwurf meta-bridge"
24
+ # The only suffix an assembled marketplace path may end in — the inverse acts on that address, and
25
+ # the doctor already refuses anything else, so the preflight uses the SAME shape (not a looser one).
26
+ ASSEMBLED_SUFFIX = "/entwurf/meta-bridge/.assembled"
24
27
 
25
28
  PERMISSION_ALLOW = [
26
29
  "Bash",
@@ -304,6 +307,44 @@ def is_installed_package(repo: Path) -> bool:
304
307
  return parts[-3:] == ("node_modules", "@junghanacs", "entwurf")
305
308
 
306
309
 
310
+ # #116 M3-b2 — the explicit plugin mode. A Herdr plugin activates on a host where nothing entwurf is
311
+ # on PATH, so the bare bin shim cannot resolve. In that mode the commands are DERIVED from the
312
+ # certified stable active runtime root: the caller passes the root, these helpers append the fixed
313
+ # suffixes, and nothing accepts an arbitrary command. The default installed branch below is
314
+ # untouched — mode is an explicit input, never a path heuristic, because a stable-runtime install
315
+ # lives at `.../active/node_modules/@junghanacs/entwurf` and already satisfies every layout test.
316
+ PLUGIN_RUNTIME: Path | None = None
317
+
318
+
319
+ def stable_active_root() -> Path:
320
+ """The ONE runtime address, derived from this process's XDG/HOME exactly as the runtime owner
321
+ derives it (`scripts/herdr-runtime.mjs` resolveRuntimeLayout). A gate pins the two equal."""
322
+ data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
323
+ return Path(data_home) / "entwurf" / "herdr-plugin" / "runtime" / "active"
324
+
325
+
326
+ def set_plugin_runtime(root: Path | None) -> None:
327
+ global PLUGIN_RUNTIME
328
+ if root is None:
329
+ PLUGIN_RUNTIME = None
330
+ return
331
+ if not root.is_absolute():
332
+ die(f"--plugin-runtime must be an ABSOLUTE stable runtime root, got {root}")
333
+ normalised = Path(os.path.normpath(str(root)))
334
+ # Named, not chosen: the caller may say which root, and it is accepted only when it IS this
335
+ # host's stable one. Any absolute directory would let a mode flag point Claude's MCP command at
336
+ # an executable of the caller's choosing.
337
+ expected = stable_active_root()
338
+ if normalised != expected:
339
+ die(f"--plugin-runtime must be this host's stable runtime root {expected}, got {normalised}")
340
+ PLUGIN_RUNTIME = normalised
341
+
342
+
343
+ def plugin_bin(name: str) -> str:
344
+ assert PLUGIN_RUNTIME is not None
345
+ return str(PLUGIN_RUNTIME / "node_modules" / ".bin" / name)
346
+
347
+
307
348
  def desired_mcp(repo: Path) -> dict[str, Any]:
308
349
  env = {
309
350
  # #50 C4: anonymous sends are refused by the BRIDGE DEFAULT now — the
@@ -313,6 +354,8 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
313
354
  # session always has an authoritative garden-id sender.
314
355
  "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
315
356
  }
357
+ if PLUGIN_RUNTIME is not None:
358
+ return {"type": "stdio", "command": plugin_bin("entwurf-bridge"), "args": [], "env": env}
316
359
  if is_installed_package(repo):
317
360
  # Installed package: wire the STABLE `entwurf-bridge` bin shim that npm/pnpm
318
361
  # place on PATH. Baking repo/mcp/entwurf-bridge/start.sh would embed the pnpm
@@ -333,6 +376,8 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
333
376
 
334
377
 
335
378
  def desired_statusline(repo: Path) -> dict[str, Any]:
379
+ if PLUGIN_RUNTIME is not None:
380
+ return {"type": "command", "command": plugin_bin("entwurf-statusline")}
336
381
  if is_installed_package(repo):
337
382
  # Installed package: mirror the MCP stable-bin pattern. The statusLine is
338
383
  # executed at render time, so a bare bin shim lets npm/pnpm update the
@@ -342,20 +387,23 @@ def desired_statusline(repo: Path) -> dict[str, Any]:
342
387
  return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
343
388
 
344
389
 
345
- def prepare(repo: Path, asm: Path) -> None:
346
- existing = load_state(required=False)
347
- state = existing if existing is not None else init_state(repo, asm)
348
- state["updatedAt"] = iso_now()
349
- state["repo"] = str(repo.resolve())
350
- state["assembledMarketplacePath"] = str(asm.resolve())
351
-
390
+ def read_live_documents() -> tuple[dict[str, Any], dict[str, Any]]:
352
391
  settings = read_json(settings_path(), {})
353
392
  root = read_json(claude_root_config_path(), {})
354
393
  if not isinstance(settings, dict):
355
394
  die(f"{settings_path()} root must be a JSON object")
356
395
  if not isinstance(root, dict):
357
396
  die(f"{claude_root_config_path()} root must be a JSON object")
397
+ return settings, root
398
+
358
399
 
400
+ def plan_snapshots(state: dict[str, Any], repo: Path, asm: Path, settings: dict[str, Any], root: dict[str, Any]) -> None:
401
+ """Every snapshot decision `prepare` makes, in one place.
402
+
403
+ #116 M3-b2 amendment. `preflight-install` runs this against DEEP COPIES so the forward promise
404
+ is the real plan and not a JSON-object smoke test: a preflight that only checked the documents
405
+ parse would go green and then let `prepare` die on an array-type mismatch it never looked at.
406
+ """
359
407
  snapshot_value(
360
408
  state,
361
409
  "settings",
@@ -397,6 +445,15 @@ def prepare(repo: Path, asm: Path) -> None:
397
445
  legacy_absent_if_equal=desired_mcp(repo),
398
446
  )
399
447
 
448
+
449
+ def prepare(repo: Path, asm: Path) -> None:
450
+ existing = load_state(required=False)
451
+ state = existing if existing is not None else init_state(repo, asm)
452
+ state["updatedAt"] = iso_now()
453
+ state["repo"] = str(repo.resolve())
454
+ state["assembledMarketplacePath"] = str(asm.resolve())
455
+ settings, root = read_live_documents()
456
+ plan_snapshots(state, repo, asm, settings, root)
400
457
  write_json(state_path(), state, mode=0o600)
401
458
  print(f"[meta-bridge-state] prepared {state_path()}")
402
459
 
@@ -446,10 +503,41 @@ def apply(repo: Path, asm: Path) -> None:
446
503
  print("[meta-bridge-state] applied managed keyset (settings.json + user MCP)")
447
504
 
448
505
 
449
- def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
506
+ def certify_entry(obj: dict[str, Any], entry: dict[str, Any], where: str = "state entry") -> str:
507
+ """Prove this entry COULD be restored, and write nothing.
508
+
509
+ #116 M3-b2 amendment. This is the read-only twin of `restore_entry`, and it exists as one
510
+ function rather than two because the first cut of the uninstall preflight re-implemented the
511
+ checks and knew only ONE of the three entry shapes: it demanded `original` on every entry, while
512
+ `snapshot_array_items` writes `{originalExisted, added}`. Every real Claude activation therefore
513
+ failed its own preflight. A preflight that re-derives the restore rules will drift from them
514
+ again the next time a kind is added — so `restore_entry` calls THIS, and so does the preflight.
515
+ """
450
516
  path = entry.get("path")
451
517
  if not isinstance(path, list) or not all(isinstance(p, str) for p in path):
452
- die("bad state entry path")
518
+ die(f"{where}: bad state entry path")
519
+ kind = entry.get("kind")
520
+ if kind in ("map-entry", "scalar"):
521
+ original = entry.get("original")
522
+ if not isinstance(original, dict) or "existed" not in original:
523
+ die(f"{where}: bad scalar/map original in state")
524
+ return kind
525
+ if kind == "array-items":
526
+ if "originalExisted" not in entry:
527
+ die(f"{where}: array-items entry records no originalExisted")
528
+ if not isinstance(entry.get("added", []), list):
529
+ die(f"{where}: bad array added list in state")
530
+ existed, value = get_nested(obj, path)
531
+ if existed and not isinstance(value, list):
532
+ die(f"{where}: {'.'.join(path)} exists but is not an array; restoration is not applicable")
533
+ return kind
534
+ die(f"{where}: unknown state entry kind {kind!r}")
535
+ raise AssertionError("unreachable")
536
+
537
+
538
+ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
539
+ certify_entry(obj, entry)
540
+ path = entry.get("path")
453
541
  kind = entry.get("kind")
454
542
  if kind in ("map-entry", "scalar"):
455
543
  original = entry.get("original")
@@ -525,8 +613,71 @@ def relinquish_retired_scalar(
525
613
 
526
614
 
527
615
  def preflight_uninstall() -> None:
528
- load_state(required=True)
529
- print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
616
+ """Prove the ENTIRE restoration plan is applicable, writing nothing.
617
+
618
+ #116 M3-b2. This used to be `load_state(required=True)` and a print — it proved the ledger
619
+ existed and nothing else. An aggregate deactivate that calls it and then claims "every inverse
620
+ was preflighted" would be asserting a check that had not run, and a teardown that starts
621
+ mutating on that promise is how a Claude failure strands a Pi half. Every entry is certified
622
+ through the SAME function `restore_entry` uses, so the two cannot drift.
623
+ """
624
+ state = load_state(required=True)
625
+ assert state is not None
626
+ owner = state.get("owner")
627
+ if owner != OWNER:
628
+ die(f"{state_path()} is not ours: owner={owner!r} (expected {OWNER!r})")
629
+ settings, root = read_live_documents()
630
+ certified = 0
631
+ for path_key, doc in (("settings", settings), ("claudeRoot", root)):
632
+ entries = state.get("files", {}).get(path_key, {}).get("keys", {})
633
+ if not isinstance(entries, dict):
634
+ die(f"{state_path()} has no {path_key} key map")
635
+ for name, entry in entries.items():
636
+ if not isinstance(entry, dict):
637
+ die(f"{state_path()} {path_key} entry {name!r} is not a record")
638
+ certify_entry(doc, entry, f"{path_key} entry {name!r}")
639
+ certified += 1
640
+ certify_assembled_path(state.get("assembledMarketplacePath"))
641
+ print(
642
+ f"[meta-bridge-state] uninstall preflight ok ({state_path()}): owner={owner}, "
643
+ f"settings+claudeRoot parse, {certified} restore entries applicable, "
644
+ f"assembled={state.get('assembledMarketplacePath')}"
645
+ )
646
+
647
+
648
+ def certify_assembled_path(recorded: Any) -> None:
649
+ """The recorded marketplace path is an address the inverse acts on, so its SHAPE is checked."""
650
+ if not isinstance(recorded, str) or not recorded:
651
+ die(f"{state_path()} records no assembledMarketplacePath")
652
+ if not os.path.isabs(recorded):
653
+ die(f"{state_path()} assembledMarketplacePath is not absolute: {recorded}")
654
+ if not recorded.endswith(ASSEMBLED_SUFFIX):
655
+ die(f"{state_path()} assembledMarketplacePath does not end in {ASSEMBLED_SUFFIX}: {recorded}")
656
+
657
+
658
+ def preflight_install(repo: Path, asm: Path) -> None:
659
+ """The forward half of the same promise: run the REAL plan on copies, write nothing.
660
+
661
+ An aggregate activation has to be able to say "nothing will fail once I start" before it starts.
662
+ A preflight that only checked the documents parse would go green and then let `prepare` die on
663
+ an array-type mismatch it never looked at, so this walks `plan_snapshots` — the very function
664
+ `prepare` calls — against deep copies and certifies every entry it produces.
665
+ """
666
+ settings, root = read_live_documents()
667
+ existing = load_state(required=False)
668
+ state = copy.deepcopy(existing) if existing is not None else init_state(repo, asm)
669
+ plan_snapshots(state, repo, asm, copy.deepcopy(settings), copy.deepcopy(root))
670
+ planned = 0
671
+ for path_key, doc in (("settings", settings), ("claudeRoot", root)):
672
+ for name, entry in state["files"][path_key]["keys"].items():
673
+ certify_entry(doc, entry, f"planned {path_key} entry {name!r}")
674
+ planned += 1
675
+ certify_assembled_path(str(asm.resolve()))
676
+ mode = "plugin-runtime" if PLUGIN_RUNTIME is not None else ("installed" if is_installed_package(repo) else "clone")
677
+ print(
678
+ f"[meta-bridge-state] install preflight ok (mode={mode}, {planned} planned entries certified, "
679
+ f"mcp={desired_mcp(repo)['command']}, statusline={desired_statusline(repo)['command']}, asm={asm})"
680
+ )
530
681
 
531
682
 
532
683
  def uninstall() -> None:
@@ -667,6 +818,7 @@ def main() -> int:
667
818
  choices=[
668
819
  "prepare",
669
820
  "apply",
821
+ "preflight-install",
670
822
  "preflight-uninstall",
671
823
  "uninstall",
672
824
  "assembled-path",
@@ -678,6 +830,7 @@ def main() -> int:
678
830
  )
679
831
  parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
680
832
  parser.add_argument("--asm", default=None, type=Path)
833
+ parser.add_argument("--plugin-runtime", default=None, type=Path)
681
834
  args = parser.parse_args()
682
835
  repo = args.repo.resolve()
683
836
  # The live artifact always lives under the XDG data dir — dev clone and
@@ -688,10 +841,13 @@ def main() -> int:
688
841
  default_asm = xdg_data / "entwurf" / "meta-bridge" / ".assembled"
689
842
  asm = (args.asm or default_asm).resolve()
690
843
  try:
844
+ set_plugin_runtime(args.plugin_runtime)
691
845
  if args.command == "prepare":
692
846
  prepare(repo, asm)
693
847
  elif args.command == "apply":
694
848
  apply(repo, asm)
849
+ elif args.command == "preflight-install":
850
+ preflight_install(repo, asm)
695
851
  elif args.command == "preflight-uninstall":
696
852
  preflight_uninstall()
697
853
  elif args.command == "uninstall":
@@ -157,10 +157,8 @@
157
157
  "claim": "CODEX-SEAT-TITLE-BECOMES-DELIVERY-TARGET",
158
158
  "title": "the caller's own garden address is replaced by the thread id the PANE TITLE matched, so a forgeable title decides who the fresh sibling is told to call back to. The sibling's first message would then be delivered against an identity nobody vouched for — the exact Hard Rule 16 crossing this lane is built to make impossible: the title is a placement input and may never become an address",
159
159
  "subject": "pi-extensions/lib/mux-fresh-call.ts",
160
- "find": ["\t\t\tcallerGardenId: params.callerGardenId,\n\t\t\tnonce,\n\t\t}),"],
161
- "replace": [
162
- "\t\t\tcallerGardenId: params.callerNativeSessionId ?? params.callerGardenId,\n\t\t\tnonce,\n\t\t}),"
163
- ],
160
+ "find": ["\t\t\tcallerGardenId,\n\t\t\tnonce,\n\t\t}),"],
161
+ "replace": ["\t\t\tcallerGardenId: params.callerNativeSessionId ?? callerGardenId,\n\t\t\tnonce,\n\t\t}),"],
164
162
  "gate": ["bash", "run.sh", "check-mux-fresh-call"],
165
163
  "timeoutSeconds": 120,
166
164
  "signature": "[QK:CODEX-SEAT-TITLE-BECOMES-DELIVERY-TARGET]",
@@ -203,12 +201,12 @@
203
201
  },
204
202
  {
205
203
  "claim": "CODEX-CALLER-PREFLIGHT-SURFACE-WIRED",
206
- "title": "the bridge stops running the caller-side preflight, so a Codex caller with no `thread-id` in its terminal title gets the generic `unresolved` seat refusal instead of the installer that repairs it a capability gap reported as an operator's missing TUI",
207
- "subject": "mcp/entwurf-bridge/src/index.ts",
204
+ "title": "the shared dispatcher stops running the caller-side preflight, so a Codex caller with no `thread-id` in its terminal title gets the generic `unresolved` seat refusal instead of the capability repair and the MCP surface, its only supplier, cannot tell the two apart",
205
+ "subject": "pi-extensions/lib/fresh-call-dispatch.ts",
208
206
  "find": [
209
- "\t\t\t\ttargetMissing === null && callerNativeSessionId !== undefined && placement === undefined\n\t\t\t\t\t? codexCallerFreshPreflight(process.env)\n\t\t\t\t\t: null;"
207
+ "\t\ttargetMissing === null && request.callerNativeSessionId !== undefined && request.placement === undefined\n\t\t\t? codexCallerFreshPreflight(env)\n\t\t\t: null;"
210
208
  ],
211
- "replace": ["\t\t\t\tnull;"],
209
+ "replace": ["\t\tnull;"],
212
210
  "gate": ["bash", "run.sh", "check-mux-fresh-call"],
213
211
  "timeoutSeconds": 120,
214
212
  "signature": "[QK:CODEX-CALLER-PREFLIGHT-SURFACE-WIRED]",