@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * smoke-entwurf-v2-spawn-resume-live — the 0.11.0 (A) acceptance gate: a REAL `pi` child is
3
+ * spawned by the production spawn-bg resume path, stands its control socket up as a resident,
4
+ * runs the resume turn, and the lock is released exactly once. This is the evidence the whole
5
+ * 0.11.0 "spawn-bg resident lifecycle" headline rests on — every other gate proves a SLICE
6
+ * (the deterministic decider table, the matrix-live transport sentinel, the spawn-live OS
7
+ * substrate watcher), but none has ever watched a real child pi resume and DO a model turn.
8
+ * 5c-3c deferred that proof to "5d"; 5d's matrix-live built only the control/mailbox/guard
9
+ * cells. This smoke closes that acceptance debt — and it is the gate v1 deprecation (0.12)
10
+ * is predicated on: until a real spawn-bg resume is seen LIVE, there is no honest ground to
11
+ * turn the v1 entwurf surface off.
12
+ *
13
+ * The FULL loop (no synthetic stand-in):
14
+ * 1. mint a `backend=pi` meta identity in a temp store → take its gardenId as the seed id.
15
+ * 2. seed a REAL dormant pi session: a one-shot `pi --mode json -p --no-extensions` under
16
+ * that gid writes a saved session JSONL into the REAL `~/.pi/agent/sessions` (the dir
17
+ * `findSessionFileById` / `resolveResumeLaunchIdentity` read — NOT env-redirected, because
18
+ * this smoke validates the production resume path, not a fixture path). No control socket
19
+ * survives the one-shot → the citizen is DORMANT.
20
+ * 3. drive the production `runEntwurfV2({intent:"owned-outcome"})` → the decider routes a
21
+ * dormant in-domain pi citizen to spawn-bg resume (DISPATCH_TABLE owned-outcome×dormant) →
22
+ * the production spawn factory launches a REAL detached `pi --entwurf-control` resume child.
23
+ * 4. assert: result = executed/spawn-bg/socket-alive/released; the lock was released EXACTLY
24
+ * once (release-seam counter) and no lock file remains; the resident pid is alive and its
25
+ * socket is connectable.
26
+ * 5. assert the child actually WORKED: poll the same session JSONL for the resume USER nonce
27
+ * (the turn was injected) and then the assistant OK nonce (the model actually replied) —
28
+ * socket-alive alone would prove "process up", not "resumed and did a turn".
29
+ *
30
+ * model-in-loop is IN here (unlike matrix-live, which is a transport/lock sentinel): the
31
+ * assistant nonce is the whole point — a resume that stands a socket up but never produces a
32
+ * model turn is not a resumed citizen. So this is LIVE-only and OUT of `pnpm check`; honest
33
+ * SKIP when LIVE!=1 (a release-gate that hard-fails without auth/model is unrunnable
34
+ * unattended — skip is CI safety, NOT an acceptance PASS).
35
+ * ENTWURF_LIVE_TARGET = "<provider>/<model>" (default "openai-codex/gpt-5.4")
36
+ * ENTWURF_SPAWN_RESUME_ASSISTANT_TIMEOUT_MS (default 180000)
37
+ * LIVE=1 ./run.sh smoke-entwurf-v2-spawn-resume-live
38
+ *
39
+ * Automation seams: the resident child is reaped (SIGTERM→socket-gone poll→SIGKILL) and its
40
+ * real control socket removed in `finally`; the ONE seed session file is deleted on a clean
41
+ * pass (never the whole real sessions dir) and PRESERVED with its path printed on failure,
42
+ * alongside the temp world / gid / lock path / pi seed stderr tail.
43
+ */
44
+
45
+ import { spawnSync } from "node:child_process";
46
+ import { existsSync, readFileSync } from "node:fs";
47
+ import * as fsp from "node:fs/promises";
48
+ import * as os from "node:os";
49
+ import * as path from "node:path";
50
+ import { fileURLToPath } from "node:url";
51
+ import type { SenderEnvelope } from "../pi-extensions/lib/entwurf-control-rpc.ts";
52
+ import { buildSessionName, findSessionFileById, readSessionIdentity } from "../pi-extensions/lib/entwurf-core.ts";
53
+ import { type LockClaim, lockPathFor, releaseLock as realReleaseLock } from "../pi-extensions/lib/entwurf-v2-lock.ts";
54
+ import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
55
+ import type { EntwurfV2RunResult } from "../pi-extensions/lib/entwurf-v2-runner.ts";
56
+ import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
57
+ import { resolveResumeLaunchIdentity } from "../pi-extensions/lib/entwurf-v2-spawn-production.ts";
58
+ import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
59
+ import { controlSocketPath } from "../pi-extensions/lib/socket-discovery.ts";
60
+
61
+ // pi owns the canonical control-socket dir; the resume child opens its socket HERE and the
62
+ // decider's expectedSocketPath is built from the SAME dir — they must coincide (a fresh gid
63
+ // keeps us off any live session's path; cleanup only ever touches our own gid's socket).
64
+ const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
65
+ const SOCKET_SUFFIX = ".sock";
66
+ // Release-gate topology: repo-under-test, not deployment smoke. The spawned
67
+ // resident must load this checkout's extension, independent of global pi packages.
68
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
69
+ const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
70
+
71
+ const SEED_TIMEOUT_MS = 120_000; // F5: a real seed model turn can be slow.
72
+ const OBSERVE_TIMEOUT_MS = 30_000; // spawn-bg watcher's socket-alive observe window.
73
+ const SEED_IDENTITY_POLL_MS = 10_000; // GPT pin: defend against seed file flush/rename timing.
74
+ const USER_TURN_TIMEOUT_MS = 60_000;
75
+ const ASSISTANT_TURN_TIMEOUT_MS = (() => {
76
+ const n = Number(process.env.ENTWURF_SPAWN_RESUME_ASSISTANT_TIMEOUT_MS);
77
+ return Number.isFinite(n) && n > 0 ? n : 180_000;
78
+ })();
79
+ const POLL_MS = 250;
80
+
81
+ let passed = 0;
82
+ const artifacts: Record<string, string> = {};
83
+
84
+ function ok(label: string, cond: boolean): void {
85
+ if (!cond) throw new Error(`SMOKE FAIL: ${label}`);
86
+ console.log(` ok ${label}`);
87
+ passed++;
88
+ }
89
+
90
+ function resolveTarget(): { provider: string; model: string } {
91
+ const combined = process.env.ENTWURF_LIVE_TARGET?.trim();
92
+ if (combined) {
93
+ const slash = combined.indexOf("/");
94
+ if (slash <= 0 || slash === combined.length - 1) {
95
+ throw new Error(`ENTWURF_LIVE_TARGET must be "<provider>/<model>", got: ${JSON.stringify(combined)}`);
96
+ }
97
+ return { provider: combined.slice(0, slash), model: combined.slice(slash + 1) };
98
+ }
99
+ return {
100
+ provider: process.env.ENTWURF_LIVE_PROVIDER?.trim() || "openai-codex",
101
+ model: process.env.ENTWURF_LIVE_MODEL?.trim() || "gpt-5.4",
102
+ };
103
+ }
104
+
105
+ function sleep(ms: number): Promise<void> {
106
+ return new Promise((resolve) => setTimeout(resolve, ms));
107
+ }
108
+
109
+ function pidAlive(pid: number): boolean {
110
+ try {
111
+ process.kill(pid, 0);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // Tear the detached resident down for real: SIGTERM, wait for both the pid to die AND the
119
+ // socket to disappear, then a SIGKILL backstop. The child is detached (no ChildProcess handle),
120
+ // so liveness is observed via `kill(pid, 0)` + the socket file, not an `exit` event.
121
+ async function terminateResident(pid: number, sockPath: string, graceMs = 3_000): Promise<void> {
122
+ try {
123
+ process.kill(pid, "SIGTERM");
124
+ } catch {
125
+ return; // already gone
126
+ }
127
+ const deadline = Date.now() + graceMs;
128
+ while (Date.now() < deadline) {
129
+ if (!pidAlive(pid) && !existsSync(sockPath)) return;
130
+ await sleep(POLL_MS);
131
+ }
132
+ try {
133
+ process.kill(pid, "SIGKILL");
134
+ } catch {
135
+ // already gone
136
+ }
137
+ }
138
+
139
+ function smokeSender(gardenId: string, cwd: string): SenderEnvelope {
140
+ return {
141
+ sessionId: gardenId,
142
+ agentId: "smoke/spawn-resume-live",
143
+ cwd,
144
+ timestamp: new Date(0).toISOString(),
145
+ origin: "pi-session",
146
+ replyable: false,
147
+ };
148
+ }
149
+
150
+ // Read the session JSONL and search ONLY the lines appended after the seed (append-only rigor:
151
+ // the resume nonces are unique so a whole-file scan would also be correct, but slicing past the
152
+ // seed line count proves the turn was added by the RESUME, not pre-existing). JSON-parse each
153
+ // line and match on the stringified entry — never assume a type/role shape (GPT pin 7).
154
+ async function pollForNonce(
155
+ sessionFile: string,
156
+ seedLineCount: number,
157
+ needle: string,
158
+ timeoutMs: number,
159
+ ): Promise<boolean> {
160
+ const deadline = Date.now() + timeoutMs;
161
+ while (Date.now() < deadline) {
162
+ const lines = readFileSync(sessionFile, "utf8")
163
+ .split("\n")
164
+ .filter((l) => l.trim());
165
+ for (const line of lines.slice(seedLineCount)) {
166
+ let entry: unknown;
167
+ try {
168
+ entry = JSON.parse(line);
169
+ } catch {
170
+ continue;
171
+ }
172
+ if (JSON.stringify(entry).includes(needle)) return true;
173
+ }
174
+ await sleep(POLL_MS);
175
+ }
176
+ return false;
177
+ }
178
+
179
+ async function main(): Promise<void> {
180
+ if (process.env.LIVE !== "1") {
181
+ console.log(
182
+ "[smoke-entwurf-v2-spawn-resume-live] skipped — set LIVE=1 to run (spawns a real pi child + opens a real socket).",
183
+ );
184
+ return;
185
+ }
186
+
187
+ const { provider, model } = resolveTarget();
188
+ // ACP override is OUT of scope for 0.11.0 (A): `--no-extensions --provider entwurf` is a
189
+ // broken combination, and this smoke proves spawn-bg resident lifecycle, not backend equality.
190
+ if (provider === "entwurf") {
191
+ throw new Error(
192
+ "spawn-resume-live: provider=entwurf is out of scope for 0.11.0 (A) — use a native target (e.g. openai-codex/gpt-5.4).",
193
+ );
194
+ }
195
+ console.log(`[smoke-entwurf-v2-spawn-resume-live] target = ${provider}/${model}`);
196
+
197
+ // ── temp world (META store / mailbox / locks are temp; pi's SESSION JSONL is REAL) ──
198
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "v2resume-"));
199
+ const sessionsDir = path.join(tmp, "meta-sessions");
200
+ const mailboxDir = path.join(tmp, "mailbox");
201
+ const lockDir = path.join(tmp, "locks");
202
+ for (const d of [sessionsDir, mailboxDir, lockDir]) await fsp.mkdir(d, { recursive: true });
203
+ process.env.ENTWURF_META_SESSIONS_DIR = sessionsDir;
204
+ process.env.ENTWURF_META_MAILBOX_DIR = mailboxDir;
205
+
206
+ let gid = "";
207
+ let seedFile: string | null = null;
208
+ let childPid: number | null = null;
209
+ let seedStderrTail = "";
210
+ let succeeded = false;
211
+
212
+ // ── release-seam wrapper (F2): the production factory builds ONE shared `release` closure
213
+ // from `seams.releaseLock` and injects it into the decider AND the spawn watcher's releaseFn,
214
+ // so this single wrapper counts the spawn-bg release too. Production code is unchanged. ──
215
+ let releaseCount = 0;
216
+ const releasedGids: string[] = [];
217
+
218
+ try {
219
+ // ── 1. mint a backend=pi meta identity; its gardenId is the seed/resume id ──
220
+ const minted = upsertMetaSession({
221
+ input: { backend: "pi", nativeSessionId: `smoke-resume-${process.pid}`, cwd: tmp, model },
222
+ dir: sessionsDir,
223
+ });
224
+ gid = minted.record.gardenId;
225
+ artifacts["gid"] = gid;
226
+ const sockPath = path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`);
227
+ artifacts["socket"] = sockPath;
228
+ artifacts["lock"] = lockPathFor(gid, lockDir);
229
+
230
+ // fresh gid must collide with NOTHING (GPT pin 1 + F1): no live socket, no saved session.
231
+ ok("fresh gid has no pre-existing control socket", !existsSync(controlSocketPath(gid, REAL_CONTROL_DIR)));
232
+ ok("fresh gid has no saved session yet (dormant-to-be)", findSessionFileById(gid) === null);
233
+
234
+ // ── 2. seed a REAL dormant pi session (one-shot, no control socket survives) ──
235
+ const nonce = `${process.pid.toString(36)}${Date.now().toString(36)}`;
236
+ artifacts["nonce"] = nonce;
237
+ const seedName = buildSessionName({
238
+ sessionId: gid,
239
+ provider,
240
+ model,
241
+ rawTitle: "spawn resume live seed",
242
+ tags: ["entwurf"], // F1: resolveResumeLaunchIdentity reads requireEntwurf:true.
243
+ });
244
+ const seedArgs = [
245
+ "--mode",
246
+ "json",
247
+ "-p",
248
+ "--no-extensions",
249
+ "--approve",
250
+ "--session-id",
251
+ gid,
252
+ "--name",
253
+ seedName,
254
+ "--provider",
255
+ provider,
256
+ "--model",
257
+ model,
258
+ `Reply exactly SEED_READY_${nonce} and nothing else.`,
259
+ ];
260
+ const seed = spawnSync("pi", seedArgs, { cwd: tmp, encoding: "utf8", timeout: SEED_TIMEOUT_MS, env: process.env });
261
+ seedStderrTail = `${seed.stdout ?? ""}\n${seed.stderr ?? ""}`.split("\n").slice(-12).join("\n");
262
+ ok("seed pi one-shot exited 0", seed.status === 0);
263
+
264
+ // ── 3. the seed must have written a resumable identity (F3 + GPT pin 5) ──
265
+ // Poll briefly: the JSONL flush/rename can lag the process exit.
266
+ let identity: ReturnType<typeof readSessionIdentity> = null;
267
+ {
268
+ const deadline = Date.now() + SEED_IDENTITY_POLL_MS;
269
+ while (Date.now() < deadline) {
270
+ const file = findSessionFileById(gid);
271
+ if (file) {
272
+ const id = readSessionIdentity(file, { requireEntwurf: true });
273
+ if (id?.modelId && id.provider && id.cwd) {
274
+ seedFile = file;
275
+ identity = id;
276
+ break;
277
+ }
278
+ }
279
+ await sleep(POLL_MS);
280
+ }
281
+ }
282
+ artifacts["seedFile"] = seedFile ?? "(none)";
283
+ ok("seed session file is resolvable by gid", seedFile !== null);
284
+ ok("seed recorded a resumable entwurf identity (requireEntwurf)", identity !== null);
285
+ ok("seed header cwd === temp cwd (cold-resume authority, #9)", identity?.cwd === tmp);
286
+ ok("seed recorded provider matches the target", identity?.provider === provider);
287
+ ok("seed recorded modelId matches the target", identity?.modelId === model);
288
+ ok("seed header id === gid", identity?.sessionId === gid);
289
+
290
+ // resume-time append boundary: count seed lines so the nonce poll scans ONLY the resume.
291
+ const seedLineCount = readFileSync(seedFile as string, "utf8")
292
+ .split("\n")
293
+ .filter((l) => l.trim()).length;
294
+
295
+ // dormant precondition: the one-shot left NO live control socket behind.
296
+ ok("seed left no live control socket (citizen is dormant)", !existsSync(sockPath));
297
+
298
+ // ── 4. drive the production owned-outcome resume → real spawn-bg child ──
299
+ const prodDeps = makeProductionEntwurfV2Deps({
300
+ senderProvider: () => smokeSender(gid, tmp),
301
+ sessionsDir,
302
+ mailboxDir,
303
+ lockDir,
304
+ controlSocketDir: REAL_CONTROL_DIR,
305
+ prefixRoots: [tmp], // F4: preflight allow for a resume into the temp cwd.
306
+ observeTimeoutMs: OBSERVE_TIMEOUT_MS,
307
+ seams: {
308
+ releaseLock: (claim: LockClaim, deps: { dir?: string }) => {
309
+ releaseCount++;
310
+ releasedGids.push(claim.gardenId);
311
+ return realReleaseLock(claim, deps);
312
+ },
313
+ spawnOverrides: {
314
+ resolveIdentity: (plan) => {
315
+ const launch = resolveResumeLaunchIdentity(plan);
316
+ return {
317
+ ...launch,
318
+ explicitExtensionArgs: [...REPO_EXTENSION_ARGS, ...launch.explicitExtensionArgs],
319
+ };
320
+ },
321
+ },
322
+ },
323
+ });
324
+
325
+ const userNeedle = `V2_SPAWN_RESUME_USER_${nonce}`;
326
+ const okNeedle = `V2_SPAWN_RESUME_OK_${nonce}`;
327
+ // `pollForNonce` scans the stringified entry regardless of role, so the USER prompt must NOT
328
+ // contain the contiguous okNeedle — otherwise the assistant poll passes the instant the user
329
+ // turn is appended (a false "model replied", GPT blocker 1). Ask the model to CONCATENATE the
330
+ // two parts with no separator so only a real assistant turn yields the contiguous okNeedle.
331
+ const resumeMessage =
332
+ `${userNeedle}\n` +
333
+ `Reply with exactly these two parts concatenated, no separator and nothing else: ` +
334
+ `the literal "V2_SPAWN_RESUME_OK_" then "${nonce}".`;
335
+ ok("resume prompt does not contain the contiguous assistant OK nonce", !resumeMessage.includes(okNeedle));
336
+ const result: EntwurfV2RunResult = await runEntwurfV2(
337
+ { target: gid, intent: "owned-outcome", message: resumeMessage },
338
+ prodDeps,
339
+ );
340
+
341
+ // ── result PASS = executed / spawn-bg / socket-alive / released (F5 + GPT pin 4) ──
342
+ ok(
343
+ "owned-outcome on a dormant pi citizen executed a spawn-bg resume",
344
+ result.kind === "executed" && result.transport === "spawn-bg",
345
+ );
346
+ if (result.kind !== "executed" || result.outcome.transport !== "spawn-bg") {
347
+ throw new Error("spawn-resume-live: result was not an executed spawn-bg outcome (see asserts above).");
348
+ }
349
+ const sb = result.outcome.result;
350
+ // GPT blocker 2: capture any available pid BEFORE the socket-alive narrowing throws — a
351
+ // `lock-retained` outcome carries `diagnostic.pid` of a child the watcher killed but could
352
+ // not confirm dead, so a fail-path throw must still hand the finally a pid to reap.
353
+ const maybePid =
354
+ "pid" in sb && typeof sb.pid === "number"
355
+ ? sb.pid
356
+ : sb.kind === "lock-retained" && typeof sb.diagnostic.pid === "number"
357
+ ? sb.diagnostic.pid
358
+ : null;
359
+ if (maybePid !== null && maybePid > 0) {
360
+ childPid = maybePid;
361
+ artifacts["childPid"] = String(maybePid);
362
+ }
363
+ ok(
364
+ "spawn-bg observed socket-alive (resident lifecycle, not child-exited/lock-retained)",
365
+ sb.kind === "socket-alive",
366
+ );
367
+ if (sb.kind !== "socket-alive") {
368
+ throw new Error(`spawn-resume-live: spawn-bg result was ${sb.kind}, not socket-alive.`);
369
+ }
370
+ ok("spawn-bg released the lock on socket-alive", sb.released === true);
371
+ const pid = sb.pid;
372
+ ok("resident pid is a real live pid", typeof pid === "number" && pid > 0);
373
+
374
+ // release accounting (F2): exactly one release, for THIS gid, and no lock file remains.
375
+ ok("lock released exactly once", releaseCount === 1);
376
+ ok("the single release was for the target gid", releasedGids.length === 1 && releasedGids[0] === gid);
377
+ ok("no lock file remains for the target", !existsSync(lockPathFor(gid, lockDir)));
378
+
379
+ // resident "still standing" re-check (GPT pin 4): pid alive AND socket connectable-present.
380
+ ok("resident pid is still alive after the watcher returned", childPid !== null && pidAlive(childPid));
381
+ ok("resident control socket is present at the canonical path", existsSync(sockPath));
382
+
383
+ // ── 5. the child actually resumed AND did a model turn (the acceptance core) ──
384
+ const userSeen = await pollForNonce(seedFile as string, seedLineCount, userNeedle, USER_TURN_TIMEOUT_MS);
385
+ ok("resume USER nonce appended to the session (the turn was injected)", userSeen);
386
+ const okSeen = await pollForNonce(seedFile as string, seedLineCount, okNeedle, ASSISTANT_TURN_TIMEOUT_MS);
387
+ ok("resume ASSISTANT OK nonce appended (the model actually replied — real work)", okSeen);
388
+
389
+ succeeded = true;
390
+ console.log(
391
+ `\nsmoke-entwurf-v2-spawn-resume-live: ${passed} checks passed (real pi resume + model turn + lock release)`,
392
+ );
393
+ } catch (err) {
394
+ console.error("\n[smoke-entwurf-v2-spawn-resume-live] FAILED — diagnostic artifacts:");
395
+ for (const [k, v] of Object.entries(artifacts)) console.error(` ${k} = ${v}`);
396
+ console.error(` releaseCount = ${releaseCount} releasedGids = ${JSON.stringify(releasedGids)}`);
397
+ if (seedStderrTail.trim())
398
+ console.error(` seed pi stdout/stderr (tail):\n${seedStderrTail.replace(/^/gm, " ")}`);
399
+ throw err;
400
+ } finally {
401
+ // reap the detached resident + its real socket (always — even on a clean pass).
402
+ if (childPid !== null && gid) {
403
+ await terminateResident(childPid, path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`)).catch(() => {});
404
+ }
405
+ if (gid) {
406
+ await fsp.rm(path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`), { force: true }).catch(() => {});
407
+ }
408
+ if (succeeded) {
409
+ // delete ONLY the one seed session file we created (never the real sessions dir).
410
+ if (seedFile) {
411
+ await fsp.rm(seedFile, { force: true }).catch(() => {});
412
+ // pi nests the seed under a cwd-hash subdir keyed by our unique temp cwd, so removing
413
+ // it IF-empty leaves no litter and can never touch another session (rmdir refuses a
414
+ // non-empty dir — belt for a surprise sibling file).
415
+ await fsp.rmdir(path.dirname(seedFile)).catch(() => {});
416
+ }
417
+ await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {});
418
+ } else {
419
+ console.error(`[smoke-entwurf-v2-spawn-resume-live] temp world preserved for inspection: ${tmp}`);
420
+ if (seedFile) console.error(`[smoke-entwurf-v2-spawn-resume-live] seed session PRESERVED: ${seedFile}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ main().catch((err) => {
426
+ console.error(err);
427
+ process.exit(1);
428
+ });
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-meta-async-drift — 1.0.0 meta-bridge step 1: drift sentinel + capability gate
4
+ # (#30 bbot review refinement #2; NEXT.md MVP step 1, "record authority FIRST, hook
5
+ # LAST" — this is the protector that freezes the green BEFORE any record/hook lands).
6
+ #
7
+ # WHY THIS EXISTS
8
+ # The whole Claude async-delivery path rides on UNDOCUMENTED Claude Code behavior:
9
+ # `asyncRewake` force-prepends `Stop hook feedback:` and ignores `rewakeMessage`;
10
+ # the payload channel is stderr-only; `watchPaths` can arm from only 3 hook events
11
+ # (SessionStart / CwdChanged / FileChanged). Half the Gotchas in
12
+ # scripts/raw-async-delivery/README.md are reverse-engineered. Claude ships ~weekly.
13
+ # So the path can break SILENTLY on any upgrade. This gate makes it SCREAM instead —
14
+ # direct lineage of the 0.8.x fail-fast tool-surface gates ("don't let a surface
15
+ # break quietly, make the gate cry out").
16
+ #
17
+ # TWO TIERS (mirrors smoke-compaction-policy: deterministic default + LIVE=1 add-on)
18
+ # DETERMINISTIC (default; free, offline, CI/pre-commit safe):
19
+ # A. VERSION LINE PINS — the three backends stay on the pinned MAJOR.MINOR
20
+ # lines. Patch drift is tolerated; minor/major drift screams.
21
+ # B. BINARY MARKERS — the undocumented-behavior identifiers must still be PRESENT in
22
+ # the installed Claude binary (binary cross-validation). A marker dropping to
23
+ # zero = the behavior was renamed/removed = the delivery path is dead = SCREAM.
24
+ # LIVE (LIVE=1; spawns one `claude -p`, metered from 2026-06-15):
25
+ # C. PLUGIN WATCH-ARM PROBE — delegate to repro-plugin-idle-wake.sh `probe`: the
26
+ # plugin SessionStart hook must fire at STARTUP and arm a per-session watchPath
27
+ # (a bare skill cannot — structural, see README). This is the end-to-end
28
+ # confirmation that arming still works on this build.
29
+ #
30
+ # PIN POLICY (2026-06-05): pins are MAJOR.MINOR lines, not exact patches. Claude
31
+ # ships ~weekly (observed 2.1.163 -> 2.1.165 same day, all 9 markers unchanged),
32
+ # so an exact-patch pin screams on every bump and the signal is lost. The
33
+ # binary-marker check (B) is the real net and runs against whatever patch is
34
+ # installed; the version check (A) only fires on a MINOR/MAJOR move, which is the
35
+ # genuine "re-verify markers + Gotchas + raw/LIVE probes" trigger. (bbot's
36
+ # "agy 0.136" was a conflation with codex-cli 0.136.0; agy's line is 1.0.)
37
+ #
38
+ # USAGE: ./run.sh smoke-meta-async-drift (LIVE=1 to add the plugin watch-arm probe)
39
+ set -euo pipefail
40
+
41
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
+ RAW_DIR="$HERE/raw-async-delivery"
43
+
44
+ # --- pinned MINOR lines (major.minor; PATCH is intentionally NOT pinned) --------
45
+ # Claude/codex/agy ship fast. A PATCH bump (e.g. 2.1.163 -> 2.1.165) almost never
46
+ # touches the undocumented delivery behavior — the binary-marker cross-validation
47
+ # in section B is the real safety net and runs against whatever patch is actually
48
+ # installed. Pinning the patch made the sentinel scream on every weekly bump =
49
+ # signal lost (observed 2026-06-05: 2.1.163 -> 2.1.165, all 9 markers unchanged).
50
+ # So pin the MINOR line; a MINOR/MAJOR move (2.1.x -> 2.2 / 3.x, 0.136 -> 0.137,
51
+ # 1.0 -> 1.1) is the real "re-verify the markers + Gotchas + raw/LIVE probes"
52
+ # trigger and DOES scream.
53
+ PIN_CLAUDE_MINOR="2.1"
54
+ PIN_CODEX_MINOR="0.136"
55
+ PIN_AGY_MINOR="1.0"
56
+
57
+ # --- undocumented-behavior identifiers that the delivery path rides on ---------
58
+ # Each MUST stay present in the installed Claude binary. Zero = drift = SCREAM.
59
+ MARKERS=(
60
+ asyncRewake # active-turn doorbell (Stop hook)
61
+ stop_hook_active # infinite-loop guard field
62
+ watchPaths # idle-wake arm channel
63
+ flushPendingAsyncRewakeHooks # engine turn-boundary flush (edge-bound delivery)
64
+ CLAUDE_CODE_STOP_HOOK_BLOCK_CAP # native re-wake cap
65
+ FileChanged # idle-wake event
66
+ rewakeMessage # the field asyncRewake IGNORES (doorbell-only proof)
67
+ hookSpecificOutput # the watchPaths emit envelope
68
+ CwdChanged # the 2nd of the 3 watch-arming events
69
+ )
70
+
71
+ pass=0 ; fail=0 ; scream=0
72
+ ok() { echo " PASS: $*"; pass=$((pass+1)); }
73
+ bad() { echo " FAIL: $*"; fail=$((fail+1)); }
74
+ cry() { echo " DRIFT!: $*"; scream=$((scream+1)); }
75
+ section(){ echo; echo "== $* =="; }
76
+
77
+ # ---------------------------------------------------------------------------- A
78
+ section "A. VERSION LINE PINS (minor/major drift = undocumented-behavior assumptions may be stale)"
79
+
80
+ # major.minor of an X.Y.Z token ("2.1.165" -> "2.1", "0.136.0" -> "0.136").
81
+ minor_of() { printf '%s' "$1" | cut -d. -f1-2; }
82
+ extract_semver() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true; }
83
+
84
+ check_version() {
85
+ local name="$1" pin_minor="$2" raw="$3"
86
+ if [ -z "$raw" ]; then
87
+ echo " SKIP: $name not on PATH (cannot verify pinned minor ${pin_minor}.x)"; return
88
+ fi
89
+ # extract first dotted version token (handles "2.1.165 (Claude Code)", "codex-cli 0.136.0", "1.0.5")
90
+ local got got_minor
91
+ got="$(extract_semver "$raw")"
92
+ if [ -z "$got" ]; then
93
+ cry "$name version output did not contain an X.Y.Z token (raw: $raw) — cannot verify pinned minor ${pin_minor}.x"
94
+ return
95
+ fi
96
+ got_minor="$(minor_of "$got")"
97
+ if [ "$got_minor" = "$pin_minor" ]; then
98
+ ok "$name $got within pinned minor ${pin_minor}.x (patch drift ignored)"
99
+ else
100
+ cry "$name $got left pinned minor ${pin_minor}.x — re-verify binary markers + Gotchas + raw/LIVE probes on the new minor, then bump the pin"
101
+ fi
102
+ }
103
+
104
+ CLAUDE_RAW="$(claude --version 2>/dev/null || true)"
105
+ check_version claude "$PIN_CLAUDE_MINOR" "$CLAUDE_RAW"
106
+ check_version codex "$PIN_CODEX_MINOR" "$(codex --version 2>/dev/null || true)"
107
+ check_version agy "$PIN_AGY_MINOR" "$(agy --version 2>/dev/null || true)"
108
+ # Full patch version drives binary resolution in section B (the marker check must
109
+ # run against the ACTUAL installed patch, not a hardcoded one).
110
+ CLAUDE_FULL="$(extract_semver "$CLAUDE_RAW")"
111
+
112
+ # ---------------------------------------------------------------------------- B
113
+ section "B. BINARY MARKERS (undocumented Claude delivery behavior still present?)"
114
+
115
+ # Resolve the installed Claude binary. `claude` on PATH is a launcher shim; the
116
+ # real bundle lives under ~/.local/share/claude/versions/<ver>. Use the ACTUAL
117
+ # installed patch (from `claude --version`); fall back to the newest on disk.
118
+ CLAUDE_BIN=""
119
+ CLAUDE_VER_DIR="$HOME/.local/share/claude/versions"
120
+ if [ -n "$CLAUDE_FULL" ] && [ -f "$CLAUDE_VER_DIR/$CLAUDE_FULL" ]; then
121
+ CLAUDE_BIN="$CLAUDE_VER_DIR/$CLAUDE_FULL"
122
+ elif [ -d "$CLAUDE_VER_DIR" ]; then
123
+ CLAUDE_BIN="$(ls -1t "$CLAUDE_VER_DIR" 2>/dev/null | head -1 | sed "s#^#$CLAUDE_VER_DIR/#")"
124
+ fi
125
+
126
+ if [ -z "$CLAUDE_BIN" ] || [ ! -f "$CLAUDE_BIN" ]; then
127
+ echo " SKIP: Claude binary not found under $CLAUDE_VER_DIR — cannot cross-validate markers"
128
+ else
129
+ echo " binary: $CLAUDE_BIN"
130
+ for m in "${MARKERS[@]}"; do
131
+ n="$(grep -aoc "$m" "$CLAUDE_BIN" 2>/dev/null || true)"
132
+ n="${n:-0}"
133
+ if [ "$n" -gt 0 ]; then
134
+ ok "marker present: $m ($n)"
135
+ else
136
+ cry "marker GONE: $m — delivery path likely broken on this build"
137
+ fi
138
+ done
139
+ fi
140
+
141
+ # ---------------------------------------------------------------------------- C
142
+ section "C. PLUGIN WATCH-ARM PROBE (LIVE only)"
143
+
144
+ if [ "${LIVE:-0}" = "1" ]; then
145
+ echo " LIVE=1 → delegating to repro-plugin-idle-wake.sh probe (spawns one claude -p)"
146
+ if bash "$RAW_DIR/repro-plugin-idle-wake.sh" probe; then
147
+ ok "plugin SessionStart hook armed a per-session watchPath at startup"
148
+ else
149
+ bad "plugin watch-arm probe failed (SessionStart did not arm the watch)"
150
+ fi
151
+ else
152
+ echo " SKIP: set LIVE=1 to run the plugin watch-arm probe (spawns one metered claude -p)"
153
+ fi
154
+
155
+ # ---------------------------------------------------------------------------- summary
156
+ section "SUMMARY"
157
+ echo " pass=$pass fail=$fail drift=$scream"
158
+ if [ "$scream" -gt 0 ]; then
159
+ echo
160
+ echo " ***** DRIFT DETECTED *****"
161
+ echo " An undocumented behavior or a pinned version moved. The async-delivery path"
162
+ echo " is built on these; do NOT assume it still works. Re-run the raw probes in"
163
+ echo " scripts/raw-async-delivery/, re-read README §Gotchas, update DELIVERY.md, and"
164
+ echo " only then bump the pins in this gate."
165
+ exit 1
166
+ fi
167
+ if [ "$fail" -gt 0 ]; then
168
+ echo " FAIL: $fail check(s) failed."
169
+ exit 1
170
+ fi
171
+ echo " OK: meta-async drift sentinel green at pinned minor lines."