@junghanacs/entwurf 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -98,13 +98,17 @@ PERMISSION_STATE_SCHEMA_VERSION = 2
98
98
  # EVERY call. The bridge was registered and two thirds of it was unusable — the same failure the
99
99
  # doctor already names for entwurf_v2, just never checked for the other two.
100
100
  #
101
- # The two tools deliberately NOT here, because a grant we do not need is a grant we should not take:
101
+ # The four tools deliberately NOT here, because a grant we do not need is a grant we should not take:
102
102
  # entwurf_inbox_read — native-push has no inbox to drain. An agy citizen has no meta-mailbox
103
103
  # directory at all (measured), so granting it would pre-approve a rail this
104
104
  # backend does not have.
105
105
  # entwurf_register_native — explicit/manual fallback, not the normal birth path (DELIVERY.md):
106
106
  # agy births automatically from PreInvocation. Auto-approving a registration
107
107
  # verb the normal path never calls is exactly the excess this list avoids.
108
+ # entwurf_fresh_call / entwurf_resume_call — both launch into the CALLER's own tmux session and
109
+ # refuse without a pane anchor. An agy conversation is not a tmux client,
110
+ # so these are not on its normal path either; the count moved from two to
111
+ # four when 0.14 shipped the mux verbs, and the granted set did not.
108
112
  ALLOW_RULES = tuple(f"mcp({SERVER_KEY}/{tool})" for tool in ("entwurf_v2", "entwurf_peers", "entwurf_self"))
109
113
  # Rules that MATCH one of our tools: its exact grant, the server-wide rule, and the action wildcard.
110
114
  # Membership here is a statement about agy's matcher, not about which list the rule sits in — the
@@ -6,7 +6,7 @@
6
6
  // exclude a built-in the Claude child still exposes (`read`); the turn must fail
7
7
  // fast into the returned stream as an `error` event — never reach a spawn, never
8
8
  // emit `done`. No live backend is launched (the preflight throws first), so this
9
- // stays deterministic and IN pnpm check.
9
+ // stays deterministic and IN pnpm run check:full.
10
10
  //
11
11
  // backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
12
12
  // convention), which `node --experimental-strip-types` cannot resolve directly.
@@ -15,7 +15,7 @@
15
15
  // Pure + temp-dir fs, no spawn/child — IN pnpm check.
16
16
 
17
17
  import { strict as assert } from "node:assert";
18
- import { mkdtempSync, writeFileSync } from "node:fs";
18
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
@@ -38,6 +38,11 @@ const BRIDGE_MARK = "operating through entwurf";
38
38
 
39
39
  // ===========================================================================
40
40
  // 1) loadEngraving is pure/deterministic + interpolates backend/mcp (sorted)
41
+ //
42
+ // This cell owns the BODY of the render. The carrier's leading boundary is cell
43
+ // 11's axis, so the body assertions here match on the tail — a cell that pinned
44
+ // the whole string would fire first on any boundary regression and steal the
45
+ // attribution from the claim that actually owns it.
41
46
  // ===========================================================================
