@junghanacs/entwurf 0.12.9 → 0.13.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 (100) hide show
  1. package/AGENTS.md +135 -248
  2. package/BASELINE.md +46 -7
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +5 -4
  6. package/README.md +46 -18
  7. package/VERIFY.md +10 -6
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +10 -4
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  28. package/mcp/entwurf-bridge/src/index.ts +190 -82
  29. package/package.json +7 -3
  30. package/pi/settings.reference.json +1 -1
  31. package/pi-extensions/acp-provider.ts +20 -10
  32. package/pi-extensions/entwurf-control.ts +69 -27
  33. package/pi-extensions/lib/acp/augment.ts +60 -2
  34. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  35. package/pi-extensions/lib/acp/backend.ts +5 -1
  36. package/pi-extensions/lib/acp/config.ts +19 -5
  37. package/pi-extensions/lib/acp/engraving.ts +3 -1
  38. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  39. package/pi-extensions/lib/acp/models.ts +69 -7
  40. package/pi-extensions/lib/acp/overlay.ts +234 -3
  41. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  42. package/pi-extensions/lib/entwurf-core.ts +26 -9
  43. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  44. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  45. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  46. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  47. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  48. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  49. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  50. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  51. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  52. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  53. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  54. package/pi-extensions/lib/meta-session.ts +17 -20
  55. package/run.sh +200 -62
  56. package/scripts/agy-bridge-config.py +249 -49
  57. package/scripts/agy-bridge.sh +59 -14
  58. package/scripts/check-acp-carrier-augment.ts +34 -2
  59. package/scripts/check-acp-cortex.ts +668 -0
  60. package/scripts/check-acp-provider-surface.ts +50 -6
  61. package/scripts/check-acp-sdk-surface.ts +22 -11
  62. package/scripts/check-acp-session-reuse.ts +64 -1
  63. package/scripts/check-agy-permission-matrix.py +655 -0
  64. package/scripts/check-entwurf-deliverability.ts +9 -6
  65. package/scripts/check-entwurf-fact-provider.ts +7 -7
  66. package/scripts/check-entwurf-facts.ts +12 -12
  67. package/scripts/check-entwurf-resume-args.ts +22 -60
  68. package/scripts/check-entwurf-self-address.ts +187 -4
  69. package/scripts/check-entwurf-v2-contract.ts +2 -2
  70. package/scripts/check-entwurf-v2-production.ts +5 -5
  71. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  72. package/scripts/check-entwurf-v2-surface.ts +327 -14
  73. package/scripts/check-gate-qualification.ts +787 -0
  74. package/scripts/check-meta-identity-consumers.ts +1 -1
  75. package/scripts/check-probe-cli-shim.ts +879 -0
  76. package/scripts/check-probe-ordering.ts +2450 -0
  77. package/scripts/check-shell-quote.ts +5 -4
  78. package/scripts/fixtures/probe-cli-shim +20 -0
  79. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  80. package/scripts/lib/mutation-qualify.ts +794 -0
  81. package/scripts/lib/probe-acp-turn.ts +207 -0
  82. package/scripts/lib/probe-cli-shim.ts +464 -0
  83. package/scripts/lib/probe-cli-target.ts +165 -0
  84. package/scripts/lib/probe-event-log.ts +383 -0
  85. package/scripts/lib/probe-verdict.ts +1213 -0
  86. package/scripts/mutants/acp-augment.json +30 -0
  87. package/scripts/mutants/acp-cortex.json +196 -0
  88. package/scripts/mutants/agy-permission.json +144 -0
  89. package/scripts/mutants/meta-identity.json +17 -0
  90. package/scripts/mutants/probe-ordering.json +1032 -0
  91. package/scripts/mutants/self-address.json +59 -0
  92. package/scripts/mutants/v2-surface.json +87 -0
  93. package/scripts/smoke-acp-cortex-live.ts +392 -0
  94. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  95. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  96. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  97. package/scripts/smoke-agy-install-state.sh +205 -20
  98. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  99. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  100. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -108,9 +108,10 @@ const SESSION_MESSAGE_TYPE = "entwurf-message";
108
108
  // with the receive-side [entwurf received ⟵] box. The provider-level context
109
109
  // filter in index.ts drops this customType before the LLM sees it — colocated
110
110
  // with the emitter so sessions without --entwurf-control are still protected.
111
- // Layer A (native path) reuses the same Box builder via renderSentMessage() but
112
- // does NOT emit a CustomMessage; the native tool result already lives in the
113
- // toolResult role and never enters LLM context as a user message.
111
+ // There is no second emitter: the v1 native send path ("Layer A") that also drew
112
+ // this box through renderSentMessage() was removed in the 0.12 cutover, and
113
+ // renderSentMessage is now registered for exactly one customType (see
114
+ // registerMessageRenderer below). Do not describe a native sender-side box.
114
115
  const SENDER_INFO_PATTERN = /<sender_info>[\s\S]*?<\/sender_info>/g;
115
116
 
116
117
  // ============================================================================
@@ -509,8 +510,8 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
509
510
  };
