@junghanacs/entwurf 0.12.10 → 0.13.1

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 (86) hide show
  1. package/AGENTS.md +6 -3
  2. package/BASELINE.md +56 -163
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +117 -261
  6. package/README.md +74 -399
  7. package/VERIFY.md +59 -85
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +231 -0
  10. package/docs/external-mcp-host.md +132 -0
  11. package/docs/fresh-cut-policy.md +99 -0
  12. package/docs/setup-clean-host.md +123 -328
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +21 -48
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +155 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  21. package/mcp/entwurf-bridge/src/index.ts +21 -48
  22. package/package.json +14 -9
  23. package/pi/settings.reference.json +1 -1
  24. package/pi-extensions/acp-provider.ts +20 -10
  25. package/pi-extensions/entwurf-control.ts +20 -49
  26. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  27. package/pi-extensions/lib/acp/augment.ts +99 -2
  28. package/pi-extensions/lib/acp/backend-adapter.ts +190 -14
  29. package/pi-extensions/lib/acp/backend.ts +310 -37
  30. package/pi-extensions/lib/acp/config.ts +19 -5
  31. package/pi-extensions/lib/acp/engraving.ts +46 -2
  32. package/pi-extensions/lib/acp/event-mapper.ts +26 -16
  33. package/pi-extensions/lib/acp/models.ts +69 -7
  34. package/pi-extensions/lib/acp/overlay.ts +234 -5
  35. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  36. package/prompts/engraving.md +13 -5
  37. package/run.sh +386 -119
  38. package/scripts/check-acp-carrier-augment.ts +246 -10
  39. package/scripts/check-acp-cortex.ts +668 -0
  40. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  41. package/scripts/check-acp-provider-surface.ts +50 -6
  42. package/scripts/check-acp-sdk-surface.ts +60 -21
  43. package/scripts/check-acp-session-reuse.ts +64 -1
  44. package/scripts/check-acp-stop-reason.ts +342 -0
  45. package/scripts/check-entwurf-v2-surface.ts +41 -0
  46. package/scripts/check-gate-qualification.ts +7 -2
  47. package/scripts/check-probe-cli-shim.ts +879 -0
  48. package/scripts/check-probe-ordering.ts +2482 -0
  49. package/scripts/check-release-gate-outcomes.ts +332 -0
  50. package/scripts/check-shell-quote.ts +4 -4
  51. package/scripts/fixtures/probe-cli-shim +20 -0
  52. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  53. package/scripts/lib/live-skip.ts +33 -0
  54. package/scripts/lib/probe-acp-turn.ts +228 -0
  55. package/scripts/lib/probe-cli-shim.ts +464 -0
  56. package/scripts/lib/probe-cli-target.ts +165 -0
  57. package/scripts/lib/probe-event-log.ts +383 -0
  58. package/scripts/lib/probe-verdict.ts +1213 -0
  59. package/scripts/lib/step-outcome.sh +88 -0
  60. package/scripts/mutants/acp-augment.json +106 -0
  61. package/scripts/mutants/acp-cortex.json +196 -0
  62. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  63. package/scripts/mutants/acp-stop-reason.json +80 -0
  64. package/scripts/mutants/probe-ordering.json +1054 -0
  65. package/scripts/mutants/release-gate.json +105 -0
  66. package/scripts/mutants/v2-surface.json +26 -15
  67. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  68. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  69. package/scripts/smoke-acp-cortex-live.ts +386 -0
  70. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  71. package/scripts/smoke-acp-mcp-live.ts +2 -2
  72. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  73. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  74. package/scripts/smoke-acp-overlay-live.ts +2 -2
  75. package/scripts/smoke-acp-provider-live.ts +2 -2
  76. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  77. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  78. package/scripts/smoke-acp-skill-live.ts +2 -2
  79. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  80. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  81. package/scripts/smoke-agy-native-push-live.ts +4 -3
  82. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  83. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  84. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  85. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
  86. package/scripts/smoke-meta-install-state.sh +4 -0
@@ -55,6 +55,7 @@ import {
55
55
  DEFAULT_CLAUDE_TOOLS,
56
56
  } from "../pi-extensions/lib/acp/tool-surface.ts";
57
57
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
58
+ import { skipLive } from "./lib/live-skip.ts";
58
59
 
59
60
  const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_OVERLAY_MODEL ?? "claude-sonnet-5";
60
61
  const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_OVERLAY_ALLOW_PATH_FALLBACK === "1";
@@ -76,8 +77,7 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
76
77
  }
77
78
 
