@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,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"
@@ -69,7 +69,22 @@ function listWorkSurface(pathspec: string[]): string[] {
69
69
  }
70
70
 
71
71
  const legacyFiles = listWorkSurface(["scripts/"]);
72
- const frameworkFiles = listWorkSurface(["test/", "vitest.config.ts"]);
72
+ // The framework axis is every vitest-managed lane, and since #119 V2 that is TWO locations:
73
+ // the migration lane under test/, and tests written beside the behaviour they certify. The
74
+ // pathspecs are `:(glob)` so `**` means "at any depth" and nothing but a test file enters —
75
+ // pi-extensions/ and plugins/ are product trees, and pulling them in whole would put 20k lines
76
+ // of product into a VERIFICATION denominator.
77
+ //
78
+ // This is what keeps the combined total honest through the V3 migration: a gate moving from
79
+ // scripts/ to a file beside its subject moves lines BETWEEN axes, and only a drop in the
80
+ // combined number is subtraction. Before this, such a gate left the denominator entirely and
81
+ // every migrated line read as a deletion.
82
+ const frameworkFiles = listWorkSurface([
83
+ "test/",
84
+ "vitest.config.ts",
85
+ ":(glob)pi-extensions/**/*.test.ts",
86
+ ":(glob)plugins/herdr/**/*.test.mjs",
87
+ ]);
73
88
 
