@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
@@ -0,0 +1,105 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "release-gate",
4
+ "mutants": [
5
+ {
6
+ "claim": "SKIP-EXIT-ONE-PROTOCOL",
7
+ "title": "the shell and TS halves name different skip codes — every honest skip silently reclassifies, and the summary says nothing",
8
+ "subject": "scripts/lib/step-outcome.sh",
9
+ "find": ["ENTWURF_STEP_SKIP_EXIT=97"],
10
+ "replace": ["ENTWURF_STEP_SKIP_EXIT=96"],
11
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
12
+ "timeoutSeconds": 180,
13
+ "signature": "[QK:SKIP-EXIT-ONE-PROTOCOL]",
14
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
15
+ },
16
+ {
17
+ "claim": "STEP-OUTCOME-SKIP-NOT-PASS",
18
+ "title": "the classifier rounds a skip up to PASS — the pre-P1 defect restored at its source: a step that declined is reported as acceptance",
19
+ "subject": "scripts/lib/step-outcome.sh",
20
+ "find": ["\t\t\"$ENTWURF_STEP_SKIP_EXIT\") printf 'SKIP\\n' ;;"],
21
+ "replace": ["\t\t\"$ENTWURF_STEP_SKIP_EXIT\") printf 'PASS\\n' ;;"],
22
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
23
+ "timeoutSeconds": 180,
24
+ "signature": "[QK:STEP-OUTCOME-SKIP-NOT-PASS]",
25
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
26
+ },
27
+ {
28
+ "claim": "CUT-REFUSES-SKIP",
29
+ "title": "--cut stops refusing a MUST SKIP — the release procedure goes back to prose and a 14-SKIP run can be quoted as acceptance",
30
+ "subject": "scripts/lib/step-outcome.sh",
31
+ "find": ["\tif [ \"$cut\" = \"1\" ] && [ \"$skipc\" -gt 0 ]; then"],
32
+ "replace": ["\tif [ \"$cut\" = \"1\" ] && [ \"$skipc\" -lt 0 ]; then"],
33
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
34
+ "timeoutSeconds": 180,
35
+ "signature": "[QK:CUT-REFUSES-SKIP]",
36
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
37
+ },
38
+ {
39
+ "claim": "NO-SMOKE-SKIPS-WITH-ZERO",
40
+ "title": "one LIVE smoke goes back to exiting 0 on its skip — the hole re-opens for that lane alone, which is how it survived unnoticed",
41
+ "subject": "scripts/smoke-acp-mcp-live.ts",
42
+ "find": ["\tskipLive(\"smoke-acp-mcp-live\", \"set LIVE=1 to run the real pi provider turn.\");"],
43
+ "replace": [
44
+ "\tconsole.error(\"[smoke-acp-mcp-live] skipped — set LIVE=1 to run the real pi provider turn.\");",
45
+ "\tprocess.exit(0);"
46
+ ],
47
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
48
+ "timeoutSeconds": 180,
49
+ "signature": "[QK:NO-SMOKE-SKIPS-WITH-ZERO]",
50
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
51
+ },
52
+ {
53
+ "claim": "LIVE-SKIP-IS-PROTOCOL-EXIT",
54
+ "title": "skipLive exits 0 — every smoke still LOOKS converted (imports, no old shape) while the aggregate reads all of them as PASS",
55
+ "subject": "scripts/lib/live-skip.ts",
56
+ "find": ["\tprocess.exit(LIVE_SKIP_EXIT);"],
57
+ "replace": ["\tprocess.exit(0);"],
58
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
59
+ "timeoutSeconds": 180,
60
+ "signature": "[QK:LIVE-SKIP-IS-PROTOCOL-EXIT]",
61
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
62
+ },
63
+ {
64
+ "claim": "WRAPPER-SKIP-IS-PROTOCOL-EXIT",
65
+ "title": "a run.sh smoke wrapper returns 0 on a missing prerequisite — the second skip surface, where cortex/matrix/spawn decline before their .ts is ever reached",
66
+ "subject": "run.sh",
67
+ "find": [
68
+ " echo \"[entwurf:skip] smoke-acp-cortex-live — set LIVE=1 (+ ENTWURF_ACP_CORTEX_CONNECTION) to run.\"",
69
+ " return \"$ENTWURF_STEP_SKIP_EXIT\""
70
+ ],
71
+ "replace": [
72
+ " echo \"[entwurf:skip] smoke-acp-cortex-live — set LIVE=1 (+ ENTWURF_ACP_CORTEX_CONNECTION) to run.\"",
73
+ " return 0"
74
+ ],
75
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
76
+ "timeoutSeconds": 180,
77
+ "signature": "[QK:WRAPPER-SKIP-IS-PROTOCOL-EXIT]",
78
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
79
+ },
80
+ {
81
+ "claim": "CUT-VERDICT-NAMES-CAUSE",
82
+ "title": "a cut blocked only by SKIP reports itself as a FAIL block — the record can no longer tell a broken call from an absent prerequisite",
83
+ "subject": "scripts/lib/step-outcome.sh",
84
+ "find": ["\t\tprintf 'cut: BLOCKED (MUST SKIP)\\n'"],
85
+ "replace": ["\t\tprintf 'cut: BLOCKED (MUST FAIL)\\n'"],
86
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
87
+ "timeoutSeconds": 180,
88
+ "signature": "[QK:CUT-VERDICT-NAMES-CAUSE]",
89
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
90
+ },
91
+ {
92
+ "claim": "NO-SILENT-AGGREGATE-OMISSION",
93
+ "title": "the cross-harness delivery chain drops out of the aggregate — a green cut goes back to saying nothing about multi-harness delivery, with no doc stating the exclusion",
94
+ "subject": "run.sh",
95
+ "find": [
96
+ " run_live_step \"smoke-entwurf-chain-live (P3: Claude Code -> pi GPT -> pi ACP Sonnet -> mailbox, identity + receipt)\" gate bash \"$self\" smoke-entwurf-chain-live"
97
+ ],
98
+ "replace": [" : # the chain quietly dropped from the aggregate"],
99
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
100
+ "timeoutSeconds": 180,
101
+ "signature": "[QK:NO-SILENT-AGGREGATE-OMISSION]",
102
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
103
+ }
104
+ ]
105
+ }
@@ -7,16 +7,13 @@
7
7
  "title": "MCP long description reverts to the false \"socket paths take a per-target lock\" claim",