78
79
  if (process.env.LIVE !== "1") {
79
- console.error("[smoke-acp-overlay-live] skipped — set LIVE=1 to run the real overlay ACP turn.");
80
- process.exit(0);
80
+ skipLive("smoke-acp-overlay-live", "set LIVE=1 to run the real overlay ACP turn.");
81
81
  }
82
82
 
83
83
  function resolveLaunch(): { command: string; args: string[]; source: string; acceptance: boolean } {
@@ -26,6 +26,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
26
26
  import { tmpdir } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
+ import { skipLive } from "./lib/live-skip.ts";
29
30
 
30
31
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
32
  const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -38,8 +39,7 @@ function fail(msg: string): never {
38
39
  }
39
40
 
40
41
  if (process.env.LIVE !== "1") {
41
- console.error("[smoke-acp-provider-live] skipped — set LIVE=1 to run the real pi provider turn.");
42
- process.exit(0);
42
+ skipLive("smoke-acp-provider-live", "set LIVE=1 to run the real pi provider turn.");
43
43
  }
44
44
 
45
45
  // A nonce makes the expected reply unique so a stray "OK" elsewhere in the
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.62.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.64.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -34,6 +34,7 @@ import { Readable, Writable } from "node:stream";
34
34
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
35
35
  import { connectAcpClient } from "../pi-extensions/lib/acp/acp-client.ts";
36
36
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
37
+ import { skipLive } from "./lib/live-skip.ts";
37
38
 
38
39
  const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_RAW_TURN_MODEL ?? "claude-sonnet-5";
39
40
  const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_RAW_TURN_ALLOW_PATH_FALLBACK === "1";
@@ -59,8 +60,7 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
59
60
  }
60
61
 
61
62
  if (process.env.LIVE !== "1") {
62
- console.error("[smoke-acp-raw-turn-live] skipped — set LIVE=1 to run the real ACP turn.");
63
- process.exit(0);
63
+ skipLive("smoke-acp-raw-turn-live", "set LIVE=1 to run the real ACP turn.");
64
64
  }
65
65
 
66
66
  // ---------------------------------------------------------------------------
@@ -25,6 +25,7 @@ import { tmpdir } from "node:os";
25
25
  import { dirname, resolve } from "node:path";
26
26
  import { fileURLToPath, pathToFileURL } from "node:url";
27
27
  import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
28
+ import { skipLive } from "./lib/live-skip.ts";
28
29
 
29
30
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
31
  const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -46,8 +47,7 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
46
47
  }
47
48
 
48
49
  if (process.env.LIVE !== "1") {
49
- console.error("[smoke-acp-session-reuse-live] skipped — set LIVE=1 to run the real 2-turn reuse acceptance.");
50
- process.exit(0);
50
+ skipLive("smoke-acp-session-reuse-live", "set LIVE=1 to run the real 2-turn reuse acceptance.");
51
51
  }
52
52
 