74
89
  function countLines(rel: string): number {
75
90
  const body = fs.readFileSync(path.join(REPO_DIR, rel), "utf8");
@@ -83,6 +98,20 @@ const H_TEXT = /readFileSync[^\n]*(?:pi-extensions|mcp\/|\.ts["'`]|SOURCE|SRC)|\
83
98
  const H_PROC = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync|fork)\s*\(|subprocess\.(?:run|Popen|check_)/;
84
99
  const H_NET = /\b(?:http|net)\.createServer|\.listen\(/;
85
100
  const H_FS = /from\s+["']node:fs["']|require\(["']node:fs["']\)/;
101
+ // H_IMPORTS asks for an ASCENDING path into a product tree, because every gate under scripts/
102
+ // has to climb to reach one. A test written beside its subject never climbs: it imports
103
+ // `./subject.ts`. The distinction is the file's own location, not its spelling — a lane living
104
+ // inside pi-extensions/ or plugins/ that imports a relative sibling is importing product by
105
+ // construction. Applied ONLY to those lanes: scripts/check-*.ts also import `./lib/*`, and that
106
+ // is the verification surface importing itself, which is a different fact the mutant inventory
107
+ // already counts as infra-subject.
108
+ const H_IMPORTS_SIBLING = /(?:from\s+["']|import\(\s*["'])\.{1,2}\//;
109
+ // Wider than the framework-axis glob (`plugins/herdr/**`) on purpose, and the asymmetry only
110
+ // runs one way: the glob decides what ENTERS the denominator, this decides how a file already
111
+ // in it is read. A test under some other plugins/ package would have to be admitted by the
112
+ // glob first, so the looser shape here cannot inflate a count — it only keeps the classifier
113
+ // from throwing on the day that glob widens.
114
+ const BESIDE_BEHAVIOUR = /^(pi-extensions|plugins)\/.*\.test\.(ts|mjs)$/;
86
115
  // H_LIVE asks whether the file's OWN CODE reads the LIVE switch — not whether the
87
116
  // three letters appear. The naive `\bLIVE=1\b` form classified
88
117
  // scripts/check-release-gate-outcomes.ts as real-live because that gate QUOTES the
@@ -145,6 +174,45 @@ const CLASS_OVERRIDES: Record<string, { cls: SemanticClass; reason: string }> =
145
174
  cls: "source-topology",
146
175
  reason: "asserts the scrubbed parent-transcript fixture's recorded shape — an artifact contract, no product run",
147
176
  },
177
+ // #119 V1 measured the classifier THROWING on these four at HEAD 5062147 — the
178
+ // assertion below did its job and named real drift, in three distinct shapes. None
179
+ // of them is reachable by widening H_IMPORTS: there is no literal `../plugins/`
180
+ // import anywhere under scripts/ or test/ (measured, 0 matches), so a `plugins`
181
+ // alternative in that predicate would match nothing and only claim coverage it
182
+ // does not have.
183
+ //
184
+ // Shape 1 — the herdr gates reach their subject through a RUNTIME-ASSEMBLED
185
+ // dynamic import, `await import(pathToFileURL(LEAF).href)`. H_IMPORTS matches a
186
+ // literal specifier by design (a computed one cannot be read as text without
187
+ // executing the gate), so these two are override territory, not predicate
188
+ // territory.
189
+ "scripts/check-herdr-plugin-profile.ts": {
190
+ cls: "behavioral-contract",
191
+ reason:
192
+ "dynamically imports plugins/herdr/lib/integration-profile.mjs and source-reads that same leaf — executes product code, no process",
193
+ },
194
+ "scripts/check-herdr-runtime-bootstrap.ts": {
195
+ cls: "behavioral-contract",
196
+ reason:
197
+ "dynamically imports the herdr runtime leaf and its plugin re-export and drives them against mkdtemp HOME/XDG fixtures — executes product code, spawns nothing",
198
+ },
199
+ // Shape 2 — the reads go through a local `read(rel)` helper, so H_TEXT's
200
+ // line-level form (readFileSync on the same line as the path literal) cannot see
201
+ // them. No product is executed: this is a manifest/installer/CI structure gate.
202
+ "scripts/check-herdr-supply.ts": {
203
+ cls: "source-topology",
204
+ reason:
205
+ "reads the supply manifest, the CI installer and the workflow as text through a read() helper; runs no product",
206
+ },
207
+ // Shape 3 — a vitest lane whose SUBJECT is the verification surface itself
208
+ // (scripts/lib), not pi-extensions/ or mcp/. The mutant inventory below already
209
+ // counts that as infra-subject; the class axis needs the same honesty rather than
210
+ // a widened "product import" predicate that would blur the two.
211
+ "test/tmux-coordinate-row.test.ts": {
212
+ cls: "pure-unit",
213
+ reason:
214
+ "executes the scripts/lib/tmux-coordinate-row leaf — an INFRA-subject lane; no fs, no text read, no process",
215
+ },
148
216
  };
149
217
 
150
218
  /**
@@ -187,9 +255,13 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
187
255
  const body = isShell ? fs.readFileSync(path.join(REPO_DIR, rel), "utf8") : effectiveBody(rel);
188
256
  const base = path.basename(rel);
189
257
 
258
+ // One notion of "imports product", used by BOTH axes below so the style table and the class
259
+ // table can never disagree about the same file.
260
+ const importsProduct = H_IMPORTS.test(body) || (BESIDE_BEHAVIOUR.test(rel) && H_IMPORTS_SIBLING.test(body));
261
+
190
262
  const style: Style = (() => {
191
263
  if (isShell) return "shell";
192
- const axes = [H_IMPORTS.test(body), H_TEXT.test(body), H_PROC.test(body)];
264
+ const axes = [importsProduct, H_TEXT.test(body), H_PROC.test(body)];
193
265
  const n = axes.filter(Boolean).length;
194
266
  if (n >= 2) return "mixed";
195
267
  if (axes[0]) return "imports-product";
@@ -207,11 +279,11 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
207
279
  ? "package-install"
208
280
  : isShell || H_PROC.test(body) || H_NET.test(body)
209
281
  ? "hermetic-integration"
210
- : H_TEXT.test(body) && !H_IMPORTS.test(body)
282
+ : H_TEXT.test(body) && !importsProduct
211
283
  ? "source-topology"
212
- : H_IMPORTS.test(body) && (H_TEXT.test(body) || H_FS.test(body))
284
+ : importsProduct && (H_TEXT.test(body) || H_FS.test(body))
213
285
  ? "behavioral-contract"
214
- : H_IMPORTS.test(body)
286
+ : importsProduct
215
287
  ? "pure-unit"
216
288
  : null;
217
289
  if (cls === null) {
@@ -224,7 +296,7 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
224
296
 
225
297
  // ── buckets ──────────────────────────────────────────────────────────────────
226
298
  const legacyGates = legacyFiles.filter((f) => /^scripts\/(check-|smoke-)/.test(f));
227
- const frameworkGates = frameworkFiles.filter((f) => /^test\/.*\.test\.ts$/.test(f));
299
+ const frameworkGates = frameworkFiles.filter((f) => /\.test\.(ts|mjs)$/.test(f));
228
300
  const lib = legacyFiles.filter((f) => f.startsWith("scripts/lib/"));
229
301
  const mutantManifests = legacyFiles.filter((f) => f.startsWith("scripts/mutants/") && f.endsWith(".json"));
230
302
  const fixtures = legacyFiles.filter((f) => f.startsWith("scripts/fixtures/"));
@@ -254,14 +326,16 @@ rule predicates (re-derive any number from these):
254
326
  H_PROC ${H_PROC}
255
327
  H_NET ${H_NET}
256
328
  H_FS ${H_FS}
329
+ H_IMPORTS_SIBLING ${H_IMPORTS_SIBLING} (only for ${BESIDE_BEHAVIOUR})
257
330
  H_LIVE ${H_LIVE}
258
331
  H_LIVE is applied to the CODE-ONLY projection (comments and inert literals blanked;
259
332
  shell double-quoted expansions kept), so a gate that merely QUOTES "LIVE=1" is not
260
333
  real-live. Two-tier gates are listed under the class breakdown.
261
334
  semantic classes, first match wins: override → real-live (name -live | H_LIVE on code) →
262
335
  package-install (name pack|install) → hermetic-integration (.sh | H_PROC | H_NET) →
263
- source-topology (H_TEXT ∧ ¬H_IMPORTS) → behavioral-contract (H_IMPORTS ∧ (H_TEXT ∨ H_FS)) →
264
- pure-unit (H_IMPORTS) → ERROR (unclassified is asserted zero)
336
+ source-topology (H_TEXT ∧ ¬importsProduct) → behavioral-contract (importsProduct ∧ (H_TEXT ∨ H_FS)) →
337
+ pure-unit (importsProduct) → ERROR (unclassified is asserted zero)
338
+ importsProduct = H_IMPORTS, plus H_IMPORTS_SIBLING for a BESIDE_BEHAVIOUR lane
265
339
  test/*.test.ts is classified together with the ./helpers/* bodies it imports.
266
340
  `);
267
341
 
@@ -275,9 +349,11 @@ console.log(` scripts/lib/: ${lib.length} files, ${total(lib
275
349
  console.log(` scripts/mutants/: ${mutantManifests.length} manifests`);
276
350
  console.log(` scripts/fixtures/: ${fixtures.length} files`);
277
351
  console.log(` other: ${legacyOther.length} files, ${total(legacyOther)} lines`);
278
- console.log(`framework axis (test/ + vitest.config.ts): ${frameworkFiles.length} files, ${frameworkTotal} lines`);
279
352
  console.log(
280
- ` vitest lanes (test/**/*.test.ts): ${frameworkGates.length} files, ${sum(rows.filter((r) => r.axis === "framework"))} lines`,
353
+ `framework axis (vitest-managed, both locations): ${frameworkFiles.length} files, ${frameworkTotal} lines`,
354
+ );
355
+ console.log(
356
+ ` lanes (test/** + beside behaviour): ${frameworkGates.length} files, ${sum(rows.filter((r) => r.axis === "framework"))} lines`,
281
357
  );
282
358
  console.log(` helpers/config: ${frameworkSupport.length} files, ${total(frameworkSupport)} lines`);
283
359
  console.log(
@@ -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 ---"