@junghanacs/entwurf 0.23.0 → 0.24.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 (102) hide show
  1. package/AGENTS.md +3 -1
  2. package/BASELINE.md +2 -0
  3. package/CHANGELOG.md +307 -0
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +43 -2
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +97 -18
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  21. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  22. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  23. package/mcp/entwurf-bridge/src/index.ts +41 -1
  24. package/mcp/entwurf-bridge/test.sh +2 -2
  25. package/package.json +15 -13
  26. package/pi-extensions/entwurf-control.ts +56 -0
  27. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  28. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  29. package/pi-extensions/lib/acp/backend.ts +20 -10
  30. package/pi-extensions/lib/acp/context.ts +30 -10
  31. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  32. package/pi-extensions/lib/acp/session-store.ts +2 -2
  33. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  34. package/pi-extensions/lib/callback-env.ts +78 -0
  35. package/pi-extensions/lib/compaction-send-guard.ts +4 -1
  36. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  37. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  38. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  39. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  40. package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
  41. package/pi-extensions/lib/herdr-placement.ts +13 -4
  42. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  43. package/pi-extensions/meta-bridge-omp.ts +35 -48
  44. package/run.sh +327 -246
  45. package/scripts/check-acp-backend-preflight.ts +117 -9
  46. package/scripts/check-acp-carrier-augment.ts +6 -4
  47. package/scripts/check-acp-event-mapper.ts +17 -6
  48. package/scripts/check-acp-prompt-builder.ts +17 -9
  49. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  50. package/scripts/check-acp-session-reuse.ts +64 -51
  51. package/scripts/check-acp-session-store.ts +31 -8
  52. package/scripts/check-acp-stop-reason.ts +20 -2
  53. package/scripts/check-acp-stream-hooks.ts +22 -5
  54. package/scripts/check-acp-usage-accounting.ts +42 -18
  55. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  56. package/scripts/check-fresh-call-dispatch.ts +4 -5
  57. package/scripts/check-gate-qualification.ts +45 -5
  58. package/scripts/check-herdr-activation.ts +73 -0
  59. package/scripts/check-herdr-fresh-call.ts +22 -6
  60. package/scripts/check-herdr-sandbox.ts +14 -2
  61. package/scripts/check-install-container.sh +1 -1
  62. package/scripts/check-pi-launch.ts +361 -0
  63. package/scripts/check-release-gate-outcomes.ts +7 -0
  64. package/scripts/inventory-verification-surface.ts +86 -10
  65. package/scripts/lib/mutation-qualify.ts +1 -1
  66. package/scripts/mutants/acp-backend-preflight.json +20 -0
  67. package/scripts/mutants/bridge-boot-resume.json +26 -0
  68. package/scripts/mutants/herdr-activation.json +16 -0
  69. package/scripts/mutants/herdr-fresh-call.json +11 -0
  70. package/scripts/mutants/herdr-plugin-build.json +1 -1
  71. package/scripts/mutants/mux-fresh-call.json +21 -8
  72. package/scripts/mutants/omp-fresh.json +5 -9
  73. package/scripts/mutants/pack-install.json +3 -3
  74. package/scripts/mutants/pi-launch.json +113 -0
  75. package/scripts/mutants/resume-args.json +6 -6
  76. package/scripts/mutants/v2-surface.json +2 -2
  77. package/scripts/mutants/v2-visible-resume.json +17 -17
  78. package/scripts/probe-bridge-command.ts +1 -0
  79. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  81. package/scripts/smoke-agy-install-state.sh +2 -2
  82. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  83. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  84. package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
  85. package/scripts/smoke-herdr-raw-install-live.sh +877 -0
  86. package/scripts/smoke-omp-fresh-live.ts +4 -2
  87. package/scripts/smoke-omp-mcp-state.sh +1 -1
  88. package/scripts/smoke-pi-provider-state.sh +2 -2
  89. package/scripts/tsconfig.json +5 -0
  90. package/scripts/check-entwurf-facts.ts +0 -391
  91. package/scripts/check-entwurf-resume-args.ts +0 -122
  92. package/scripts/check-entwurf-v2-decider.ts +0 -657
  93. package/scripts/check-entwurf-v2-lock.ts +0 -371
  94. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  95. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  96. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  97. package/scripts/check-entwurf-v2-release.ts +0 -151
  98. package/scripts/check-entwurf-v2-runner.ts +0 -448
  99. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  100. package/scripts/check-entwurf-v2-send.ts +0 -458
  101. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  102. package/scripts/check-socket-discovery.ts +0 -401