53
53
  type Stream = AsyncIterable<AssistantMessageEvent> & {
@@ -28,6 +28,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
29
  import { dirname, join, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
+ import { skipLive } from "./lib/live-skip.ts";
31
32
 
32
33
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
34
  const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -40,8 +41,7 @@ function fail(msg: string): never {
40
41
  }
41
42
 
42
43
  if (process.env.LIVE !== "1") {
43
- console.error("[smoke-acp-skill-live] skipped — set LIVE=1 to run the real pi provider turn.");
44
- process.exit(0);
44
+ skipLive("smoke-acp-skill-live", "set LIVE=1 to run the real pi provider turn.");
45
45
  }
46
46
 
47
47
  const STUB_PATTERN = /AcpBackendNotImplementedError|not implemented in S0/i;
@@ -31,6 +31,7 @@ import { fileURLToPath } from "node:url";
31
31
  import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensions/lib/entwurf-control-rpc.ts";
32
32
  import { scanSocketProbes } from "../pi-extensions/lib/socket-discovery.ts";
33
33
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
34
+ import { skipLive } from "./lib/live-skip.ts";
34
35
  import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
35
36
 
36
37
  const ACP_PROVIDER = "entwurf";
@@ -76,10 +77,10 @@ async function waitForGone(sockPath: string, timeoutMs: number): Promise<boolean
76
77
 
77
78
  async function main(): Promise<void> {
78
79
  if (process.env.LIVE !== "1") {
79
- console.log(
80
- "[smoke-acp-socket-citizen-live] skipped — set LIVE=1 to run (spawns a real pi --entwurf-control + opens a real socket).",
80
+ skipLive(
81
+ "smoke-acp-socket-citizen-live",
82
+ "set LIVE=1 to run (spawns a real pi --entwurf-control + opens a real socket).",
81
83
  );
82
- return;
83
84
  }
84
85
 
85
86
  const tmp = os.tmpdir();
@@ -81,6 +81,7 @@ import * as readline from "node:readline";
81
81
  import { fileURLToPath } from "node:url";
82
82
  import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
83
83
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
84
+ import { skipLive } from "./lib/live-skip.ts";
84
85
  import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
85
86
 
86
87
  const ACP_PROVIDER = "entwurf";
@@ -196,10 +197,10 @@ function driveTurn(child: ChildProcess, prompt: string): Promise<TurnCapture> {
196
197
 
197
198
  async function main(): Promise<void> {
198
199
  if (process.env.LIVE !== "1") {
199
- console.log(
200
- "[smoke-acp-v2-send-live] skipped — set LIVE=1 to run (spawns a real pi --entwurf-control resident + drives one model turn).",
200
+ skipLive(
201
+ "smoke-acp-v2-send-live",
202
+ "set LIVE=1 to run (spawns a real pi --entwurf-control resident + drives one model turn).",
201
203
  );
202
- return;
203
204
  }
204
205
 
205
206
  // One temp root holding the whole garden this smoke can see: store, mailbox and
@@ -54,6 +54,7 @@ import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
54
54
  import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
55
55
  import { antigravityAdapter } from "../pi-extensions/lib/native-push/adapter.ts";
56
56
  import { registerNativeConversation } from "../pi-extensions/lib/native-push/register.ts";
57
+ import { skipLive } from "./lib/live-skip.ts";
57
58
 
58
59
  const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
59
60
 
@@ -79,10 +80,10 @@ function smokeSender(gardenId: string, cwd: string): SenderEnvelope {
79
80
 
80
81
  async function main(): Promise<void> {
81
82
  if (process.env.LIVE !== "1") {
82
- console.log(
83
- "[smoke-agy-native-push-live] skipped — set LIVE=1 (+ AGY_CONVERSATION_ID) to run (sends into a real agy conversation).",
83
+ skipLive(
84
+ "smoke-agy-native-push-live",
85
+ "set LIVE=1 (+ AGY_CONVERSATION_ID) to run (sends into a real agy conversation).",
84
86
  );
85
- return;
86
87
  }
87
88
 
88
89
  // ── doctor-static preflight (runs FIRST; a dangling agy config FAILs here — the ③ gate) ──
@@ -18,11 +18,21 @@ set -euo pipefail
18
18
  REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19
19
 
20
20
  if [ "${LIVE:-0}" != "1" ]; then
21
- echo "[smoke-claude-native-resume-live] skipped — set LIVE=1 to run (spawns two real Claude Code native turns)."
22
- exit 0
21
+ . "$REPO/scripts/lib/step-outcome.sh"
22
+ echo "[entwurf:skip] smoke-claude-native-resume-live — set LIVE=1 to run (spawns two real Claude Code native turns)."
23
+ exit "$ENTWURF_STEP_SKIP_EXIT"
23
24
  fi
24
25
 
25
- command -v claude >/dev/null || { echo "FAIL: claude not on PATH" >&2; exit 1; }
26
+ # A missing `claude` is a PREREQUISITE, not a defect: this smoke drives the real
27
+ # native binary, so a host without it has nothing to prove wrong. Under the P1
28
+ # outcome protocol that is a SKIP the aggregate reports and `--cut` refuses —
29
+ # reporting it as FAIL would make a host's own toolchain look like a release
30
+ # defect, and the old exit 1 did exactly that once this smoke joined the gate.
31
+ command -v claude >/dev/null || {
32
+ . "$REPO/scripts/lib/step-outcome.sh"
33
+ echo "[entwurf:skip] smoke-claude-native-resume-live — claude not on PATH (install Claude Code and complete its own login flow)."
34
+ exit "$ENTWURF_STEP_SKIP_EXIT"
35
+ }
26
36
  command -v node >/dev/null || { echo "FAIL: node not on PATH" >&2; exit 1; }
27
37
  command -v python3 >/dev/null || { echo "FAIL: python3 not on PATH" >&2; exit 1; }
28
38
 
@@ -0,0 +1,352 @@
1
+ // P3 — the cross-harness delivery CHAIN, on real authenticated rails.
2
+ //
3
+ // LIVE=1 ./run.sh smoke-entwurf-chain-live
4
+ //
5
+ // WHY THIS EXISTS. Every v2 delivery proof we had covered ONE hop between TWO
6
+ // citizens on ONE rail: matrix-live dispatches programmatically, v2-send-live has
7
+ // an ACP model send into a mailbox, bundled-mcp-live only reads identity. The
8
+ // claim the project actually makes — that independent harnesses address each
9
+ // other by garden id — is a CHAIN claim, and nothing exercised a chain. A gate
10
+ // that never links three harnesses cannot catch the failure where hop 2 loses the
11
+ // sender identity hop 1 carried, or where a rail change breaks only the seam
12
+ // between two backends.
13
+ //
14
+ // THE CHAIN (each arrow is a real model turn on a real authenticated backend):
15
+ //
16
+ // A native Claude Code --entwurf_v2--> B pi / openai-codex (GPT)
17
+ // B --entwurf_v2--> C pi / entwurf claude-sonnet-5 (ACP)
18
+ // C --entwurf_v2--> D mailbox-backed self-fetch citizen
19
+ //
20
+ // Three harnesses, three rails: A is a native self-fetch citizen born by its own
21
+ // SessionStart hook, B and C are live control-socket citizens, and D is the
22
+ // mailbox terminus. The terminus is what makes the READ-RECEIPT axis observable
23
+ // at all — a control-socket delivery has no receipt to stamp, so a chain that
24
+ // ended at C could not produce one. Draining D through the production inbox
25
+ // reader stamps `lastReadAt`, and that is the receipt.
26
+ //
27
+ // WHAT IS REAL AND WHAT IS SMOKE-OWNED (state this honestly or the evidence is
28
+ // worth nothing):
29
+ // - REAL: A's meta-record and its first arming are minted by the operator's own
30
+ // Claude Code SessionStart hook; all three deliveries are production
31
+ // `entwurf_v2` dispatches chosen by the decider; all three model turns are
32
+ // real authenticated calls; every sender envelope is the real one.
33
+ // - SMOKE-OWNED: the isolated world (store / mailbox / receivers under one temp
34
+ // root) so the operator's live garden is never paged, and D's arming marker.
35
+ // D is a seeded citizen exactly as smoke-acp-v2-send-live seeds its peer.
36
+ // - NOT CLAIMED: this proves the chain LINKS, not that any model chooses the
37
+ // entwurf surface on its own. Each hop is TOLD which tool to call, which is
38
+ // why this is a MUST-tier delivery gate and not a BEHAVIOR-lane signal (the
39
+ // lane note in run.sh draws exactly that line).
40
+ //
41
+ // The nonce is the thread: it is minted here, embedded in A's instruction, and
42
+ // must arrive at D having been re-sent by B and again by C. A nonce at D proves
43
+ // the payload traversed every hop — no hop can fabricate it.
44
+ //
45
+ // Sender identity is proven the same way v2-send-live proves it: no hop is ever
46
+ // told its OWN garden id, so a gid can only appear downstream because the real
47
+ // envelope carried it.
48
+
49
+ import { strict as assert } from "node:assert";
50
+ import { type ChildProcess, spawn, spawnSync } from "node:child_process";
51
+ import crypto from "node:crypto";
52
+ import { existsSync } from "node:fs";
53
+ import fsp from "node:fs/promises";
54
+ import os from "node:os";
55
+ import path from "node:path";
56
+ import { fileURLToPath } from "node:url";
57
+ import { readMetaInbox, upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
58
+ import { terminateChild } from "./lib/acp-child-cleanup.ts";
59
+ import { skipLive } from "./lib/live-skip.ts";
60
+
61
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
62
+ const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
63
+ const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
64
+ const SOCKET_SUFFIX = ".sock";
65
+
66
+ const GPT_TARGET = process.env.ENTWURF_CHAIN_GPT_TARGET?.trim() || "openai-codex/gpt-5.4";
67
+ const ACP_TARGET = process.env.ENTWURF_CHAIN_ACP_TARGET?.trim() || "entwurf/claude-sonnet-5";
68
+ const BOOT_TIMEOUT_MS = 45_000;
69
+ const CLAUDE_TURN_TIMEOUT_MS = Number(process.env.ENTWURF_CHAIN_CLAUDE_TIMEOUT_MS) || 240_000;
70
+ const CHAIN_TIMEOUT_MS = Number(process.env.ENTWURF_CHAIN_TIMEOUT_MS) || 420_000;
71
+ const POLL_MS = 250;
72
+
73
+ let passed = 0;
74
+ function ok(label: string, cond: boolean): void {
75
+ assert.ok(cond, `smoke-entwurf-chain-live: ${label}`);
76
+ console.log(` ok ${label}`);
77
+ passed++;
78
+ }
79
+
80
+ function splitTarget(t: string): [provider: string, model: string] {
81
+ const i = t.indexOf("/");
82
+ assert.ok(i > 0, `target must be <provider>/<model>, got ${t}`);
83
+ return [t.slice(0, i), t.slice(i + 1)];
84
+ }
85
+
86
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
87
+
88
+ async function readRecords(dir: string): Promise<Array<{ gardenId: string; backend: string }>> {
89
+ const out: Array<{ gardenId: string; backend: string }> = [];
90
+ for (const f of await fsp.readdir(dir).catch(() => [] as string[])) {
91
+ if (!f.endsWith(".meta.json")) continue;
92
+ try {
93
+ const r = JSON.parse(await fsp.readFile(path.join(dir, f), "utf8")) as Record<string, unknown>;
94
+ if (typeof r.gardenId === "string" && typeof r.backend === "string") {
95
+ out.push({ gardenId: r.gardenId, backend: r.backend });
96
+ }
97
+ } catch {
98
+ /* a half-written record on the next poll is not an error */
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Wait for ONE new record of `backend` that is not already in `known`. */
105
+ async function waitForNewRecord(dir: string, backend: string, known: Set<string>, ms: number): Promise<string | null> {
106
+ const until = Date.now() + ms;
107
+ while (Date.now() < until) {
108
+ for (const r of await readRecords(dir)) {
109
+ if (r.backend === backend && !known.has(r.gardenId)) return r.gardenId;
110
+ }
111
+ await sleep(POLL_MS);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ async function waitForSocket(p: string, ms: number): Promise<boolean> {
117
+ const until = Date.now() + ms;
118
+ while (Date.now() < until) {
119
+ if (existsSync(p)) return true;
120
+ await sleep(POLL_MS);
121
+ }
122
+ return false;
123
+ }
124
+
125
+ async function main(): Promise<void> {
126
+ if (process.env.LIVE !== "1") {
127
+ skipLive(
128
+ "smoke-entwurf-chain-live",
129
+ "set LIVE=1 to run (drives three real authenticated model turns across Claude Code, pi GPT and pi ACP).",
130
+ );
131
+ }
132
+ if (spawnSync("bash", ["-lc", "command -v claude"], { encoding: "utf8" }).status !== 0) {
133
+ skipLive("smoke-entwurf-chain-live", "claude not on PATH — hop 1 is a real native Claude Code turn.");
134
+ }
135
+ const [gptProvider, gptModel] = splitTarget(GPT_TARGET);
136
+ const [acpProvider, acpModel] = splitTarget(ACP_TARGET);
137
+ // pi holds each backend's credentials in its own auth store; a missing one is a
138
+ // PREREQUISITE (the operator has not logged that backend in), never a defect.
139
+ const authPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
140
+ let auth: Record<string, unknown> = {};
141
+ try {
142
+ auth = JSON.parse(await fsp.readFile(authPath, "utf8")) as Record<string, unknown>;
143
+ } catch {
144
+ skipLive("smoke-entwurf-chain-live", `no pi auth store at ${authPath} — log in the chain's backends first.`);
145
+ }
146
+ if (!(gptProvider in auth)) {
147
+ skipLive(
148
+ "smoke-entwurf-chain-live",
149
+ `pi has no '${gptProvider}' credentials (hop 2 target ${GPT_TARGET}); log that backend in, or point ` +
150
+ "ENTWURF_CHAIN_GPT_TARGET at one you have.",
151
+ );
152
+ }
153
+
154
+ const world = await fsp.mkdtemp(path.join(os.tmpdir(), "entwurf-chain-"));
155
+ const sessionsDir = path.join(world, "sessions");
156
+ const mailboxDir = path.join(world, "mailbox");
157
+ const receiversDir = path.join(world, "receivers");
158
+ for (const d of [sessionsDir, mailboxDir, receiversDir]) await fsp.mkdir(d, { recursive: true });
159
+ const worldEnv = {
160
+ ENTWURF_META_SESSIONS_DIR: sessionsDir,
161
+ ENTWURF_META_MAILBOX_DIR: mailboxDir,
162
+ ENTWURF_META_RECEIVERS_DIR: receiversDir,
163
+ };
164
+
165
+ const nonce = `ENTWURF-CHAIN-${crypto.randomBytes(5).toString("hex").toUpperCase()}`;
166
+ console.error(`[smoke-entwurf-chain-live] repo: ${REPO_ROOT}`);
167
+ console.error(`[smoke-entwurf-chain-live] world: ${world}`);
168
+ console.error(`[smoke-entwurf-chain-live] hop2: ${GPT_TARGET}`);
169
+ console.error(`[smoke-entwurf-chain-live] hop3: ${ACP_TARGET}`);
170
+ console.error(`[smoke-entwurf-chain-live] nonce: ${nonce}`);
171
+
172
+ // ── D: the mailbox terminus (seeded + armed by this process, like v2-send) ──
173
+ const terminus = upsertMetaSession({
174
+ input: { backend: "claude-code", nativeSessionId: `chain-terminus-${process.pid}`, cwd: world },
175
+ dir: sessionsDir,
176
+ });
177
+ const gidD = terminus.record.gardenId;
178
+ writeMetaReceiverMarker({
179
+ gardenId: gidD,
180
+ backend: "claude-code",
181
+ nativeSessionId: terminus.record.nativeSessionId,
182
+ ownerPid: process.pid,
183
+ armProvenance: "session-start",
184
+ receiversDir,
185
+ });
186
+ console.error(`[smoke-entwurf-chain-live] D: ${gidD} (mailbox terminus)`);
187
+
188
+ const children: ChildProcess[] = [];
189
+ let streamB = "";
190
+ let streamC = "";
191
+ try {
192
+ // ── C: the ACP Claude Sonnet citizen (spawned first so B can be told its id) ──
193
+ const known = new Set((await readRecords(sessionsDir)).map((r) => r.gardenId));
194
+ const c = spawn(
195
+ "pi",
196
+ [...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", acpProvider, "--model", acpModel, "--mode", "rpc"],
197
+ { cwd: world, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...worldEnv } },
198
+ );
199
+ children.push(c);
200
+ c.stdout?.on("data", (b: Buffer) => {
201
+ streamC = (streamC + b.toString()).slice(-200_000);
202
+ });
203
+ c.stderr?.on("data", (b: Buffer) => {
204
+ streamC = (streamC + b.toString()).slice(-200_000);
205
+ });
206
+ const gidC = await waitForNewRecord(sessionsDir, "pi", known, BOOT_TIMEOUT_MS);
207
+ ok(`C birthed its own pi record (${ACP_TARGET})`, gidC !== null);
208
+ known.add(gidC as string);
209
+ ok(
210
+ "C stood up a control socket",
211
+ await waitForSocket(path.join(REAL_CONTROL_DIR, `${gidC}${SOCKET_SUFFIX}`), BOOT_TIMEOUT_MS),
212
+ );
213
+
214
+ // ── B: the pi GPT citizen ────────────────────────────────────────────────
215
+ const b = spawn(
216
+ "pi",
217
+ [...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", gptProvider, "--model", gptModel, "--mode", "rpc"],
218
+ { cwd: world, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...worldEnv } },
219
+ );
220
+ children.push(b);
221
+ b.stdout?.on("data", (x: Buffer) => {
222
+ streamB = (streamB + x.toString()).slice(-200_000);
223
+ });
224
+ b.stderr?.on("data", (x: Buffer) => {
225
+ streamB = (streamB + x.toString()).slice(-200_000);
226
+ });
227
+ const gidB = await waitForNewRecord(sessionsDir, "pi", known, BOOT_TIMEOUT_MS);
228
+ ok(`B birthed its own pi record (${GPT_TARGET})`, gidB !== null);
229
+ known.add(gidB as string);
230
+ ok(
231
+ "B stood up a control socket",
232
+ await waitForSocket(path.join(REAL_CONTROL_DIR, `${gidB}${SOCKET_SUFFIX}`), BOOT_TIMEOUT_MS),
233
+ );
234
+ ok("B, C and D are three distinct citizens", new Set([gidB, gidC, gidD]).size === 3);
235
+
236
+ // ── A: one real native Claude Code turn that opens the chain ─────────────
237
+ // The instruction each hop receives is the message the previous hop sends.
238
+ // No hop is told its own garden id — that is what makes the downstream
239
+ // sender assertions meaningful.
240
+ const hop3 = [
241
+ `${nonce} hop3. You are the third hop of a delivery chain.`,
242
+ `Call the tool mcp__entwurf-bridge__entwurf_v2 exactly once with target=${gidD}, intent=fire-and-forget,`,
243
+ `and message=${nonce} terminus. Then reply with only the tool's outcome line.`,
244
+ ].join(" ");
245
+ const hop2 = [
246
+ `${nonce} hop2. You are the second hop of a delivery chain.`,
247
+ `Call the tool mcp__entwurf-bridge__entwurf_v2 exactly once with target=${gidC}, intent=fire-and-forget,`,
248
+ "and message set to exactly the text between INNER-BEGIN and INNER-END.",
249
+ "Then reply with only the tool's outcome line.",
250
+ "INNER-BEGIN",
251
+ hop3,
252
+ "INNER-END",
253
+ ].join("\n");
254
+ const hop1Prompt = [
255
+ "You are the first hop of a delivery chain. Do exactly this and nothing else.",
256
+ `Call the tool mcp__entwurf-bridge__entwurf_v2 exactly once with target=${gidB}, intent=fire-and-forget,`,
257
+ "and message set to exactly the text between BEGIN and END.",
258
+ "Then reply with only the tool's outcome line.",
259
+ "BEGIN",
260
+ hop2,
261
+ "END",
262
+ ].join("\n");
263
+
264
+ const claudeRun = spawnSync("claude", ["-p", "--output-format=json", hop1Prompt], {
265
+ cwd: world,
266
+ encoding: "utf8",
267
+ timeout: CLAUDE_TURN_TIMEOUT_MS,
268
+ env: { ...process.env, ...worldEnv },
269
+ });
270
+ const claudeOut = `${claudeRun.stdout ?? ""}\n${claudeRun.stderr ?? ""}`;
271
+ ok("A's native Claude Code turn ran", claudeRun.status === 0);
272
+
273
+ const gidA = await waitForNewRecord(sessionsDir, "claude-code", new Set([gidD]), BOOT_TIMEOUT_MS);
274
+ ok("A was born a garden citizen by its OWN SessionStart hook (not seeded here)", gidA !== null);
275
+ ok("A is distinct from every other citizen", !new Set([gidB, gidC, gidD]).has(gidA as string));
276
+ console.error(`[smoke-entwurf-chain-live] A: ${gidA} (native Claude Code)`);
277
+
278
+ // ── the chain runs on its own from here; wait for the terminus ───────────
279
+ const boxDir = path.join(mailboxDir, gidD);
280
+ const until = Date.now() + CHAIN_TIMEOUT_MS;
281
+ let msgs: string[] = [];
282
+ while (Date.now() < until) {
283
+ msgs = (await fsp.readdir(boxDir).catch(() => [] as string[])).filter(
284
+ (f) => f.endsWith(".msg") || f.endsWith(".msg.delivered"),
285
+ );
286
+ if (msgs.length > 0) break;
287
+ await sleep(POLL_MS);
288
+ }
289
+ if (msgs.length === 0) {
290
+ console.error(`[smoke-entwurf-chain-live] A turn output:\n${claudeOut.slice(0, 1500)}`);
291
+ console.error(`[smoke-entwurf-chain-live] B stream tail:\n${streamB.slice(-2000)}`);
292
+ console.error(`[smoke-entwurf-chain-live] C stream tail:\n${streamC.slice(-2000)}`);
293
+ }
294
+ ok(`the chain reached the mailbox terminus (${msgs.length} .msg)`, msgs.length === 1);
295
+ ok("the terminus doorbell was poked", existsSync(path.join(boxDir, "inbox.signal")));
296
+
297
+ const body = await fsp.readFile(path.join(boxDir, msgs[0] as string), "utf8");
298
+
299
+ // ── hop-by-hop evidence ─────────────────────────────────────────────────
300
+ // hop 1: B saw the payload AND the real sender identity of a native
301
+ // Claude Code citizen. A never learned its own gid, so its presence in B's
302
+ // stream can only come from the envelope entwurf_v2 rendered.
303
+ ok("hop 1 — B received the chain payload", streamB.includes(nonce));
304
+ ok("hop 1 — B saw A's real garden id as the sender", streamB.includes(gidA as string));
305
+ ok("hop 1 — the sender was rendered replyable to B", /replyable/i.test(streamB));
306
+
307
+ // hop 2: C saw the payload and B's identity, so the chain did not collapse
308
+ // into A talking to C directly.
309
+ ok("hop 2 — C received the chain payload", streamC.includes(nonce));
310
+ ok("hop 2 — C saw B's real garden id as the sender", streamC.includes(gidB as string));
311
+
312
+ // hop 3: the terminus body carries C's identity, its model, and replyable.
313
+ ok("hop 3 — the terminus payload is the original nonce", body.includes(nonce));
314
+ ok("hop 3 — the terminus names C as the sender", body.includes(gidC as string));
315
+ ok(`hop 3 — the terminus names C's backend model (${acpModel})`, body.includes(acpModel));
316
+ ok("hop 3 — the terminus sender is replyable", /replyable/i.test(body));
317
+ ok(
318
+ "the terminus was not reached by a shortcut — A and B are not its sender",
319
+ !body.includes(gidA as string) && !body.includes(gidB as string),
320
+ );
321
+
322
+ // ── the read receipt: the one axis only the mailbox rail can produce ─────
323
+ // The PRODUCTION reader, not a re-implementation: reading is what stamps the
324
+ // receipt, so a hand-rolled drain would prove nothing about the real path.
325
+ const statePath = path.join(mailboxDir, gidD, "state.json");
326
+ const beforeRaw = await fsp.readFile(statePath, "utf8").catch(() => "");
327
+ const drained = readMetaInbox({ gardenId: gidD, sessionsDir, mailboxDir });
328
+ ok(
329
+ "draining the terminus returned the chain payload",
330
+ drained.messages.some((m) => m.body.includes(nonce)),
331
+ );
332
+ ok(
333
+ "the drain stamped a read receipt (lastReadAt)",
334
+ typeof drained.readAt === "string" && drained.readAt.length > 0,
335
+ );
336
+ const afterRaw = await fsp.readFile(statePath, "utf8").catch(() => "");
337
+ ok(
338
+ "the receipt is durable on disk, not just in the return value",
339
+ /lastReadAt/.test(afterRaw) && afterRaw !== beforeRaw,
340
+ );
341
+
342
+ console.error(`[smoke-entwurf-chain-live] chain: ${gidA} → ${gidB} → ${gidC} → ${gidD}`);
343
+ } finally {
344
+ for (const ch of children) await terminateChild(ch).catch(() => undefined);
345
+ }
346
+
347
+ console.log(
348
+ `[smoke-entwurf-chain-live] ok — ${passed} assertions; three harnesses linked on real authenticated rails`,
349
+ );
350
+ }
351
+
352
+ await main();
@@ -64,6 +64,7 @@ import {
64
64
  writeMetaReceiverMarker,
65
65
  } from "../pi-extensions/lib/meta-session.ts";
66
66
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
67
+ import { skipLive } from "./lib/live-skip.ts";
67
68
  import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
68
69
 
69
70
  // pi's control socket lives at the canonical dir keyed by the RECORD's garden id (#50 C4: the
@@ -130,8 +131,7 @@ function smokeSender(gardenId: string, cwd: string): SenderEnvelope {
130
131
 
131
132
  async function main(): Promise<void> {
132
133
  if (process.env.LIVE !== "1") {
133
- console.log("[smoke-entwurf-v2-matrix-live] skipped — set LIVE=1 to run (spawns a real pi + opens a real socket).");
134
- return;
134
+ skipLive("smoke-entwurf-v2-matrix-live", "set LIVE=1 to run (spawns a real pi + opens a real socket).");
135
135
  }
136
136
 
137
137
  const { provider, model } = resolveTarget();
@@ -59,6 +59,7 @@ import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
59
59
  import { resolveResumeLaunchIdentity } from "../pi-extensions/lib/entwurf-v2-spawn-production.ts";
60
60
  import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
61
61
  import { controlSocketPath } from "../pi-extensions/lib/socket-discovery.ts";
62
+ import { skipLive } from "./lib/live-skip.ts";
62
63
 
63
64
  // pi owns the canonical control-socket dir; the resume child opens its socket HERE and the
64
65
  // decider's expectedSocketPath is built from the SAME dir — they must coincide (a fresh gid
@@ -208,10 +209,7 @@ async function pollForNonce(
208
209
 
209
210
  async function main(): Promise<void> {
210
211
  if (process.env.LIVE !== "1") {
211
- console.log(
212
- "[smoke-entwurf-v2-spawn-resume-live] skipped — set LIVE=1 to run (spawns a real pi child + opens a real socket).",
213
- );
214
- return;
212
+ skipLive("smoke-entwurf-v2-spawn-resume-live", "set LIVE=1 to run (spawns a real pi child + opens a real socket).");
215
213
  }
216
214
 
217
215
  const { provider, model } = resolveTarget();
@@ -87,6 +87,10 @@ case "$1${2:+ $2}" in
87
87
  "mcp get")
88
88
  printf '%s\n' "Scope: User config" "Status: ✔ Connected"
89
89
  [ "${FAKE_MCP_RC:-0}" != 0 ] && exit "$FAKE_MCP_RC"
90
+ # Give grep -q a scheduling window to consume the match and close the pipe
91
+ # before the long tail starts. Without this, a fast host can buffer the whole
92
+ # tail before grep exits, making the old broken probe spuriously green.
93
+ sleep 0.05
90
94
  # Keep writing well past the pipe buffer: a reader that leaves early SIGPIPEs us.
91
95
  cat "$FAKE_MCP_TAIL"
92
96
  exit $? ;;