8
8
  "subject": "mcp/entwurf-bridge/src/index.ts",
9
9
  "find": [
10
- "\t\t\"(delivered / rejected / lock-retained / delivered-but-lock-dirty). LOCK POLICY (do not \" +",
11
- "\t\t\"over-generalize it): the per-target lock is taken for a control-socket-DOMAIN dispatch, which is \" +",
12
- "\t\t\"both the live send AND the dormant cell's spawn-bg resume — spawn-bg is a separate relaunch \" +",
13
- "\t\t\"transport yet it still runs under that domain's lock. The mailbox and native-push rails are \" +",
14
- "\t\t\"lock-free: the mailbox is guarded instead by active-receiver deliverability, and native-push by \" +",
15
- "\t\t\"its adapter probe. The decider — not the \" +"
10
+ "\t\t\"native-push-no-resume-authority. LOCK: taken for a control-socket-DOMAIN dispatch — the live send AND \" +",
11
+ "\t\t\"the dormant cell's spawn-bg resume, a separate transport that still runs under that domain's lock. The \" +",
12
+ "\t\t\"mailbox and native-push rails are lock-free — deliverability and the adapter probe guard them. mode \" +"
16
13
  ],
17
14
  "replace": [
18
- "\t\t\"(delivered / rejected / lock-retained / delivered-but-lock-dirty). LOCK POLICY: socket paths \" +",
19
- "\t\t\"take a per-target lock; the mailbox path does not. The decider — not the \" +"
15
+ "\t\t\"native-push-no-resume-authority. LOCK POLICY: socket paths take a per-target lock; the mailbox path \" +",
16
+ "\t\t\"does not. mode \" +"
20
17
  ],
21
18
  "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
22
19
  "timeoutSeconds": 60,
@@ -68,20 +65,34 @@
68
65
  "title": "MCP long description merges the two owned rejects back under backend-liveness-unsupported",
69
66
  "subject": "mcp/entwurf-bridge/src/index.ts",