510
511
 
511
512
  // Sender-side payload — what renderSentMessage needs to draw the [entwurf sent →]
512
- // box. Carried verbatim by both Layer A (native renderResult) and Layer B
513
- // (CustomMessage details for ACP path). All four envelope fields are intentionally
513
+ // box. Carried by the ACP path's CustomMessage `details` the ONLY carrier since
514
+ // the v1 native renderResult path was removed (0.12 cutover). All four envelope fields are intentionally
514
515
  // echoed in the box even though the sender is "this same session" — operators
515
516
  // reading a busy multi-session transcript should be able to verify at a glance
516
517
  // which 담당자 is on the wire (cwd) and which model identity (agentId) actually
@@ -520,9 +521,10 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
520
521
  // re-renders (resize, expand toggle) keep showing the moment the message was
521
522
  // actually delivered rather than drifting forward to "now".
522
523
  //
523
- // wants_reply mirrors the receive-side etiquette badge. Native schema does not
524
- // yet expose it (see registerSessionTool's entwurfSendParameters); leave undefined
525
- // from the native call site until the schema grows the field.
524
+ // wants_reply mirrors the receive-side etiquette badge. It stays optional because
525
+ // the box is drawn from a CustomMessage whose `details` may predate the field; the
526
+ // v1 `registerSessionTool` / `entwurfSendParameters` schema this note used to point
527
+ // at is gone (0.12 cutover), so there is no native call site left to grow.
526
528
  interface SentBoxData {
527
529
  to: string; // target sessionId
528
530
  from?: string; // sender agentId, e.g. "entwurf/claude-opus-5"
@@ -1274,9 +1276,18 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1274
1276
  target: Type.String({ description: "Target garden id (use entwurf_peers to discover)" }),
1275
1277
  intent: StringEnum(["fire-and-forget", "owned-outcome"] as const, {
1276
1278
  description:
1277
- "fire-and-forget = send/reply/hand-off to a LIVE or meta-session target (set wants_reply for an answer); " +
1278
- "owned-outcome = wake a DORMANT pi via spawn-bg resume ONLY on a live target it is rejected " +
1279
- "(owned-live-no-autosend) and never auto-converted",
1279
+ "fire-and-forget = send/reply/hand-off to a LIVE socket target (currently backend pi) or to any " +
1280
+ "citizen with no socket liveness the decider picks that citizen's rail, and a rail can also " +
1281
+ "REJECT: self-fetch (e.g. Claude Code) meta-bridge mailbox while that mailbox is DELIVERABLE, " +
1282
+ "else rejected as mailbox-undeliverable; native-push (e.g. Antigravity) → direct injection into " +
1283
+ "its live conversation, which has NO mailbox, and its probe is three-valued — alive: injected, " +
1284
+ "dead: native-push-target-dead, indeterminate: native-push-probe-indeterminate (two rejects, " +
1285
+ "not one). " +
1286
+ "Set wants_reply for an answer. " +
1287
+ "owned-outcome = wake a DORMANT socket-domain citizen via spawn-bg resume ONLY — on a live target " +
1288
+ "rejected as owned-live-no-autosend, on self-fetch as backend-liveness-unsupported, on " +
1289
+ "native-push as native-push-no-resume-authority (both lack resume authority, but the reasons " +
1290
+ "differ because native-push IS probe-measured), and never auto-converted",
1280
1291
  }),
1281
1292
  message: Type.String({
1282
1293
  description:
@@ -1285,7 +1296,9 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1285
1296
  }),
1286
1297
  mode: Type.Optional(
1287
1298
  StringEnum(["steer", "follow_up"] as const, {
1288
- description: "Delivery mode for a live send: steer (immediate) or follow_up (after task)",
1299
+ description:
1300
+ "Injection style for a CONTROL-SOCKET send only: steer (immediate) or follow_up (after task). " +
1301
+ "The mailbox, native-push, and spawn-bg plans carry no mode, so it has no effect on those rails.",
1289
1302
  }),
1290
1303
  ),
1291
1304
  wants_reply: Type.Optional(Type.Boolean({ description: "Human-conversation reply hint (default false)" })),
@@ -1299,6 +1312,11 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1299
1312
  wants_reply?: boolean;
1300
1313
  };
1301
1314
 
1315
+ // TS2589 ("type instantiation is excessively deep") workaround: pi's registerTool
1316
+ // generic infers the handler signature from the TypeBox schema, and this schema is
1317
+ // deep enough to blow the instantiation budget. Casting the FUNCTION (not the
1318
+ // argument) keeps the schema itself typed while stopping the inference walk.
1319
+ // Revisit when pi's registerTool takes an explicit params type parameter.
1302
1320
  const registerTool = pi.registerTool as (def: any) => void;
1303
1321
 