@@ -291,7 +291,15 @@ async function main(): Promise<void> {
291
291
  if (callerPane?.workspaceId === undefined) {
292
292
  fail(`pane get of the caller pane failed or carried no workspace: ${callerGet.stderr || callerGet.stdout}`);
293
293
  }
294
- const tabRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: REPO_DIR }));
294
+ const tabCallback = {
295
+ target: "20260101T010101-aaaaaa",
296
+ nonce: "mux-fresh-call-deadbeefdeadbeefdeadbeef",
297
+ };
298
+ const tabRun = herdr(
299
+ bin,
300
+ sandbox,
301
+ buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: REPO_DIR, callback: tabCallback }),
302
+ );
295
303
  const tab = tabRun.status === 0 ? parseHerdrTabCreateResponse(tabRun.stdout) : null;
296
304
  if (tab === null) fail(`tab create failed or was unreadable: ${tabRun.stderr || tabRun.stdout}`);
297
305
  const tabRootPane = tab.rootPane;
@@ -401,7 +409,11 @@ async function main(): Promise<void> {
401
409
  );
402
410
  }
403
411
 
404
- const spareRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId }));
412
+ const spareRun = herdr(
413
+ bin,
414
+ sandbox,
415
+ buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, callback: tabCallback }),
416
+ );
405
417
  const spareTab = spareRun.status === 0 ? parseHerdrTabCreateResponse(spareRun.stdout) : null;
406
418
  if (spareTab === null)
407
419
  fail(`the second tab create failed or was unreadable: ${spareRun.stderr || spareRun.stdout}`);