70
67
  "find": [
71
- "\t\t\"owned-outcome is ONLY for waking a DORMANT socket-domain citizen (spawn-bg resume, currently \" +",
72
- "\t\t\"backend pi); on a live target it is rejected as owned-live-no-autosend. Neither self-fetch nor \" +",
73
- "\t\t\"native-push has resume authority, but they reject under DIFFERENT reasons — self-fetch as \" +",
74
- "\t\t\"backend-liveness-unsupported, native-push as native-push-no-resume-authority. \" +"
68
+ "\t\t\"owned-outcome wakes a DORMANT socket-domain citizen by spawn-bg resume ONLY — live target → \" +",
69
+ "\t\t\"owned-live-no-autosend, self-fetch → backend-liveness-unsupported, native-push → \" +",
70
+ "\t\t\"native-push-no-resume-authority. LOCK: taken for a control-socket-DOMAIN dispatch — the live send AND \" +"
75
71
  ],
76
72
  "replace": [
77
- "\t\t\"owned-outcome is ONLY for waking a DORMANT socket-domain citizen (spawn-bg resume, currently \" +",
78
- "\t\t\"backend pi); on a live target it is rejected as owned-live-no-autosend, and on self-fetch and \" +",
79
- "\t\t\"native-push alike it is rejected as backend-liveness-unsupported. \" +"
73
+ "\t\t\"owned-outcome wakes a DORMANT socket-domain citizen by spawn-bg resume ONLY — live target → \" +",
74
+ "\t\t\"owned-live-no-autosend, and on self-fetch and native-push alike it is rejected as \" +",
75
+ "\t\t\"backend-liveness-unsupported. LOCK: taken for a control-socket-DOMAIN dispatch — the live send AND \" +"
80
76
  ],
81
77
  "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
82
78
  "timeoutSeconds": 60,
83
79
  "signature": "[QK:V2SURF-MERGED-REJECT]",
84
80
  "signatureSource": "scripts/check-entwurf-v2-surface.ts"
81
+ },
82
+ {
83
+ "claim": "V2SURF-DESC-FITS-HOST-CAP",
84
+ "title": "the v2 tool description grows back past the host's 2048-char cap — the INTENT contract is silently cut off before the model that must choose an intent ever reads it",
85
+ "subject": "mcp/entwurf-bridge/src/index.ts",
86
+ "find": [
87
+ "\t\"CANONICAL DELIVERY SURFACE for garden ids: message, reply, or hand off to whoever an id names. The id \" +"
88
+ ],
89
+ "replace": [
90
+ "\t\"CANONICAL DELIVERY SURFACE for garden ids: message, reply, or hand off to whoever an id names. The id \" +\n\t\t\"PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING \" +\n\t\t\"PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING PADDING \" +"
91
+ ],
92
+ "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
93
+ "timeoutSeconds": 60,
94
+ "signature": "[QK:V2SURF-DESC-FITS-HOST-CAP]",
95
+ "signatureSource": "scripts/check-entwurf-v2-surface.ts"
85
96
  }
86
97
  ]
87
98
  }
@@ -52,6 +52,7 @@ import * as readline from "node:readline";
52
52
  import { fileURLToPath } from "node:url";
53
53
  import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensions/lib/entwurf-control-rpc.ts";
54
54
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
55
+ import { skipLive } from "./lib/live-skip.ts";
55
56
  import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
56
57
 
57
58
  const ACP_PROVIDER = "entwurf";
@@ -160,10 +161,10 @@ function driveSelfTurn(child: ChildProcess, prompt: string): Promise<TurnCapture
160
161
 
