@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,322 @@
1
+ /**
2
+ * claude-launcher-fence — the shared fail-closed protection for the operator's real `claude`
3
+ * launcher, owed by BOTH mux LIVE smokes (issue #67).
4
+ *
5
+ * The observed incident: a Claude child launched with the operator's real HOME but a fixture
6
+ * `XDG_DATA_HOME` self-updated INTO the fixture tree and rewrote the real launcher (the
7
+ * `~/.local/bin/claude` install shim) to point at `<fixture>/claude/versions/<v>`. Fixture
8
+ * teardown then deleted that tree and left the operator's launcher dangling. This module owns
9
+ * the three protections the smokes share, so neither carries its own copy:
10
+ *
11
+ * 1. PREFLIGHT (`snapshotClaudeLauncher`) — fail-closed. Before any Claude-capable child
12
+ * starts, resolve the actual absolute launcher from the exact child PATH, require it and
13
+ * its symlink/realpath-resolved target to be present, regular, executable and OUTSIDE the
14
+ * fixture cleanup root, and capture enough identity/content evidence (kind, link text,
15
+ * resolved path, content hash) to prove "unchanged" later. Any doubt throws — a smoke that
16
+ * cannot pin the launcher's identity must not launch the child that could destroy it.
17
+ * 2. INTEGRITY ORACLE (`verifyClaudeLauncher`) — on success AND on every failure teardown,
18
+ * before fixture removal, re-derive the same facts and report every mismatch by name.
19
+ * 3. CLEANUP GUARD (`launcherReferencesFixture`) — the one question that decides whether
20
+ * `rm -rf <fixture>` is destructive: does the launcher, or what it currently resolves to,
21
+ * live inside the fixture root? If yes, the smoke must BLOCK its own cleanup loudly and
22
+ * leave the tree in place rather than dangle the operator's launcher.
23
+ *
24
+ * It also owns the env repair that removes the incident's trigger: `restoreOriginalXdg` gives a
25
+ * real-HOME Claude cell EXACT operator-env parity on the four XDG roots — each variable restored
26
+ * to its original value if it was set, and DELETED if it was originally absent. Filling a
27
+ * canonical default for an absent variable would be a guess about how the runtime reads its
28
+ * config; parity, not widening, is the contract. That parity covers the four PERSISTENT
29
+ * CONFIG/DATA/STATE/CACHE roots only: `XDG_RUNTIME_DIR` deliberately remains the smokes' private
30
+ * fixture runtime surface, so this is not a claim of total operator-env parity.
31
+ *
32
+ * This is a scripts-side harness module. It never inspects or alters anything unless the caller
33
+ * hands it an environment and a fixture root, and it must stay out of shipped production source.
34
+ */
35
+
36
+ import { createHash } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+
40
+ /** The four XDG roots a real-HOME Claude cell must see with exact operator parity. */
41
+ export const CLAUDE_CELL_XDG_VARS = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] as const;
42
+
43
+ /** Original presence/value of each XDG root: a string if it was set (even empty), null if absent. */
44
+ export type OriginalXdg = Record<string, string | null>;
45
+
46
+ /** Capture the four XDG roots BEFORE any fixture redirect touches the environment. */
47
+ export function snapshotOriginalXdg(env: NodeJS.ProcessEnv): OriginalXdg {
48
+ const out: OriginalXdg = {};
49
+ for (const name of CLAUDE_CELL_XDG_VARS) out[name] = env[name] ?? null;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Restore EXACT original presence/value of the four XDG roots on a cell env. An originally
55
+ * absent variable is deleted, never filled with a canonical default — parity, not a guess.
56
+ */
57
+ export function restoreOriginalXdg(env: NodeJS.ProcessEnv, originals: OriginalXdg): NodeJS.ProcessEnv {
58
+ for (const name of CLAUDE_CELL_XDG_VARS) {
59
+ const original = originals[name];
60
+ if (original === null || original === undefined) delete env[name];
61
+ else env[name] = original;
62
+ }
63
+ return env;
64
+ }
65
+
66
+ /** Everything the preflight pinned, and the oracle later re-derives. */
67
+ export interface ClaudeLauncherSnapshot {
68
+ /** Absolute launcher path selected from the exact child PATH. */
69
+ launcherPath: string;
70
+ /** What the launcher IS at its own path: a symlink or a regular file. */
71
+ kind: "symlink" | "file";
72
+ /** Raw readlink text when the launcher is a symlink, else null. */
73
+ linkText: string | null;
74
+ /** Fully realpath-resolved target the launcher executes. */
75
+ resolvedPath: string;
76
+ /** sha256 of the resolved target's content — the one axis that catches a same-path, same-link in-place rewrite. */
77
+ sha256: string;
78
+ /** Realpath of the fixture cleanup root the launcher must stay outside of. */
79
+ fixtureRoot: string;
80
+ }
81
+
82
+ function isInside(parent: string, p: string): boolean {
83
+ const rel = path.relative(parent, p);
84
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
85
+ }
86
+
87
+ function sha256File(file: string): string {
88
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
89
+ }
90
+
91
+ function isExecutableFile(p: string): boolean {
92
+ try {
93
+ if (!fs.statSync(p).isFile()) return false;
94
+ fs.accessSync(p, fs.constants.X_OK);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Resolve `cmd` the way the child's exec will: first PATH entry holding an executable file.
103
+ * Returns the joined candidate path (not yet symlink-resolved), or null when nothing matches.
104
+ */
105
+ export function resolveFromPath(cmd: string, env: NodeJS.ProcessEnv): string | null {
106
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
107
+ if (!dir) continue;
108
+ const candidate = path.join(dir, cmd);
109
+ if (isExecutableFile(candidate)) return candidate;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * FAIL-CLOSED PREFLIGHT. Pin the launcher's identity before any Claude-capable child starts,
116
+ * or throw. Every refusal names itself: a smoke that cannot prove the launcher is a real,
117
+ * executable, fixture-independent file must not launch the child that could rewrite it.
118
+ */
119
+ export function snapshotClaudeLauncher(opts: { env: NodeJS.ProcessEnv; fixtureRoot: string }): ClaudeLauncherSnapshot {
120
+ const fixtureRoot = fs.realpathSync(opts.fixtureRoot);
121
+ const launcherPath = resolveFromPath("claude", opts.env);
122
+ if (!launcherPath) {
123
+ throw new Error(
124
+ "claude-launcher-fence: fail-closed — no executable `claude` on the exact child PATH, so the launcher's identity cannot be pinned before launch",
125
+ );
126
+ }
127
+ if (!path.isAbsolute(launcherPath)) {
128
+ throw new Error(
129
+ `claude-launcher-fence: fail-closed — \`claude\` resolved through a relative PATH entry (${launcherPath}); a cwd-dependent launcher cannot be pinned`,
130
+ );
131
+ }
132
+ if (isInside(fixtureRoot, launcherPath)) {
133
+ throw new Error(
134
+ `claude-launcher-fence: fail-closed — the selected launcher ${launcherPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
135
+ );
136
+ }
137
+ const lst = fs.lstatSync(launcherPath);
138
+ const kind: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
139
+ if (kind === "file" && !lst.isFile()) {
140
+ throw new Error(
141
+ `claude-launcher-fence: fail-closed — the launcher ${launcherPath} is neither a regular file nor a symlink`,
142
+ );
143
+ }
144
+ const linkText = kind === "symlink" ? fs.readlinkSync(launcherPath) : null;
145
+ let resolvedPath: string;
146
+ try {
147
+ resolvedPath = fs.realpathSync(launcherPath);
148
+ } catch (err) {
149
+ throw new Error(
150
+ `claude-launcher-fence: fail-closed — the launcher ${launcherPath} does not resolve to a real target: ${
151
+ err instanceof Error ? err.message : String(err)
152
+ }`,
153
+ );
154
+ }
155
+ if (!isExecutableFile(resolvedPath)) {
156
+ throw new Error(
157
+ `claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is not a regular executable file`,
158
+ );
159
+ }
160
+ if (isInside(fixtureRoot, resolvedPath)) {
161
+ throw new Error(
162
+ `claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
163
+ );
164
+ }
165
+ return {
166
+ launcherPath,
167
+ kind,
168
+ linkText,
169
+ resolvedPath,
170
+ sha256: sha256File(resolvedPath),
171
+ fixtureRoot,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * INTEGRITY ORACLE. Re-derive every pinned fact and return one problem string per mismatch —
177
+ * empty means the launcher, its link, its resolved target and that target's content are all
178
+ * exactly as the preflight saw them, and the target is still a regular executable. Runs on the
179
+ * success path AND on every failure teardown, BEFORE fixture removal.
180
+ */
181
+ export function verifyClaudeLauncher(snapshot: ClaudeLauncherSnapshot): string[] {
182
+ const problems: string[] = [];
183
+ let lst: fs.Stats;
184
+ try {
185
+ lst = fs.lstatSync(snapshot.launcherPath);
186
+ } catch {
187
+ problems.push(`the launcher ${snapshot.launcherPath} no longer exists`);
188
+ return problems;
189
+ }
190
+ const kindNow: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
191
+ if (kindNow !== snapshot.kind) {
192
+ problems.push(`the launcher changed kind: was ${snapshot.kind}, is now ${kindNow}`);
193
+ }
194
+ if (kindNow === "symlink") {
195
+ // A raced-away link is an INTEGRITY PROBLEM, never an exception that could mask the run
196
+ // error this oracle runs alongside.
197
+ let linkNow: string | null = null;
198
+ try {
199
+ linkNow = fs.readlinkSync(snapshot.launcherPath);
200
+ } catch (err) {
201
+ problems.push(
202
+ `the launcher's link text could not be re-read: ${err instanceof Error ? err.message : String(err)}`,
203
+ );
204
+ }
205
+ if (linkNow !== null && linkNow !== snapshot.linkText) {
206
+ problems.push(`the launcher's link text changed: was ${snapshot.linkText ?? "(none)"}, is now ${linkNow}`);
207
+ }
208
+ }
209
+ let resolvedNow: string | null = null;
210
+ try {
211
+ resolvedNow = fs.realpathSync(snapshot.launcherPath);
212
+ } catch {
213
+ problems.push(`the launcher ${snapshot.launcherPath} no longer resolves to a real target`);
214
+ }
215
+ if (resolvedNow !== null) {
216
+ if (resolvedNow !== snapshot.resolvedPath) {
217
+ problems.push(`the launcher's resolved target moved: was ${snapshot.resolvedPath}, is now ${resolvedNow}`);
218
+ }
219
+ if (!isExecutableFile(resolvedNow)) {
220
+ problems.push(`the launcher's resolved target ${resolvedNow} is no longer a regular executable file`);
221
+ } else {
222
+ try {
223
+ if (sha256File(resolvedNow) !== snapshot.sha256) {
224
+ problems.push(`the launcher's resolved target ${resolvedNow} changed content (sha256 mismatch)`);
225
+ }
226
+ } catch (err) {
227
+ problems.push(
228
+ `the launcher's resolved target ${resolvedNow} could not be hashed: ${err instanceof Error ? err.message : String(err)}`,
229
+ );
230
+ }
231
+ }
232
+ if (isInside(snapshot.fixtureRoot, resolvedNow)) {
233
+ problems.push(
234
+ `the launcher's resolved target ${resolvedNow} now lies INSIDE the fixture cleanup root ${snapshot.fixtureRoot}`,
235
+ );
236
+ }
237
+ }
238
+ return problems;
239
+ }
240
+
241
+ /** What the cleanup guard could prove, stated honestly: a KNOWN fixture reference and an
242
+ * UNPROVABLE state both block removal, but each is named as itself in `problems`. */
243
+ export interface LauncherCleanupVerdict {
244
+ /** True only when removal is PROVEN safe: the launcher demonstrably does not reference the fixture. */
245
+ safeToRemove: boolean;
246
+ /** Named reasons removal is blocked — a fixture reference, or a syscall that left safety unproven. */
247
+ problems: string[];
248
+ }
249
+
250
+ /**
251
+ * CLEANUP GUARD. Decides whether `rm -rf <fixture>` is destructive: does the launcher, its
252
+ * current link target, or its current resolved path lie inside the fixture cleanup root — the
253
+ * exact state in which removal would dangle the operator's launcher? FAIL-CLOSED on uncertainty:
254
+ * a readlink/lstat/realpath error blocks removal with a "not proven safe" problem rather than
255
+ * being laundered into either a false "references fixture" or a false all-clear. A launcher that
256
+ * is missing ENTIRELY is safe to clean around — nothing is left for removal to sever, and the
257
+ * integrity oracle reports that damage by name.
258
+ */
259
+ export function assessLauncherCleanup(snapshot: ClaudeLauncherSnapshot): LauncherCleanupVerdict {
260
+ if (isInside(snapshot.fixtureRoot, snapshot.launcherPath)) {
261
+ return {
262
+ safeToRemove: false,
263
+ problems: [`the launcher ${snapshot.launcherPath} itself lies inside the fixture cleanup root`],
264
+ };
265
+ }
266
+ let lst: fs.Stats;
267
+ try {
268
+ lst = fs.lstatSync(snapshot.launcherPath);
269
+ } catch (err) {
270
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { safeToRemove: true, problems: [] };
271
+ return {
272
+ safeToRemove: false,
273
+ problems: [
274
+ `the launcher ${snapshot.launcherPath} could not be inspected (${
275
+ err instanceof Error ? err.message : String(err)
276
+ }) — removal is not proven safe`,
277
+ ],
278
+ };
279
+ }
280
+ if (lst.isSymbolicLink()) {
281
+ let hop: string;
282
+ try {
283
+ hop = path.resolve(path.dirname(snapshot.launcherPath), fs.readlinkSync(snapshot.launcherPath));
284
+ } catch (err) {
285
+ return {
286
+ safeToRemove: false,
287
+ problems: [
288
+ `the launcher's link text could not be read (${
289
+ err instanceof Error ? err.message : String(err)
290
+ }) — removal is not proven safe`,
291
+ ],
292
+ };
293
+ }
294
+ if (isInside(snapshot.fixtureRoot, hop)) {
295
+ return {
296
+ safeToRemove: false,
297
+ problems: [`the launcher's link target ${hop} lies inside the fixture cleanup root`],
298
+ };
299
+ }
300
+ }
301
+ try {
302
+ const resolved = fs.realpathSync(snapshot.launcherPath);
303
+ if (isInside(snapshot.fixtureRoot, resolved)) {
304
+ return {
305
+ safeToRemove: false,
306
+ problems: [`the launcher's resolved target ${resolved} lies inside the fixture cleanup root`],
307
+ };
308
+ }
309
+ } catch (err) {
310
+ // A dangling or unresolvable chain: the first hop above was proven outside the fixture, but
311
+ // the FULL chain was not — fail closed rather than guess.
312
+ return {
313
+ safeToRemove: false,
314
+ problems: [
315
+ `the launcher ${snapshot.launcherPath} could not be fully resolved (${
316
+ err instanceof Error ? err.message : String(err)
317
+ }) — removal is not proven safe`,
318
+ ],
319
+ };
320
+ }
321
+ return { safeToRemove: true, problems: [] };
322
+ }
@@ -0,0 +1,33 @@
1
+ // The TypeScript half of the release-gate STEP OUTCOME protocol
2
+ // (scripts/lib/step-outcome.sh owns the contract prose and the shell half).
3
+ //
4
+ // A LIVE smoke that lacks a prerequisite must be DISTINGUISHABLE from one that
5
+ // ran and passed. Before P1 every smoke exited 0 on a skip, so `run_live_step`
6
+ // counted it PASS and the release summary could not prove a required call had
7
+ // happened at all. `skipLive` is the single exit any smoke takes when it
8
+ // declines: one reserved code the aggregate classifies as SKIP, plus one marker
9
+ // line so an operator reading the log sees WHICH prerequisite was missing
10
+ // without decoding a number.
11
+ //
12
+ // The two halves must agree. `check-release-gate-outcomes` reads this constant
13
+ // and the shell one and refuses a mismatch — a protocol that drifted between
14
+ // languages would silently reclassify every skip as a failure (or worse).
15
+
16
+ /** SKIP. Must equal `ENTWURF_STEP_SKIP_EXIT` in scripts/lib/step-outcome.sh. */
17
+ export const LIVE_SKIP_EXIT = 97;
18
+
19
+ /** The machine-greppable prefix every skip line carries. */
20
+ export const LIVE_SKIP_MARKER = "[entwurf:skip]";
21
+
22
+ /**
23
+ * Decline this smoke: print the marker + reason on stderr and exit with the
24
+ * protocol's SKIP code. Never returns.
25
+ *
26
+ * `label` is the smoke's own name (so a multi-step aggregate log stays
27
+ * attributable); `reason` states the missing prerequisite AND how to supply it —
28
+ * an operator who hits a SKIP in a cut run needs the fix, not the diagnosis.
29
+ */
30
+ export function skipLive(label: string, reason: string): never {
31
+ console.error(`${LIVE_SKIP_MARKER} ${label} — ${reason}`);
32
+ process.exit(LIVE_SKIP_EXIT);
33
+ }
@@ -78,6 +78,70 @@ export function signatureOnFailureLine(output: string, token: string): boolean {
78
78
  return output.split("\n").some((line) => line.includes(token) && !/^\s*ok\b/.test(line));
79
79
  }
80
80
 
81
+ /** run_vitest prints this line when the runner asked it for a structured report.
82
+ * Its PRESENCE — not the report's readability — is what declares the lane structured. */
83
+ export const VITEST_STRUCTURED_MARKER = "__ENTWURF_VITEST_JSON__";
84
+
85
+ /**
86
+ * What a gate run said about WHICH tests failed.
87
+ *
88
+ * - `"legacy"`: no structured marker — a hand-built node:assert/shell gate whose
89
+ * `ok`-line oracle is unchanged.
90
+ * - `"unreadable"`: the marker was printed but the report is missing or malformed.
91
+ * This NEVER falls back to token scanning: a structured lane that lost its report
92
+ * has no attribution at all, so the mutant is WRONG-REASON, not KILLED.
93
+ * - a title array: the exact `fullName` of every FAILED test.
94
+ */
95
+ export type FailedTestTitles = "legacy" | "unreadable" | string[];
96
+
97
+ /** Read the failed-test titles a vitest gate wrote, if it declared itself structured. */
98
+ export function readVitestFailedTitles(output: string, reportPath: string | null): FailedTestTitles {
99
+ if (!output.split("\n").some((line) => line.trim() === VITEST_STRUCTURED_MARKER)) return "legacy";
100
+ if (!reportPath) return "unreadable";
101
+ let raw: string;
102
+ try {
103
+ raw = fs.readFileSync(reportPath, "utf8");
104
+ } catch {
105
+ return "unreadable";
106
+ }
107
+ let report: {
108
+ testResults?: Array<{ assertionResults?: Array<{ status?: string; fullName?: string; title?: string }> }>;
109
+ };
110
+ try {
111
+ report = JSON.parse(raw);
112
+ } catch {
113
+ return "unreadable";
114
+ }
115
+ if (!Array.isArray(report.testResults)) return "unreadable";
116
+ const titles: string[] = [];
117
+ for (const suite of report.testResults) {
118
+ for (const assertion of suite.assertionResults ?? []) {
119
+ if (assertion.status !== "failed") continue;
120
+ titles.push(assertion.fullName ?? assertion.title ?? "");
121
+ }
122
+ }
123
+ return titles;
124
+ }
125
+
126
+ /**
127
+ * Attribute a kill to its claim. A Vitest failure's CODE FRAME quotes the source lines
128
+ * around the assertion — including an adjacent PASSING test's `it("[QK:…]" …)` title —
129
+ * so scanning output lines would certify a claim whose test never failed (measured:
130
+ * issue #62 review). Structured lanes therefore read only the failed-test title set.
131
+ */
132
+ export function signatureAttributedToFailure(output: string, token: string, failed: FailedTestTitles): boolean {
133
+ if (failed === "legacy") return signatureOnFailureLine(output, token);
134
+ if (failed === "unreadable") return false;
135
+ return failed.some((title) => title.includes(token));
136
+ }
137
+
138
+ /** One legible word for the report line, so a WRONG-REASON says WHY it could not attribute. */
139
+ export function describeAttribution(failed: FailedTestTitles): string {
140
+ if (failed === "legacy") return "failure-line";
141
+ if (failed === "unreadable") return "vitest-structured-but-unreadable";
142
+ return `vitest-failed-titles(${failed.length})`;
143
+ }
144
+
81
145
  // ── manifest schema (fail-loud, exact keys) ─────────────────────────────────
82
146
 
83
147
  export interface MutantSpec {
@@ -430,6 +494,8 @@ export interface GateRunResult {
430
494
  timedOut: boolean;
431
495
  output: string;
432
496
  seconds: number;
497
+ /** Structured failed-test attribution; `"legacy"` for every non-vitest gate. */
498
+ failedTitles: FailedTestTitles;
433
499
  }
434
500
 
435
501
  const OUTPUT_CAP = 4 * 1024 * 1024;
@@ -437,12 +503,23 @@ const OUTPUT_CAP = 4 * 1024 * 1024;
437
503
  /** Env prefixes the outer fence strips so a gate child starts from a neutral host. */
438
504
  const STRIP_ENV_PREFIXES = ["ENTWURF_", "AGY_", "PI_SESSION_ID", "PI_AGENT_ID"];
439
505
 
506
+ /** Where a vitest-backed gate writes its machine report for this one invocation. */
507
+ function vitestReportPath(invocationDir: string): string {
508
+ return path.join(invocationDir, "vitest-report.json");
509
+ }
510
+
440
511
  function fencedEnv(invocationDir: string): NodeJS.ProcessEnv {
441
512
  const env: NodeJS.ProcessEnv = {};
442
513
  for (const [k, v] of Object.entries(process.env)) {
443
514
  if (STRIP_ENV_PREFIXES.some((p) => k === p || k.startsWith(p))) continue;
444
515
  env[k] = v;
445
516
  }
517
+ // A vitest-backed gate writes its machine report HERE, inside the per-invocation dir
518
+ // (outside the snapshot repo, so the work-surface purity hash never sees it). The
519
+ // report is a FILE and not stdout on purpose: `output` merges stdout+stderr, so one
520
+ // vite warning ahead of the JSON would break the parse and silently downgrade a real
521
+ // kill to WRONG-REASON. Legacy gates ignore the variable.
522
+ env.ENTWURF_MUTATION_VITEST_REPORT = vitestReportPath(invocationDir);
446
523
  for (const d of ["home", "xdg-data", "xdg-config", "xdg-cache", "xdg-state"]) {
447
524
  fs.mkdirSync(path.join(invocationDir, d), { recursive: true });
448
525
  }
@@ -499,7 +576,9 @@ export function runGateBounded(opts: {
499
576
  });
500
577
  child.on("close", (code) => {
501
578
  clearTimeout(timer);
502
- resolve({ exitCode: code, timedOut, output, seconds: (Date.now() - started) / 1000 });
579
+ // Read the report BEFORE the caller removes the invocation dir.
580
+ const failedTitles = readVitestFailedTitles(output, vitestReportPath(opts.invocationDir));
581
+ resolve({ exitCode: code, timedOut, output, failedTitles, seconds: (Date.now() - started) / 1000 });
503
582
  });
504
583
  });
505
584
  }
@@ -548,6 +627,29 @@ function countOccurrences(haystack: string, needle: string): number {
548
627
  return count;
549
628
  }
550
629
 
630
+ /** Tail of a red control run's captured output that gets echoed into the log. */
631
+ const CONTROL_TAIL_LINES = 40;
632
+
633
+ /**
634
+ * Echo a RED control run's captured output. Without this the runner reports the
635
+ * verdict (`RED (exit=1 …)`) and DROPS the reason it already holds, which makes a
636
+ * CONTROL-RED that only reproduces on one host — a CI runner, say — undiagnosable
637
+ * from its log: the same single line no matter how often it is re-run. A bounded
638
+ * tail is enough, because a gate names its failure on the way out. Silence is a
639
+ * finding too, so an empty capture is reported as empty rather than skipped.
640
+ */
641
+ function logControlOutput(output: string, log: (line: string) => void): void {
642
+ const body = output.replace(/\s+$/, "");
643
+ if (body === "") {
644
+ log(" │ (the control run produced no output — the failure is upstream of the gate's own reporting)");
645
+ return;
646
+ }
647
+ const lines = body.split("\n");
648
+ const dropped = Math.max(0, lines.length - CONTROL_TAIL_LINES);
649
+ if (dropped > 0) log(` │ … ${dropped} earlier line(s) omitted`);
650
+ for (const line of lines.slice(-CONTROL_TAIL_LINES)) log(` │ ${line}`);
651
+ }
652
+
551
653
  /**
552
654
  * Run every mutant grouped by its gate command, with the control-mutant-restore-
553
655
  * control state machine. A red CONTROL-PRE aborts the whole group (every mutant
@@ -593,6 +695,7 @@ export async function qualifyMutants(
593
695
  ` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
594
696
  );
595
697
  if (!controlPreOk) {
698
+ logControlOutput(pre.output, log);
596
699
  group.control = "pre-red";
597
700
  for (const m of groupMutants) {
598
701
  group.mutants.push({
@@ -678,7 +781,7 @@ export async function qualifyMutants(
678
781
  matchCount: 1,
679
782
  timedOut: run.timedOut,
680
783
  exitCode: run.exitCode,
681
- signatureOnFailureLine: signatureOnFailureLine(run.output, m.signature),
784
+ signatureOnFailureLine: signatureAttributedToFailure(run.output, m.signature, run.failedTitles),
682
785
  restoredOk,
683
786
  });
684
787
  if (!restoredOk) groupImpure = true;
@@ -687,7 +790,9 @@ export async function qualifyMutants(
687
790
  verdict,
688
791
  seconds: run.seconds,
689
792
  subjectSha256: originalSha,
690
- detail: `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}`,
793
+ detail:
794
+ `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}` +
795
+ ` attribution=${describeAttribution(run.failedTitles)}`,
691
796
  });
692
797
  log(
693
798
  ` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
@@ -705,6 +810,7 @@ export async function qualifyMutants(
705
810
  log(
706
811
  ` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
707
812
  );
813
+ if (!controlPostOk) logControlOutput(post.output, log);
708
814
  }
709
815
 
710
816
  const postTree = computeTreeManifest(snapshot.repoDir);
@@ -29,20 +29,29 @@ export interface ProbePhaseTimeouts {
29
29
  initializeMs: number;
30
30
  newSessionMs: number;
31
31
  setModelMs: number;
32
- promptMs: number;
33
32
  }
34
33
 
35
- // Phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/SET_MODEL/
36
- // PROMPT_TIMEOUT_MS (check-probe-ordering pins them against the source). §11-7:
37
- // a D verdict is only readable against these boundaries, so the probe may not
38
- // invent its own.
34
+ // BOOTSTRAP phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/
35
+ // SET_MODEL_TIMEOUT_MS (check-probe-ordering pins them against the source).
36
+ // §11-7: a bootstrap-phase D verdict is only readable against production's own
37
+ // boundaries, so the probe may not invent its own.
39
38
  export const PROBE_PHASE_TIMEOUTS: ProbePhaseTimeouts = {
40
39
  initializeMs: 30_000,
41
40
  newSessionMs: 30_000,
42
41
  setModelMs: 30_000,
43
- promptMs: 600_000,
44
42
  };
45
43
 
44
+ // The prompt phase has NO production counterpart to match: backend.ts ends a
45
+ // prompt on lifecycle events only (resolve / abort / child gone) and carries no
46
+ // wall-clock cutoff, by policy — an active turn is not a failed turn for being
47
+ // long. This value is therefore the MEASUREMENT HARNESS's own bounded
48
+ // observation horizon: it stops a probe RUN from hanging forever. It is not a
49
+ // production contract, not an equivalence, and not a deadline any §11-7 verdict
50
+ // is read against. Horizon expiry means the run's prompt window is
51
+ // INCONCLUSIVE — the artifact is preserved and read as "not measured", never as
52
+ // a model of production killing a turn.
53
+ export const PROBE_PROMPT_OBSERVATION_MS = 600_000;
54
+
46
55
  /** The two adapter methods the turn drives — satisfied by the REAL claudeAdapter
47
56
  * (emitted) in the LIVE runner and by recording stubs in the gate. */
48
57
  export interface ProbeAdapterSeam {
@@ -108,7 +117,10 @@ export interface ProbeTurnParams {
108
117
  adapter: ProbeAdapterSeam;
109
118
  enrichMcpServers: ProbeMcpEnricher;
110
119
  log: (event: ProbeEventName, payload?: Record<string, unknown>) => void;
120
+ /** Bootstrap boundaries (production-pinned). Defaults to PROBE_PHASE_TIMEOUTS. */
111
121
  timeouts?: ProbePhaseTimeouts;
122
+ /** Harness observation horizon for the prompt phase — NOT a production deadline. */
123
+ promptObservationMs?: number;
112
124
  }
113
125
 
114
126
  export interface ProbeTurnResult {
@@ -158,7 +170,7 @@ export async function driveProbeTurn(connection: AcpConnectionLike, params: Prob
158
170
  }),
159
171
  );
160
172
 
161
- // _meta + wire servers exactly as backend.ts assembles them (S2g/§9-4): the
173
+ // _meta + wire servers exactly as backend.ts assembles them (S2g / carrier-less shape): the
162
174
  // injected production buildSessionMeta (omitted key when undefined) + the
163
175
  // injected production envelope enrichment over the resolved config.
164
176
  const sessionMeta = params.adapter.buildSessionMeta(
@@ -196,11 +208,20 @@ export async function driveProbeTurn(connection: AcpConnectionLike, params: Prob
196
208
  }),
197
209
  );
198
210
 
199
- const promptResult = await runPhase("prompt", PROBE_EVENTS.promptStart, PROBE_EVENTS.promptEnd, log, t.promptMs, () =>
200
- connection.prompt({
201
- sessionId: acpSessionId,
202
- prompt: [{ type: "text", text: params.promptText }],
203
- }),
211
+ // The horizon below bounds THIS MEASUREMENT RUN, not the turn: production has
212
+ // no prompt deadline, so an expiry here is an inconclusive observation.
213
+ const promptObservationMs = params.promptObservationMs ?? PROBE_PROMPT_OBSERVATION_MS;
214
+ const promptResult = await runPhase(
215
+ "prompt",
216
+ PROBE_EVENTS.promptStart,
217
+ PROBE_EVENTS.promptEnd,
218
+ log,
219
+ promptObservationMs,
220
+ () =>
221
+ connection.prompt({
222
+ sessionId: acpSessionId,
223
+ prompt: [{ type: "text", text: params.promptText }],
224
+ }),
204
225
  );
205
226
 
206
227
  return { acpSessionId, stopReason: promptResult?.stopReason };