1304
1322
  registerTool({
@@ -1306,28 +1324,51 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1306
1324
  label: "Dispatch (v2)",
1307
1325
  description: `CANONICAL delivery surface for a garden id. When you have a garden id and want to
1308
1326
  reach whoever it names — message / reply / hand-off — use THIS verb. A garden id alone does not
1309
- reveal whether the target is a live pi session, a dormant pi session, or a Claude Code meta-session,
1327
+ reveal which rail that citizen answers on — a live socket session, a dormant one, a mailbox-backed
1328
+ self-fetch session, or a native-push session —
1310
1329
  and entwurf_v2 is the one surface that reads that and routes correctly (so "when unsure which
1311
1330
  transport, use entwurf_v2"). It dispatches to EXISTING targets; brand-new sibling creation is deferred
1312
1331
  to a later v2 lane. Dispatch to a garden citizen through the unified entwurf_v2 verb: the 5b decider
1313
- picks the transport (live control-socket send / spawn-bg resume / meta-mailbox enqueue) from the
1314
- target's liveness + your intent, runs it under the v2 lock policy (pi paths take a per-target lock;
1315
- the mailbox path is lock-free, guarded by active-receiver deliverability), and reports one outcome
1316
- (delivered / rejected / lock-retained / delivered-but-lock-dirty).
1332
+ picks the transport (live control-socket send / spawn-bg resume / meta-mailbox enqueue / native-push
1333
+ direct injection) from the target's liveness + your intent, runs it under the v2 lock policy, and reports
1334
+ one outcome (delivered / rejected / lock-retained / delivered-but-lock-dirty).
1335
+
1336
+ LOCK POLICY (do not over-generalize it): the per-target lock is taken for a control-socket-DOMAIN
1337
+ dispatch, which is BOTH the live send AND the dormant cell's spawn-bg resume — spawn-bg is a separate
1338
+ relaunch transport yet it still runs under that domain's lock. The mailbox and native-push rails are
1339
+ lock-free: the mailbox is guarded instead by active-receiver deliverability, and native-push by its
1340
+ adapter probe.
1317
1341
 
1318
1342
  - target: the garden id of the citizen to reach (required).
1319
1343
  - intent: fire-and-forget (a send with no owned result) or owned-outcome (you own the result).
1320
1344
  - message: the message/prompt to dispatch (required).
1321
- - mode: steer or follow_up for a live send (optional).
1322
- - wants_reply: reply hint for a live send (optional, default false).
1345
+ - mode: steer or follow_up for a CONTROL-SOCKET send (optional). The mailbox, native-push, and
1346
+ spawn-bg plans carry no mode, so it has no effect on those rails — a native-push send IS live and
1347
+ still ignores it.
1348
+ - wants_reply: reply hint; it rides every rail (optional, default false).
1323
1349
 
1324
1350
  CHOOSING INTENT (picking wrong is rejected, never auto-fixed): to message / reply / hand off a peer
1325
- that entwurf_peers shows as liveness=alive (a live pi citizen), use intent: fire-and-forget
1326
- — it routes to the live control-socket; set wants_reply:true if you need an answer (wants_reply is NOT
1327
- owned-outcome). For a meta-session (liveness=unsupported, e.g. Claude Code), replies are ALSO
1328
- fire-and-forget (→ mailbox). owned-outcome is ONLY for waking a DORMANT pi citizen (spawn-bg resume);
1329
- on a live target it is rejected as owned-live-no-autosend, on an unsupported backend as
1330
- backend-liveness-unsupported, and is NEVER auto-converted so pick the right intent up front.
1351
+ that entwurf_peers shows as liveness=alive (a live socket citizen, currently backend pi), use intent:
1352
+ fire-and-forget — it routes to the live control-socket; set wants_reply:true if you need an answer
1353
+ (wants_reply is NOT owned-outcome). Replies to a citizen with NO socket liveness
1354
+ (liveness=unsupported) are ALSO fire-and-forget, and the decider picks that citizen's own rail: a
1355
+ self-fetch backend (e.g. Claude Code) gets the meta-bridge mailbox, while a native-push backend
1356
+ (e.g. Antigravity) gets direct injection into its live conversation and has NO mailbox at all — do
1357
+ not assume mailbox semantics for every unsupported citizen. A native-push target IS measured by its
1358
+ own adapter probe, and that probe is THREE-valued, so the send is never silently queued: alive →
1359
+ injected; dead → rejected as native-push-target-dead; indeterminate → rejected as
1360
+ native-push-probe-indeterminate. Those last two stay separate on purpose — "we could not establish
1361
+ it" is not "it is gone". THERE IS A THIRD RESULT, so do not read
1362
+ liveness=unsupported as "reachable by some rail": the mailbox path delivers only to a DELIVERABLE
1363
+ citizen (a self-fetch backend whose receiver is live and armed), so a terminated Claude Code session —
1364
+ and any record whose backend has no adapter on this lane, e.g. codex — is rejected as
1365
+ mailbox-undeliverable rather than queued into an inbox nobody drains. unsupported means only "this
1366
+ backend has no control-socket probe".
1367
+ owned-outcome is ONLY for waking a DORMANT socket-domain citizen (spawn-bg resume, currently backend
1368
+ pi); on a live target it is rejected as owned-live-no-autosend. Neither self-fetch nor native-push
1369
+ has resume authority, but they reject under DIFFERENT reasons — self-fetch as
1370
+ backend-liveness-unsupported, native-push as native-push-no-resume-authority.
1371
+ It is NEVER auto-converted — so pick the right intent up front.
1331
1372
 
1332
1373
  The decider — not this surface — chooses the transport.`,
1333
1374
  parameters: entwurfV2Parameters,
@@ -1433,8 +1474,9 @@ async function renderEntwurfPeersForSurface(): Promise<{ text: string; payload:
1433
1474
  }
1434
1475
 
1435
1476
  function registerListSessionsTool(pi: ExtensionAPI): void {
1436
- // Same TS2589 workaround as registerSessionTool — see the comment block
1437
- // in that function for the revisit conditions.
1477
+ // Same TS2589 workaround as registerEntwurfV2Tool — see the comment block there
1478
+ // for the revisit conditions. (It used to point at registerSessionTool, which was
1479
+ // removed in the 0.12 cutover, so the pointer dangled.)
1438
1480
  const registerTool = pi.registerTool as (def: any) => void;
1439
1481
  registerTool({
1440
1482
  name: "entwurf_peers",
@@ -30,14 +30,64 @@
30
30
 
31
31
  import { existsSync, readFileSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
- import { join } from "node:path";
33
+ import { join, resolve } from "node:path";
34
34
  import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../../../protocol.js";
35
35
  import type { AcpTextBlock } from "./context.js";
36
36
 
37
37
  const MAX_AUGMENT_BYTES = 50 * 1024;
38
38
 
39
+ // Carrier-less backends expose no `_meta.systemPrompt` (docs/acp-backend-rail.md
40
+ // §9-4). claude folds the operator engraving into that carrier; a carrier-less
41
+ // backend (cortex, the first — precisely: SYSTEM-PROMPT-carrier-less, since
42
+ // cortex does read `_meta` for an unpromoted caller-session-id seam, §11-8)
43
+ // has nowhere to put it — so its operator engraving
44
+ // rides HERE, prepended as the LEADING section of the first-user augment. It
45
+ // travels on the WIRE only (new-only, never the config signature), so an
46
+ // engraving change applies to the NEXT new cortex session rather than
47
+ // invalidating a live reused one — the documented asymmetry vs claude, where the
48
+ // carrier feeds bridgeConfigSignature.
49
+ const CARRIER_LESS_BACKENDS: ReadonlySet<string> = new Set(["cortex"]);
50
+
51
+ // The env var an operator points at a personal engraving file. Shared surface
52
+ // with the claude carrier (engraving.ts) BY NAME, but read INLINE here (not
53
+ // imported): augment.ts is loaded raw by the strip-types carrier-augment gate,
54
+ // which cannot resolve a `.js` VALUE import of the sibling `.ts` engraving
55
+ // module (augment.ts only value-imports real `.js` files). So the carrier-less
56
+ // override read is self-contained. Unlike engraving.ts's shipped-default path,
57
+ // this ONLY ever reads the operator OVERRIDE file: the shipped claude default
58
+ // (a preset-replacement / auto-memory lever) is meaningless for a non-claude
59
+ // backend and is never injected into a carrier-less augment.
60
+ const ENGRAVING_OVERRIDE_ENV = "ENTWURF_ACP_ENGRAVING_PATH";
61
+
62
+ /**
63
+ * Render the operator engraving OVERRIDE for a carrier-less backend, or null
64
+ * when unset / empty / whitespace / unreadable. Pure w.r.t. (override file
65
+ * contents, backend, sorted mcpServerNames). Mirrors engraving.ts's `{{backend}}`
66
+ * / `{{mcp_servers}}` token substitution so the operator's file behaves the same
67
+ * on the augment as it would on the claude carrier.
68
+ */
69
+ function loadCarrierlessOperatorEngraving(backend: string, mcpServerNames: readonly string[]): string | null {
70
+ const envPath = process.env[ENGRAVING_OVERRIDE_ENV]?.trim();
71
+ if (!envPath) return null;
72
+ let source: string;
73
+ try {
74
+ source = readFileSync(resolve(envPath), "utf8");
75
+ } catch {
76
+ return null;
77
+ }
78
+ const names = [...mcpServerNames].sort();
79
+ const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
80
+ const rendered = source
81
+ .replace(/\{\{backend\}\}/g, backend)
82
+ .replace(/\{\{mcp_servers\}\}/g, mcpList)
83
+ .trim();
84
+ return rendered.length > 0 ? rendered : null;
85
+ }
86
+
39
87
  export interface PiContextAugmentParams {
40
- /** Claude-only this cut; kept as a field so the narrative names the backend. */
88
+ /** The routed backend ("claude" | "cortex"). Load-bearing, not decorative: the
89
+ * narrative names it AND `CARRIER_LESS_BACKENDS` keys off it to decide whether
90
+ * the operator engraving leads the augment. */
41
91
  backend: string;
42
92
  cwd: string;
43
93
  mcpServerNames: readonly string[];
@@ -58,6 +108,14 @@ export function buildPiContextAugment(params: PiContextAugmentParams): string {
58
108
 
59
109
  const sections: string[] = [];
60
110
 
111
+ // System-prompt-carrier-less backend (cortex): the operator engraving leads the augment,
112
+ // since there is no `_meta.systemPrompt` carrier to hold it. null when no
113
+ // override is configured — the shipped claude default is never injected here.
114
+ if (CARRIER_LESS_BACKENDS.has(params.backend)) {
115
+ const engraving = loadCarrierlessOperatorEngraving(params.backend, params.mcpServerNames);
116
+ if (engraving) sections.push(engraving);
117
+ }
118
+
61
119
  // Bridge identity + caller-side sibling stance. The closing line is a
62
120
  // caller-perspective statement: when THIS session throws an entwurf, the peer
63
121
  // on the other end is a sibling, not a worker.
@@ -19,15 +19,32 @@
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
+ import { homedir } from "node:os";
22
23
  import { dirname, join } from "node:path";
23
24
 
24
25
  import type { AcpConnectionLike } from "./acp-client.js";
25
- import type { ResolvedAcpConfig } from "./config.js";
26
+ import { enrichMcpServersWithEnvelope, type ResolvedAcpConfig } from "./config.js";
26
27
  import { loadEngraving } from "./engraving.js";
27
- import { curatedClaudeModels, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./models.js";
28
- import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay } from "./overlay.js";
28
+ import {
29
+ CORTEX_MODEL_PREFIX,
30
+ curatedClaudeModels,
31
+ curatedCortexModels,
32
+ SUPPORTED_ANTHROPIC_MODEL_IDS,
33
+ SUPPORTED_CORTEX_MODEL_IDS,
34
+ } from "./models.js";
35
+ import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay, ensureCortexDualHomeOverlay } from "./overlay.js";
29
36
  import { buildClaudeSessionMeta } from "./tool-surface.js";
30
37
 
38
+ // POSIX-safe single-quote wrapper for shell arg interpolation. Byte-for-byte
39
+ // identical to the reference in entwurf-core.ts; PARITY-PINNED by
40
+ // scripts/check-shell-quote.ts (SOURCE_SITES). Used only by the cortex override
41
+ // path below, where operator-configured connection/model tokens are appended to
42
+ // an operator `bash -lc` string — quoting keeps a connection name with shell
43
+ // metacharacters from being reinterpreted by the shell.
44
+ function shellQuote(value: string): string {
45
+ return `'${value.replace(/'/g, `'\\''`)}'`;
46
+ }
47
+
31
48
  // ---------------------------------------------------------------------------
32
49
  // Shared shapes
33
50
  // ---------------------------------------------------------------------------
@@ -64,13 +81,19 @@ export interface AcpCarrierParams {
64
81
 
65
82
  /** ensureOverlay input — cwd + (native) model id + the resolved config. A backend
66
83
  * whose overlay/env depends on its OWN settings reads them off `config.adapterSettings`
67
- * here; backend.ts never inspects config. Same shape as AcpLaunchParams (overlay and
68
- * launch are distinct phases, so they keep distinct names). */
84
+ * here; backend.ts never inspects config. Same shape as AcpLaunchParams plus the
85
+ * session key (overlay and launch are distinct phases, so they keep distinct names). */
69
86
  export interface AcpOverlayParams {
70
87
  cwd: string;
71
88
  modelId: string;
72
89
  nativeModelId: string;
73
90
  config: ResolvedAcpConfig;
91
+ /** The AUTHORITATIVE per-session key backend.ts already computed
92
+ * (resolveSessionKey: opts.sessionId → PI_SESSION_ID → cwd). A session-scoped
93
+ * overlay MUST scope on this value, never on an ambient re-derivation — the
94
+ * re-derived form drops `opts.sessionId`, so two sessions in one process/cwd
95
+ * would alias one overlay (GPT review 2026-07-29, cortex P0-1). */
96
+ sessionKey: string;
74
97
  }
75
98
 
76
99
  /** buildSessionMeta input — mirrors the newSession `_meta` inputs. */
@@ -270,9 +293,161 @@ export const claudeAdapter: AcpBackendAdapter = {
270
293
  // ---------------------------------------------------------------------------
271
294
 
272
295
  /** Registered adapters. Order carries NO routing authority — routeModel decides.
273
- * Step A: claude only. A second backend appends here with its reserved prefix
274
- * (e.g. `<backend>-*`), and the fail-fast below proves no two adapters claim one id. */
275
- const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter];
296
+ * claude (unprefixed ids) + cortex (the `cortex-` prefix). A further backend
297
+ * appends here with its own reserved prefix; the fail-fast below proves no two
298
+ * adapters claim one id. */
299
+ // ---------------------------------------------------------------------------
300
+ // cortex adapter — Snowflake Cortex Code, the first non-claude backend on the
301
+ // rail (docs/acp-backend-rail.md §4/§6). It adds ZERO to the common layer:
302
+ // everything cortex-specific lives here + models.ts + overlay.ts + the gates.
303
+ // ---------------------------------------------------------------------------
304
+
305
+ const SUPPORTED_CORTEX_IDS: ReadonlySet<string> = new Set(SUPPORTED_CORTEX_MODEL_IDS);
306
+
307
+ /** Cortex's OWN settings (§10 B): a Snowflake connection name, or null. Opaque to
308
+ * config.ts / backend.ts — only cortexAdapter reads it (casting back). */
309
+ export interface CortexAdapterSettings {
310
+ cortexConnection: string | null;
311
+ }
312
+
313
+ /** The env var an operator sets to pin a Snowflake connection per-shell without
314
+ * editing settings.json. Wins over `entwurfProvider.cortexConnection`. The
315
+ * ENTWURF_ACP_* convention (PR #40's legacy PI_SHELL_ACP* cortex-connection var renamed). */
316
+ export const CORTEX_CONNECTION_ENV = "ENTWURF_ACP_CORTEX_CONNECTION";
317
+
318
+ export const cortexAdapter: AcpBackendAdapter = {
319
+ backend: "cortex",
320
+
321
+ // Cortex owns the reserved `cortex-` prefix (§9-1). routeModel strips it to the
322
+ // native id: `cortex-auto` → "auto", `cortex-claude-sonnet-5` → "claude-sonnet-5".
323
+ routeModel(modelId) {
324
+ if (!SUPPORTED_CORTEX_IDS.has(modelId)) return undefined;
325
+ return { nativeModelId: modelId.slice(CORTEX_MODEL_PREFIX.length) };
326
+ },
327
+
328
+ curatedModels() {
329
+ return curatedCortexModels();
330
+ },
331
+
332
+ // Cortex's ONLY own setting is the connection name. env override wins over
333
+ // settings (per-shell pin); empty/whitespace → null (Cortex falls back to its
334
+ // own default connection). A non-string settings value fails loud.
335
+ resolveAdapterSettings({ mergedBlock, projectBlock, globalPath, projectPath }): CortexAdapterSettings {
336
+ const raw = mergedBlock.cortexConnection;
337
+ if (raw !== undefined && typeof raw !== "string") {
338
+ const offending = projectBlock.cortexConnection !== undefined ? projectPath : globalPath;
339
+ throw new Error(`${offending}: invalid entwurfProvider settings: cortexConnection must be a string`);
340
+ }
341
+ const envConn = process.env[CORTEX_CONNECTION_ENV]?.trim();
342
+ const settingsConn = raw?.trim();
343
+ const cortexConnection = envConn || settingsConn || null;
344
+ return { cortexConnection };
345
+ },
346
+
347
+ // `cortex acp serve` resolved from PATH (the CLI itself IS the ACP server — no
348
+ // `*-acp` npm package, unlike claude). `-c <conn>` appended when a connection
349
+ // is pinned. NO `-m`: the model is enforced per-turn via
350
+ // session/set_config_option (enforceModel below, CP0-M measured GO) and a
351
+ // launch pin would be a SECOND model authority that drifts from it.
352
+ // CORTEX_ACP_COMMAND override runs via `bash -lc` with the selection flags
353
+ // appended so the bridge's choice wins (later yargs args override earlier ones).
354
+ resolveLaunch({ config }) {
355
+ const settings = config.adapterSettings as CortexAdapterSettings | undefined;
356
+ const connection = settings?.cortexConnection?.trim() || undefined;
357
+ const selectionArgs: string[] = [];
358
+ if (connection) selectionArgs.push("-c", connection);
359
+ const override = process.env.CORTEX_ACP_COMMAND?.trim();
360
+ if (override) {
361
+ const command = selectionArgs.length > 0 ? `${override} ${selectionArgs.map(shellQuote).join(" ")}` : override;
362
+ return { command: "bash", args: ["-lc", command] };
363
+ }
364
+ return { command: "cortex", args: ["acp", "serve", ...selectionArgs] };
365
+ },
366
+
367
+ // The overlay location is SESSION-SCOPED (never static), so the spawn env
368
+ // rides ensureOverlay(...).envOverrides; there is no static launch env. The
369
+ // v1.1.8-era CORTEX_DISABLE_AUTO_APPLY_PROFILES knob was retired with the
370
+ // dual-HOME redesign: profiles now live inside the overlay-owned isolated
371
+ // home (empty by construction), and the knob is unmeasured on v1.1.52.
372
+ launchEnvDefaults() {
373
+ return {};
374
+ },
375
+
376
+ // Dual-HOME containment (CP0 D2/D3/D9/D10 — see the overlay module header):
377
+ // refuse an ambient CORTEX_HOME outright, then materialize the session-scoped
378
+ // isolated HOME with auth symlinks, `autoUpdate:false`, and the mcp.json
379
+ // projection of the envelope-enriched explicit servers (cortex ignores the
380
+ // wire mcpServers param, so this file IS how tools reach a cortex session).
381
+ ensureOverlay({ modelId, config, sessionKey }) {
382
+ // D3 — presence refusal, empty string included: upstream's resolver treats
383
+ // a set-but-empty CORTEX_HOME differently from unset, and one ambient value
384
+ // would silently bypass SNOWFLAKE_HOME (the probe's CLAUDE_CODE_EXECUTABLE
385
+ // precondition is the same family). Refuse the ambiguity; never pick a side.
386
+ if ("CORTEX_HOME" in process.env) {
387
+ throw new Error(
388
+ "entwurf: CORTEX_HOME is present in the environment (empty string included) — it overrides " +
389
+ "SNOWFLAKE_HOME inside cortex and would bypass the dual-HOME overlay entirely (CP0 D3). " +
390
+ "Unset it to run a cortex ACP turn.",
391
+ );
392
+ }
393
+ // The scope authority is the AUTHORITATIVE params.sessionKey backend.ts
394
+ // computed — never an ambient re-derivation, which would drop opts.sessionId
395
+ // and alias two same-process/cwd sessions onto one overlay (P0-1). The
396
+ // envelope below still reads PI_SESSION_ID: that is the identity CARRIER for
397
+ // the bridge child (the same source the turn loop's wire enrichment uses),
398
+ // a different axis from overlay-dir scoping.
399
+ const piSessionId = process.env.PI_SESSION_ID?.trim() || undefined;
400
+ const enriched = enrichMcpServersWithEnvelope(config.mcpServers, { modelId, piSessionId });
401
+ const overlay = ensureCortexDualHomeOverlay({
402
+ scopeKey: sessionKey,
403
+ mcpServers: enriched,
404
+ realHome: homedir(),
405
+ });
406
+ return { envOverrides: { HOME: overlay.home, SNOWFLAKE_HOME: overlay.snowflakeHome } };
407
+ },
408
+
409
+ // System-prompt-carrier-less (§9-4/§11-8): Cortex ACP exposes no
410
+ // `_meta.systemPrompt` and has no developer_instructions / GEMINI_SYSTEM_MD
411
+ // equivalent. (It does READ `_meta` — a caller-session-id seam, measured but
412
+ // unexplored and deliberately not part of this contract.) loadCarrier returns
413
+ // null WITHOUT calling loadEngraving, so the cortex turn never touches the
414
+ // shipped-engraving / appendSystemPrompt signature; buildSessionMeta returns
415
+ // undefined so backend.ts omits the `_meta` key entirely. The operator
416
+ // engraving instead rides the first-user augment (augment.ts).
417
+ loadCarrier() {
418
+ return null;
419
+ },
420
+
421
+ buildSessionMeta() {
422
+ return undefined;
423
+ },
424
+
425
+ // Per-turn enforcement via session/set_config_option — the SAME wire call the
426
+ // claude adapter makes, measured live against cortex v1.1.52 (CP0-M): the
427
+ // option id is "model", accepted values are the NATIVE ids (`auto`,
428
+ // `claude-sonnet-5`, `openai-gpt-5.4`, …), and a value cortex no longer
429
+ // serves fails loud BEFORE the prompt (`Unsupported model: …`). PR #40's
430
+ // launch-time `-m` pin was retired for this: set-model is the single model
431
+ // authority (resolveLaunch never passes `-m`), and "auto" is set explicitly
432
+ // rather than treated as an unspoken default.
433
+ async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
434
+ const setConfig = connection.setSessionConfigOption;
435
+ if (typeof setConfig !== "function") {
436
+ throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
437
+ }
438
+ await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
439
+ },
440
+
441
+ // A connection change must invalidate a reused session (§4/§7). Flat,
442
+ // sorted-stable primitive map; reads ONLY the opaque adapterSettings. `backend`
443
+ // + `nativeModelId` are added by backend.ts.
444
+ configSignatureFields(adapterSettings) {
445
+ const settings = adapterSettings as CortexAdapterSettings | undefined;
446
+ return { cortexConnection: settings?.cortexConnection ?? null };
447
+ },
448
+ };
449
+
450
+ const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
276
451
 
277
452
  /**
278
453
  * Resolve the backend adapter that owns `modelId`.
@@ -644,7 +644,11 @@ export function streamAcpTurn(
644
644
  pushAcpLifecycleNotice(state, `preparing ${adapter.backend} session`);
645
645
  // GPT §9-5: materialize the overlay first, then spawn with launchEnvDefaults
646
646
  // + overlay.envOverrides merged over process.env (defaultDeps spawnChild).
647
- const overlay = adapter.ensureOverlay({ cwd, modelId: model.id, nativeModelId, config });
647
+ // sessionKey is the AUTHORITATIVE per-session identity (resolveSessionKey:
648
+ // opts.sessionId → PI_SESSION_ID → cwd) — a session-scoped overlay must
649
+ // scope on it, so it rides the params explicitly and the adapter never
650
+ // re-derives a weaker key from ambient env (P0-1).
651
+ const overlay = adapter.ensureOverlay({ cwd, modelId: model.id, nativeModelId, config, sessionKey });
648
652
  const launch = adapter.resolveLaunch({ cwd, modelId: model.id, nativeModelId, config });
649
653
  child = deps.spawnChild(launch, cwd, { ...adapter.launchEnvDefaults(), ...overlay.envOverrides });
650
654
  const spawned = child;
@@ -8,11 +8,23 @@
8
8
  // config and hands it to the backend so the documented passthrough actually
9
9
  // reaches `newSession`.
10
10
  //
11
- // Claude-only scope (NEXT §스코프). Ported from the v0.11.0 behavior oracle
12
- // (index.ts `loadProviderSettings`/`readSettingsFile` + acp-bridge.ts
11
+ // Scope. Ported from the v0.11.0 behavior oracle (index.ts
12
+ // `loadProviderSettings`/`readSettingsFile` + acp-bridge.ts
13
13
  // `normalizeMcpServers`/`enrichMcpServersWithEnvelope`) — structure new, behavior
14
- // preserved. Codex/Gemini fields (codexDisabledFeatures, …) are out of scope and
15
- // deliberately NOT carried.
14
+ // preserved. Retired Codex/Gemini-era fields (codexDisabledFeatures, …) are NOT
15
+ // carried and are silently ignored if present.
16
+ //
17
+ // The COMMON keys parsed here are Claude-SHAPED by origin (tools / permissionAllow /
18
+ // disallowedTools / settingSources / skillPlugins are claude-agent-acp's surface).
19
+ // What varies per backend is whether they are PROJECTED ONTO THE BACKEND SESSION —
20
+ // not whether they are read at all. Cortex runs native tools and reaches MCP through
21
+ // its own overlay mcp.json projection, so none of those keys shape a cortex session;
22
+ // they nonetheless stay live on the common path, where backend.ts reads `tools` for
23
+ // the backend-invariant exclude-tools preflight and folds every one of them into
24
+ // `bridgeConfigSignature` (so changing one still invalidates a reused cortex
25
+ // session). Backend-OWNED settings never land here: they ride the opaque
26
+ // `adapterSettings` seam (§10 B), which is what keeps a backend-named key like
27
+ // `cortexConnection` off this common shape.
16
28
  //
17
29
  // PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
18
30
  // - `normalizeMcpServers` is pure: a SORTED, validated server list + a sha256
@@ -71,7 +83,9 @@ export interface ProviderSettings {
71
83
  disallowedTools?: string[];
72
84
  }
73
85
 
74
- /** The fully-resolved Claude provider config the backend hands to newSession. */
86
+ /** The fully-resolved common provider config the turn loop hands to newSession.
87
+ * Claude-shaped by origin; a backend reads only the fields it consumes, and its
88
+ * OWN keys ride `adapterSettings`. */
75
89
  export interface ResolvedAcpConfig {
76
90
  /** Operator-declared backend (diagnostic guard only — see ProviderSettings.backend).
77
91
  * backend.ts asserts this matches the adapter the model id routes to. */
@@ -44,7 +44,9 @@ const HERE = dirname(fileURLToPath(import.meta.url));
44
44
  const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
45
45
 
46
46
  export interface EngravingParams {
47
- /** Claude-only this cut; kept as a field so the `{{backend}}` token interpolates. */
47
+ /** Always "claude" in practice a system-prompt-carrier-less backend (cortex)
48
+ * returns null from `loadCarrier` WITHOUT calling this loader, so claudeAdapter is
49
+ * its only caller. Kept as a field so the `{{backend}}` token interpolates. */
48
50
  backend: string;
49
51
  /** MCP server names exposed to the session. SORTED before render for determinism. */
50
52
  mcpServerNames: readonly string[];
@@ -4,11 +4,18 @@
4
4
  // AssistantMessageEvent protocol (text/thinking blocks + tool/permission
5
5
  // notices + usage), maintaining a running `partial: AssistantMessage`.
6
6
  //
7
- // Claude-only collapse (NEXT §스코프 + oracle F): the 0.11.0 event-mapper
7
+ // Single-dialect collapse (NEXT §스코프 + oracle F): the 0.11.0 event-mapper
8
8
  // reconciled three backend dialects (Claude rawOutput=array / Codex
9
9
  // CallToolResult / Gemini content[]) plus an entwurf_v2 sent-box custom promotion.
10
- // This lane drives ONLY claude-agent-acp, so the dialect collapses to one
11
- // (rawOutput=array) and the entwurf/gemini/codex special-casing is dropped.
10
+ // This lane collapsed that to ONE dialect (rawOutput=array) and dropped the
11
+ // entwurf/gemini/codex special-casing.
12
+ //
13
+ // This module is COMMON layer, not a claude module: the cortex landing reused THIS
14
+ // FILE with no adapter branch and no edit, and the CP2 LIVE turn ran through it.
15
+ // (That is a fact about this mapper only — the landing did touch other common files,
16
+ // e.g. backend.ts's overlay call site.) The collapse is therefore a standing bet,
17
+ // not a proof: a future backend whose session_notification dialect differs from
18
+ // rawOutput=array reopens this file rather than forking it per backend.
12
19
  //
13
20
  // CRITICAL boundary (GPT S2c Q3): an ACP `tool_call` / `tool_call_update` is
14
21
  // rendered as an INFORMATIONAL TEXT NOTICE, never a structured pi `toolcall_*`