161
162
  async function main(): Promise<void> {
162
163
  if (process.env.LIVE !== "1") {
163
- console.log(
164
- "[smoke-acp-bundled-mcp-live] skipped — set LIVE=1 to run (spawns a real pi --entwurf-control resident + drives one model turn).",
164
+ skipLive(
165
+ "smoke-acp-bundled-mcp-live",
166
+ "set LIVE=1 to run (spawns a real pi --entwurf-control resident + drives one model turn).",
165
167
  );
166
- return;
167
168
  }
168
169
 
169
170
  const tmp = os.tmpdir();
@@ -45,6 +45,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
45
45
  import { tmpdir } from "node:os";
46
46
  import { dirname, join, resolve } from "node:path";
47
47
  import { fileURLToPath } from "node:url";
48
+ import { skipLive } from "./lib/live-skip.ts";
48
49
 
49
50
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
50
51
  const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -57,8 +58,7 @@ function fail(msg: string): never {
57
58
  }
58
59
 
59
60
  if (process.env.LIVE !== "1") {
60
- console.error("[smoke-acp-carrier-augment-live] skipped — set LIVE=1 to run the real pi provider turn.");
61
- process.exit(0);
61
+ skipLive("smoke-acp-carrier-augment-live", "set LIVE=1 to run the real pi provider turn.");
62
62
  }
63
63
 
64
64
  // Billing-reclassification canaries (핀1). The empty carrier must NOT trip metered
@@ -0,0 +1,386 @@
1
+ // CP2 LIVE — the cortex ACP citizen path, end to end: a REAL `cortex acp serve`
2
+ // child behind the entwurf provider carries a model to outbound sibling
3
+ // dispatch, under the dual-HOME overlay, and is reclaimed by process-group
4
+ // teardown afterwards.
5
+ //
6
+ // LIVE=1 ENTWURF_ACP_CORTEX_CONNECTION=<conn> ./run.sh smoke-acp-cortex-live
7
+ //
8
+ // WHY this exact axis. The CP0 audit (docs/acp-backend-rail.md, Cortex D1–D10) measured
9
+ // three things no offline gate can prove:
10
+ // - D9/D10: cortex ignores the wire `mcpServers` param, so the bundled
11
+ // entwurf-bridge reaches a cortex session ONLY through the overlay-private
12
+ // mcp.json projection — and only the dual-HOME env restore keeps the bridge
13
+ // child's garden store real (an isolated-HOME bridge saw "(none)" citizens).
14
+ // The CP0 LIVE run proved tool exposure + entwurf_peers; the outbound
15
+ // `entwurf_v2` send was DELIBERATELY not exercised there ("흉내내지 않았다")
16
+ // and is owed to THIS smoke.
17
+ // - CP0-M: set-model is accepted live, but "the prompt actually runs on the
18
+ // selected model" needed a model turn — this smoke drives one.
19
+ // - teardown: an MCP-configured cortex child does NOT exit on stdin EOF
20
+ // (measured twice); only process-group signalling reclaims it. This smoke
21
+ // asserts the reclaim actually happens after the resident dies.
22
+ //
23
+ // METHOD — mirrors smoke-acp-v2-send-live: seed an isolated garden world (store
24
+ // + mailbox + receivers) with ONE armed self-fetch receiver, launch a real
25
+ // `pi --entwurf-control --mode rpc` resident on entwurf/cortex-claude-sonnet-5
26
+ // with that world in env, drive one turn telling the model to call
27
+ // `mcp__entwurf-bridge__entwurf_v2` at the seeded receiver with a nonce, then
28
+ // assert ON DISK: the landed .msg, the envelope naming the resident's own gid +
29
+ // entwurf/<cortex model>, the overlay scope dir + its mcp.json projection, and
30
+ // (after teardown) that no process still lives inside this run's overlay.
31
+ //
32
+ // LIVE-only and OUTSIDE the claude-only AGGREGATE release floor (capability
33
+ // dignity): a host that runs no cortex must not redden a claude release. That is
34
+ // a wiring decision — shipping cortex still owes a deliberate run of this smoke,
35
+ // and the aggregate gate's silence is not a pass (ACP rail verification boundary).
36
+ // Honest skip when LIVE!=1, when `cortex` is not on PATH, or when
37
+ // ENTWURF_ACP_CORTEX_CONNECTION is unset — the overlay denies the operator's
38
+ // cortex settings.json (where a default connection would live), so the
39
+ // connection must arrive through the adapter's own env/settings seam, and
40
+ // running without one would measure a config hole, not the rail. This gate
41
+ // touches the real host exactly where LIVE gates may: the operator's real
42
+ // cortex auth (read-only through symlinks) and ~/.pi/agent/cortex-overlays.
43
+ //
44
+ // Model override: ENTWURF_ACP_CORTEX_MODEL (default cortex-claude-sonnet-5).
45
+
46
+ import { type ChildProcess, spawn } from "node:child_process";
47
+ import * as crypto from "node:crypto";
48
+ import { existsSync } from "node:fs";
49
+ import * as fsp from "node:fs/promises";
50
+ import * as os from "node:os";
51
+ import * as path from "node:path";
52
+ import * as readline from "node:readline";
53
+ import { fileURLToPath } from "node:url";
54
+ import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
55
+ import { terminateChild } from "./lib/acp-child-cleanup.ts";
56
+ import { skipLive } from "./lib/live-skip.ts";
57
+ import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
58
+
59
+ const ACP_PROVIDER = "entwurf";
60
+ const ACP_MODEL = process.env.ENTWURF_ACP_CORTEX_MODEL?.trim() || "cortex-claude-sonnet-5";
61
+
62
+ const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
63
+ const CORTEX_OVERLAYS_ROOT = path.join(os.homedir(), ".pi", "agent", "cortex-overlays");
64
+ const SOCKET_SUFFIX = ".sock";
65
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
66
+ // Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
67
+ const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
68
+
69
+ const BOOT_TIMEOUT_MS = 30_000;
70
+ // The cortex CLI self-extracts on first launch and its newSession alone is
71
+ // ~2.5 s; keep the generous default of the sibling smoke.
72
+ const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_CORTEX_TIMEOUT_MS) || 300_000;
73
+ const RECLAIM_TIMEOUT_MS = 20_000;
74
+ const POLL_MS = 100;
75
+
76
+ let passed = 0;
77
+ function ok(label: string, cond: boolean): void {
78
+ if (!cond) throw new Error(`SMOKE FAIL: ${label}`);
79
+ console.log(` ok ${label}`);
80
+ passed++;
81
+ }
82
+
83
+ async function writeFailureArtifact(cap: { stream: string } | null, stderrTail: string, err: unknown): Promise<void> {
84
+ try {
85
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
86
+ const file = path.join(os.tmpdir(), `entwurf-smoke-acp-cortex-live-FAIL-${stamp}.log`);
87
+ await fsp.writeFile(
88
+ file,
89
+ `# smoke-acp-cortex-live FAILURE\n# ${err instanceof Error ? err.message : String(err)}\n` +
90
+ `# model: ${ACP_PROVIDER}/${ACP_MODEL}\n\n` +
91
+ `## event stream\n${cap?.stream ?? "(no turn captured)"}\n\n` +
92
+ `## resident stderr tail\n${stderrTail || "(empty)"}\n`,
93
+ "utf8",
94
+ );
95
+ console.error(`[smoke-acp-cortex-live] FAILURE transcript: ${file}`);
96
+ } catch {
97
+ console.error("[smoke-acp-cortex-live] could not write the failure transcript (reporting the original error)");
98
+ }
99
+ }
100
+
101
+ function sleep(ms: number): Promise<void> {
102
+ return new Promise((resolve) => setTimeout(resolve, ms));
103
+ }
104
+
105
+ async function waitForSocket(sockPath: string, timeoutMs: number): Promise<boolean> {
106
+ const deadline = Date.now() + timeoutMs;
107
+ while (Date.now() < deadline) {
108
+ if (existsSync(sockPath)) return true;
109
+ await sleep(POLL_MS);
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** pids (other than ours) whose environ carries `needle` — the overlay scope
115
+ * path is unique per run, so this finds exactly the cortex child + its
116
+ * grandchildren, never an unrelated operator cortex. Foreign-user /proc
117
+ * entries EACCES-skip (they cannot be our children). */
118
+ async function pidsWithEnvNeedle(needle: string): Promise<number[]> {
119
+ const hits: number[] = [];
120
+ const entries = await fsp.readdir("/proc").catch(() => [] as string[]);
121
+ for (const entry of entries) {
122
+ if (!/^\d+$/.test(entry)) continue;
123
+ const pid = Number(entry);
124
+ if (pid === process.pid) continue;
125
+ try {
126
+ const environ = await fsp.readFile(path.join("/proc", entry, "environ"), "utf8");
127
+ if (environ.includes(needle)) hits.push(pid);
128
+ } catch {
129
+ // gone or not ours — either way not a leak we own
130
+ }
131
+ }
132
+ return hits;
133
+ }
134
+
135
+ interface TurnCapture {
136
+ agentStartSeen: boolean;
137
+ agentEndSeen: boolean;
138
+ extensionErrors: Array<{ path: unknown; event: unknown; error: unknown }>;
139
+ promptAccepted: boolean;
140
+ stream: string;
141
+ }
142
+
143
+ // Drive exactly one model turn over the resident's stdin RPC and capture the
144
+ // stdout event stream until `agent_end` (or the hard turn timeout).
145
+ function driveTurn(child: ChildProcess, prompt: string): Promise<TurnCapture> {
146
+ return new Promise((resolve) => {
147
+ const cap: TurnCapture = {
148
+ agentStartSeen: false,
149
+ agentEndSeen: false,
150
+ extensionErrors: [],
151
+ promptAccepted: false,
152
+ stream: "",
153
+ };
154
+ let settled = false;
155
+ const finish = (): void => {
156
+ if (settled) return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ rl.close();
160
+ resolve(cap);
161
+ };
162
+ const timer = setTimeout(finish, TURN_TIMEOUT_MS);
163
+ child.once("exit", finish);
164
+
165
+ const rl = readline.createInterface({ input: child.stdout! });
166
+ rl.on("line", (line: string) => {
167
+ const trimmed = line.trim();
168
+ if (!trimmed) return;
169
+ cap.stream += `${trimmed}\n`;
170
+ let evt: Record<string, unknown>;
171
+ try {
172
+ evt = JSON.parse(trimmed) as Record<string, unknown>;
173
+ } catch {
174
+ return;
175
+ }
176
+ if (evt.type === "agent_start") cap.agentStartSeen = true;
177
+ if (evt.type === "extension_error") {
178
+ cap.extensionErrors.push({ path: evt.extensionPath, event: evt.event, error: evt.error });
179
+ }
180
+ if (evt.type === "response" && evt.command === "prompt") cap.promptAccepted = evt.success === true;
181
+ if (evt.type === "agent_end") {
182
+ cap.agentEndSeen = true;
183
+ finish();
184
+ }
185
+ });
186
+
187
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", message: prompt, id: "cortexv2" })}\n`);
188
+ });
189
+ }
190
+
191
+ async function main(): Promise<void> {
192
+ if (process.env.LIVE !== "1") {
193
+ skipLive("smoke-acp-cortex-live", "set LIVE=1 to run (spawns a real cortex ACP resident + one model turn).");
194
+ }
195
+ const connection = process.env.ENTWURF_ACP_CORTEX_CONNECTION?.trim();
196
+ if (!connection) {
197
+ skipLive(
198
+ "smoke-acp-cortex-live",
199
+ "set ENTWURF_ACP_CORTEX_CONNECTION=<snowflake connection> (the dual-HOME overlay denies the operator cortex settings.json, so the connection must ride the adapter's own seam).",
200
+ );
201
+ }
202
+ if ("CORTEX_HOME" in process.env) {
203
+ skipLive("smoke-acp-cortex-live", "unset CORTEX_HOME first (the adapter refuses its presence, CP0 D3).");
204
+ }
205
+
206
+ // One temp root holding the whole garden this smoke can see: store, mailbox
207
+ // and receiver markers. Only these move — the operator's real cortex auth
208
+ // (via overlay symlinks) and the control dir stay real.
209
+ const world = await fsp.mkdtemp(path.join(os.tmpdir(), "acp-cortex-"));
210
+ const sessionsDir = path.join(world, "sessions");
211
+ const mailboxDir = path.join(world, "mailbox");
212
+ const receiversDir = path.join(world, "receivers");
213
+ for (const d of [sessionsDir, mailboxDir, receiversDir]) await fsp.mkdir(d, { recursive: true });
214
+
215
+ const nonce = `CORTEXV2-${crypto.randomBytes(5).toString("hex").toUpperCase()}`;
216
+
217
+ console.error(`[smoke-acp-cortex-live] repo: ${REPO_ROOT}`);
218
+ console.error(`[smoke-acp-cortex-live] model: ${ACP_PROVIDER}/${ACP_MODEL} (connection ${connection})`);
219
+ console.error(`[smoke-acp-cortex-live] world: ${world}`);
220
+
221
+ // ── the peer: an armed self-fetch citizen, deliverable by the mailbox rail ──
222
+ const receiver = upsertMetaSession({
223
+ input: { backend: "claude-code", nativeSessionId: `acp-cortex-${process.pid}`, cwd: world },
224
+ dir: sessionsDir,
225
+ });
226
+ const receiverGid = receiver.record.gardenId;
227
+ writeMetaReceiverMarker({
228
+ gardenId: receiverGid,
229
+ backend: "claude-code",
230
+ nativeSessionId: receiver.record.nativeSessionId,
231
+ ownerPid: process.pid,
232
+ armProvenance: "session-start",
233
+ receiversDir,
234
+ });
235
+ console.error(`[smoke-acp-cortex-live] peer: ${receiverGid} (armed mailbox receiver)`);
236
+
237
+ let senderGid = "";
238
+ let stderrTail = "";
239
+ let resident: ChildProcess | null = null;
240
+ let cap: TurnCapture | null = null;
241
+ let overlayScopeDir = "";
242
+ try {
243
+ resident = spawn(
244
+ "pi",
245
+ [...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", ACP_PROVIDER, "--model", ACP_MODEL, "--mode", "rpc"],
246
+ {
247
+ cwd: world,
248
+ stdio: ["pipe", "pipe", "pipe"],
249
+ detached: false,
250
+ env: {
251
+ ...process.env,
252
+ ENTWURF_META_SESSIONS_DIR: sessionsDir,
253
+ ENTWURF_META_MAILBOX_DIR: mailboxDir,
254
+ ENTWURF_META_RECEIVERS_DIR: receiversDir,
255
+ },
256
+ },
257
+ );
258
+ resident.stderr?.on("data", (b: Buffer) => {
259
+ stderrTail = (stderrTail + b.toString()).slice(-4000);
260
+ });
261
+
262
+ const bornGid = await waitForPiRecord(sessionsDir, BOOT_TIMEOUT_MS);
263
+ ok(`cortex-model resident birthed its own V3 record (${ACP_PROVIDER}/${ACP_MODEL})`, bornGid !== null);
264
+ senderGid = bornGid as string;
265
+ console.error(`[smoke-acp-cortex-live] sender:${senderGid} (never told to the model)`);
266
+ ok("sender and receiver are distinct citizens", senderGid !== receiverGid);
267
+ const sockPath = path.join(REAL_CONTROL_DIR, `${senderGid}${SOCKET_SUFFIX}`);
268
+ ok(
269
+ "resident stood up a control socket keyed on its record gardenId",
270
+ await waitForSocket(sockPath, BOOT_TIMEOUT_MS),
271
+ );
272
+
273
+ // The prompt names the TARGET but never the sender's own gid — that can
274
+ // only reach the mailbox via the real envelope the projection carried.
275
+ const prompt =
276
+ `Call the mcp__entwurf-bridge__entwurf_v2 tool exactly once with these arguments: ` +
277
+ `target="${receiverGid}", intent="fire-and-forget", message="${nonce}". ` +
278
+ `Then reply with ONLY the tool's outcome line.`;
279
+ cap = await driveTurn(resident, prompt);
280
+
281
+ ok("the prompt RPC command was accepted", cap.promptAccepted);
282
+ ok("a real model turn ran over the stdin RPC (agent_start)", cap.agentStartSeen);
283
+ ok("the turn completed cleanly over RPC (agent_end — no hang)", cap.agentEndSeen);
284
+ ok("no extension_error during the turn", cap.extensionErrors.length === 0);
285
+
286
+ // ── the dual-HOME overlay is a disk fact, not an implementation detail ──
287
+ // scope id = `<resident pid>-<hash>` (the resident hosts the ACP spawn).
288
+ const scopeEntries = (await fsp.readdir(CORTEX_OVERLAYS_ROOT).catch(() => [] as string[])).filter((e) =>
289
+ e.startsWith(`${resident?.pid}-`),
290
+ );
291
+ ok(
292
+ `the resident materialized exactly one overlay scope dir (got ${scopeEntries.length})`,
293
+ scopeEntries.length === 1,
294
+ );
295
+ overlayScopeDir = path.join(CORTEX_OVERLAYS_ROOT, scopeEntries[0] as string);
296
+ const ovlCortex = path.join(overlayScopeDir, "home", ".snowflake", "cortex");
297
+ const ovlSettings = JSON.parse(await fsp.readFile(path.join(ovlCortex, "settings.json"), "utf8"));
298
+ ok("the overlay pinned autoUpdate:false (D4)", ovlSettings.autoUpdate === false);
299
+ const ovlMcp = JSON.parse(await fsp.readFile(path.join(ovlCortex, "mcp.json"), "utf8"));
300
+ const bridge = ovlMcp.mcpServers?.["entwurf-bridge"];
301
+ ok("the overlay mcp.json projected the entwurf-bridge entry (D9)", Boolean(bridge));
302
+ ok("the projected bridge entry restored the REAL operator HOME (D10 dual-HOME)", bridge.env?.HOME === os.homedir());
303
+ ok(
304
+ "the projected bridge entry carries the live PI_SESSION_ID envelope",
305
+ typeof bridge.env?.PI_SESSION_ID === "string" && bridge.env.PI_SESSION_ID.length > 0,
306
+ );
307
+ ok(
308
+ `the projected bridge entry carries PI_AGENT_ID=entwurf/${ACP_MODEL}`,
309
+ bridge.env?.PI_AGENT_ID === `${ACP_PROVIDER}/${ACP_MODEL}`,
310
+ );
311
+
312
+ // ── the outbound proof is on disk, not in the transcript ──
313
+ const boxDir = path.join(mailboxDir, receiverGid);
314
+ const entries = await fsp.readdir(boxDir).catch(() => [] as string[]);
315
+ const msgs = entries.filter((f) => f.endsWith(".msg"));
316
+ ok(`the model's entwurf_v2 call enqueued exactly one .msg to the peer (got ${msgs.length})`, msgs.length === 1);
317
+ ok("the doorbell inbox.signal was poked", existsSync(path.join(boxDir, "inbox.signal")));
318
+
319
+ const body = await fsp.readFile(path.join(boxDir, msgs[0] as string), "utf8");
320
+ const SEPARATOR = "────────────────────────────────────────";
321
+ const sepAt = body.indexOf(`\n${SEPARATOR}\n`);
322
+ ok("the delivered body carries the rendered envelope separator", sepAt !== -1);
323
+ const header = body.slice(0, sepAt + 1);
324
+ const payload = body.slice(sepAt + 1 + SEPARATOR.length + 1);
325
+
326
+ const rx = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
327
+ ok("the payload is EXACTLY the nonce (no envelope text folded into it)", payload === `${nonce}\n`);
328
+ ok(
329
+ "the landed sender agentId is the cortex ACP model (anchored envelope line)",
330
+ new RegExp(`^ {2}from: {8}${rx(ACP_PROVIDER)}/${rx(ACP_MODEL)} @ \\S`, "m").test(header),
331
+ );
332
+ ok(
333
+ "the landed sender session IS the resident's own garden id, replyable (anchored envelope line)",
334
+ new RegExp(
335
+ `^ {2}session: {5}${rx(senderGid)} \\(replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget\\)$`,
336
+ "m",
337
+ ).test(header),
338
+ );
339
+ ok("the sender gid appears ONLY in the envelope, never in the payload", !payload.includes(senderGid));
340
+ ok("the landed sender renders as a pi-session, not a meta-session", !header.includes("(meta-session"));
341
+
342
+ // ── process-group reclaim (CP0 teardown finding) ──
343
+ // While the resident lives, its retained cortex child inhabits this run's
344
+ // overlay (SNOWFLAKE_HOME env is unique per scope). An MCP-configured
345
+ // cortex child ignores stdin EOF, so only the resident's process-group
346
+ // teardown can reclaim it — kill the resident and require extinction.
347
+ const liveBefore = await pidsWithEnvNeedle(overlayScopeDir);
348
+ ok(
349
+ `the cortex child (group) is alive inside this run's overlay pre-teardown (got ${liveBefore.length})`,
350
+ liveBefore.length > 0,
351
+ );
352
+ await terminateChild(resident);
353
+ resident = null;
354
+ let leaked: number[] = [];
355
+ const deadline = Date.now() + RECLAIM_TIMEOUT_MS;
356
+ for (;;) {
357
+ leaked = await pidsWithEnvNeedle(overlayScopeDir);
358
+ if (leaked.length === 0 || Date.now() > deadline) break;
359
+ await sleep(250);
360
+ }
361
+ ok(
362
+ `process-group teardown reclaimed every process in this run's overlay (leaked: ${leaked.join(",") || "none"})`,
363
+ leaked.length === 0,
364
+ );
365
+ } catch (err) {
366
+ await writeFailureArtifact(cap, stderrTail, err);
367
+ throw err;
368
+ } finally {
369
+ if (resident) await terminateChild(resident);
370
+ if (overlayScopeDir) await fsp.rm(overlayScopeDir, { recursive: true, force: true }).catch(() => {});
371
+ if (process.env.ENTWURF_KEEP_SMOKE_WORLD === "1") {
372
+ console.error(`[smoke-acp-cortex-live] kept world: ${world}`);
373
+ } else {
374
+ await fsp.rm(world, { recursive: true, force: true }).catch(() => {});
375
+ }
376
+ }
377
+
378
+ if (stderrTail && process.env.ENTWURF_SMOKE_VERBOSE === "1") {
379
+ console.error(`[smoke-acp-cortex-live] resident stderr tail:\n${stderrTail}`);
380
+ }
381
+ console.log(
382
+ `smoke-acp-cortex-live: PASS (${passed} assertions) — a cortex ACP model delivered to a peer as itself and was reclaimed`,
383
+ );
384
+ }
385
+
386
+ await main();