@@ -321,7 +321,7 @@ function finish(t) {
321
321
  // artifact ship" is precisely the question it owns. The subset form named only the five
322
322
  // pre-0.14 verbs and would have accepted a candidate with no entwurf_fresh_call /
323
323
  // entwurf_resume_call; equality also refuses an extra or duplicated verb.
324
- const EXPECT_TOOLS = ['entwurf_fresh_call', 'entwurf_inbox_read', 'entwurf_peers', 'entwurf_register_native', 'entwurf_resume_call', 'entwurf_self', 'entwurf_v2'];
324
+ const EXPECT_TOOLS = ['entwurf_callback', 'entwurf_fresh_call', 'entwurf_inbox_read', 'entwurf_peers', 'entwurf_register_native', 'entwurf_resume_call', 'entwurf_self', 'entwurf_v2'];
325
325
  if (names.length !== EXPECT_TOOLS.length || EXPECT_TOOLS.some((n, i) => names[i] !== n)) {
326
326
  console.error('MCP tool set MISMATCH — want exactly [' + EXPECT_TOOLS.join(',') + '] got [' + names.join(',') + ']');
327
327
  process.exit(1);
@@ -0,0 +1,361 @@
1
+ /**
2
+ * check-pi-launch — deterministic gate for the MANAGED pi launch (`entwurf pi`, #118 홉 2).
3
+ * Hermetic: no real pi, no network, no model turn, no write outside its own temp root.
4
+ *
5
+ * SAME ORACLE AS check-copilot-launch, and deliberately the same one: the subject is a
6
+ * process REPLACEMENT, so the truth is a FAKE VENDOR — a real executable named `pi` on a
7
+ * sandbox PATH that reports the argv, environment and pid it was actually handed. Nothing
8
+ * below is asserted by reading the launcher's source, and the launcher is driven through
9
+ * its PUBLIC address (`run.sh pi`) because the dispatcher's own argv handling is part of
10
+ * the contract. Reusing that harness shape rather than importing it is the honest split:
11
+ * the two launchers share a posture, not a body, and a shared fixture would have to grow a
12
+ * vendor-name parameter that neither production path has.
13
+ *
14
+ * WHY THE PRECONDITION SET IS EMPTY, measured rather than assumed. `entwurf copilot` checks
15
+ * six receiver-state facts before it execs, because setting its flag is a PROMISE of a
16
+ * doorbell that may not exist. `entwurf pi` promises nothing pi does not already own: on a
17
+ * host where the entwurf extension is not registered, `pi --entwurf-control` refuses itself
18
+ * with `Error: Unknown option: --entwurf-control` and exit 1 (`[측정 2026-09-20]` pi 0.86.0,
19
+ * sandbox HOME + PI_CODING_AGENT_DIR, that exact stderr line and exit 1). A
20
+ * pre-check here would duplicate that refusal and go stale the day pi renames it.
21
+ *
22
+ * WHY ONE RECURSION FENCE AND NOT TWO. The sentinel closes the only real loop — a PATH
23
+ * executable named `pi` that shells back to `entwurf pi`. Copilot's second fence (resolve
24
+ * the binary, refuse our own entrypoints) closes a case that is NOT a loop here, and the
25
+ * cell below measures that rather than asserting it: a PATH `pi` symlinked to run.sh is
26
+ * exec'd as `run.sh --entwurf-control …`, an unknown verb this dispatcher already refuses.
27
+ *
28
+ * WHY THE FLAG IS ADDED AND NEVER DEDUPLICATED. `pit`/`pius`-style operator wrappers already
29
+ * pass `--entwurf-control`; passing it twice was measured byte-identical to passing it once
30
+ * (pi 0.85.1, extension registered — that receipt was NOT retaken at 0.86.0, because it needs a
31
+ * host with the extension registered and taking it would have meant opening a control session on
32
+ * the operator's own pi; the 0.86.0 re-measure above covers the unregistered refusal instead). The launcher therefore injects exactly ONE and leaves
33
+ * the operator's copies alone — an argv scan would be code earning nothing.
34
+ */
35
+
36
+ import assert from "node:assert/strict";
37
+ import { execFileSync, spawnSync } from "node:child_process";
38
+ import {
39
+ chmodSync,
40
+ existsSync,
41
+ mkdirSync,
42
+ mkdtempSync,
43
+ readFileSync,
44
+ rmSync,
45
+ symlinkSync,
46
+ writeFileSync,
47
+ } from "node:fs";
48
+ import { tmpdir } from "node:os";
49
+ import * as path from "node:path";
50
+ import { fileURLToPath } from "node:url";
51
+
52
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
53
+ const RUN_SH = path.join(REPO, "run.sh");
54
+
55
+ let passed = 0;
56
+ function ok(label: string, cond: boolean, detail = ""): void {
57
+ assert.ok(cond, detail ? `${label}\n${detail}` : label);
58
+ console.log(` ok ${label}`);
59
+ passed++;
60
+ }
61
+
62
+ // ── the flag SSOT, read as text ──────────────────────────────────────────────
63
+ // The shell cannot import a TypeScript constant, so the launcher spells the flag as a
64
+ // literal and this gate is what keeps the two in step. The SSOT is the constant; every
65
+ // argv-composing site below must carry exactly its value.
66
+ const storeSrc = readFileSync(path.join(REPO, "pi-extensions", "lib", "acp", "session-store.ts"), "utf8");
67
+ const flagMatch = /export const ENTWURF_CONTROL_FLAG = "([^"]+)";/.exec(storeSrc);
68
+ assert.ok(flagMatch, "ENTWURF_CONTROL_FLAG is not declared in pi-extensions/lib/acp/session-store.ts");
69
+ const FLAG = flagMatch[1];
70
+
71
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-pi-launch."));
72
+ try {
73
+ const bin = path.join(root, "bin");
74
+ mkdirSync(bin, { recursive: true });
75
+
76
+ // The fake vendor, installed under the REAL name `pi`. There is no "which command
77
+ // stands in for the vendor" switch: a production env seam that can redirect an exec is
78
+ // an authority, not a test convenience.
79
+ const vendor = path.join(bin, "pi");
80
+ writeFileSync(
81
+ vendor,
82
+ `#!/usr/bin/env bash
83
+ echo "PID=$$"
84
+ echo "SENTINEL=[\${ENTWURF_PI_LAUNCH_ACTIVE-<unset>}]"
85
+ for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
86
+ exit "\${FAKE_PI_EXIT:-0}"
87
+ `,
88
+ );
89
+ chmodSync(vendor, 0o755);
90
+
91
+ // A PATH with no `pi` anywhere on it — built by dropping every real entry that holds
92
+ // one, rather than by emptying PATH (the launcher still needs bash and friends).
93
+ // The sandbox is TOTAL, the same way check-copilot-launch's is. The launcher writes
94
+ // nothing itself, but it EXECS a vendor, and a fixture that leaves the operator's HOME
95
+ // and XDG roots reachable is one careless vendor away from touching them. Naming every
96
+ // root explicitly is also what #119 asks of each test rather than of each gate.
97
+ const sandboxEnv = {
98
+ HOME: path.join(root, "home"),
99
+ XDG_DATA_HOME: path.join(root, "xdg", "data"),
100
+ XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
101
+ XDG_STATE_HOME: path.join(root, "xdg", "state"),
102
+ XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
103
+ PI_CODING_AGENT_DIR: path.join(root, "pi-agent"),
104
+ };
105
+ for (const d of Object.values(sandboxEnv)) mkdirSync(d, { recursive: true });
106
+
107
+ const pathWithoutVendor = (process.env.PATH ?? "")
108
+ .split(":")
109
+ .filter((d) => d !== "" && !existsSync(path.join(d, "pi")))
110
+ .join(":");
111
+
112
+ interface Run {
113
+ status: number | null;
114
+ out: string;
115
+ args: string[];
116
+ sentinel: string;
117
+ pid: string;
118
+ }
119
+ function launch(args: string[], extraEnv: Record<string, string> = {}, withVendor = true): Run {
120
+ const r = spawnSync("bash", [RUN_SH, "pi", ...args], {
121
+ cwd: root,
122
+ encoding: "utf8",
123
+ env: {
124
+ ...process.env,
125
+ ...sandboxEnv,
126
+ PATH: withVendor ? `${bin}:${pathWithoutVendor}` : pathWithoutVendor,
127
+ ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
128
+ FAKE_PI_EXIT: undefined as unknown as string,
129
+ ...extraEnv,
130
+ },
131
+ });
132
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
133
+ const argv: string[] = [];
134
+ for (const line of out.split("\n")) {
135
+ const m = /^ARG<([\s\S]*)>$/.exec(line);
136
+ if (m) argv.push(m[1]);
137
+ }
138
+ return {
139
+ status: r.status,
140
+ out,
141
+ args: argv,
142
+ sentinel: /^SENTINEL=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
143
+ pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
144
+ };
145
+ }
146
+
147
+ // ── 1. the flag literal agrees with its SSOT, everywhere argv is composed ──
148
+ {
149
+ // CODE only. The branch explains itself at length and quotes the flag four times in
150
+ // prose; a literal check that read those goes green on a launcher whose actual exec
151
+ // line carries a typo — measured, and it is why the comments are dropped first.
152
+ const branch = /^ {2}pi\)$[\s\S]*?^ {4};;$/m.exec(readFileSync(RUN_SH, "utf8"))?.[0] ?? "";
153
+ const launcher = branch
154
+ .split("\n")
155
+ .filter((line) => !/^\s*#/.test(line))
156
+ .join("\n");
157
+ // Deliberately narrow: this cell owns the LITERAL and nothing else. Asserting the
158
+ // whole exec line here would swallow every other claim below — a mutation of the
159
+ // argv order, the quoting or the exec itself would all die on this assertion first,
160
+ // and each mutant is supposed to name exactly one broken thing. The token boundary
161
+ // is what makes a near-miss like `--entwurf-controll` a failure rather than a
162
+ // substring hit.
163
+ ok(
164
+ "[QK:PILAUNCH-FLAG-SSOT] the run.sh launcher spells the flag exactly as ENTWURF_CONTROL_FLAG declares it — the shell cannot import the constant, so the literal is checked as a whole token",
165
+ new RegExp(`(^|\\s)${FLAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m").test(launcher),
166
+ `FLAG=${FLAG}\n--- launcher branch, comments dropped ---\n${launcher}`,
167
+ );
168
+ // EXHAUSTIVE, not a list. The first cut named five files and claimed "every
169
+ // argv-composing site"; cross-review found eight more that the list did not know
170
+ // about. They all happened to be spelled correctly, so there was no false success —
171
+ // but a drift OUTSIDE the list would have passed silently, which is the same gate
172
+ // rot in a slower form. So the sweep walks every tracked source file and requires
173
+ // that every `--entwurf-…` token it finds IS this flag. A typo, a rename, or a
174
+ // second spelling anywhere becomes a named red, and a new call site needs no edit
175
+ // here to be covered.
176
+ //
177
+ // Prose is excluded and that exclusion is the load-bearing part: comments and
178
+ // markdown quote the flag constantly, and a checker that read them would be a spell
179
+ // checker for documentation rather than a guard on what pi actually receives. Only
180
+ // lines that can compose argv are read.
181
+ const tracked = execFileSync(
182
+ "git",
183
+ ["ls-files", "--", "scripts", "pi-extensions", "mcp", "demo", "plugins", "run.sh"],
184
+ {
185
+ cwd: REPO,
186
+ encoding: "utf8",
187
+ },
188
+ )
189
+ .split("\n")
190
+ .filter((f) => f.length > 0 && /\.(ts|mjs|js|sh)$/.test(f));
191
+
192
+ // Narrowed to the DRIFT class, and the first run is why. A pattern of `--entwurf-…`
193
+ // swept up `--entwurf-bootstrap` (scripts/check-omp-birth-hook.ts), which is a real and
194
+ // unrelated flag — this cell is not the registry of every entwurf flag. What it owns is
195
+ // that no VARIANT of the control flag exists: `--entwurf-controll`, `--entwurf-control-x`
196
+ // and friends. A wholesale rename to a different word is a different change, and the SSOT
197
+ // cell above catches it by reading the constant.
198
+ const FLAGLIKE = new RegExp(`${FLAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[A-Za-z0-9-]*`, "g");
199
+ const wrong: string[] = [];
200
+ let sites = 0;
201
+ for (const rel of tracked) {
202
+ const body = readFileSync(path.join(REPO, rel), "utf8");
203
+ body.split("\n").forEach((line, i) => {
204
+ // A whole-line comment in any of the three syntaxes these files use. An inline
205
+ // trailing comment is NOT stripped: `#` and `//` both appear inside real argv
206
+ // strings here, and guessing where a comment starts would drop live code.
207
+ if (/^\s*(\/\/|#|\*|\/\*)/.test(line)) return;
208
+ for (const m of line.matchAll(FLAGLIKE)) {
209
+ sites++;
210
+ if (m[0] !== FLAG) wrong.push(`${rel}:${i + 1}: ${m[0]}`);
211
+ }
212
+ });
213
+ }
214
+ ok(
215
+ `[QK:PILAUNCH-FLAG-SITES] every control-flag PREFIX VARIANT in tracked code is exactly the constant (${sites} occurrences swept, prose excluded)`,
216
+ wrong.length === 0,
217
+ `FLAG=${FLAG}\ndisagreeing:\n${wrong.join("\n")}`,
218
+ );
219
+ // A sweep that found nothing would also report zero disagreements, so the count is
220
+ // asserted too: the launcher alone guarantees at least one.
221
+ ok(
222
+ "[QK:PILAUNCH-FLAG-SWEEP-REACHES] the sweep actually reached code that names the flag",
223
+ sites > 1,
224
+ `sites=${sites}`,
225
+ );
226
+ }
227
+
228
+ // ── 2. the dispatcher verb must not reach the vendor ──────────────────────
229
+ {
230
+ const r = launch(["--model", "x"]);
231
+ ok(
232
+ "[QK:PILAUNCH-VERB-DROPPED] the dispatcher verb never reaches the vendor — without the shift, `pi` arrives as a prompt argument",
233
+ r.status === 0 && !r.args.includes("pi"),
234
+ `argv: ${JSON.stringify(r.args)}`,
235
+ );
236
+ ok(
237
+ "[QK:PILAUNCH-FLAG-FIRST] the injected flag is the FIRST token, so the control socket is stood up before anything the operator asked for",
238
+ r.args[0] === FLAG,
239
+ `argv: ${JSON.stringify(r.args)}`,
240
+ );
241
+ ok(
242
+ "[QK:PILAUNCH-NOTHING-ELSE-INJECTED] the launcher adds the flag and NOTHING else — operator taste is not a launcher decision (Rule 9)",
243
+ JSON.stringify(r.args) === JSON.stringify([FLAG, "--model", "x"]),
244
+ `argv: ${JSON.stringify(r.args)}`,
245
+ );
246
+ }
247
+
248
+ // ── 3. operator argv crosses byte-identical ───────────────────────────────
249
+ {
250
+ const operator = ["--model", "a b", "", "-", "--", "한글 인자", " spaced "];
251
+ const r = launch(operator);
252
+ ok(
253
+ "[QK:PILAUNCH-ARGV-BYTES] every operator element crosses byte-identical — empty strings, embedded spaces, a bare dash, the terminator and non-ASCII",
254
+ JSON.stringify(r.args) === JSON.stringify([FLAG, ...operator]),
255
+ `expected: ${JSON.stringify([FLAG, ...operator])}\nactual: ${JSON.stringify(r.args)}`,
256
+ );
257
+ }
258
+
259
+ // ── 4. the flag is ADDED, never deduplicated ──────────────────────────────
260
+ {
261
+ const r = launch([FLAG, "--model", "x"]);
262
+ ok(
263
+ "[QK:PILAUNCH-FLAG-NOT-DEDUPED] an operator who already passes the flag gets it twice, because pi was measured to eat the duplicate and a scan would earn nothing",
264
+ JSON.stringify(r.args) === JSON.stringify([FLAG, FLAG, "--model", "x"]),
265
+ `argv: ${JSON.stringify(r.args)}`,
266
+ );
267
+ }
268
+
269
+ // ── 5. the vendor's exit status is this command's exit status ─────────────
270
+ // THIS CELL CARRIES NO MUTANT, and the reason is measured rather than conceded: run.sh
271
+ // is `set -euo pipefail` (run.sh:14), so every mutation that stops the vendor status
272
+ // from reaching the caller also stops `exec` from replacing the process. The non-exec
273
+ // form still exits 7, because set -e aborts before a planted `exit 0` can run. A mutant
274
+ // that kills two claims is not an attributable mutant, so this stands on its assertion
275
+ // alone until that coupling changes.
276
+ // Asserted BEFORE the pid cell on purpose: dropping `exec` for a plain call still
277
+ // propagates the status, while running the vendor and then exiting 0 breaks both. With
278
+ // the status checked first each of those two mutations lands on exactly one claim.
279
+ {
280
+ const r = launch([], { FAKE_PI_EXIT: "7" });
281
+ ok(
282
+ "[QK:PILAUNCH-EXIT-PASSTHROUGH] the vendor's exit status is the launch's exit status",
283
+ r.status === 7,
284
+ `status=${r.status}`,
285
+ );
286
+ }
287
+
288
+ // ── 6. exec, not fork: the pid survives ───────────────────────────────────
289
+ {
290
+ const r = spawnSync("bash", ["-c", `echo "SHELL_PID=$$"; exec bash "${RUN_SH}" pi`], {
291
+ cwd: root,
292
+ encoding: "utf8",
293
+ env: {
294
+ ...process.env,
295
+ ...sandboxEnv,
296
+ PATH: `${bin}:${pathWithoutVendor}`,
297
+ ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
298
+ },
299
+ });
300
+ const shellPid = /^SHELL_PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
301
+ const vendorPid = /^PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
302
+ ok(
303
+ "[QK:PILAUNCH-EXEC-KEEPS-PID] the vendor REPLACES this process — a fork would leave run.sh as a parent that supervises nothing",
304
+ Boolean(shellPid) && shellPid === vendorPid,
305
+ `shell=${shellPid} vendor=${vendorPid}`,
306
+ );
307
+ }
308
+
309
+ // ── 7. an absent vendor is a NAMED refusal, never a quiet success ─────────
310
+ {
311
+ const r = launch([], {}, false);
312
+ ok(
313
+ "[QK:PILAUNCH-VENDOR-ABSENT] with no `pi` on PATH the launch refuses by name and non-zero, and never reports success",
314
+ r.status !== 0 && r.out.includes("no 'pi' executable found on PATH") && r.pid === "",
315
+ `status=${r.status}\n${r.out}`,
316
+ );
317
+ }
318
+
319
+ // ── 8. the recursion fence, and the sentinel that carries it ──────────────
320
+ {
321
+ const armed = launch([]);
322
+ ok(
323
+ "[QK:PILAUNCH-SENTINEL-EXPORTED] the sentinel is EXPORTED, so a PATH `pi` that shells back here sees it on re-entry",
324
+ armed.sentinel === "1",
325
+ `sentinel=${armed.sentinel}`,
326
+ );
327
+ const r = launch([], { ENTWURF_PI_LAUNCH_ACTIVE: "1" });
328
+ ok(
329
+ "[QK:PILAUNCH-RECURSION-FENCE] a re-entered launch refuses by name instead of spinning forever behind a hung terminal",
330
+ r.status !== 0 && r.out.includes("recursive managed launch detected") && r.pid === "",
331
+ `status=${r.status}\n${r.out}`,
332
+ );
333
+ }
334
+
335
+ // ── 9. the case copilot's SECOND fence covers, measured here instead ───────
336
+ {
337
+ const loopBin = path.join(root, "loopbin");
338
+ mkdirSync(loopBin, { recursive: true });
339
+ symlinkSync(RUN_SH, path.join(loopBin, "pi"));
340
+ const r = spawnSync("bash", [RUN_SH, "pi"], {
341
+ cwd: root,
342
+ encoding: "utf8",
343
+ timeout: 30_000,
344
+ env: {
345
+ ...process.env,
346
+ ...sandboxEnv,
347
+ PATH: `${loopBin}:${pathWithoutVendor}`,
348
+ ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
349
+ },
350
+ });
351
+ ok(
352
+ "[QK:PILAUNCH-SYMLINK-NOT-A-LOOP] a PATH `pi` symlinked to run.sh is exec'd as an unknown verb and fails closed — this is why the second fence copilot carries is not needed here",
353
+ r.status !== 0 && r.signal === null,
354
+ `status=${r.status} signal=${r.signal}`,
355
+ );
356
+ }
357
+
358
+ console.log(`\ncheck-pi-launch: ${passed} checks passed`);
359
+ } finally {
360
+ rmSync(root, { recursive: true, force: true });
361
+ }
@@ -316,6 +316,13 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
316
316
  // history that earns a place in the gate blocking every cut. On-demand, with the exclusion
317
317
  // stated where an operator reads it.
318
318
  "smoke-herdr-plugin-build-live": ["VERIFY.md", "The release aggregate does not require the plugin build smoke"],
319
+ // #118 홉 1, third on the same rail and for the same two reasons: herdr is OPTIONAL, so an
320
+ // aggregate that required this would block a cut on a host that does not have the rail at
321
+ // all; and first evidence precedes promotion — this smoke's acceptance runs are hours old,
322
+ // which is evidence the axis works rather than the run history that earns a place in the
323
+ // gate blocking every cut. It also needs a Docker daemon and the public network, neither of
324
+ // which the aggregate owns. On-demand, with the exclusion stated where an operator reads it.
325
+ "smoke-herdr-raw-install-live": ["VERIFY.md", "The plugin's FIRST USER PATH is its own on-demand axis"],
319
326
  };
320
327
 
321
328
  const allLive = globSync("scripts/smoke-*live*.{ts,sh}", { cwd: REPO_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(
@@ -617,7 +617,7 @@ function sha256File(file: string): string {
617
617
  return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
618
618
  }
619
619
 
620
- function countOccurrences(haystack: string, needle: string): number {
620
+ export function countOccurrences(haystack: string, needle: string): number {
621
621
  let count = 0;
622
622
  let at = haystack.indexOf(needle);
623
623
  while (at !== -1) {
@@ -0,0 +1,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-backend-preflight",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACP-PREFLIGHT-REPLAYS-TRANSCRIPT-TOOLS",
7
+ "title": "reading a `context.tools` field instead of replaying the transcript makes the exclude-tools preflight unfireable on every pi 0.86 turn — the operator is told a tool is excluded while the backend still runs it",
8
+ "subject": "pi-extensions/lib/acp/backend.ts",
9
+ "find": ["\t\t\tconst activeToolNames = getCurrentTools(context.messages).map((t) => t.name);"],
10
+ "replace": [
11
+ "\t\t\tconst activeToolNames =",
12
+ "\t\t\t\t(context as unknown as { tools?: { name: string }[] }).tools?.map((t) => t.name) ?? [\"read\", \"bash\", \"edit\", \"write\"];"
13
+ ],
14
+ "gate": ["bash", "run.sh", "check-acp-backend-preflight"],
15
+ "timeoutSeconds": 300,
16
+ "signature": "[QK:ACP-PREFLIGHT-REPLAYS-TRANSCRIPT-TOOLS]",
17
+ "signatureSource": "scripts/check-acp-backend-preflight.ts"
18
+ }
19
+ ]
20
+ }
@@ -40,6 +40,32 @@
40
40
  "timeoutSeconds": 120,
41
41
  "signature": "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET]",
42
42
  "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
43
+ },
44
+ {
45
+ "claim": "BRIDGEBOOT-CALLBACK-REGISTERED",
46
+ "title": "the callback verb is imported but never registered — invisible to every host, while every import-level assertion stays green",
47
+ "subject": "mcp/entwurf-bridge/src/index.ts",
48
+ "find": ["server.tool(\n\t\"entwurf_callback\","],
49
+ "replace": [
50
+ "const _unregisteredCallbackTool = (...a: unknown[]) => void a;\n_unregisteredCallbackTool(\n\t\"entwurf_callback\","
51
+ ],
52
+ "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
53
+ "timeoutSeconds": 120,
54
+ "signature": "[QK:BRIDGEBOOT-CALLBACK-REGISTERED]",
55
+ "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
56
+ },
57
+ {
58
+ "claim": "BRIDGEBOOT-CALLBACK-ZERO-ARG",
59
+ "title": "a target parameter is added to the callback schema, giving the model a way to supply an address the env pair was meant to own",
60
+ "subject": "mcp/entwurf-bridge/src/index.ts",
61
+ "find": ["\t\"MCP child). No arguments, no fallback to a model-supplied target.\",\n\t{},"],
62
+ "replace": [
63
+ "\t\"MCP child). No arguments, no fallback to a model-supplied target.\",\n\t{ target: z.string().optional() },"
64
+ ],
65
+ "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
66
+ "timeoutSeconds": 120,
67
+ "signature": "[QK:BRIDGEBOOT-CALLBACK-ZERO-ARG]",
68
+ "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
43
69
  }
44
70
  ]
45
71
  }