@junghanacs/entwurf 0.13.0 → 0.14.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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -0,0 +1,452 @@
1
+ // Deterministic gate for the release-gate STEP OUTCOME protocol (P1).
2
+ //
3
+ // THE DEFECT THIS OWNS. The aggregate release gate documented — in run.sh's own
4
+ // usage, README, and VERIFY — that a cut needs `LIVE=1` and `SKIP=0`. Nothing
5
+ // enforced it. Exit authority read the FAIL counter alone, so
6
+ // `./run.sh release-gate <dir>` returned 0 while printing 14 SKIPs and the words
7
+ // "all green". And a step that WAS invoked could decline a prerequisite and exit
8
+ // 0 — Cortex without `ENTWURF_ACP_CORTEX_CONNECTION` is the measured case — which
9
+ // the aggregate counted as PASS. Both holes are the same shape: a skip that
10
+ // cannot be told apart from an acceptance, which means a release summary cannot
11
+ // prove the calls it claims.
12
+ //
13
+ // WHAT THIS GATE PINS, in the order the cells run (the order is load-bearing:
14
+ // each mutant must die on ITS claim, so a cell that would fire first on another
15
+ // cell's mutation is deliberately kept narrow):
16
+ // 1. the protocol is ONE number, agreed across the shell and TS halves;
17
+ // 2. the classifier never rounds a skip up to a pass;
18
+ // 3. `--cut` refuses a MUST skip while a bare diagnostic run does not, AND the
19
+ // refusal names its cause — a step that RAN AND BROKE is a different fact
20
+ // from one that NEVER RAN, and the counters are never fudged to carry it;
21
+ // 4. no LIVE smoke still carries the pre-P1 exit-0 skip shape;
22
+ // 5. a real smoke invoked with LIVE unset propagates the protocol code out
23
+ // through run_ts (the "direct LIVE!=1" case);
24
+ // 6. a run.sh wrapper that declines its own prerequisite does the same (the
25
+ // "internal prerequisite" case, Cortex being the one that was measured).
26
+ // 7. every LIVE smoke is either wired into the aggregate or excluded for a
27
+ // reason the docs actually state — the protocol cannot vouch for a step the
28
+ // gate never lists.
29
+ //
30
+ // Cells 5-6 SPAWN the real subcommands rather than reasoning about them: the
31
+ // whole defect was an assumption about what a step would do, so an assumption is
32
+ // exactly what this gate must not make. They are cheap — each smoke declines
33
+ // before it does any work.
34
+ //
35
+ // Pure + subprocess, no network/model — IN pnpm run check:full.
36
+
37
+ import { strict as assert } from "node:assert";
38
+ import { execFileSync } from "node:child_process";
39
+ import { globSync, readFileSync } from "node:fs";
40
+ import { basename, join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { LIVE_SKIP_EXIT, LIVE_SKIP_MARKER } from "./lib/live-skip.ts";
43
+
44
+ const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
45
+ const SHELL_LIB = "scripts/lib/step-outcome.sh";
46
+
47
+ /** Run a snippet with the shell half sourced; returns trimmed stdout. */
48
+ function inShell(snippet: string): string {
49
+ return execFileSync("bash", ["-c", `. "${SHELL_LIB}"; ${snippet}`], {
50
+ cwd: REPO_DIR,
51
+ encoding: "utf8",
52
+ timeout: 20_000,
53
+ }).trim();
54
+ }
55
+
56
+ /** Invoke a real run.sh subcommand; returns its exit code + combined output. */
57
+ function runSubcommand(sub: string, env: Record<string, string | undefined>): { code: number; output: string } {
58
+ const childEnv: Record<string, string> = {};
59
+ for (const [k, v] of Object.entries({ ...process.env, ...env })) {
60
+ if (v !== undefined) childEnv[k] = v;
61
+ }
62
+ try {
63
+ const out = execFileSync("bash", ["run.sh", sub], {
64
+ cwd: REPO_DIR,
65
+ encoding: "utf8",
66
+ timeout: 120_000,
67
+ env: childEnv,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ });
70
+ return { code: 0, output: out };
71
+ } catch (err) {
72
+ const e = err as { status?: number | null; stdout?: string; stderr?: string };
73
+ return { code: e.status ?? -1, output: `${e.stdout ?? ""}${e.stderr ?? ""}` };
74
+ }
75
+ }
76
+
77
+ // ===========================================================================
78
+ // 1) ONE protocol, two languages. A drifted constant does not degrade
79
+ // gracefully — it silently reclassifies every skip in the aggregate.
80
+ // ===========================================================================
81
+ {
82
+ const shellValue = inShell('echo "$ENTWURF_STEP_SKIP_EXIT"');
83
+ assert.equal(
84
+ shellValue,
85
+ String(LIVE_SKIP_EXIT),
86
+ `[QK:SKIP-EXIT-ONE-PROTOCOL] ${SHELL_LIB} and scripts/lib/live-skip.ts must name the SAME skip exit code. ` +
87
+ "The smokes exit with the TS constant and the aggregate classifies with the shell one, so a drift between " +
88
+ "them turns every honest skip into a FAIL (or, the other way, into a PASS) with nothing in the summary " +
89
+ `saying so. shell=${shellValue} ts=${LIVE_SKIP_EXIT}`,
90
+ );
91
+ // The code must also stay clear of the ranges that already mean something
92
+ // else, or a dependency's unrelated verdict reads as a skip.
93
+ assert.ok(
94
+ LIVE_SKIP_EXIT > 4 && LIVE_SKIP_EXIT < 126,
95
+ "the skip code must avoid the per-tool contract band (0..4) and the shell's signal band (126+)",
96
+ );
97
+ }
98
+
99
+ // ===========================================================================
100
+ // 2) The classifier. A skip is its own outcome, never rounded up or down.
101
+ // ===========================================================================
102
+ {
103
+ const table: Array<[string, string]> = [
104
+ ["0", "PASS"],
105
+ [String(LIVE_SKIP_EXIT), "SKIP"],
106
+ ["1", "FAIL"],
107
+ ["2", "FAIL"],
108
+ ["127", "FAIL"],
109
+ ["137", "FAIL"],
110
+ ];
111
+ for (const [code, expected] of table) {
112
+ const got = inShell(`entwurf_step_outcome ${code}`);
113
+ assert.equal(
114
+ got,
115
+ expected,
116
+ `[QK:STEP-OUTCOME-SKIP-NOT-PASS] exit ${code} must classify as ${expected}, got ${got}. A skip rounded up to ` +
117
+ "PASS is exactly the pre-P1 defect: the aggregate then reports acceptance for a step that told it, in the " +
118
+ "only channel it has, that it never ran. A skip rounded down to FAIL is the mirror error and makes the " +
119
+ "unattended diagnostic unusable.",
120
+ );
121
+ }
122
+ }
123
+
124
+ // ===========================================================================
125
+ // 3) Cut authority. `--cut` is the executable half of "a CUT needs SKIP=0";
126
+ // without it the diagnostic must stay green so an unattended run is usable.
127
+ // ===========================================================================
128
+ {
129
+ const releasable = (failc: number, skipc: number, cut: number): boolean =>
130
+ inShell(`if entwurf_release_releasable ${failc} ${skipc} ${cut}; then echo YES; else echo NO; fi`) === "YES";
131
+
132
+ assert.ok(releasable(0, 0, 1), "a cut with no failures and no skips is releasable");
133
+ assert.ok(!releasable(1, 0, 1), "a failure blocks a cut");
134
+ assert.ok(!releasable(1, 0, 0), "a failure blocks the diagnostic too — FAIL was always blocking");
135
+ assert.ok(
136
+ releasable(0, 3, 0),
137
+ "a DIAGNOSTIC run with skips stays exit 0 — an unattended `./run.sh release-gate` must remain runnable, and " +
138
+ "turning it red was never the ask",
139
+ );
140
+ assert.ok(
141
+ !releasable(0, 3, 1),
142
+ "[QK:CUT-REFUSES-SKIP] `--cut` must refuse a MUST SKIP. This is the whole point: the release procedure said " +
143
+ '"a CUT needs LIVE=1, SKIP=0" in prose while the code returned 0 with 14 skips, so a summary could be quoted ' +
144
+ "as acceptance for calls that never happened. It also removes the need for a separate LIVE assertion — with " +
145
+ "LIVE unset every LIVE-gated step skips, and the skip count is what blocks.",
146
+ );
147
+
148
+ // …and the refusal must SAY WHICH of the two it is. A blocked cut caused by a
149
+ // broken call and one caused by an absent prerequisite need different actions
150
+ // from whoever reads the record, and the counters must not be fudged to carry
151
+ // that (a synthesized FAIL=1 for a policy block erases the distinction).
152
+ const verdict = (failc: number, skipc: number, cut: number): string =>
153
+ inShell(`entwurf_release_verdict ${failc} ${skipc} ${cut}`);
154
+
155
+ assert.equal(verdict(0, 0, 1), "cut: OK", "a clean cut says so in one token");
156
+ assert.equal(
157
+ verdict(0, 3, 0),
158
+ "cut: n/a (diagnostic, 3 SKIP)",
159
+ "a diagnostic run names its skips without claiming a cut",
160
+ );
161
+ assert.equal(verdict(0, 0, 0), "cut: n/a (diagnostic)", "a clean diagnostic run still does not claim a cut");
162
+ assert.equal(
163
+ verdict(1, 0, 1),
164
+ "cut: BLOCKED (MUST FAIL)",
165
+ "a step that RAN AND BROKE must be named as a failure — that is a defect to fix",
166
+ );
167
+ assert.equal(
168
+ verdict(0, 3, 1),
169
+ "cut: BLOCKED (MUST SKIP)",
170
+ "[QK:CUT-VERDICT-NAMES-CAUSE] a cut blocked ONLY by skips must say so in its own token, distinct from a failure " +
171
+ "block. An operator (and the P5 release record) reads two different actions out of these: a MUST FAIL is a " +
172
+ "broken call to fix, a MUST SKIP is a prerequisite to supply. Collapsing them into one string — or worse, " +
173
+ "synthesizing FAIL=1 for the policy block — throws away the exact distinction this protocol was built to make.",
174
+ );
175
+ assert.equal(
176
+ verdict(2, 5, 1),
177
+ "cut: BLOCKED (MUST FAIL)",
178
+ "when both are present the FAILURE is the headline — a broken call outranks a missing prerequisite",
179
+ );
180
+ }
181
+
182
+ // ===========================================================================
183
+ // 4) No LIVE smoke still carries the pre-P1 skip shape (static, all of them).
184
+ // A future smoke that hand-rolls `exit 0` on a skip re-opens the hole for
185
+ // one lane only, which is precisely how this survived so long.
186
+ // ===========================================================================
187
+ {
188
+ const smokes = globSync("scripts/smoke-*live*.ts", { cwd: REPO_DIR }).sort();
189
+ assert.ok(smokes.length >= 15, `expected the LIVE smoke family, found ${smokes.length}`);
190
+ let liveGated = 0;
191
+ for (const rel of smokes) {
192
+ const src = readFileSync(join(REPO_DIR, rel), "utf8");
193
+ if (!src.includes("process.env.LIVE")) continue; // gated in run.sh instead (cell 6 owns that surface)
194
+ liveGated++;
195
+ // ONE assertion, three ways to fail it: no protocol import, or either of
196
+ // the two pre-P1 shapes (exit 0 / bare return) still inside the LIVE gate.
197
+ // Kept as one so the claim owns every way a smoke can go back to being
198
+ // indistinguishable from success — a split would let a mutation die on an
199
+ // unclaimed sibling assertion instead of here.
200
+ const importsProtocol = src.includes('from "./lib/live-skip.ts"');
201
+ const exitsZero = /LIVE !== "1"[\s\S]{0,400}?process\.exit\(0\)/.test(src);
202
+ const bareReturns = /LIVE !== "1"[\s\S]{0,400}?\n\t+return;/.test(src);
203
+ assert.ok(
204
+ importsProtocol && !exitsZero && !bareReturns,
205
+ `[QK:NO-SMOKE-SKIPS-WITH-ZERO] ${basename(rel)} gates on LIVE but does not decline through skipLive ` +
206
+ `(importsProtocol=${importsProtocol} exitsZero=${exitsZero} bareReturns=${bareReturns}). Every LIVE smoke ` +
207
+ "must take the one protocol exit — a hand-rolled `process.exit(0)` or bare `return` is indistinguishable " +
208
+ "from success, which is exactly what let the aggregate count a never-run step as PASS.",
209
+ );
210
+ }
211
+ assert.ok(liveGated >= 15, `expected most LIVE smokes to gate on LIVE, got ${liveGated}`);
212
+ }
213
+
214
+ // ===========================================================================
215
+ // 5) REAL propagation — the direct `LIVE!=1` case, end to end through run_ts.
216
+ // Static source pins cannot see a transport that swallows the code.
217
+ // ===========================================================================
218
+ {
219
+ const { code, output } = runSubcommand("smoke-acp-raw-turn-live", { LIVE: undefined });
220
+ assert.equal(
221
+ code,
222
+ LIVE_SKIP_EXIT,
223
+ `[QK:LIVE-SKIP-IS-PROTOCOL-EXIT] a LIVE smoke invoked with LIVE unset must leave the protocol's SKIP code on ` +
224
+ `the process, all the way out through run_ts — got exit ${code}. Exit 0 here is the original defect: the ` +
225
+ `aggregate cannot tell "I declined" from "I passed", so it reports acceptance. Output: ` +
226
+ `${JSON.stringify(output.slice(-300))}`,
227
+ );
228
+ assert.ok(
229
+ output.includes(LIVE_SKIP_MARKER),
230
+ `the skip must also be readable by a human in the log — ${LIVE_SKIP_MARKER} names the missing prerequisite so an ` +
231
+ "operator who hits a red --cut run knows what to supply. Output: " +
232
+ JSON.stringify(output.slice(-300)),
233
+ );
234
+ }
235
+
236
+ // ===========================================================================
237
+ // 6) REAL propagation — a run.sh WRAPPER declining its own prerequisite. This
238
+ // is the second skip surface: some smokes never reach their .ts file at all.
239
+ // ===========================================================================
240
+ {
241
+ const viaWrapper = runSubcommand("smoke-acp-cortex-live", { LIVE: undefined });
242
+ assert.equal(
243
+ viaWrapper.code,
244
+ LIVE_SKIP_EXIT,
245
+ `[QK:WRAPPER-SKIP-IS-PROTOCOL-EXIT] a run.sh smoke WRAPPER that declines a prerequisite must return the protocol ` +
246
+ `SKIP code, not 0 — got exit ${viaWrapper.code}. The wrapper is a skip surface of its own (cortex and matrix ` +
247
+ "both decline before their .ts is ever reached), so fixing only the TypeScript " +
248
+ `half would leave the aggregate counting those as PASS. Output: ${JSON.stringify(viaWrapper.output.slice(-300))}`,
249
+ );
250
+
251
+ // The measured Cortex cell: LIVE IS set, but the connection the adapter needs
252
+ // is not. Which branch declines (the wrapper's `cortex` PATH check or the
253
+ // smoke's own connection check) depends on the host; the OUTCOME must not.
254
+ const missingPrereq = runSubcommand("smoke-acp-cortex-live", {
255
+ LIVE: "1",
256
+ ENTWURF_ACP_CORTEX_CONNECTION: undefined,
257
+ });
258
+ assert.equal(
259
+ missingPrereq.code,
260
+ LIVE_SKIP_EXIT,
261
+ "LIVE=1 with no ENTWURF_ACP_CORTEX_CONNECTION must be a SKIP, not a PASS — this is the exact cell that made a " +
262
+ "cortex-less host look like cortex acceptance. Output: " +
263
+ JSON.stringify(missingPrereq.output.slice(-300)),
264
+ );
265
+ }
266
+
267
+ // ===========================================================================
268
+ // 7) NO SILENT AGGREGATE OMISSION. The protocol tells the truth about the steps
269
+ // the gate RUNS; it says nothing about steps the gate never lists. Three LIVE
270
+ // smokes (cortex, spawn-live, claude-native-resume) sat outside the aggregate
271
+ // with no stated reason until 2026-07-31, so a green cut was silent about the
272
+ // second backend, the spawn substrate, and native resume. (spawn-live was
273
+ // deleted with its transport in the visible-first cut; the incident is kept
274
+ // verbatim because it is what this rule was derived from.)
275
+ //
276
+ // So: every LIVE smoke is either WIRED into release_gate or EXCLUDED for a
277
+ // reason an operator can read in the docs. The exclusion half is checked
278
+ // against the doc text, not against a list in this file — an exclusion that
279
+ // only this gate believes in is how the omission would come back.
280
+ // ===========================================================================
281
+ {
282
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
283
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
284
+ assert.ok(gateBody.length > 1000, "located the release_gate body");
285
+
286
+ // name → the sentence in the docs that carries its exclusion, and where.
287
+ const DOCUMENTED_EXCLUSIONS: Record<string, [file: string, sentence: string]> = {
288
+ "smoke-acp-long-turn-live": ["VERIFY.md", "on-demand, not part of `release-gate`"],
289
+ "smoke-mux-fresh-call-live": ["VERIFY.md", "Fresh-call LIVE is on-demand, not part of `release-gate`"],
290
+ "smoke-agy-native-push-live": ["VERIFY.md", "Aggregate release-gate does not own an agy conversation id"],
291
+ "smoke-acp-ordering-probe-live": ["docs/acp-backend-rail.md", "opt-in paired observation"],
292
+ // Cortex needs an external Snowflake connection the HOST owns, so an aggregate
293
+ // that required it would block every cut taken without that account. Excluded —
294
+ // NOT waived: its direct call stays required for a Cortex-rail cut, and running
295
+ // it without the connection still reports protocol SKIP rather than a pass.
296
+ "smoke-acp-cortex-live": ["VERIFY.md", "The release aggregate does not re-certify Cortex"],
297
+ };
298
+
299
+ const allLive = globSync("scripts/smoke-*live*.{ts,sh}", { cwd: REPO_DIR })
300
+ .map((p) => basename(p).replace(/\.(ts|sh)$/, ""))
301
+ .sort();
302
+ assert.ok(allLive.length >= 18, `expected the full LIVE smoke family, found ${allLive.length}`);
303
+
304
+ for (const name of allLive) {
305
+ const wired = gateBody.includes(`"$self" ${name}\n`) || gateBody.includes(`"$self" ${name} `);
306
+ const excused = DOCUMENTED_EXCLUSIONS[name];
307
+ if (wired) {
308
+ assert.ok(!excused, `${name} is both wired and excused — pick one`);
309
+ continue;
310
+ }
311
+ assert.ok(
312
+ excused,
313
+ `[QK:NO-SILENT-AGGREGATE-OMISSION] ${name} is neither wired into release_gate nor excluded in the docs. ` +
314
+ "A LIVE smoke that exists but is never listed makes a green cut silent about the axis it covers — exactly " +
315
+ "how cortex (the second shipped backend) and claude-native-resume went unrun for releases. " +
316
+ "Wire it, or state the exclusion where an operator reads it.",
317
+ );
318
+ const [file, sentence] = excused as [string, string];
319
+ assert.ok(
320
+ readFileSync(join(REPO_DIR, file), "utf8").includes(sentence),
321
+ `${name} claims a documented exclusion, but ${file} no longer says "${sentence}" — an exclusion only this ` +
322
+ "gate believes in is not documented",
323
+ );
324
+ }
325
+ }
326
+
327
+ // ===========================================================================
328
+ // 8. qualification scheduling topology — the subtraction has its own oracle.
329
+ // check-gate-qualification left the default check chains (operator
330
+ // inner-loop cost, 2026-08 subtraction). That move is a gate/release
331
+ // contract: the step must stay REACHABLE on the axes that now own it — the
332
+ // CI check job on every push and release_gate as its own MUST step — and
333
+ // must not silently return to the default chain. Without this cell,
334
+ // deleting the release_gate qualification block or the CI line leaves every
335
+ // focused gate green while a cut quietly loses its discriminating-power
336
+ // step.
337
+ //
338
+ // TWO claims, because #70 added a second independent contract here. 8a is
339
+ // REACHABILITY (absent from the default chain; present exactly once in CI and
340
+ // exactly once as a wired release_gate MUST step; named in VERIFY). 8b is what
341
+ // the CI step qualifies (the FULL floor, before the qualification run). Each
342
+ // carries its own replant — one mutant must never stand in for both.
343
+ // ===========================================================================
344
+ {
345
+ // The default chain is tiered (#70): `check` (core) and `check:full` compose the
346
+ // named group scripts, so the reachability scan covers EVERY check* script —
347
+ // qualification sneaking into any group re-doubles the closure floor.
348
+ const pkgScripts = (
349
+ JSON.parse(readFileSync(join(REPO_DIR, "package.json"), "utf8")) as { scripts: Record<string, string> }
350
+ ).scripts;
351
+ const pkgCheck = Object.entries(pkgScripts)
352
+ .filter(([name]) => name === "check" || name.startsWith("check:"))
353
+ .map(([, body]) => body)
354
+ .join(" && ");
355
+ const ciYml = readFileSync(join(REPO_DIR, ".github/workflows/ci.yml"), "utf8");
356
+ const ciHits = ciYml.split("- run: ./run.sh check-gate-qualification").length - 1;
357
+ const runShQ = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
358
+ const qualGateBody = runShQ.slice(runShQ.indexOf("release_gate() {"), runShQ.indexOf("# 5. Summary"));
359
+ const invocations = qualGateBody.split('bash "$self" check-gate-qualification').length - 1;
360
+ const verifyDoc = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
361
+
362
+ // One claim token, one assert (the qualification runner requires the exact-once
363
+ // signature); each broken axis names itself in the joined message.
364
+ const holes: string[] = [];
365
+ if (pkgCheck.includes("check-gate-qualification"))
366
+ holes.push(
367
+ "package.json's default `check` chain contains check-gate-qualification again (doubles every closure floor)",
368
+ );
369
+ if (ciHits !== 1) holes.push(`the CI check job runs check-gate-qualification ${ciHits}x (need exactly once)`);
370
+ if (invocations !== 1)
371
+ holes.push(`release_gate invokes check-gate-qualification ${invocations}x (need exactly one MUST step)`);
372
+ if (
373
+ !qualGateBody.includes('results+=("PASS check-gate-qualification")') ||
374
+ !qualGateBody.includes('results+=("FAIL check-gate-qualification")')
375
+ )
376
+ holes.push("the release_gate qualification step does not wire PASS/FAIL into the MUST counters");
377
+ if (!verifyDoc.includes("in the CI `check` job on every push, and as a release-gate MUST step"))
378
+ holes.push("VERIFY.md no longer names the owners of the moved qualification step");
379
+ assert.ok(
380
+ holes.length === 0,
381
+ "[QK:QUALIFICATION-SCHEDULING-REACHABLE] check-gate-qualification left the default check chains " +
382
+ "deliberately, so it must stay REACHABLE on the axes that own it now — absent from the default chain, " +
383
+ "exactly once in the CI check job, exactly once as a release_gate MUST step with its " +
384
+ `outcome wired, and named in VERIFY. Broken: ${holes.join("; ")}`,
385
+ );
386
+
387
+ // 8b. WHAT the CI qualification step qualifies — its own claim, not a branch
388
+ // folded into the one above. #70 split the deterministic floor into tiers,
389
+ // so "CI runs qualification once" stopped being sufficient evidence on its
390
+ // own: qualification on top of the ≤60s core would certify kill-power over
391
+ // a floor no candidate ships on. This is ONE contract with one condition —
392
+ // the full floor is present in the CI check job AND qualification follows
393
+ // it — because a floor that is absent and a floor that runs afterwards
394
+ // break the same promise identically. The committed replant qualifies the
395
+ // downgrade/omission axis (the tier rename downgrading CI back to core);
396
+ // ordering stays directly asserted here and has no incident-earned mutant.
397
+ const ciFloorAt = ciYml.indexOf("- run: pnpm run check:full");
398
+ const ciQualAt = ciYml.indexOf("- run: ./run.sh check-gate-qualification");
399
+ assert.ok(
400
+ ciFloorAt !== -1 && ciQualAt > ciFloorAt,
401
+ "[QK:CI-FULL-FLOOR-QUALIFIED] the CI check job owns the FULL deterministic floor (#70): it must run " +
402
+ "`pnpm run check:full` — not the ≤60s core — and check-gate-qualification must come AFTER it, so the " +
403
+ "kill-power proof covers the floor a candidate actually ships on. The committed replant qualifies the " +
404
+ "downgrade/omission axis; ordering is directly asserted by this same oracle. " +
405
+ `Broken: check:full at index ${ciFloorAt}, qualification at index ${ciQualAt}.`,
406
+ );
407
+ }
408
+
409
+ // ===========================================================================
410
+ // The integrated mux lifecycle is a MUST, and the focused fresh-call LIVE is not
411
+ //
412
+ // Two LIVE smokes now cover the mux rail and they are NOT interchangeable:
413
+ // `smoke-mux-fresh-call-live` drives the composition from SOURCE and is on-demand,
414
+ // while `smoke-mux-lifecycle-live` enters through the real MCP surface and follows a
415
+ // citizen through resume and recall. A cut that ran only the focused one would be
416
+ // green about the axis nobody proved. So the MUST wiring is pinned here, exactly
417
+ // once, together with the doc sentence that keeps the two apart for an operator.
418
+ // ===========================================================================
419
+ {
420
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
421
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
422
+ const verify = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
423
+ const mustSteps = gateBody.split("\n").filter((l) => l.includes('"$self" smoke-mux-lifecycle-live'));
424
+ const gaps: string[] = [];
425
+ if (mustSteps.length !== 1)
426
+ gaps.push(`release_gate runs smoke-mux-lifecycle-live ${mustSteps.length}x (need exactly one MUST step)`);
427
+ if (mustSteps.length === 1 && !mustSteps[0]?.includes("run_live_step"))
428
+ gaps.push("the lifecycle step does not go through run_live_step, so its SKIP would not reach the classifier");
429
+ if (!verify.includes("Fresh-call LIVE is on-demand, not part of `release-gate`"))
430
+ gaps.push("VERIFY.md no longer excuses the focused fresh-call LIVE from the aggregate");
431
+ if (!verify.includes("smoke-mux-lifecycle-live"))
432
+ gaps.push("VERIFY.md does not name the integrated lifecycle MUST at all");
433
+ assert.ok(
434
+ gaps.length === 0,
435
+ "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST] the integrated mux lifecycle LIVE must be a release-gate MUST exactly once " +
436
+ "and go through run_live_step (so a missing prerequisite is a SKIP the cut refuses, never a silent pass), " +
437
+ "while the focused fresh-call LIVE stays on-demand and VERIFY keeps the two distinguishable — a cut that " +
438
+ `ran only the source-level smoke would be green about the surface no one entered. Broken: ${gaps.join("; ")}`,
439
+ );
440
+ }
441
+
442
+ console.log(
443
+ "[check-release-gate-outcomes] ok — STEP OUTCOME protocol: one skip exit code shared by the shell and TS halves " +
444
+ `(${LIVE_SKIP_EXIT}, clear of the per-tool 0..4 and shell 126+ bands), classifier maps 0→PASS / skip→SKIP / ` +
445
+ "everything else→FAIL (never rounding a skip up), `--cut` refuses a MUST SKIP while a bare diagnostic run stays " +
446
+ "exit 0, no LIVE smoke still carries the pre-P1 exit-0 skip shape, and both real skip surfaces were INVOKED and " +
447
+ "observed to propagate the code: a smoke with LIVE unset (through run_ts, with its operator-readable marker) and " +
448
+ "a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
449
+ "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
450
+ "check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
451
+ "exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it",
452
+ );