@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,264 @@
1
+ /**
2
+ * check-mux-launcher-fence — deterministic gate for the shared operator-launcher protection
3
+ * (`scripts/lib/claude-launcher-fence.ts`) both mux LIVE smokes must consume (issue #67).
4
+ *
5
+ * The closed incident: a Claude child launched with real HOME + fixture XDG_DATA_HOME
6
+ * self-updated into the fixture tree and rewrote the operator's real `claude` launcher to point
7
+ * there; fixture teardown then deleted the tree and left the launcher dangling. Everything below
8
+ * replants that shape into DISPOSABLE fixture paths only — a simulated "operator" bin/versions
9
+ * tree and a simulated fixture cleanup root, both under one mkdtemp. The gate never inspects,
10
+ * touches or even resolves the real operator launcher.
11
+ *
12
+ * LAUNCHFENCE-PREFLIGHT-FAIL-CLOSED the preflight refuses to pin a launcher whose resolved
13
+ * target already lives inside the fixture cleanup root
14
+ * LAUNCHFENCE-RETARGET-BLOCKS-CLEANUP a post-launch retarget into the fixture (the incident's
15
+ * exact end state: a dangling reference into a tree about
16
+ * to be deleted) makes the cleanup guard BLOCK removal
17
+ * LAUNCHFENCE-CONTENT-CHANGE-DETECTED an in-place rewrite of the resolved target — same path,
18
+ * same link — is still reported before cleanup
19
+ * LAUNCHFENCE-XDG-EXACT-RESTORE a real-HOME Claude cell gets EXACT operator XDG parity:
20
+ * original values restored, absent variables DELETED,
21
+ * never filled with a canonical default
22
+ * LAUNCHFENCE-WIRED-LIFECYCLE smoke-mux-lifecycle-live actually consumes the fence:
23
+ * LAUNCHFENCE-WIRED-FRESH-CALL preflight + integrity oracle BEFORE fixture removal +
24
+ * removal gated on cleanup verdict AND tracked-pane
25
+ * quiescence + XDG parity, in each smoke's own source
26
+ * LAUNCHFENCE-LIFECYCLE-CELL-BRANCH lifecycle keeps its two truthful cell branches: ACP pi
27
+ * retains the four MEASURED canonical REAL_XDG_* roots,
28
+ * the direct Claude cell gets exact-parity restore
29
+ */
30
+
31
+ import assert from "node:assert/strict";
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import {
37
+ assessLauncherCleanup,
38
+ resolveFromPath,
39
+ restoreOriginalXdg,
40
+ snapshotClaudeLauncher,
41
+ snapshotOriginalXdg,
42
+ verifyClaudeLauncher,
43
+ } from "./lib/claude-launcher-fence.ts";
44
+
45
+ let passed = 0;
46
+ function ok(label: string, cond: boolean): void {
47
+ assert.ok(cond, label);
48
+ console.log(` ok ${label}`);
49
+ passed++;
50
+ }
51
+
52
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
53
+ const read = (rel: string): string => fs.readFileSync(path.join(ROOT, rel), "utf8");
54
+
55
+ function refusal(fn: () => unknown): string {
56
+ try {
57
+ fn();
58
+ } catch (err) {
59
+ return err instanceof Error ? err.message : String(err);
60
+ }
61
+ return "";
62
+ }
63
+
64
+ function relink(link: string, target: string): void {
65
+ fs.rmSync(link);
66
+ fs.symlinkSync(target, link);
67
+ }
68
+
69
+ function main(): void {
70
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "check-mux-launcher-fence-"));
71
+ try {
72
+ // ── a disposable stand-in for the operator's install: bin/claude → versions/1.0.0/claude ──
73
+ const opBin = path.join(tmp, "operator", "bin");
74
+ const opTarget = path.join(tmp, "operator", "versions", "1.0.0", "claude");
75
+ fs.mkdirSync(opBin, { recursive: true });
76
+ fs.mkdirSync(path.dirname(opTarget), { recursive: true });
77
+ fs.writeFileSync(opTarget, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
78
+ const launcher = path.join(opBin, "claude");
79
+ fs.symlinkSync(opTarget, launcher);
80
+ const fixtureRoot = path.join(tmp, "fixture");
81
+ fs.mkdirSync(fixtureRoot);
82
+ const env: NodeJS.ProcessEnv = { PATH: opBin };
83
+
84
+ // ── PATH resolution mirrors the child's exec ─────────────────────────────
85
+ ok(
86
+ "resolution: the launcher is selected from the exact child PATH, not from this process's",
87
+ resolveFromPath("claude", env) === launcher && resolveFromPath("claude", { PATH: tmp }) === null,
88
+ );
89
+
90
+ // ── the fail-closed preflight ────────────────────────────────────────────
91
+ const snap = snapshotClaudeLauncher({ env, fixtureRoot });
92
+ ok(
93
+ "preflight: a healthy launcher is pinned — kind, link text, resolved target and content hash",
94
+ snap.launcherPath === launcher &&
95
+ snap.kind === "symlink" &&
96
+ snap.linkText === opTarget &&
97
+ snap.resolvedPath === fs.realpathSync(opTarget) &&
98
+ snap.sha256.length === 64,
99
+ );
100
+ ok(
101
+ "preflight: no `claude` on the child PATH refuses loudly instead of launching unpinned",
102
+ refusal(() => snapshotClaudeLauncher({ env: { PATH: path.join(tmp, "empty") }, fixtureRoot })).includes(
103
+ "no executable",
104
+ ),
105
+ );
106
+ {
107
+ // The launcher ITSELF inside the cleanup root: refused on the launcher-path axis.
108
+ const inBin = path.join(fixtureRoot, "in-bin");
109
+ fs.mkdirSync(inBin, { recursive: true });
110
+ fs.writeFileSync(path.join(inBin, "claude"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
111
+ ok(
112
+ "preflight: a launcher that itself lives inside the fixture cleanup root is refused",
113
+ refusal(() => snapshotClaudeLauncher({ env: { PATH: inBin }, fixtureRoot })).includes(
114
+ "INSIDE the fixture cleanup root",
115
+ ),
116
+ );
117
+ }
118
+ {
119
+ // The incident's precondition, replanted disposably: a launcher OUTSIDE the fixture whose
120
+ // resolved target is INSIDE it — exactly what a prior self-update leaves behind.
121
+ const fixtureExec = path.join(fixtureRoot, "claude", "versions", "9.0.0", "claude");
122
+ fs.mkdirSync(path.dirname(fixtureExec), { recursive: true });
123
+ fs.writeFileSync(fixtureExec, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
124
+ const trapBin = path.join(tmp, "trap-bin");
125
+ fs.mkdirSync(trapBin);
126
+ fs.symlinkSync(fixtureExec, path.join(trapBin, "claude"));
127
+ ok(
128
+ "[QK:LAUNCHFENCE-PREFLIGHT-FAIL-CLOSED] preflight: a launcher whose RESOLVED TARGET lies inside the fixture cleanup root is refused before any child starts — launching would hand the self-updater a tree teardown is about to delete",
129
+ refusal(() => snapshotClaudeLauncher({ env: { PATH: trapBin }, fixtureRoot })).includes(
130
+ "INSIDE the fixture cleanup root",
131
+ ),
132
+ );
133
+ ok(
134
+ "cleanup guard: a launcher retargeted at an EXISTING file inside the fixture blocks removal too, naming the reference",
135
+ (() => {
136
+ relink(launcher, fixtureExec);
137
+ const verdict = assessLauncherCleanup(snap);
138
+ relink(launcher, opTarget);
139
+ return (
140
+ !verdict.safeToRemove && verdict.problems.some((p) => p.includes("lies inside the fixture cleanup root"))
141
+ );
142
+ })(),
143
+ );
144
+ }
145
+
146
+ // ── the incident's END state: a retarget whose fixture path is already gone ──
147
+ {
148
+ relink(launcher, path.join(fixtureRoot, "claude", "versions", "9.9.9", "claude"));
149
+ const problems = verifyClaudeLauncher(snap);
150
+ const verdict = assessLauncherCleanup(snap);
151
+ ok(
152
+ "[QK:LAUNCHFENCE-RETARGET-BLOCKS-CLEANUP] teardown: a launcher rewritten to point into the fixture tree — even when that fixture path is already dangling — is caught BEFORE removal: the integrity oracle reports it and the cleanup guard blocks rm naming the KNOWN fixture reference, not a vague uncertainty",
153
+ problems.length > 0 &&
154
+ !verdict.safeToRemove &&
155
+ verdict.problems.some((p) => p.includes("lies inside the fixture cleanup root")),
156
+ );
157
+ ok(
158
+ "teardown: the retarget is named on the link axis, not laundered into a generic failure",
159
+ problems.some((p) => p.includes("link text changed")),
160
+ );
161
+ relink(launcher, opTarget);
162
+ }
163
+
164
+ // ── uncertainty is fail-closed AND honest: blocked, but never claimed as a known reference ──
165
+ {
166
+ relink(launcher, path.join(tmp, "operator", "versions", "gone", "claude"));
167
+ const verdict = assessLauncherCleanup(snap);
168
+ ok(
169
+ "cleanup guard: a launcher whose chain cannot be fully resolved OUTSIDE the fixture blocks removal as 'not proven safe' — fail-closed on uncertainty without minting a false fixture reference",
170
+ !verdict.safeToRemove &&
171
+ verdict.problems.some((p) => p.includes("not proven safe")) &&
172
+ !verdict.problems.some((p) => p.includes("lies inside the fixture cleanup root")),
173
+ );
174
+ relink(launcher, opTarget);
175
+ }
176
+
177
+ // ── change detection with NO path movement at all ────────────────────────
178
+ {
179
+ const fresh = snapshotClaudeLauncher({ env, fixtureRoot });
180
+ fs.appendFileSync(opTarget, "# self-update rewrote me in place\n");
181
+ const problems = verifyClaudeLauncher(fresh);
182
+ ok(
183
+ "[QK:LAUNCHFENCE-CONTENT-CHANGE-DETECTED] teardown: an in-place rewrite of the resolved target — same launcher path, same link text — is still reported before cleanup, because content identity is part of the pinned evidence",
184
+ problems.some((p) => p.includes("sha256 mismatch")),
185
+ );
186
+ ok(
187
+ "teardown: after the healthy state is restored, the oracle is quiet and the guard PROVES removal safe",
188
+ (() => {
189
+ fs.writeFileSync(opTarget, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
190
+ const verdict = assessLauncherCleanup(fresh);
191
+ return verifyClaudeLauncher(fresh).length === 0 && verdict.safeToRemove && verdict.problems.length === 0;
192
+ })(),
193
+ );
194
+ }
195
+
196
+ // ── exact operator-env XDG parity for a real-HOME Claude cell ────────────
197
+ {
198
+ const before: NodeJS.ProcessEnv = {
199
+ XDG_CONFIG_HOME: "/op/custom-config",
200
+ XDG_CACHE_HOME: "",
201
+ };
202
+ const originals = snapshotOriginalXdg(before);
203
+ ok(
204
+ "xdg: the snapshot records presence and value — a set variable (even empty) is a string, an absent one is null",
205
+ originals.XDG_CONFIG_HOME === "/op/custom-config" &&
206
+ originals.XDG_CACHE_HOME === "" &&
207
+ originals.XDG_DATA_HOME === null &&
208
+ originals.XDG_STATE_HOME === null,
209
+ );
210
+ const cell: NodeJS.ProcessEnv = {
211
+ HOME: "/real/home",
212
+ XDG_CONFIG_HOME: path.join(fixtureRoot, "xdg-config"),
213
+ XDG_DATA_HOME: path.join(fixtureRoot, "xdg-data"),
214
+ XDG_STATE_HOME: path.join(fixtureRoot, "xdg-state"),
215
+ XDG_CACHE_HOME: path.join(fixtureRoot, "xdg-cache"),
216
+ };
217
+ restoreOriginalXdg(cell, originals);
218
+ ok(
219
+ "[QK:LAUNCHFENCE-XDG-EXACT-RESTORE] xdg: the cell gets EXACT operator parity — original values restored byte-for-byte and originally ABSENT variables DELETED, never filled with a canonical default path, because real HOME + fixture XDG_DATA_HOME is the measured state that let self-update rewrite the real launcher",
220
+ cell.XDG_CONFIG_HOME === "/op/custom-config" &&
221
+ cell.XDG_CACHE_HOME === "" &&
222
+ !("XDG_DATA_HOME" in cell) &&
223
+ !("XDG_STATE_HOME" in cell) &&
224
+ cell.HOME === "/real/home",
225
+ );
226
+ }
227
+
228
+ // ── the fence is actually consumed by BOTH smokes ────────────────────────
229
+ const wired = (src: string): boolean => {
230
+ const verifyAt = src.indexOf("verifyClaudeLauncher(launcherSnapshot)");
231
+ const rmAt = src.indexOf("rmSync(root");
232
+ return (
233
+ src.includes('from "./lib/claude-launcher-fence.ts"') &&
234
+ src.includes("snapshotClaudeLauncher({ env: process.env, fixtureRoot: root })") &&
235
+ src.includes("assessLauncherCleanup(launcherSnapshot)") &&
236
+ src.includes(".safeToRemove || !panesGone") &&
237
+ src.includes("restoreOriginalXdg(") &&
238
+ verifyAt !== -1 &&
239
+ rmAt !== -1 &&
240
+ verifyAt < rmAt
241
+ );
242
+ };
243
+ ok(
244
+ "[QK:LAUNCHFENCE-WIRED-LIFECYCLE] wiring: smoke-mux-lifecycle-live consumes the shared fence — fail-closed preflight from the exact child env, the integrity oracle BEFORE fixture removal, and a removal gated on BOTH the cleanup verdict and tracked-pane quiescence",
245
+ wired(read("scripts/smoke-mux-lifecycle-live.ts")),
246
+ );
247
+ ok(
248
+ "[QK:LAUNCHFENCE-WIRED-FRESH-CALL] wiring: smoke-mux-fresh-call-live consumes the SAME shared fence rather than a private copy — one protection, two smokes, per the issue's shared-repair requirement",
249
+ wired(read("scripts/smoke-mux-fresh-call-live.ts")),
250
+ );
251
+ ok(
252
+ "[QK:LAUNCHFENCE-LIFECYCLE-CELL-BRANCH] wiring: lifecycle's real-HOME cell env keeps its two truthful branches — the ACP-backed pi cell retains all four MEASURED canonical REAL_XDG_* assignments, and the direct Claude Code cell (the else branch) gets exact-parity restoreOriginalXdg instead",
253
+ /if \(backend === "pi"\) \{\s*serverEnv\.XDG_CONFIG_HOME = REAL_XDG_CONFIG_HOME;\s*serverEnv\.XDG_DATA_HOME = REAL_XDG_DATA_HOME;\s*serverEnv\.XDG_STATE_HOME = REAL_XDG_STATE_HOME;\s*serverEnv\.XDG_CACHE_HOME = REAL_XDG_CACHE_HOME;\s*\} else \{\s*restoreOriginalXdg\(serverEnv, ORIGINAL_XDG\);\s*\}/.test(
254
+ read("scripts/smoke-mux-lifecycle-live.ts"),
255
+ ),
256
+ );
257
+ } finally {
258
+ fs.rmSync(tmp, { recursive: true, force: true });
259
+ }
260
+
261
+ console.log(`\ncheck-mux-launcher-fence: ${passed} checks passed`);
262
+ }
263
+
264
+ main();
@@ -0,0 +1,195 @@
1
+ /**
2
+ * check-mux-parent-artifact — deterministic gate for the scrubbed parent-transcript fixture
3
+ * (`scripts/fixtures/mux-parent-transcript.scrubbed.jsonl`).
4
+ *
5
+ * ── What the fixture is, and what it is NOT ──
6
+ *
7
+ * It is a version-pinned SAMPLE OF THE PARENT-SIDE SHAPE: what an `entwurf_fresh_call` and the
8
+ * sibling's nonce callback actually look like in the transcript of the citizen that made the
9
+ * call. Its structure was extracted from a fixture-only real Pi parent retake and then scrubbed —
10
+ * every id, uuid, toolCallId, timestamp, model, path, cwd and message body is a fixture value,
11
+ * while the event order, roles, content types, toolName, the `toolCallId` join on the result, the
12
+ * nonce flowing from the launch receipt into the callback body, and the `sender_info` envelope
13
+ * field names are real.
14
+ *
15
+ * It is NOT placement evidence. Nothing here says a window opened; that axis is the launch
16
+ * receipt and the LIVE lifecycle gate, and reading this file as proof of a window would be
17
+ * exactly the receipt-merging docs/mux-launch-rail.md §12 forbids.
18
+ *
19
+ * ── Why a gate for a static file ──
20
+ *
21
+ * The downstream that needs this shape (`entwurf-peek`'s repair) parses parent transcripts. A
22
+ * fixture whose only guarantee is "someone scrubbed it once" would rot silently into a shape
23
+ * nothing produces, and the next reader would go back to searching private transcripts — the one
24
+ * outcome the artifact exists to prevent. So the claims below pin the shape AND the scrub:
25
+ *
26
+ * MUXARTIFACT-EVENT-ORDER provenance → the fresh_call toolResult → the later callback
27
+ * MUXARTIFACT-RESULT-CARRIES-CALL-ID MEASURED: pi records NO separate toolCall row, so the
28
+ * join a parser follows is the `toolCallId` on the RESULT. A
29
+ * fixture that invented a toolCall row would send the next
30
+ * reader looking for an event pi never writes
31
+ * MUXARTIFACT-NONCE-FLOW the nonce in the launch receipt is the SAME string that comes
32
+ * back in the callback body — that flow is the whole correlation
33
+ * contract, and a fixture that broke it would teach the opposite
34
+ * MUXARTIFACT-SENDER-INFO-SHAPE the callback body embeds the <sender_info> JSON envelope whose
35
+ * field names a reader keys on
36
+ * MUXARTIFACT-RECEIPT-GRAMMAR the scrubbed launch receipt is still GRAMMAR-VALID: session
37
+ * `$N`, window `@N`, pane `%N`, a decimal pid, and a runtime
38
+ * that is an absolute path to an executable. A fixture that
39
+ * collapses those sigils documents a receipt no parser meets,
40
+ * which defeats the only reason the file is tracked
41
+ * MUXARTIFACT-SCRUBBED no operator path, no real garden id, no real uuid survives
42
+ * MUXARTIFACT-PROVENANCE-HONEST the file says where it came from and denies being placement
43
+ * evidence, in its own first record
44
+ */
45
+
46
+ import assert from "node:assert/strict";
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+
51
+ let passed = 0;
52
+ function ok(label: string, cond: boolean, detail?: string): void {
53
+ assert.ok(cond, `${label}${detail ? `\n${detail}` : ""}`);
54
+ console.log(` ok ${label}`);
55
+ passed++;
56
+ }
57
+
58
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
59
+ const ARTIFACT = path.join(ROOT, "scripts/fixtures/mux-parent-transcript.scrubbed.jsonl");
60
+
61
+ /** The fixture values the scrub substitutes. Anything OUTSIDE this vocabulary that looks like an
62
+ * id, a path or a uuid is a leak. */
63
+ const FIXTURE_GID = /^\d{8}T\d{6}-[0-9a-f]{6}$/;
64
+ const FIXTURE_GIDS = new Set(["20260101T000000-aaaaaa", "20260101T000001-bbbbbb"]);
65
+
66
+ interface Event {
67
+ type?: string;
68
+ note?: string;
69
+ message?: {
70
+ role?: string;
71
+ toolName?: string;
72
+ toolCallId?: string;
73
+ content?: { type?: string; text?: string }[];
74
+ };
75
+ }
76
+
77
+ function main(): void {
78
+ ok("the artifact exists at its pinned path", fs.existsSync(ARTIFACT), `--- expected ---\n${ARTIFACT}`);
79
+ const raw = fs.readFileSync(ARTIFACT, "utf8");
80
+ const lines = raw.split("\n").filter((l) => l.trim());
81
+ const events: Event[] = lines.map((l, i) => {
82
+ try {
83
+ return JSON.parse(l) as Event;
84
+ } catch (err) {
85
+ throw new Error(`line ${i + 1} is not JSON — ${err instanceof Error ? err.message : String(err)}`);
86
+ }
87
+ });
88
+
89
+ ok("the artifact is exactly three JSONL records", events.length === 3, `--- got ${events.length} ---`);
90
+
91
+ const [provenance, result, callback] = events as [Event, Event, Event];
92
+
93
+ // ── provenance ───────────────────────────────────────────────────────────────
94
+ ok(
95
+ "[QK:MUXARTIFACT-PROVENANCE-HONEST] the first record states where the shape came from AND denies being placement evidence — a reader who takes this for proof that a window opened has merged the two receipts this rail keeps apart",
96
+ provenance.type === "fixture-provenance" &&
97
+ /scrub/i.test(provenance.note ?? "") &&
98
+ /real Pi parent/i.test(provenance.note ?? "") &&
99
+ /NOT placement evidence/i.test(provenance.note ?? ""),
100
+ `--- record 1 ---\n${JSON.stringify(provenance)}`,
101
+ );
102
+
103
+ // ── order and linkage ────────────────────────────────────────────────────────
104
+ ok(
105
+ "[QK:MUXARTIFACT-EVENT-ORDER] the two transcript events are in the order a parent actually records them: the fresh_call RESULT first, then the callback that arrives later as its own event — an artifact with the callback before the launch would teach a reader to correlate backwards",
106
+ result.message?.role === "toolResult" && callback.type === "custom_message",
107
+ `--- shapes ---\n${events.map((e) => `${e.type}/${e.message?.role ?? "-"}`).join(" → ")}`,
108
+ );
109
+ ok(
110
+ "[QK:MUXARTIFACT-RESULT-CARRIES-CALL-ID] the RESULT carries both toolName and toolCallId — measured: pi writes no separate toolCall row, so that id on the result is the whole join, and a fixture that invented a toolCall event would send the next reader hunting for something pi never records",
111
+ result.message?.toolName === "entwurf_fresh_call" && Boolean(result.message?.toolCallId),
112
+ );
113
+
114
+ // ── the nonce flow ───────────────────────────────────────────────────────────
115
+ const resultText = (result.message?.content ?? []).map((c) => c.text ?? "").join("\n");
116
+ // The callback is a custom_message: its body is a plain string, not a content array.
117
+ const callbackText = String((callback as unknown as { content?: string }).content ?? "");
118
+ const nonce = /nonce:\s+(mux-fresh-call-\S+)/.exec(resultText)?.[1] ?? "";
119
+ ok(
120
+ "the launch receipt in the toolResult carries a nonce and reads as a launch receipt",
121
+ nonce.length > 0 && /LAUNCH receipt/i.test(resultText),
122
+ `--- toolResult text ---\n${resultText}`,
123
+ );
124
+ ok(
125
+ "[QK:MUXARTIFACT-NONCE-FLOW] the SAME nonce string that the launch receipt printed comes back inside the callback body — that flow is the entire correlation contract, and a fixture with two different nonces would document a rail that does not exist",
126
+ nonce.length > 0 && callbackText.includes(nonce),
127
+ `--- nonce ---\n${nonce}\n--- callback text ---\n${callbackText}`,
128
+ );
129
+
130
+ // ── the sender envelope ──────────────────────────────────────────────────────
131
+ const envRaw = /<sender_info>(\{[\s\S]*?\})<\/sender_info>/.exec(callbackText)?.[1] ?? "";
132
+ let envelope: Record<string, unknown> = {};
133
+ try {
134
+ envelope = JSON.parse(envRaw) as Record<string, unknown>;
135
+ } catch {
136
+ envelope = {};
137
+ }
138
+ ok(
139
+ "[QK:MUXARTIFACT-SENDER-INFO-SHAPE] the callback body embeds a <sender_info> JSON envelope carrying the exact field names a reader keys on — sessionId (the sibling's garden id), agentId, cwd, timestamp, origin and replyable. Those NAMES are the contract; a fixture that renamed one would document a parser that cannot exist",
140
+ ["sessionId", "agentId", "cwd", "timestamp", "origin", "replyable"].every((k) => k in envelope),
141
+ `--- envelope keys ---\n${Object.keys(envelope).join(", ") || "(none)"}`,
142
+ );
143
+ ok(
144
+ "the callback event is the delivery custom_message pi actually writes (customType entwurf-message)",
145
+ (callback as unknown as { customType?: string }).customType === "entwurf-message",
146
+ );
147
+ const senderGid = String(envelope.sessionId ?? "");
148
+ ok(
149
+ "the envelope's sessionId holds a garden id in the canonical grammar — that is the field the correlation reads, so its SHAPE must survive scrubbing",
150
+ FIXTURE_GID.test(senderGid),
151
+ `--- session field ---\n${senderGid}`,
152
+ );
153
+
154
+ // ── the receipt is still grammar-valid after scrubbing ───────────────────────
155
+ const grammar: string[] = [];
156
+ const win = /^\s*window:\s+(@\d+) \(index (\d+)\) in session (\$\d+)/m.exec(resultText);
157
+ const pane = /^\s*pane:\s+(%\d+) pid (\d+)/m.exec(resultText);
158
+ const runtime = /^\s*backend:\s+\S+ \((\S+)\)/m.exec(resultText);
159
+ if (!win) grammar.push("no `window: @N (index N) in session $N` line survives");
160
+ if (!pane) grammar.push("no `pane: %N pid N` line survives");
161
+ if (!runtime) grammar.push("no `backend: <name> (<runtime>)` line survives");
162
+ if (runtime && !runtime[1]?.startsWith("/")) grammar.push(`the runtime is not an absolute path (${runtime[1]})`);
163
+ // The fixture vocabulary is fixed, so this is exact rather than heuristic: the scrub's runtime
164
+ // value is one known string. "Looks like an executable" is not something a regex can decide,
165
+ // and claiming it would promise more than the check performs.
166
+ if (runtime && runtime[1] !== "/fixture/bin/pi")
167
+ grammar.push(`the runtime is not the fixture runtime value /fixture/bin/pi (${runtime[1]})`);
168
+ ok(
169
+ "[QK:MUXARTIFACT-RECEIPT-GRAMMAR] the scrubbed launch receipt still parses as one: session `$N`, window `@N`, pane `%N`, a decimal pid, and the fixture runtime path `/fixture/bin/pi` — an executable-shaped value, not a project directory. Substituting a value is only safe while it stays inside its own grammar — a fixture whose session id reads `@0` teaches a parser to accept something tmux never emits",
170
+ grammar.length === 0,
171
+ `--- grammar problems ---\n${grammar.join("\n")}\n--- receipt ---\n${resultText}`,
172
+ );
173
+
174
+ // ── the scrub ────────────────────────────────────────────────────────────────
175
+ const leaks: string[] = [];
176
+ if (/\/home\/(?!junghan-fixture)[a-z]/i.test(raw.replace(/\/fixture\/[^"\s]*/g, "")))
177
+ leaks.push("an operator home path");
178
+ if (/\.pi\/agent\/sessions/.test(raw)) leaks.push("a real pi sessions path");
179
+ if (/\.claude\/projects/.test(raw)) leaks.push("a real Claude projects path");
180
+ for (const gid of raw.match(/\d{8}T\d{6}-[0-9a-f]{6}/g) ?? []) {
181
+ if (!FIXTURE_GIDS.has(gid)) leaks.push(`a garden id outside the fixture vocabulary (${gid})`);
182
+ }
183
+ for (const uuid of raw.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? []) {
184
+ if (uuid !== "0199f000-0000-7000-8000-000000000000") leaks.push(`a uuid outside the fixture vocabulary (${uuid})`);
185
+ }
186
+ ok(
187
+ "[QK:MUXARTIFACT-SCRUBBED] nothing outside the fixture vocabulary survives — no operator path, no real garden id, no real session uuid. The artifact exists so downstream never has to open a private transcript; a leak here would defeat the reason it is tracked at all",
188
+ leaks.length === 0,
189
+ `--- leaks ---\n${leaks.join("\n")}`,
190
+ );
191
+
192
+ console.log(`\ncheck-mux-parent-artifact: ${passed} checks passed`);
193
+ }
194
+
195
+ main();