42
47
  {
43
48
  const file = join(tmp, "engraving.md");
@@ -48,16 +53,15 @@ const BRIDGE_MARK = "operating through entwurf";
48
53
  const a = loadEngraving({ backend: "claude", mcpServerNames: ["zebra", "alpha"] });
49
54
  const b = loadEngraving({ backend: "claude", mcpServerNames: ["zebra", "alpha"] });
50
55
  assert.equal(a, b, "loadEngraving is deterministic: same inputs → same output");
51
- assert.equal(a, "backend=claude mcp=alpha, zebra", "interpolates {{backend}} and SORTED {{mcp_servers}}");
56
+ assert.ok(a?.endsWith("backend=claude mcp=alpha, zebra"), "interpolates {{backend}} and SORTED {{mcp_servers}}");
52
57
  // Order of the input must not change the render (signature-stability guard).
53
58
  assert.equal(
54
59
  loadEngraving({ backend: "claude", mcpServerNames: ["alpha", "zebra"] }),
55
60
  a,
56
61
  "mcpServerNames order does not drift the rendered carrier (sorted)",
57
62
  );
58
- assert.equal(
59
- loadEngraving({ backend: "claude", mcpServerNames: [] }),
60
- "backend=claude mcp=(none registered)",
63
+ assert.ok(
64
+ loadEngraving({ backend: "claude", mcpServerNames: [] })?.endsWith("backend=claude mcp=(none registered)"),
61
65
  "no mcp servers → (none registered)",
62
66
  );
63
67
  } finally {
@@ -86,7 +90,15 @@ const BRIDGE_MARK = "operating through entwurf";
86
90
  const prev = process.env.ENTWURF_ACP_ENGRAVING_PATH;
87
91
  process.env.ENTWURF_ACP_ENGRAVING_PATH = whitespace;
88
92
  try {
89
- assert.equal(loadEngraving({ backend: "claude", mcpServerNames: [] }), null, "whitespace-only template → null");
93
+ assert.equal(
94
+ loadEngraving({ backend: "claude", mcpServerNames: [] }),
95
+ null,
96
+ "[QK:CARRIER-OPT-OUT-SURVIVES-SEPARATOR] a whitespace-only override must still be the operator OPT-OUT (null), " +
97
+ "never a carrier made of nothing but the A-join boundary. Emptiness is decided on the TRIMMED BODY, before " +
98
+ "the leading separator is attached — attach it first and an emptied engraving file silently becomes a " +
99
+ 'non-empty "\\n\\n" carrier: the opt-out disappears, the claude_code preset gets replaced by whitespace, and ' +
100
+ "bridgeConfigSignature folds a string the operator never wrote",
101
+ );
90
102
  } finally {
91
103
  if (prev === undefined) delete process.env.ENTWURF_ACP_ENGRAVING_PATH;
92
104
  else process.env.ENTWURF_ACP_ENGRAVING_PATH = prev;
@@ -106,9 +118,9 @@ const BRIDGE_MARK = "operating through entwurf";
106
118
  // `_meta.systemPrompt` → full preset replacement). That replacement is what
107
119
  // strips the preset's auto-memory section so the model never learns it has a
108
120
  // per-session memory store — the memory containment v1 shipped, restored here.
109
- assert.equal(
110
- loadEngraving({ backend: "claude", mcpServerNames: [] }),
111
- "# Engraving Here",
121
+ // (`includes`, not `equal`: the leading A-join boundary is cell 11's axis.)
122
+ assert.ok(
123
+ loadEngraving({ backend: "claude", mcpServerNames: [] })?.includes("# Engraving Here"),
112
124
  "shipped default engraving is the non-empty v1 lever → string carrier (preset replaced, auto-memory stripped)",
113
125
  );
114
126
 
@@ -320,10 +332,234 @@ function ctxWith(firstUser: string): Context {
320
332
  );
321
333
  }
322
334
 
335
+ // ===========================================================================
336
+ // 10) CARRIER PROVENANCE — the augment states what it is, per rail.
337
+ //
338
+ // Measured 2026-07-30: asked where its instructions came from, the Claude ACP
339
+ // model reported the bridge-identity and task-stance paragraphs as its SYSTEM
340
+ // prompt. They are a first-user-message prepend. The model was not lying — on
341
+ // the wire it cannot tell a long first user message from a system prompt, and
342
+ // nothing in the block said which it was. The rails also differ for real
343
+ // (backend-adapter.ts: claude's buildSessionMeta carries `_meta.systemPrompt`;
344
+ // cortex's returns undefined so no `_meta` is sent), so ONE generic sentence
345
+ // would be false on one of them.
346
+ //
347
+ // What this pins is a STATEABILITY claim, not an obedience claim: the boundary
348
+ // must be present and rail-correct in the text the model reads. It cannot make
349
+ // a model answer honestly — it removes the excuse that it had no way to know.
350
+ // ===========================================================================
351
+ {
352
+ const claudeAug = buildPiContextAugment({ backend: "claude", cwd: tmp, mcpServerNames: [], homeDir: tmp });
353
+ const cortexAug = buildPiContextAugment({ backend: "cortex", cwd: tmp, mcpServerNames: [], homeDir: tmp });
354
+
355
+ for (const [rail, aug] of [
356
+ ["claude", claudeAug],
357
+ ["cortex", cortexAug],
358
+ ] as const) {
359
+ assert.ok(
360
+ aug.includes("prepended to the FIRST USER MESSAGE of this session. It is not your system prompt."),
361
+ `[QK:CARRIER-PROVENANCE-STATED] the ${rail} augment must say what it IS (first-user-message text) and what it ` +
362
+ "is NOT (the system prompt) — without that line a model reading it has no way to attribute it, and the " +
363
+ "measured failure was exactly that misattribution",
364
+ );
365
+ assert.ok(
366
+ aug.indexOf("# entwurf: where this text comes from") < aug.indexOf(BRIDGE_MARK),
367
+ `the ${rail} provenance frame precedes the bridge narrative it is about`,
368
+ );
369
+ }
370
+
371
+ // Rail-correct, not generic: claude names its carrier and scopes it to the
372
+ // engraving; cortex denies having one at all.
373
+ assert.ok(
374
+ claudeAug.includes("does have a system-prompt carrier (`_meta.systemPrompt`)") &&
375
+ claudeAug.includes("it carries the operator engraving only"),
376
+ "[QK:CARRIER-FRAME-NAMES-CLAUDE-CARRIER] the claude frame must name the real carrier AND scope it to the engraving — " +
377
+ `saying only "this is not your system prompt" leaves the model to guess what the system prompt then is. Got: ${JSON.stringify(claudeAug.slice(0, 500))}`,
378
+ );
379
+ assert.ok(
380
+ cortexAug.includes("carries no system-prompt carrier at all") &&
381
+ !cortexAug.includes("does have a system-prompt carrier"),
382
+ "[QK:CARRIER-FRAME-DENIES-CORTEX-CARRIER] the cortex frame must deny the carrier — cortex's buildSessionMeta returns " +
383
+ `undefined so no _meta is sent; claiming a tiny carrier there would be a lie. Got: ${JSON.stringify(cortexAug.slice(0, 500))}`,
384
+ );
385
+
386
+ // The rail difference is not a doc claim — it is in the adapter source.
387
+ const adapterSrc = readFileSync(join(REPO_DIR, "pi-extensions/lib/acp/backend-adapter.ts"), "utf8");
388
+ const cortexAt = adapterSrc.indexOf('backend: "cortex"');
389
+ assert.ok(cortexAt > 0, "backend-adapter.ts still declares the cortex adapter");
390
+ assert.match(
391
+ adapterSrc.slice(cortexAt),
392
+ /buildSessionMeta\(\)\s*\{\s*return undefined;/,
393
+ "[QK:CARRIER-RAIL-DIFF-IS-SOURCE-PINNED] cortex's buildSessionMeta must still return undefined — the source fact the " +
394
+ "cortex frame's 'no carrier at all' sentence rests on. If this ever grows a carrier, the frame becomes a lie.",
395
+ );
396
+
397
+ // DISJOINT SURFACES: the thing the model is told is its system prompt (the
398
+ // engraving) must not contain the narrative it is told is user text. If the
399
+ // two ever overlapped, the frame would be unfalsifiable prose.
400
+ const shippedCarrier = loadEngraving({ backend: "claude", mcpServerNames: [] });
401
+ assert.ok(shippedCarrier, "shipped carrier is present (fail-loud lever)");
402
+ assert.ok(
403
+ !(shippedCarrier as string).includes(BRIDGE_MARK) && !claudeAug.includes(shippedCarrier as string),
404
+ `the claude carrier and the augment must stay disjoint — the carrier carries the ` +
405
+ `engraving only and the augment carries the narrative, which is precisely what the provenance frame tells the ` +
406
+ `model. Carrier: ${JSON.stringify(shippedCarrier)}`,
407
+ );
408
+ }
409
+
410
+ // ===========================================================================
411
+ // 11) A-JOIN — the carrier owns its own leading boundary.
412
+ //
413
+ // Measured LIVE 2026-07-31 (0.64.0 adapter, fresh Claude ACP): the system prompt
414
+ // reached the model as
415
+ // `You are a Claude agent, built on Anthropic's Claude Agent SDK.# Engraving Here`
416
+ // A string-form `_meta.systemPrompt` REPLACES the claude_code preset
417
+ // (acp-agent.js), but the SDK still prefixes its own fixed identity sentence and
418
+ // joins the two with NOTHING — the operator's heading was swallowed into the tail
419
+ // of that sentence.
420
+ //
421
+ // The fix cannot live in engraving.md. The render is trimmed (cell 1's
422
+ // determinism guard: operator file whitespace must not drift
423
+ // bridgeConfigSignature), so a leading blank line in the markdown is eaten before
424
+ // it ever reaches the wire. These cells pin the boundary where it can survive —
425
+ // in the LOADER — and pin the two ways it can be lost again: an opt-out turned
426
+ // into a whitespace carrier (cell 2), and a downstream normalize at the meta hop.
427
+ // ===========================================================================
428
+ {
429
+ // The exact sentence measured on the wire. Only its CONCATENATION is our
430
+ // contract — the SDK owns the wording and may change it; what may not change
431
+ // is that our carrier starts a block of its own after whatever precedes it.
432
+ const SDK_FIXED_SENTENCE = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
433
+
434
+ const bare = join(tmp, "bare-engraving.md");
435
+ writeFileSync(bare, "# Operator Engraving");
436
+ const padded = join(tmp, "padded-engraving.md");
437
+ writeFileSync(padded, "\n\n\n# Operator Engraving\n\n");
438
+
439
+ const prev = process.env.ENTWURF_ACP_ENGRAVING_PATH;
440
+ let bareCarrier: string | null;
441
+ let paddedCarrier: string | null;
442
+ try {
443
+ process.env.ENTWURF_ACP_ENGRAVING_PATH = bare;
444
+ bareCarrier = loadEngraving({ backend: "claude", mcpServerNames: [] });
445
+ process.env.ENTWURF_ACP_ENGRAVING_PATH = padded;
446
+ paddedCarrier = loadEngraving({ backend: "claude", mcpServerNames: [] });
447
+ } finally {
448
+ if (prev === undefined) delete process.env.ENTWURF_ACP_ENGRAVING_PATH;
449
+ else process.env.ENTWURF_ACP_ENGRAVING_PATH = prev;
450
+ }
451
+
452
+ const shipped = loadEngraving({ backend: "claude", mcpServerNames: [] });
453
+ assert.ok(shipped, "shipped carrier is present (fail-loud lever)");
454
+ assert.equal(
455
+ loadEngraving({ backend: "claude", mcpServerNames: [] }),
456
+ shipped,
457
+ "the bounded shipped carrier is still a pure function of its inputs (no per-call drift → no per-turn rebuild)",
458
+ );
459
+
460
+ // Reproduce the SDK join with NO glue of our own — the exact concatenation
461
+ // acp-agent.js performs — and read the result the way the model does. The
462
+ // operator-override row is what proves the boundary is the LOADER's: that file
463
+ // carries no leading whitespace at all.
464
+ for (const [what, carrier] of [
465
+ ["shipped default", shipped as string],
466
+ ["operator override with no leading whitespace", bareCarrier as string],
467
+ ] as const) {
468
+ const joined = `${SDK_FIXED_SENTENCE}${carrier}`;
469
+ const lines = joined.split("\n");
470
+ assert.ok(
471
+ lines[0] === SDK_FIXED_SENTENCE && lines[1] === "" && (lines[2]?.length ?? 0) > 0,
472
+ `[QK:CARRIER-LEADS-ITS-OWN-BLOCK] the ${what} must open its own block after the fixed SDK sentence. The SDK ` +
473
+ "concatenates that sentence with a string `_meta.systemPrompt` and puts NOTHING between them, and the render " +
474
+ "is trimmed, so the boundary has to come from the loader — measured 2026-07-31 as the swallowed heading " +
475
+ `\`…Claude Agent SDK.# Engraving Here\`, which is what taught the model its engraving was part of the SDK's ` +
476
+ `own sentence. Joined: ${JSON.stringify(joined.slice(0, 160))}`,
477
+ );
478
+ }
479
+
480
+ // …and the boundary is a loader CONSTANT: whatever the operator's file leads
481
+ // with, the carrier reads the same. Otherwise file whitespace would drift the
482
+ // carrier and, through appendSystemPrompt, the reuse signature.
483
+ assert.equal(
484
+ paddedCarrier,
485
+ bareCarrier,
486
+ "template leading/trailing whitespace never reaches the carrier — the boundary is ours, not the file's",
487
+ );
488
+ assert.equal(bareCarrier, "\n\n# Operator Engraving", "the boundary is exactly one blank line + the trimmed body");
489
+
490
+ // TINY is the billing axis, not a style rule (engraving.ts header): size, not
491
+ // shape, is what reclassifies a Claude OAuth subscription call as metered
492
+ // "extra usage". The boundary costs 2 bytes; the shipped carrier stays a
493
+ // placeholder and rich context keeps riding the first-user augment.
494
+ const CARRIER_BUDGET_BYTES = 512;
495
+ const shippedBytes = Buffer.byteLength(shipped as string, "utf8");
496
+ assert.ok(
497
+ shippedBytes <= CARRIER_BUDGET_BYTES,
498
+ `[QK:CARRIER-STAYS-TINY] the SHIPPED carrier must stay under ${CARRIER_BUDGET_BYTES} bytes — a carrier that grows ` +
499
+ "materially past the SDK-default size routes subscription (OAuth) calls to metered extra usage, which is an " +
500
+ "HTTP 400 for an operator with no metered balance. AGENTS.md, the bridge narrative and tool catalogs ride the " +
501
+ `first-user-message augment, never this carrier. Got ${shippedBytes} bytes: ` +
502
+ `${JSON.stringify((shipped as string).slice(0, 200))}`,
503
+ );
504
+
505
+ // The LAST hop. backend.ts folds the loadCarrier result into
506
+ // bridgeConfigSignature (`appendSystemPrompt`) and hands the SAME string to
507
+ // buildSessionMeta. A normalize here would strip the boundary back off on the
508
+ // wire while the signature still folded the bounded value: the A-join returns
509
+ // invisibly AND reuse keys on a string that was never sent.
510
+ const metaFromShipped = buildClaudeSessionMeta(
511
+ {
512
+ modelId: "claude-x",
513
+ tools: ["Read"],
514
+ permissionAllow: ["Read(*)"],
515
+ disallowedTools: [],
516
+ settingSources: [],
517
+ strictMcpConfig: false,
518
+ skillPlugins: [],
519
+ },
520
+ shipped as string,
521
+ );
522
+ assert.equal(
523
+ metaFromShipped.systemPrompt,
524
+ shipped,
525
+ "[QK:CARRIER-META-SENDS-CARRIER-VERBATIM] `_meta.systemPrompt` must be the loader's string BYTE-FOR-BYTE. " +
526
+ "backend.ts folds that same string into bridgeConfigSignature's appendSystemPrompt slot, so any normalize at " +
527
+ "this hop desynchronizes the wire from the signature — reuse would key on a carrier the backend never received, " +
528
+ `and a trim in particular re-opens the A-join with every gate above still green. Got ${JSON.stringify(metaFromShipped.systemPrompt)}`,
529
+ );
530
+
531
+ // The boundary is signature-relevant, which is what makes the desync above
532
+ // detectable at all: a build that silently dropped it is judged INCOMPATIBLE
533
+ // (fresh ACP session with the corrected carrier), never quietly reused.
534
+ const sigBase = {
535
+ backend: "claude" as const,
536
+ modelId: "claude-x",
537
+ nativeModelId: "claude-x",
538
+ mcpServersHash: "deadbeef",
539
+ settingSources: [],
540
+ strictMcpConfig: true,
541
+ tools: ["Read", "Bash", "Edit", "Write"],
542
+ skillPlugins: [],
543
+ permissionAllow: ["Read(*)"],
544
+ disallowedTools: [],
545
+ };
546
+ assert.notEqual(
547
+ bridgeConfigSignature({ ...sigBase, appendSystemPrompt: shipped as string }),
548
+ bridgeConfigSignature({ ...sigBase, appendSystemPrompt: (shipped as string).trim() }),
549
+ "the boundary is part of the reuse signature — dropping it invalidates a live session instead of passing unnoticed",
550
+ );
551
+ }
552
+
323
553
  console.log(
324
554
  "[check-acp-carrier-augment] ok — engraving carrier: pure/deterministic, sorted mcp interpolation, " +
325
555
  "empty/whitespace/missing → null, shipped-default → non-empty v1 lever (preset replaced), carrier absent → no _meta.systemPrompt key, carrier change → " +
326
556
  "signature change (stable carrier → stable signature); augment: prepended on `new` only (reuse delta has none), " +
327
557
  "wire-only so it never enters contextMessageSignatures, entwurf cwd/AGENTS.md de-dup (present → drop only that " +
328
- "section, home kept; absent → kept), day-granularity date, 50KB truncation marker, shipped AGENTS budget",
558
+ "section, home kept; absent → kept), day-granularity date, 50KB truncation marker, shipped AGENTS budget; " +
559
+ "provenance: both rails state the augment is first-user-message text and not the system prompt, claude names its " +
560
+ "tiny engraving-only carrier while cortex denies having one (pinned against cortexAdapter.buildSessionMeta → " +
561
+ "undefined), and carrier/augment stay disjoint; A-join: the carrier opens its own block after the SDK's fixed " +
562
+ "sentence, the boundary is a loader constant (not the template's whitespace, which trim eats) and does not " +
563
+ "resurrect an opted-out carrier, the shipped carrier stays under the tiny budget, and the meta hop sends the " +
564
+ "loader string byte-for-byte so the wire and the reuse signature cannot desynchronize",
329
565
  );
@@ -1,12 +1,12 @@
1
1
  // Deterministic gate for the Cortex (Snowflake Cortex Code) ACP backend — the
2
- // first non-claude adapter on the rail (docs/acp-backend-rail.md §4/§6/§11-8).
2
+ // first non-claude adapter on the rail (docs/acp-backend-rail.md, Cortex audit D1–D10).
3
3
  // The cortex source lives in one `cortexAdapter` object (backend-adapter.ts) +
4
4
  // its curated surface (models.ts) + the dual-HOME overlay (overlay.ts); the
5
- // 결합 규칙 requires the gate to land WITH it, and §6 said EXTEND the
5
+ // 결합 규칙 requires the gate to land WITH it, and the rail's “Shipped adapters” said EXTEND the
6
6
  // `check-acp-*` family, so cortex's whole deterministic axis lives here.
7
7
  //
8
8
  // The contract under test is the CP0-measured one (v1.1.52, 2026-07-29), not
9
- // PR #40's v1.1.8-era shape — the D-numbers refer to §11-8:
9
+ // PR #40's v1.1.8-era shape — the D-numbers refer to the current Cortex audit:
10
10
  // 1. curated surface is the GLG-decided 4-row set, riding real registry bases;
11
11
  // 2. `cortex-` prefix routes to cortexAdapter; prefix-strip recovers the
12
12
  // native id; the unprefixed claude ids are never claimed;
@@ -22,7 +22,7 @@
22
22
  // session-scoped dirs with exact rewrite + dead-pid sweep;
23
23
  // 6. CORTEX_HOME ambient presence (empty string INCLUDED) refuses the spawn
24
24
  // (D3 — upstream consumers disagree about empty);
25
- // 7. system-prompt-carrier-less augment (§9-4/§11-8) — the operator engraving OVERRIDE rides the
25
+ // 7. system-prompt-carrier-less augment — the operator engraving OVERRIDE rides the
26
26
  // first-user augment; a carrier backend (claude) never folds it in.
27
27
  //
28
28
  // [QK:*] labels mark the claims kill-qualified by scripts/mutants/acp-cortex.json
@@ -329,7 +329,7 @@ const enrichedServers: AcpMcpServer[] = [
329
329
  }
330
330
 
331
331
  // ---------------------------------------------------------------------------
332
- // Layer A.3 — system-prompt-carrier-less augment (§9-4/§11-8): the operator engraving override
332
+ // Layer A.3 — system-prompt-carrier-less augment: the operator engraving override
333
333
  // rides the FIRST-USER AUGMENT for cortex; claude (a carrier backend) never
334
334
  // folds it in; no override → no engraving injected.
335
335
  // ---------------------------------------------------------------------------
@@ -3,8 +3,9 @@
3
3
  // Drives ensureClaudeConfigOverlay against INJECTED temp realDir/overlayDir
4
4
  // (no operator ~/.claude touched), then asserts the overlay shape the ACP
5
5
  // child must see:
6
- // - settings.json: permissions.defaultMode "default", autoMemoryEnabled
7
- // false, hooks === {} (configured-but-empty → mailbox absence by design);
6
+ // - settings.json: permissions.defaultMode "bypassPermissions" (unattended
7
+ // ACP turns never pause on a prompt), autoMemoryEnabled false, hooks === {}
8
+ // (configured-but-empty → mailbox absence by design);
8
9
  // - whitelisted operator entries symlinked to their real path;
9
10
  // - projects/sessions are overlay-PRIVATE real dirs, NOT symlinks;
10
11
  // - non-whitelist operator entries (CLAUDE.md, settings.local.json, plugins,
@@ -73,7 +74,16 @@ try {
73
74
 
74
75
  // settings.json
75
76
  const settings = JSON.parse(readFileSync(join(overlayDir, "settings.json"), "utf8"));
76
- assert.equal(settings.permissions?.defaultMode, "default", "settings.permissions.defaultMode must be 'default'");
77
+ assert.equal(
78
+ settings.permissions?.defaultMode,
79
+ "bypassPermissions",
80
+ "[QK:ACP-OVERLAY-UNATTENDED-BYPASS] the overlay we author must pin permissions.defaultMode to " +
81
+ "'bypassPermissions'. The isolated overlay is the ONLY authority here — CLAUDE_CONFIG_DIR redirects the " +
82
+ "SDK away from the operator's native settings, so an unattended entwurf ACP turn inherits nothing and a " +
83
+ "regression to 'default' suspends that turn on an interactive permission prompt with no operator at the " +
84
+ "keyboard. The callable surface stays bounded by explicit tools/disallowedTools and backend auth, not by " +
85
+ "this mode.",
86
+ );
77
87
  assert.equal(settings.autoMemoryEnabled, false, "settings.autoMemoryEnabled must be false");
78
88
  assert.ok(
79
89
  settings.hooks && typeof settings.hooks === "object" && Object.keys(settings.hooks).length === 0,