@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,445 @@
1
+ /**
2
+ * check-entwurf-v2-visible-resume — deterministic gate for the visible-resume composition
3
+ * (`pi-extensions/lib/entwurf-v2-visible-resume.ts`).
4
+ *
5
+ * Every IO seam is injected, so this gate drives the whole state machine — including the timeout
6
+ * branch — without a tmux, a lock file, a socket or a clock.
7
+ *
8
+ * Block order is deliberate: the REFUSALS run before the happy path. A defect that makes the
9
+ * composition launch something it should have refused must be reported by the assertion that owns
10
+ * that rule, not by whichever happy-path expectation happens to notice first.
11
+ *
12
+ * V2RESUME-LIVE-REFUSED an already-live citizen is refused, never reopened — that is
13
+ * also what stops two windows existing for one garden id
14
+ * V2RESUME-INDETERMINATE-NO-START nothing is started when the probe cannot prove dormancy
15
+ * V2RESUME-CONFLICT-NO-START a corrupt control-socket address starts nothing
16
+ * V2RESUME-LOCKED-NO-RESOLVE a locked target refuses before identity is even read
17
+ * V2RESUME-NO-TRANSCRIPT-FAILS-LOUD a citizen with no transcript keeps the leaf's own cause and
18
+ * opens no window
19
+ * V2RESUME-LOCK-BEFORE-LIVENESS the lock is taken BEFORE the liveness question is asked
20
+ * V2RESUME-IDENTITY-BEFORE-WINDOW identity is resolved before the launcher is ever called
21
+ * V2RESUME-ARGV-FROM-RECORD the launcher gets the record's cwd and the measured argv
22
+ * V2RESUME-OBSERVE-IS-BOUNDED-WAIT the observation WAITS (measured: the socket appeared ~2–4s
23
+ * after launch, so an immediate single probe would report a
24
+ * successful resume as unobserved)
25
+ * V2RESUME-RECEIPTS-SEPARATE launch and observation are two objects, not one verdict
26
+ * V2RESUME-RENDER-SEPARATES the rendered text keeps them apart by name
27
+ * V2RESUME-UNOBSERVED-IS-REAL a timeout is ok:true + resume-unobserved: the window is real
28
+ * V2RESUME-TIMEOUT-RELEASES …and the lock is released, the pane untouched, nothing retried
29
+ * V2RESUME-RELEASE-FAILURE-LOUD a release that does not return "released" throws
30
+ * V2RESUME-NOT-PI-REFUSED an out-of-domain backend is a NAMED refusal, not an error
31
+ * V2RESUME-NOT-PI-BY-FIELD …matched on the error's reason FIELD, backend quoted from it
32
+ * V2RESUME-SHARED-ID-GRAMMAR the address grammar is the session-id SSOT, not a local copy
33
+ */
34
+
35
+ import assert from "node:assert/strict";
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ import type { AcquireLockResult, LockClaim, ReleaseResult } from "../pi-extensions/lib/entwurf-v2-lock.ts";
40
+ import {
41
+ makeVisibleResumeDeps,
42
+ observeSameGidSocket,
43
+ RESUME_OBSERVE_INTERVAL_MS,
44
+ RESUME_OBSERVE_TIMEOUT_MS,
45
+ renderVisibleResume,
46
+ type VisibleResumeDeps,
47
+ type VisibleResumeResult,
48
+ visibleResume,
49
+ } from "../pi-extensions/lib/entwurf-v2-visible-resume.ts";
50
+ import { type LaunchIdentity, ResumeBackendUnsupportedError } from "../pi-extensions/lib/resume-launch-identity.ts";
51
+ import type { TargetSocketInspection } from "../pi-extensions/lib/socket-discovery.ts";
52
+ import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
53
+
54
+ let passed = 0;
55
+ function ok(label: string, cond: boolean): void {
56
+ assert.ok(cond, label);
57
+ console.log(` ok ${label}`);
58
+ passed++;
59
+ }
60
+
61
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
62
+ const read = (rel: string): string => fs.readFileSync(path.join(ROOT, rel), "utf8");
63
+
64
+ const GID = "20260806T183749-145a26";
65
+ const SOCK = `/home/op/.pi/entwurf-control/${GID}.sock`;
66
+
67
+ const IDENTITY: LaunchIdentity = {
68
+ sessionFile: "/home/op/.pi/agent/sessions/-p/2026-08-06T09-37-48-973Z_019fd66f.jsonl",
69
+ cwd: "/home/op/project",
70
+ explicitExtensionArgs: ["-e", "/opt/entwurf"],
71
+ provider: "entwurf",
72
+ model: "claude-sonnet-5",
73
+ };
74
+
75
+ const CLAIM: LockClaim = {
76
+ gardenId: GID,
77
+ pid: 4242,
78
+ hostname: "h",
79
+ createdAt: "2026-08-06T09:00:00.000Z",
80
+ nonce: "n",
81
+ owner: "entwurf_v2",
82
+ lockPath: `/tmp/locks/${GID}.lock`,
83
+ };
84
+
85
+ interface Harness {
86
+ deps: VisibleResumeDeps;
87
+ trace: string[];
88
+ readonly launches: number;
89
+ }
90
+
91
+ /** Records the ORDER of every seam, which is where most of this contract lives. */
92
+ function harness(opts: {
93
+ livenessSequence: SocketLiveness[];
94
+ inspectionKind?: TargetSocketInspection["kind"];
95
+ acquire?: AcquireLockResult;
96
+ release?: ReleaseResult;
97
+ resolveIdentity?: () => LaunchIdentity;
98
+ launchOk?: boolean;
99
+ }): Harness {
100
+ const trace: string[] = [];
101
+ let clock = 0;
102
+ let probeIdx = 0;
103
+ let launches = 0;
104
+ const kind = opts.inspectionKind ?? "socket-file";
105
+ const deps: VisibleResumeDeps = {
106
+ acquireLock: (gid) => {
107
+ trace.push(`acquireLock:${gid}`);
108
+ return opts.acquire ?? { ok: true, claim: CLAIM };
109
+ },
110
+ releaseLock: () => {
111
+ trace.push("releaseLock");
112
+ return opts.release ?? "released";
113
+ },
114
+ resolveIdentity: (gid) => {
115
+ trace.push(`resolveIdentity:${gid}`);
116
+ return (opts.resolveIdentity ?? (() => IDENTITY))();
117
+ },
118
+ inspectSocket: async () => {
119
+ trace.push("inspectSocket");
120
+ return kind === "absent"
121
+ ? { kind: "absent", socketPath: SOCK }
122
+ : kind === "address-conflict"
123
+ ? { kind: "address-conflict", socketPath: SOCK, reason: "symlink" }
124
+ : kind === "indeterminate"
125
+ ? { kind: "indeterminate", socketPath: SOCK, error: "EACCES" }
126
+ : { kind: "socket-file", socketPath: SOCK };
127
+ },
128
+ probeSocket: async () => {
129
+ const value = opts.livenessSequence[Math.min(probeIdx, opts.livenessSequence.length - 1)] ?? "dead";
130
+ probeIdx++;
131
+ trace.push(`probeSocket:${value}`);
132
+ return value;
133
+ },
134
+ launch: (input) => {
135
+ launches++;
136
+ trace.push(`launch:${input.cwd}:${input.runtimeArgs.join(" ")}`);
137
+ return opts.launchOk === false
138
+ ? { ok: false, reason: "cwd-missing", hint: "the recorded cwd no longer exists" }
139
+ : {
140
+ ok: true,
141
+ handle: {
142
+ serverPid: "1",
143
+ sessionId: "$0",
144
+ windowId: "@9",
145
+ windowIndex: "9",
146
+ paneId: "%9",
147
+ panePid: "999",
148
+ runtimePath: "/usr/bin/pi",
149
+ cwd: input.cwd,
150
+ },
151
+ };
152
+ },
153
+ sleep: async (ms) => {
154
+ trace.push(`sleep:${ms}`);
155
+ clock += ms;
156
+ },
157
+ now: () => clock,
158
+ };
159
+ return {
160
+ deps,
161
+ trace,
162
+ get launches() {
163
+ return launches;
164
+ },
165
+ };
166
+ }
167
+
168
+ async function main(): Promise<void> {
169
+ // ══ REFUSALS FIRST — see the header note on block order ═════════════════════════
170
+
171
+ // ── a malformed target ───────────────────────────────────────────────────────────
172
+ {
173
+ const h = harness({ livenessSequence: ["dead"] });
174
+ const result = await visibleResume("../../etc/passwd", h.deps);
175
+ ok(
176
+ "a malformed garden id is refused BEFORE the lock — it names no citizen, so it never becomes a filesystem path",
177
+ !result.ok && result.reason === "target-invalid" && h.trace.length === 0,
178
+ );
179
+ }
180
+
181
+ // ── a locked target ──────────────────────────────────────────────────────────────
182
+ {
183
+ const h = harness({
184
+ livenessSequence: ["dead"],
185
+ acquire: {
186
+ ok: false,
187
+ conflict: { reason: "target-locked", lockPath: "/tmp/l", holder: null, detail: "held by pid 7" },
188
+ },
189
+ });
190
+ const result = await visibleResume(GID, h.deps);
191
+ ok(
192
+ "[QK:V2RESUME-LOCKED-NO-RESOLVE] a locked target refuses without resolving identity, inspecting, or launching — the lock IS the gate, and reading a record behind someone else's lock is how two resumes race one transcript",
193
+ !result.ok &&
194
+ result.reason === "target-locked" &&
195
+ h.launches === 0 &&
196
+ !h.trace.some((t) => t.startsWith("resolveIdentity")),
197
+ );
198
+ ok("a failed acquire releases nothing — there is no claim to give back", !h.trace.includes("releaseLock"));
199
+ }
200
+
201
+ // ── an already-live citizen ──────────────────────────────────────────────────────
202
+ {
203
+ const h = harness({ livenessSequence: ["alive"] });
204
+ const result = await visibleResume(GID, h.deps);
205
+ ok(
206
+ "[QK:V2RESUME-LIVE-REFUSED] a LIVE citizen is refused and no window is opened — it is addressed with entwurf_v2, and refusing here is also what stops two windows existing for one garden id",
207
+ !result.ok && result.reason === "target-live" && h.launches === 0,
208
+ );
209
+ ok("the live refusal still releases the lock", h.trace.includes("releaseLock"));
210
+ ok(
211
+ "the live refusal points the caller at the verb that DOES work",
212
+ !result.ok && /entwurf_v2 fire-and-forget/.test(result.detail),
213
+ );
214
+ }
215
+
216
+ // ── an inconclusive probe ────────────────────────────────────────────────────────
217
+ {
218
+ const h = harness({ livenessSequence: ["indeterminate"] });
219
+ const result = await visibleResume(GID, h.deps);
220
+ ok(
221
+ "[QK:V2RESUME-INDETERMINATE-NO-START] an inconclusive probe starts NOTHING — 'not provably dormant' is not 'dormant', and resuming a citizen that is actually running would put two processes on one transcript",
222
+ !result.ok && result.reason === "target-indeterminate" && h.launches === 0,
223
+ );
224
+ ok("the indeterminate refusal releases the lock", h.trace.includes("releaseLock"));
225
+ }
226
+
227
+ // ── a corrupt address ────────────────────────────────────────────────────────────
228
+ {
229
+ const h = harness({ livenessSequence: ["dead"], inspectionKind: "address-conflict" });
230
+ const result = await visibleResume(GID, h.deps);
231
+ ok(
232
+ "[QK:V2RESUME-CONFLICT-NO-START] a symlinked / non-socket control path is refused as an address conflict, unprobed and unlaunched — standing a citizen up behind a corrupt address would make the forgery addressable",
233
+ !result.ok && result.reason === "target-address-conflict" && h.launches === 0,
234
+ );
235
+ }
236
+
237
+ // ── an unresumable record: the leaf's cause-rich throw survives ──────────────────
238
+ {
239
+ const h = harness({
240
+ livenessSequence: ["dead"],
241
+ resolveIdentity: () => {
242
+ throw new Error(
243
+ "resume-launch-identity: has no recorded transcriptPath — the citizen never wrote a session file (no turn yet)",
244
+ );
245
+ },
246
+ });
247
+ let thrown: Error | null = null;
248
+ await visibleResume(GID, h.deps).catch((err) => {
249
+ thrown = err as Error;
250
+ });
251
+ ok(
252
+ "[QK:V2RESUME-NO-TRANSCRIPT-FAILS-LOUD] a citizen with a socket but NO transcript (measured: a pi that never took a turn has both a record and a socket) fails LOUD with the leaf's own cause and opens no window — the cause is not re-typed into a reject enum, where the second copy is what goes stale",
253
+ thrown !== null && /no recorded transcriptPath/.test(String(thrown)) && h.launches === 0,
254
+ );
255
+ ok("the lock is released even when identity resolution throws", h.trace.includes("releaseLock"));
256
+ }
257
+
258
+ // ── a citizen of a backend that has no same-id resume ────────────────────────────
259
+ {
260
+ const h = harness({
261
+ livenessSequence: ["dead"],
262
+ resolveIdentity: () => {
263
+ throw new ResumeBackendUnsupportedError(GID, "claude-code");
264
+ },
265
+ });
266
+ // The throw is CAUGHT here rather than allowed to escape: "this refusal was reported as a
267
+ // raw error" is precisely the defect under test, and an uncaught throw would take the gate
268
+ // down before this assertion could name it.
269
+ const result = await visibleResume(GID, h.deps).catch(
270
+ (err): VisibleResumeResult => ({
271
+ ok: false,
272
+ reason: "target-invalid",
273
+ detail: `threw instead of refusing: ${err instanceof Error ? err.message : String(err)}`,
274
+ }),
275
+ );
276
+ ok(
277
+ "[QK:V2RESUME-NOT-PI-REFUSED] a claude-code / agy target is a NAMED refusal, not an error report: nothing is corrupt, the record is right, and the operator simply reached past a capability boundary — only pi stands a control socket up",
278
+ !result.ok && result.reason === "target-not-pi" && h.launches === 0,
279
+ );
280
+ ok(
281
+ "[QK:V2RESUME-NOT-PI-BY-FIELD] the refusal is matched on the error's own reason FIELD and its backend is quoted from the error, so no message string is parsed and the record is never read a second time to ask which backend it was",
282
+ !result.ok &&
283
+ /claude-code/.test(result.detail) &&
284
+ h.trace.filter((t) => t.startsWith("resolveIdentity")).length === 1,
285
+ );
286
+ ok("the not-pi refusal releases the lock", h.trace.includes("releaseLock"));
287
+ }
288
+
289
+ // ── a refused launch ─────────────────────────────────────────────────────────────
290
+ {
291
+ const h = harness({ livenessSequence: ["dead"], launchOk: false });
292
+ const result = await visibleResume(GID, h.deps);
293
+ ok(
294
+ "a refused launch is a named reject carrying the launcher's own reason and hint, and it is NOT retried",
295
+ !result.ok && result.reason === "launch-refused" && /cwd-missing/.test(result.detail) && h.launches === 1,
296
+ );
297
+ }
298
+
299
+ // ══ THE HAPPY PATH ══════════════════════════════════════════════════════════════
300
+ {
301
+ // dead at preflight, still dead on the first observation, alive on the second — the
302
+ // measured shape: the window opens and the socket arrives a couple of seconds later.
303
+ const h = harness({ livenessSequence: ["dead", "dead", "alive"] });
304
+ const result = await visibleResume(GID, h.deps);
305
+
306
+ ok(
307
+ "[QK:V2RESUME-LOCK-BEFORE-LIVENESS] the lock is acquired BEFORE the liveness question — asked first, the answer could change under us while a competing resume passed its own dead check",
308
+ h.trace[0] === `acquireLock:${GID}` && h.trace.indexOf("inspectSocket") > 0,
309
+ );
310
+ ok(
311
+ "[QK:V2RESUME-IDENTITY-BEFORE-WINDOW] identity is resolved under the lock BEFORE the liveness question and long before the launcher — a record read after the preflight would mean the liveness answer applies to a citizen whose transcript has not been proven resumable yet, and any window would already be the wrong one",
312
+ h.trace.indexOf(`resolveIdentity:${GID}`) < h.trace.indexOf("inspectSocket") &&
313
+ h.trace.indexOf("inspectSocket") < h.trace.findIndex((t) => t.startsWith("launch:")),
314
+ );
315
+ ok(
316
+ "[QK:V2RESUME-ARGV-FROM-RECORD] the launcher receives the RECORD's cwd and the measured interactive argv — no prompt, no --mode, no -p, and the bridge args exactly where the runtime accepted them",
317
+ h.trace.includes(
318
+ `launch:${IDENTITY.cwd}:--entwurf-control -e /opt/entwurf --session ${IDENTITY.sessionFile} --provider entwurf --model claude-sonnet-5`,
319
+ ),
320
+ );
321
+ ok(
322
+ "[QK:V2RESUME-OBSERVE-IS-BOUNDED-WAIT] the observation SLEEPS and re-inspects rather than probing once — measured, the resumed socket appeared ~2–4s after the window opened, so a single immediate probe would have reported a successful resume as unobserved",
323
+ result.ok &&
324
+ result.observation.kind === "socket-alive" &&
325
+ h.trace.filter((t) => t.startsWith("probeSocket")).length >= 3 &&
326
+ h.trace.some((t) => t.startsWith("sleep:")),
327
+ );
328
+ ok("the launcher was called exactly once — a startup wait is not a retry loop", h.launches === 1);
329
+ ok("the lock is released on the success path too", h.trace.filter((t) => t === "releaseLock").length === 1);
330
+ ok(
331
+ "[QK:V2RESUME-RECEIPTS-SEPARATE] launch and observation are two separate objects: the launch receipt carries the window handle and the transcript and says nothing about liveness, and only the observation says the citizen answered",
332
+ result.ok &&
333
+ result.launch.handle.windowId === "@9" &&
334
+ result.launch.sessionFile === IDENTITY.sessionFile &&
335
+ !("kind" in result.launch) &&
336
+ result.observation.socketPath === SOCK,
337
+ );
338
+ const rendered = renderVisibleResume(result.ok ? result : result);
339
+ ok(
340
+ "[QK:V2RESUME-RENDER-SEPARATES] the rendered text keeps the two receipts apart BY NAME, so a reader can tell WHICH fact is missing when only one is there, and it refuses to claim a turn ran",
341
+ rendered.text.includes("LAUNCH receipt") &&
342
+ rendered.text.includes("OBSERVATION receipt") &&
343
+ /No turn was run/.test(rendered.text) &&
344
+ !rendered.isError,
345
+ );
346
+ }
347
+
348
+ // ══ THE TIMEOUT BRANCH ══════════════════════════════════════════════════════════
349
+ {
350
+ const h = harness({ livenessSequence: ["dead"] });
351
+ const result = await visibleResume(GID, h.deps);
352
+ ok(
353
+ "[QK:V2RESUME-UNOBSERVED-IS-REAL] a timeout is ok:true with observation=resume-unobserved — the window WAS opened and is left open, which is the same rule fresh-call already ships: a launch with no callback is a real outcome, not an error to retry",
354
+ result.ok && result.observation.kind === "resume-unobserved",
355
+ );
356
+ ok(
357
+ "[QK:V2RESUME-TIMEOUT-RELEASES] the timeout releases the lock, never relaunches, and never kills the pane — a retained lock on a visible artifact is the worse failure and would have to be cleared by hand on every slow host",
358
+ h.trace.includes("releaseLock") && h.launches === 1 && !h.trace.some((t) => /kill|relaunch/.test(t)),
359
+ );
360
+ ok(
361
+ "the observation stops AT the deadline rather than running forever",
362
+ result.ok && result.observation.waitedMs >= RESUME_OBSERVE_TIMEOUT_MS,
363
+ );
364
+ const rendered = renderVisibleResume(result);
365
+ ok(
366
+ "the unobserved text tells the operator exactly what was and was not done: window open, lock released, nothing retried, nothing killed",
367
+ /resume-unobserved/.test(rendered.text) && /nothing was retried/.test(rendered.text) && !rendered.isError,
368
+ );
369
+ }
370
+
371
+ // ══ A FAILED RELEASE ════════════════════════════════════════════════════════════
372
+ {
373
+ const h = harness({ livenessSequence: ["dead", "alive"], release: "not-owned" });
374
+ let thrown: Error | null = null;
375
+ await visibleResume(GID, h.deps).catch((err) => {
376
+ thrown = err as Error;
377
+ });
378
+ ok(
379
+ '[QK:V2RESUME-RELEASE-FAILURE-LOUD] a release that does not return "released" THROWS — a lock this verb took and could not give back will refuse the next resume or dispatch, and folding it into a green receipt would send the operator looking at the wrong layer',
380
+ thrown !== null && /instead of/.test(String(thrown)) && /clear it by hand/.test(String(thrown)),
381
+ );
382
+ }
383
+
384
+ // ══ THE OBSERVATION HELPER, DRIVEN DIRECTLY ═════════════════════════════════════
385
+ {
386
+ let clock = 0;
387
+ let calls = 0;
388
+ const receipt = await observeSameGidSocket(
389
+ GID,
390
+ SOCK,
391
+ {
392
+ inspectSocket: async () => ({ kind: "socket-file", socketPath: SOCK }),
393
+ probeSocket: async () => {
394
+ calls++;
395
+ return calls >= 5 ? "alive" : "dead";
396
+ },
397
+ sleep: async (ms) => {
398
+ clock += ms;
399
+ },
400
+ now: () => clock,
401
+ },
402
+ 10_000,
403
+ 500,
404
+ );
405
+ ok(
406
+ "the observation returns as soon as the socket answers, and reports how long it actually waited — the number is evidence, not decoration",
407
+ receipt.kind === "socket-alive" && receipt.waitedMs === 2000,
408
+ );
409
+ }
410
+
411
+ // ══ CONSTANTS AND BOUNDARIES, ASSERTED ON SOURCE ════════════════════════════════
412
+ {
413
+ const SRC = read("pi-extensions/lib/entwurf-v2-visible-resume.ts");
414
+ // CODE, not prose. The module explains at length why there is no watcher and no retry, so
415
+ // scanning the raw file would forbid the explanation instead of the behaviour.
416
+ const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
417
+ ok(
418
+ "the timeout and interval are product constants with no env knob — a tunable would make 'unobserved' mean different things on different hosts and the receipt would stop being comparable",
419
+ RESUME_OBSERVE_TIMEOUT_MS === 30_000 && RESUME_OBSERVE_INTERVAL_MS === 500 && !/process\.env/.test(CODE),
420
+ );
421
+ ok(
422
+ "[QK:V2RESUME-SHARED-ID-GRAMMAR] the address grammar comes from the session-id SSOT, not a local regex — this value becomes a lock path and a control-socket path a few lines later, and a second copy could drift into accepting an id the record or the socket filename would reject",
423
+ /from "\.\/session-id\.js"/.test(CODE) &&
424
+ /isValidSessionId\(targetGardenId\)/.test(CODE) &&
425
+ !/\\d\{8\}T\\d\{6\}/.test(CODE),
426
+ );
427
+ ok(
428
+ "the composition ships no watcher and no retry machinery: no interval timer, no child-process handling, no kill",
429
+ !/setInterval|spawn|child_process|\.kill\(|SIGTERM/.test(CODE),
430
+ );
431
+ ok(
432
+ "production deps wire the real lock, record leaf, socket inspection and probe, and take the launcher as an argument",
433
+ typeof makeVisibleResumeDeps === "function" &&
434
+ /makeVisibleResumeDeps\(\s*launch/.test(SRC) &&
435
+ /resolveResumeLaunchIdentity/.test(SRC),
436
+ );
437
+ }
438
+
439
+ console.log(`\ncheck-entwurf-v2-visible-resume: ${passed} checks passed`);
440
+ }
441
+
442
+ main().catch((err) => {
443
+ console.error(`check-entwurf-v2-visible-resume: ${err instanceof Error ? err.message : String(err)}`);
444
+ process.exit(1);
445
+ });
@@ -1014,7 +1014,7 @@ esac
1014
1014
  # The three probe rows are driven for real through fakes, never asserted from prose:
1015
1015
  # the adapter takes its host scan from `pgrep`/`ss` on PATH and its metadata call from
1016
1016
  # $AGY_BIN, so a PATH-local fake pins each row deterministically — including DEAD,
1017
- # which must not depend on whether the operator running `pnpm check` happens to have
1017
+ # which must not depend on whether the operator running `pnpm run check:full` happens to have
1018
1018
  # agy open.
1019
1019
  echo "[check-fresh-cut-gate] H. native-push conversations (the marker-less surface)"
1020
1020
  mkdir -p "$SANDBOX/npfake"
@@ -21,9 +21,9 @@
21
21
  * before/after (P1-5 — porcelain text alone misses byte changes in already-
22
22
  * modified files).
23
23
  *
24
- * No tiers: the full mutant set measured ~80s on the reference host, under the agreed
25
- * ~120s split threshold, so every run executes every mutant one command, one truth,
26
- * no advisory lane. If the set ever outgrows the budget, re-open the fast/full split
24
+ * No tiers: the committed mutant set is one tier every run executes every mutant,
25
+ * one command, one truth, no advisory lane. Runtime is recorded when this runs on a
26
+ * frozen candidate; if the set ever outgrows its budget, re-open the fast/full split
27
27
  * from the design record instead of silently skipping mutants.
28
28
  *
29
29
  * Evidence: claim IDs + killed mutant IDs, never assertion counts.
@@ -45,8 +45,10 @@ import {
45
45
  originHead,
46
46
  originWorkSurfaceSha,
47
47
  qualifyMutants,
48
+ readVitestFailedTitles,
48
49
  reportPassed,
49
50
  runGateBounded,
51
+ signatureAttributedToFailure,
50
52
  signatureOnFailureLine,
51
53
  sweepStaleSnapshots,
52
54
  validateManifest,
@@ -76,6 +78,83 @@ function throws(label: string, fn: () => void, naming: string): void {
76
78
  assert.fail(`${label} — expected a loud refusal, got silence`);
77
79
  }
78
80
 
81
+ /**
82
+ * Vitest attribution, proved on the MEASURED false-positive shape (issue #62 review).
83
+ *
84
+ * Vitest prints a code frame around the failing assertion, and that frame quotes the
85
+ * neighbouring source lines — including an adjacent PASSING test's `it("[QK:…]" …)`
86
+ * title. Measured verbatim on vitest 4.1.9:
87
+ *
88
+ * × [QK:B] fails on its first body line
89
+ * 4| it("[QK:A] one-liner that passes", () => expect(1).toBe(1));
90
+ * 5| it("[QK:B] fails on its first body line", () => {
91
+ *
92
+ * The legacy `ok`-line oracle answers TRUE for [QK:A] there — a mutant claiming A
93
+ * would be certified KILLED by a red that belongs to B. The structured oracle reads
94
+ * only the failed-test title set, so it must answer FALSE for A and TRUE for B.
95
+ *
96
+ * The cell is discriminating in BOTH directions: it asserts that the legacy scanner
97
+ * really does say TRUE on this input, so the negative is not vacuous.
98
+ */
99
+ function checkVitestAttribution(): void {
100
+ // Declared ONCE as a literal: the manifest's exact-once signature rule counts
101
+ // occurrences of this token in signatureSource, so every other use goes through
102
+ // the binding.
103
+ const claimed = "[QK:VITEST-FAILED-TITLE-ATTRIBUTION]";
104
+ const actualFailure = "[QK:ADJACENT-ACTUAL-FAILURE]";
105
+ const codeFrame = [
106
+ "__ENTWURF_VITEST_JSON__",
107
+ ` × ${actualFailure} fails on its first body line`,
108
+ ` 4| it("${claimed} one-liner that passes", () => expect(1).toBe(1));`,
109
+ ` 5| it("${actualFailure} fails on its first body line", () => {`,
110
+ ].join("\n");
111
+ const report = JSON.stringify({
112
+ testResults: [
113
+ {
114
+ assertionResults: [
115
+ { status: "passed", fullName: `frame probe ${claimed} one-liner that passes` },
116
+ { status: "failed", fullName: `frame probe ${actualFailure} fails on its first body line` },
117
+ ],
118
+ },
119
+ ],
120
+ });
121
+
122
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-attribution-selftest-"));
123
+ const reportPath = path.join(dir, "vitest-report.json");
124
+ try {
125
+ fs.writeFileSync(reportPath, report);
126
+ const titles = readVitestFailedTitles(codeFrame, reportPath);
127
+
128
+ ok(
129
+ "attribution self-test is not vacuous — the legacy failure-line oracle really does certify the adjacent PASSING claim on this measured code frame",
130
+ signatureOnFailureLine(codeFrame, claimed),
131
+ );
132
+ ok(
133
+ `${claimed} Vitest attribution reads only structured failed-test titles — an adjacent passing QK quoted by the failing code frame cannot certify the mutant`,
134
+ !signatureAttributedToFailure(codeFrame, claimed, titles) &&
135
+ signatureAttributedToFailure(codeFrame, actualFailure, titles),
136
+ );
137
+ ok(
138
+ "a structured lane whose report is missing is UNREADABLE, never a silent fallback to token scanning",
139
+ readVitestFailedTitles(codeFrame, path.join(dir, "absent.json")) === "unreadable" &&
140
+ !signatureAttributedToFailure(codeFrame, actualFailure, "unreadable"),
141
+ );
142
+ ok(
143
+ "a gate that printed no marker keeps the legacy failure-line oracle unchanged",
144
+ readVitestFailedTitles(" not ok 1 boom [QK:LEGACY]", reportPath) === "legacy" &&
145
+ signatureAttributedToFailure(" not ok 1 boom [QK:LEGACY]", "[QK:LEGACY]", "legacy"),
146
+ );
147
+ } finally {
148
+ fs.rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ checkVitestAttribution();
153
+ if (process.argv.includes("--attribution-self-test")) {
154
+ console.log(`[gate-qualification] attribution self-test: ${passed} checks passed`);
155
+ process.exit(0);
156
+ }
157
+
79
158
  // ═══ Phase 1a — the pure classifier, exhausted as a truth table ═════════════
80
159
 
81
160
  {
@@ -718,13 +797,28 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
718
797
  // manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
719
798
  // Extend this inventory and the manifests TOGETHER, never silently.
720
799
  const EXPECTED_LANE_MUTANTS: Record<string, number> = {
721
- "acp-augment": 2,
800
+ "acp-augment": 10,
722
801
  "acp-cortex": 12,
802
+ "acp-overlay": 1,
803
+ "acp-prompt-lifecycle": 8,
804
+ "acp-stop-reason": 6,
805
+ "acp-stream-hooks": 10,
723
806
  "agy-permission": 6,
724
- "meta-identity": 1,
725
- "probe-ordering": 83,
807
+ "bridge-boot-resume": 3,
808
+ "meta-facts": 4,
809
+ "meta-identity": 4,
810
+ "mux-boundary": 14,
811
+ "mux-fresh-call": 15,
812
+ "mux-launcher-fence": 7,
813
+ "mux-parent-artifact": 3,
814
+ "mux-resume-call": 12,
815
+ "probe-ordering": 1,
816
+ "release-gate": 11,
817
+ "resume-args": 6,
818
+ "resume-launch-identity": 6,
726
819
  "self-address": 3,
727
- "v2-surface": 4,
820
+ "v2-surface": 7,
821
+ "v2-visible-resume": 17,
728
822
  };
729
823
  const laneTally: Record<string, number> = {};
730
824
  for (const man of manifests) laneTally[man.lane] = (laneTally[man.lane] ?? 0) + man.mutants.length;
@@ -316,8 +316,16 @@ function finish(t) {
316
316
  if (done) return; done = true; clearTimeout(timer);
317
317
  let msg; try { msg = JSON.parse(t); } catch { console.error('unparseable tools/list: ' + t.slice(0, 300)); process.exit(1); }
318
318
  const names = (msg?.result?.tools ?? []).map((x) => x?.name).sort();
319
- for (const need of ['entwurf_v2', 'entwurf_peers', 'entwurf_self', 'entwurf_inbox_read', 'entwurf_register_native'])
320
- if (!names.includes(need)) { console.error('missing MCP tool: ' + need + ' — got ' + names.join(',')); process.exit(1); }
319
+ // EXACT set, not a floor. This cell consumes bytes it did not build (M3 hands it the
320
+ // preserved candidate via ENTWURF_CANDIDATE_TGZ), so "which surface did this exact
321
+ // artifact ship" is precisely the question it owns. The subset form named only the five
322
+ // pre-0.14 verbs and would have accepted a candidate with no entwurf_fresh_call /
323
+ // entwurf_resume_call; equality also refuses an extra or duplicated verb.
324
+ const EXPECT_TOOLS = ['entwurf_fresh_call', 'entwurf_inbox_read', 'entwurf_peers', 'entwurf_register_native', 'entwurf_resume_call', 'entwurf_self', 'entwurf_v2'];
325
+ if (names.length !== EXPECT_TOOLS.length || EXPECT_TOOLS.some((n, i) => names[i] !== n)) {
326
+ console.error('MCP tool set MISMATCH — want exactly [' + EXPECT_TOOLS.join(',') + '] got [' + names.join(',') + ']');
327
+ process.exit(1);
328
+ }
321
329
  console.log(names.join(','));
322
330
  child.kill('SIGTERM'); process.exit(0);
323
331
  }
@@ -226,7 +226,7 @@ const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.le
226
226
  // ─── S5 — verification must not rewire the operator's live host ──────────────────────
227
227
  {
228
228
  // A LIVE gate legitimately drives the real host (that is what LIVE means). The offline
229
- // floor — everything in `pnpm check` — must not: it runs on every commit, and a smoke
229
+ // floor — everything in the check tiers (`pnpm check` / `pnpm run check:full`) — must not: it runs on every commit, and a smoke
230
230
  // that writes the real ~/.claude, ~/.gemini or ~/.pi would uninstall the operator's
231
231
  // own bridge as a side effect of "testing".
232
232
  const LIVE_GATES = new Set(["smoke-resident-garden-guard.sh", "smoke-meta-async-drift.sh"]);
@@ -17,7 +17,7 @@ pi-owned key — exact match OR ancestor/descendant (a fragment that sets
17
17
  Cross-repo and not hermetic (the fragment path is an argument), so it lives
18
18
  OUTSIDE `pnpm check` / release-gate — it is for agent-config CI and manual
19
19
  cross-checks. The synthetic-fixture regression of this script's own logic lives
20
- in `smoke-meta-keyset-guard.sh`, which IS in `pnpm check`.
20
+ in `smoke-meta-keyset-guard.sh`, which IS in `pnpm run check:full`.
21
21
 
22
22
  usage: check-keyset-overlap.py <consumer-fragment.json> [more.json ...]
23
23
  exit 0 = disjoint (invariant holds); exit 1 = overlap (invariant violated);