@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
@@ -39,6 +39,18 @@ export function connectAcpClient(stream, handlers) {
39
39
  newSession: (params) => agent.request(AGENT_METHODS.session_new, params),
40
40
  prompt: (params) => agent.request(AGENT_METHODS.session_prompt, params),
41
41
  setSessionConfigOption: (params) => agent.request(AGENT_METHODS.session_set_config_option, params),
42
+ cancel: (params) => {
43
+ // Notification, not a request: nothing resolves it, and the connection
44
+ // may already be closing when the operator aborts. Swallow both the sync
45
+ // throw and the rejected send — the caller's abort path continues either
46
+ // way (it escalates to teardown after a bounded grace).
47
+ try {
48
+ void Promise.resolve(agent.notify(AGENT_METHODS.session_cancel, params)).catch(() => { });
49
+ }
50
+ catch {
51
+ // connection already closed — the abort path escalates on its own.
52
+ }
53
+ },
42
54
  close: (error) => {
43
55
  // Best-effort by contract (see AcpConnectionLike.close): a teardown-path
44
56
  // close that threw would mask the turn's real error and skip the child
@@ -1,9 +1,10 @@
1
1
  // ACP backend adapter rail — the PRODUCT seam by which a curated model id selects
2
- // which ACP backend (claude / future backend / …) drives a turn. See docs/acp-backend-rail.md §9.
2
+ // which ACP backend (claude / future backend / …) drives a turn. See the adapter contract in
3
+ // docs/acp-backend-rail.md.
3
4
  //
4
5
  // This seam is DISTINCT from `AcpTurnDeps` (backend.ts), which is the test/runtime
5
- // seam (fake spawn/connection/clock for the gates). The two are kept apart on
6
- // purpose (GPT-agreed §9-2): merging them would make a fake-deps fixture look like
6
+ // seam (fake spawn/connection/clock for the gates). Merging them would make a
7
+ // fake-deps fixture look like
7
8
  // a fake backend and force the adapter to carry clock/sessionDir/createConnection.
8
9
  // The wiring is `defaultDeps(adapter)` — the turn loop in backend.ts stays
9
10
  // backend-invariant; only these per-backend functions change with `adapter`.
@@ -18,11 +19,22 @@
18
19
  // lib/acp modules — no new strip-types fence.
19
20
  import { readFileSync } from "node:fs";
20
21
  import { createRequire } from "node:module";
22
+ import { homedir } from "node:os";
21
23
  import { dirname, join } from "node:path";
24
+ import { enrichMcpServersWithEnvelope } from "./config.js";
22
25
  import { loadEngraving } from "./engraving.js";
23
- import { curatedClaudeModels, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./models.js";
24
- import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay } from "./overlay.js";
26
+ import { CORTEX_MODEL_PREFIX, curatedClaudeModels, curatedCortexModels, SUPPORTED_ANTHROPIC_MODEL_IDS, SUPPORTED_CORTEX_MODEL_IDS, } from "./models.js";
27
+ import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay, ensureCortexDualHomeOverlay } from "./overlay.js";
25
28
  import { buildClaudeSessionMeta } from "./tool-surface.js";
29
+ // POSIX-safe single-quote wrapper for shell arg interpolation. Byte-for-byte
30
+ // identical to the reference in entwurf-core.ts; PARITY-PINNED by
31
+ // scripts/check-shell-quote.ts (SOURCE_SITES). Used only by the cortex override
32
+ // path below, where operator-configured connection/model tokens are appended to
33
+ // an operator `bash -lc` string — quoting keeps a connection name with shell
34
+ // metacharacters from being reinterpreted by the shell.
35
+ function shellQuote(value) {
36
+ return `'${value.replace(/'/g, `'\\''`)}'`;
37
+ }
26
38
  // ---------------------------------------------------------------------------
27
39
  // claude adapter — the first implementation (the rail's reference backend)
28
40
  // ---------------------------------------------------------------------------
@@ -43,7 +55,7 @@ function resolveClaudeLaunch() {
43
55
  }
44
56
  export const claudeAdapter = {
45
57
  backend: "claude",
46
- // Claude owns its UNPREFIXED curated ids only (GPT-agreed §9-1). The native id
58
+ // Claude owns its UNPREFIXED curated ids only (rail “Adapter contract”). The native id
47
59
  // equals the curated id — claude carries no curation prefix to strip.
48
60
  routeModel(modelId) {
49
61
  return SUPPORTED_CLAUDE_IDS.has(modelId) ? { nativeModelId: modelId } : undefined;
@@ -105,13 +117,145 @@ export const claudeAdapter = {
105
117
  // Registry — modelId → adapter
106
118
  // ---------------------------------------------------------------------------
107
119
  /** Registered adapters. Order carries NO routing authority — routeModel decides.
108
- * Step A: claude only. A second backend appends here with its reserved prefix
109
- * (e.g. `<backend>-*`), and the fail-fast below proves no two adapters claim one id. */
110
- const ADAPTERS = [claudeAdapter];
120
+ * claude (unprefixed ids) + cortex (the `cortex-` prefix). A further backend
121
+ * appends here with its own reserved prefix; the fail-fast below proves no two
122
+ * adapters claim one id. */
123
+ // ---------------------------------------------------------------------------
124
+ // cortex adapter — Snowflake Cortex Code, the first non-claude backend on the
125
+ // rail (docs/acp-backend-rail.md, “Shipped adapters”). It adds ZERO to the common layer:
126
+ // everything cortex-specific lives here + models.ts + overlay.ts + the gates.
127
+ // ---------------------------------------------------------------------------
128
+ const SUPPORTED_CORTEX_IDS = new Set(SUPPORTED_CORTEX_MODEL_IDS);
129
+ /** The env var an operator sets to pin a Snowflake connection per-shell without
130
+ * editing settings.json. Wins over `entwurfProvider.cortexConnection`. The
131
+ * ENTWURF_ACP_* convention (PR #40's legacy PI_SHELL_ACP* cortex-connection var renamed). */
132
+ export const CORTEX_CONNECTION_ENV = "ENTWURF_ACP_CORTEX_CONNECTION";
133
+ export const cortexAdapter = {
134
+ backend: "cortex",
135
+ // Cortex owns the reserved `cortex-` prefix (rail “Adapter contract”). routeModel strips it to the
136
+ // native id: `cortex-auto` → "auto", `cortex-claude-sonnet-5` → "claude-sonnet-5".
137
+ routeModel(modelId) {
138
+ if (!SUPPORTED_CORTEX_IDS.has(modelId))
139
+ return undefined;
140
+ return { nativeModelId: modelId.slice(CORTEX_MODEL_PREFIX.length) };
141
+ },
142
+ curatedModels() {
143
+ return curatedCortexModels();
144
+ },
145
+ // Cortex's ONLY own setting is the connection name. env override wins over
146
+ // settings (per-shell pin); empty/whitespace → null (Cortex falls back to its
147
+ // own default connection). A non-string settings value fails loud.
148
+ resolveAdapterSettings({ mergedBlock, projectBlock, globalPath, projectPath }) {
149
+ const raw = mergedBlock.cortexConnection;
150
+ if (raw !== undefined && typeof raw !== "string") {
151
+ const offending = projectBlock.cortexConnection !== undefined ? projectPath : globalPath;
152
+ throw new Error(`${offending}: invalid entwurfProvider settings: cortexConnection must be a string`);
153
+ }
154
+ const envConn = process.env[CORTEX_CONNECTION_ENV]?.trim();
155
+ const settingsConn = raw?.trim();
156
+ const cortexConnection = envConn || settingsConn || null;
157
+ return { cortexConnection };
158
+ },
159
+ // `cortex acp serve` resolved from PATH (the CLI itself IS the ACP server — no
160
+ // `*-acp` npm package, unlike claude). `-c <conn>` appended when a connection
161
+ // is pinned. NO `-m`: the model is enforced per-turn via
162
+ // session/set_config_option (enforceModel below, CP0-M measured GO) and a
163
+ // launch pin would be a SECOND model authority that drifts from it.
164
+ // CORTEX_ACP_COMMAND override runs via `bash -lc` with the selection flags
165
+ // appended so the bridge's choice wins (later yargs args override earlier ones).
166
+ resolveLaunch({ config }) {
167
+ const settings = config.adapterSettings;
168
+ const connection = settings?.cortexConnection?.trim() || undefined;
169
+ const selectionArgs = [];
170
+ if (connection)
171
+ selectionArgs.push("-c", connection);
172
+ const override = process.env.CORTEX_ACP_COMMAND?.trim();
173
+ if (override) {
174
+ const command = selectionArgs.length > 0 ? `${override} ${selectionArgs.map(shellQuote).join(" ")}` : override;
175
+ return { command: "bash", args: ["-lc", command] };
176
+ }
177
+ return { command: "cortex", args: ["acp", "serve", ...selectionArgs] };
178
+ },
179
+ // The overlay location is SESSION-SCOPED (never static), so the spawn env
180
+ // rides ensureOverlay(...).envOverrides; there is no static launch env. The
181
+ // v1.1.8-era CORTEX_DISABLE_AUTO_APPLY_PROFILES knob was retired with the
182
+ // dual-HOME redesign: profiles now live inside the overlay-owned isolated
183
+ // home (empty by construction), and the knob is unmeasured on v1.1.52.
184
+ launchEnvDefaults() {
185
+ return {};
186
+ },
187
+ // Dual-HOME containment (CP0 D2/D3/D9/D10 — see the overlay module header):
188
+ // refuse an ambient CORTEX_HOME outright, then materialize the session-scoped
189
+ // isolated HOME with auth symlinks, `autoUpdate:false`, and the mcp.json
190
+ // projection of the envelope-enriched explicit servers (cortex ignores the
191
+ // wire mcpServers param, so this file IS how tools reach a cortex session).
192
+ ensureOverlay({ modelId, config, sessionKey }) {
193
+ // D3 — presence refusal, empty string included: upstream's resolver treats
194
+ // a set-but-empty CORTEX_HOME differently from unset, and one ambient value
195
+ // would silently bypass SNOWFLAKE_HOME (the probe's CLAUDE_CODE_EXECUTABLE
196
+ // precondition is the same family). Refuse the ambiguity; never pick a side.
197
+ if ("CORTEX_HOME" in process.env) {
198
+ throw new Error("entwurf: CORTEX_HOME is present in the environment (empty string included) — it overrides " +
199
+ "SNOWFLAKE_HOME inside cortex and would bypass the dual-HOME overlay entirely (CP0 D3). " +
200
+ "Unset it to run a cortex ACP turn.");
201
+ }
202
+ // The scope authority is the AUTHORITATIVE params.sessionKey backend.ts
203
+ // computed — never an ambient re-derivation, which would drop opts.sessionId
204
+ // and alias two same-process/cwd sessions onto one overlay (P0-1). The
205
+ // envelope below still reads PI_SESSION_ID: that is the identity CARRIER for
206
+ // the bridge child (the same source the turn loop's wire enrichment uses),
207
+ // a different axis from overlay-dir scoping.
208
+ const piSessionId = process.env.PI_SESSION_ID?.trim() || undefined;
209
+ const enriched = enrichMcpServersWithEnvelope(config.mcpServers, { modelId, piSessionId });
210
+ const overlay = ensureCortexDualHomeOverlay({
211
+ scopeKey: sessionKey,
212
+ mcpServers: enriched,
213
+ realHome: homedir(),
214
+ });
215
+ return { envOverrides: { HOME: overlay.home, SNOWFLAKE_HOME: overlay.snowflakeHome } };
216
+ },
217
+ // System-prompt-carrier-less (ACP rail “Cortex Code audit”): Cortex ACP exposes no
218
+ // `_meta.systemPrompt` and has no developer_instructions / GEMINI_SYSTEM_MD
219
+ // equivalent. (It does READ `_meta` — a caller-session-id seam, measured but
220
+ // unexplored and deliberately not part of this contract.) loadCarrier returns
221
+ // null WITHOUT calling loadEngraving, so the cortex turn never touches the
222
+ // shipped-engraving / appendSystemPrompt signature; buildSessionMeta returns
223
+ // undefined so backend.ts omits the `_meta` key entirely. The operator
224
+ // engraving instead rides the first-user augment (augment.ts).
225
+ loadCarrier() {
226
+ return null;
227
+ },
228
+ buildSessionMeta() {
229
+ return undefined;
230
+ },
231
+ // Per-turn enforcement via session/set_config_option — the SAME wire call the
232
+ // claude adapter makes, measured live against cortex v1.1.52 (CP0-M): the
233
+ // option id is "model", accepted values are the NATIVE ids (`auto`,
234
+ // `claude-sonnet-5`, `openai-gpt-5.4`, …), and a value cortex no longer
235
+ // serves fails loud BEFORE the prompt (`Unsupported model: …`). PR #40's
236
+ // launch-time `-m` pin was retired for this: set-model is the single model
237
+ // authority (resolveLaunch never passes `-m`), and "auto" is set explicitly
238
+ // rather than treated as an unspoken default.
239
+ async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
240
+ const setConfig = connection.setSessionConfigOption;
241
+ if (typeof setConfig !== "function") {
242
+ throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
243
+ }
244
+ await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
245
+ },
246
+ // A connection change must invalidate a reused session (rail: Adapter contract). Flat,
247
+ // sorted-stable primitive map; reads ONLY the opaque adapterSettings. `backend`
248
+ // + `nativeModelId` are added by backend.ts.
249
+ configSignatureFields(adapterSettings) {
250
+ const settings = adapterSettings;
251
+ return { cortexConnection: settings?.cortexConnection ?? null };
252
+ },
253
+ };
254
+ const ADAPTERS = [claudeAdapter, cortexAdapter];
111
255
  /**
112
256
  * Resolve the backend adapter that owns `modelId`.
113
257
  *
114
- * GPT-agreed §9-1 fail-fast contract:
258
+ * Routing fail-fast contract (rail “Adapter contract”):
115
259
  * - 0 matches → throw (unknown model — no silent default).
116
260
  * - 2+ matches → throw (prefix collision — a startup-visible registry bug).
117
261
  *
@@ -135,7 +279,7 @@ export function resolveAcpBackendAdapter(modelId) {
135
279
  return matches[0];
136
280
  }
137
281
  /** Every curated model row across all registered adapters — for provider registration.
138
- * GPT-agreed §9-6 registration-time fail-fast: every curated id must route to EXACTLY
282
+ * Registration-time fail-fast (rail “Adapter contract”): every curated id must route to EXACTLY
139
283
  * one adapter and no id may be duplicated across adapters. Catching it here means a
140
284
  * prefix-collision / duplicate surfaces at provider registration, not mid-turn. */
141
285
  export function allCuratedModels() {
@@ -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 (rail “Adapter contract”), 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
@@ -35,11 +35,30 @@
35
35
  // incompatible and the next turn opens a fresh ACP session with the new carrier.
36
36
  // That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
37
37
  // shipped default (which stays cached precisely so a resident never rebuilds).
38
+ //
39
+ // A-JOIN (measured LIVE 2026-07-31, 0.64.0 adapter, fresh Claude ACP): the model's
40
+ // system prompt arrived as
41
+ // `You are a Claude agent, built on Anthropic's Claude Agent SDK.# Engraving Here`
42
+ // A string-form `_meta.systemPrompt` replaces the `claude_code` preset, but the
43
+ // SDK still PREFIXES its own fixed identity sentence and joins the two with
44
+ // NOTHING — so the operator's heading was swallowed into the tail of the SDK's
45
+ // sentence. The boundary therefore belongs to the CARRIER, and it cannot be
46
+ // delegated to engraving.md: the render is trimmed (below), so a leading blank
47
+ // line in the markdown is eaten before it ever reaches the wire.
38
48
  import { readFileSync } from "node:fs";
39
49
  import { dirname, join, resolve } from "node:path";
40
50
  import { fileURLToPath } from "node:url";
41
51
  const HERE = dirname(fileURLToPath(import.meta.url));
42
52
  const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
53
+ /**
54
+ * The carrier's LEADING boundary — the one the Claude Agent SDK does not supply
55
+ * (see A-JOIN above). A constant, never derived from the template, so the render
56
+ * stays a pure function of (template, backend, mcpServerNames) and the operator's
57
+ * file whitespace can never drift `bridgeConfigSignature`. One blank line is the
58
+ * whole lever: it puts the carrier's first line at the start of its own block
59
+ * instead of at the end of the SDK's fixed sentence.
60
+ */
61
+ export const CARRIER_LEAD_SEPARATOR = "\n\n";
43
62
  let cached = null;
44
63
  /** Point the loader at an alternate engraving file (A/B); bypasses the cache. */
45
64
  function resolveEngravingPath() {
@@ -65,6 +84,22 @@ function interpolate(template, params) {
65
84
  const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
66
85
  return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
67
86
  }
87
+ /**
88
+ * Render one template into a wire-ready carrier, or `""` when the template has no
89
+ * body (the caller turns that into the opt-out / fail-loud branches).
90
+ *
91
+ * The ORDER here is the A-join fix. Trim first: the operator template's own
92
+ * leading/trailing whitespace must not reach the wire (it would drift
93
+ * bridgeConfigSignature) and the emptiness test must see the BODY — a
94
+ * separator-only string is an opt-out, not a carrier. Then attach OUR boundary,
95
+ * which is what the SDK's fixed sentence has nothing of.
96
+ */
97
+ function renderCarrier(source, params) {
98
+ const body = interpolate(source, params).trim();
99
+ if (body.length === 0)
100
+ return "";
101
+ return `${CARRIER_LEAD_SEPARATOR}${body}`;
102
+ }
68
103
  /**
69
104
  * The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
70
105
  * (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
@@ -77,6 +112,12 @@ function interpolate(template, params) {
77
112
  * Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
78
113
  * entirely (passing "" as the `appendSystemPrompt` signature input) so
79
114
  * subscription billing is never reclassified.
115
+ *
116
+ * A non-null carrier always LEADS with `CARRIER_LEAD_SEPARATOR`, and callers must
117
+ * pass it on BYTE-FOR-BYTE: the same string feeds `bridgeConfigSignature`
118
+ * (`appendSystemPrompt`) and the wire (`_meta.systemPrompt`), so normalizing it at
119
+ * either hop both re-opens the A-join and makes reuse key on a string that was
120
+ * never sent.
80
121
  */
81
122
  export function loadEngraving(params) {
82
123
  const filePath = resolveEngravingPath();
@@ -92,7 +133,7 @@ export function loadEngraving(params) {
92
133
  }
93
134
  return null;
94
135
  }
95
- const rendered = interpolate(source, params).trim();
136
+ const rendered = renderCarrier(source, params);
96
137
  if (rendered.length === 0) {
97
138
  if (isShippedDefault) {
98
139
  throw new Error(`entwurf: shipped engraving carrier at ${filePath} is empty — it is the auto-memory ` +
@@ -1,14 +1,17 @@
1
- // ACP plugin — curated Claude model surface (S0 loader/fence slice).
1
+ // ACP plugin — curated model surface (S0 loader/fence slice; cortex rows added
2
+ // in 0.13).
2
3
  //
3
4
  // entwurf is an ACP *plugin* on the v2 core, NOT a general-purpose
4
5
  // Anthropic provider. It deliberately does not expose the full pi-ai model
5
- // registry — the surface is curated to the Claude anchor the plugin commits to
6
- // driving through a real ACP backend (the backend lands in S2; S0 only stands
7
- // the provider/model surface up).
6
+ // registry — the surface is curated to the ids the plugin commits to driving
7
+ // through a real ACP backend.
8
8
  //
9
- // Claude-first scope (NEXT §스코프): Codex is already a native garden citizen
10
- // and Gemini / major tools use native, so the curated surface is Claude-only on
11
- // this lane. A second governed backend would EXTEND this set — it does not change
9
+ // Two curated sets live here, one per adapter on the rail
10
+ // (backend-adapter.ts): the unprefixed Claude ids (the reference backend) and
11
+ // the `cortex-` prefixed Snowflake Cortex Code ids (the second backend, landed
12
+ // 0.13.0 — docs/acp-backend-rail.md “Cortex Code audit”). Codex has native
13
+ // delivery-probe evidence but no managed citizen lane; neither it nor Gemini is an ACP backend here.
14
+ // A third governed backend EXTENDS this file the same way — it does not change
12
15
  // the pattern.
13
16
  // pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
14
17
  // `@earendil-works/pi-ai/compat` entrypoint (the global-API churn). We import it
@@ -71,6 +74,62 @@ function requireRegistryModel(models, id) {
71
74
  function claudeContextWindow(model) {
72
75
  return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
73
76
  }
77
+ // ── Cortex (Snowflake Cortex Code) curated surface ──────────────────────────
78
+ //
79
+ // entwurf's ACP rail is backend-extensible: Cortex is the first NON-claude
80
+ // backend to land through it (docs/acp-backend-rail.md, “Shipped adapters”). pi-ai carries no
81
+ // snowflake/cortex model source, so this surface is HAND-CURATED. Every id
82
+ // carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
83
+ // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
84
+ // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
85
+ // `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
86
+ // recover the native id, which is enforced per-turn via
87
+ // session/set_config_option — never a launch `-m` pin (CP0-M).
88
+ //
89
+ // The 4-row set is the GLG-decided curation (2026-07-29) — a subset of the 11
90
+ // native ids cortex v1.1.52 exposed live when authenticated. A curated id that
91
+ // the running cortex no longer serves fails LOUD at set-model, before the
92
+ // prompt (measured: `Unsupported model: …` names the live set). Adding an id
93
+ // here is the same verify-both-axes commitment as the Claude set — do not
94
+ // extend casually.
95
+ export const CORTEX_MODEL_PREFIX = "cortex-";
96
+ export const SUPPORTED_CORTEX_MODEL_IDS = [
97
+ "cortex-auto",
98
+ "cortex-claude-opus-5",
99
+ "cortex-claude-sonnet-5",
100
+ "cortex-openai-gpt-5.4",
101
+ ];
102
+ // Hand-set conservative context window. Cortex reports the LIVE window via its
103
+ // own ACP session config, not this curated metadata — this is a floor for the
104
+ // registry surface, deliberately not inflated to the Claude 1M.
105
+ const CORTEX_CONTEXT_DEFAULT = 200_000;
106
+ /**
107
+ * The curated Cortex model rows handed to the single `entwurf` provider via
108
+ * `allCuratedModels()`. The two Claude rows ride their OWN registry base
109
+ * (opus-5 / sonnet-5); `auto` (default family Claude) and the GPT row (no pi-ai
110
+ * source) ride the sonnet-5 base as a metadata floor. id / name / contextWindow
111
+ * are overridden; the `cortex-` prefix keeps them from colliding with the
112
+ * Claude curated ids. Same row shape as `curatedClaudeModels()` (AcpModelRow).
113
+ */
114
+ export function curatedCortexModels() {
115
+ const sonnetBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-sonnet-5");
116
+ const opusBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-5");
117
+ const row = (id, name, base) => ({
118
+ id,
119
+ name,
120
+ reasoning: base.reasoning,
121
+ input: base.input,
122
+ cost: base.cost,
123
+ contextWindow: CORTEX_CONTEXT_DEFAULT,
124
+ maxTokens: base.maxTokens,
125
+ });
126
+ return [
127
+ row("cortex-auto", "Cortex · Auto", sonnetBase),
128
+ row("cortex-claude-opus-5", "Cortex · Claude Opus 5", opusBase),
129
+ row("cortex-claude-sonnet-5", "Cortex · Claude Sonnet 5", sonnetBase),
130
+ row("cortex-openai-gpt-5.4", "Cortex · OpenAI GPT-5.4", sonnetBase),
131
+ ];
132
+ }
74
133
  /**
75
134
  * The curated Claude model rows handed to `pi.registerProvider({ models })`.
76
135
  * Fail-loud if the anchor is absent from the pi-ai registry.
@@ -9,11 +9,15 @@
9
9
  // operator entries a backend needs (credentials, caches, built-in skills)
10
10
  // reachable through a TIGHT symlink whitelist — nothing else.
11
11
  //
12
- // Scope (NEXT §스코프 / §S2-scout 핀3): Claude-only on this lane. Claude is the one
13
- // shipped ACP backend, so this materializer is written for its config surface alone.
14
- // Codex/Gemini overlays (CODEX_HOME / admin.toml) are behavior-oracle territory and
15
- // out of scope here; a SECOND ACP backend enters only once the entry conditions in
16
- // docs/acp-backend-rail.md are met, and it brings its own overlay.
12
+ // Scope (NEXT §스코프 / §S2-scout 핀3): the block above describes the CLAUDE
13
+ // overlay `ensureClaudeConfigOverlay` and its symlink whitelist are written for
14
+ // claude-agent-acp's config surface alone. A second backend brings its OWN
15
+ // materializer, and cortex did: `ensureCortexDualHomeOverlay` (+ projectCortexMcpJson /
16
+ // sweepDeadCortexOverlays / cortexOverlayScopeId) lives further down this file with its
17
+ // own D-number rationale — an isolated HOME rather than a redirect knob, because cortex
18
+ // has no CLAUDE_CONFIG_DIR equivalent (ACP rail Cortex audit D2). The two share the module, never the
19
+ // strategy. Codex/Gemini overlays (CODEX_HOME / admin.toml) remain behavior-oracle
20
+ // territory and are out of scope here.
17
21
  //
18
22
  // Two deliberate divergences from the literal 0.11.0 illustrative comment block
19
23
  // (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
@@ -28,6 +32,7 @@
28
32
  // answering — 0.11.0 LIVE probe). Keeping it `{}` inherits NO operator hook —
29
33
  // which is exactly the "mailbox absence by design" the plugin commits to
30
34
  // (no meta-bridge hook on this child's settings surface → no mailbox).
35
+ import { createHash } from "node:crypto";
31
36
  import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
32
37
  import { homedir } from "node:os";
33
38
  import { join } from "node:path";
@@ -194,3 +199,183 @@ export function ensureClaudeConfigOverlay(realDir = CLAUDE_REAL_CONFIG_DIR, over
194
199
  }
195
200
  }
196
201
  }
202
+ // ============================================================================
203
+ // Cortex dual-HOME overlay — the as-measured containment for `cortex acp serve`
204
+ // (CP0 audit 2026-07-29, Cortex Code v1.1.52; docs/acp-backend-rail.md “Cortex Code audit”).
205
+ // ============================================================================
206
+ //
207
+ // Why this is NOT the claude-shaped `SNOWFLAKE_HOME`-only overlay PR #40 shipped
208
+ // (each point is a measured defect, not a preference — Cortex audit D-numbers):
209
+ //
210
+ // - D2: cortex reads `CONFIG_DIRS = [".claude", ".cortex"]` at `homedir()` and
211
+ // `~/.claude/skills` — a SNOWFLAKE_HOME redirect cannot move that axis. An
212
+ // overlay session carrying only auth symlinks still advertised the
213
+ // operator's 42 `~/.claude/skills` entries. Cortex has no
214
+ // `CLAUDE_CONFIG_DIR`-equivalent redirect knob (string occurs 0 times in the
215
+ // binary), so the only containment that closes the leak is an ISOLATED HOME:
216
+ // measured global-scope skill count 0, hook trace 0, bundled/project surface
217
+ // intact. Scope note: `homedir()`-anchored *operator-global* state is what
218
+ // the isolation closes; explicit cwd PROJECT scope (`<cwd>/.claude/*`) is
219
+ // retained by contract — a sibling working in a repo sees that repo's
220
+ // declared project surface.
221
+ // - D3: `CORTEX_HOME` beats `SNOWFLAKE_HOME` in cortex's own resolver, so one
222
+ // ambient operator variable would silently bypass the whole overlay. The
223
+ // adapter REFUSES to spawn when `CORTEX_HOME` is present at all (empty
224
+ // string included) — same presence-refusal family as the ordering probe's
225
+ // `CLAUDE_CODE_EXECUTABLE` precondition (backend-adapter.ts).
226
+ // - D9: cortex ACP `newSession` reads only `cwd` and `_meta` — the wire
227
+ // `mcpServers` param the backend-invariant turn loop passes is IGNORED. The
228
+ // explicit `entwurfProvider.mcpServers` (envelope-enriched) are therefore
229
+ // PROJECTED into the overlay-private `$SNOWFLAKE_HOME/cortex/mcp.json` (the
230
+ // door `cortex mcp add` writes). Exact-author every spawn; an entry type the
231
+ // file cannot represent fails loud BEFORE spawn (no silent drop).
232
+ // - D10: an isolated HOME also cuts `~/.pi/agent` (garden store, sockets,
233
+ // spawn surface) off the bundled entwurf-bridge — tools reach the model but
234
+ // see an EMPTY garden. Dual-HOME closes it: the `entwurf-bridge` mcp.json
235
+ // entry ALONE gets `HOME=<real operator home>` restored; every other MCP
236
+ // child stays in the isolated home. The real home is captured by the parent
237
+ // as an absolute path BEFORE spawn — never re-derived inside the child.
238
+ // - D4: cortex self-updates on launch by default, and `acp serve` accepts no
239
+ // `--no-auto-update` (the global flag position boots a TUI with exit 0 —
240
+ // protocol corruption, not a server). The one remaining door is the overlay
241
+ // writing `"autoUpdate": false` into its own `cortex/settings.json`. That is
242
+ // a mid-turn self-replacement OFF switch, not a version pin.
243
+ // - D5/F: auth passthrough is the measured MINIMUM: `connections.toml`,
244
+ // `config.toml` (optional — absent on the measured host),
245
+ // `cortex/cache/credential_cache` (auth succeeded with exactly this set).
246
+ // The WHOLE `cortex/cache` leaks operator tool_outputs/tip history; operator
247
+ // `cortex/skills` and operator `cortex/mcp.json` are denied outright.
248
+ // Symlink-through only — entwurf never copies/parses/mediates the Snowflake
249
+ // credential (AGENTS §ACP Plugin Boundary, Hard Rule 9).
250
+ //
251
+ // The overlay is SESSION/CHILD-SCOPED, never a static shared dir: two residents
252
+ // with different envelopes/configs would race one mcp.json. Scope id =
253
+ // `<host pid>-<sha256(scopeKey) 12 hex>`; the scope dir is torn down and
254
+ // exact-rewritten on every spawn (the prior child for the key is already dead —
255
+ // backend.ts tears it down before a "new" decision spawns), which is also the
256
+ // memory containment: nothing cortex wrote into the isolated home survives into
257
+ // the next session. Scope dirs whose host pid is gone are swept opportunistically.
258
+ /** Root under which per-session cortex dual-HOME overlays are materialized. */
259
+ export const CORTEX_OVERLAYS_ROOT = join(homedir(), ".pi", "agent", "cortex-overlays");
260
+ /** The name of the ONE mcp.json entry whose child gets the real operator HOME
261
+ * restored (D10 dual-HOME). Everything else stays in the isolated home. */
262
+ export const CORTEX_DUAL_HOME_BRIDGE_SERVER = "entwurf-bridge";
263
+ /** Deterministic per-(host process, session key) overlay dir name. */
264
+ export function cortexOverlayScopeId(scopeKey, pid = process.pid) {
265
+ const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 12);
266
+ return `${pid}-${digest}`;
267
+ }
268
+ /** Overlay-authored `$SNOWFLAKE_HOME/cortex/settings.json` — D4's one door. */
269
+ export function cortexOverlaySettingsJson() {
270
+ return `${JSON.stringify({ autoUpdate: false }, null, "\t")}\n`;
271
+ }
272
+ /**
273
+ * Project the (envelope-enriched) explicit server list into cortex's
274
+ * `$SNOWFLAKE_HOME/cortex/mcp.json` shape (D9). Only stdio entries are measured
275
+ * through this door — an http/sse entry fails loud BEFORE spawn rather than
276
+ * being silently dropped. The `entwurf-bridge` entry alone carries
277
+ * `HOME=<realHome>` (D10); a bridge-declared HOME env is overridden, not merged.
278
+ */
279
+ export function projectCortexMcpJson(servers, realHome) {
280
+ const out = {};
281
+ for (const server of servers) {
282
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
283
+ throw new Error(`entwurf: cortex mcp.json projection cannot represent ${server.type} server ` +
284
+ `${JSON.stringify(server.name)} — only stdio entries are measured through ` +
285
+ `$SNOWFLAKE_HOME/cortex/mcp.json (CP0 D9). Remove it from entwurfProvider.mcpServers ` +
286
+ `for cortex models or front it with a stdio bridge.`);
287
+ }
288
+ const stdio = server;
289
+ const env = {};
290
+ for (const kv of stdio.env)
291
+ env[kv.name] = kv.value;
292
+ if (stdio.name === CORTEX_DUAL_HOME_BRIDGE_SERVER)
293
+ env.HOME = realHome;
294
+ out[stdio.name] = { type: "stdio", command: stdio.command, args: [...stdio.args], env };
295
+ }
296
+ return `${JSON.stringify({ mcpServers: out }, null, "\t")}\n`;
297
+ }
298
+ function cortexPidAlive(pid) {
299
+ try {
300
+ process.kill(pid, 0);
301
+ return true;
302
+ }
303
+ catch (error) {
304
+ // EPERM = alive but not ours; anything else (ESRCH) = gone.
305
+ return error.code === "EPERM";
306
+ }
307
+ }
308
+ /**
309
+ * Opportunistic GC of overlay scope dirs whose HOST pid is gone (process
310
+ * resources only — the overlay holds no records/transcripts worth preserving;
311
+ * conversations state is contained BY DESIGN, Hard Rule 8 does not apply to it).
312
+ * Entries not matching the scope-id grammar are left alone.
313
+ */
314
+ export function sweepDeadCortexOverlays(overlaysRoot, isPidAlive = cortexPidAlive) {
315
+ let entries;
316
+ try {
317
+ entries = readdirSync(overlaysRoot);
318
+ }
319
+ catch {
320
+ return; // root absent — nothing to sweep
321
+ }
322
+ for (const entry of entries) {
323
+ const match = /^(\d+)-[0-9a-f]{12}$/.exec(entry);
324
+ if (!match)
325
+ continue;
326
+ const pid = Number(match[1]);
327
+ if (pid === process.pid)
328
+ continue;
329
+ if (isPidAlive(pid))
330
+ continue;
331
+ try {
332
+ rmSync(join(overlaysRoot, entry), { recursive: true, force: true });
333
+ }
334
+ catch {
335
+ // Best-effort GC; a stuck dir is retried on the next spawn.
336
+ }
337
+ }
338
+ }
339
+ /** Symlink `realPath` at `overlayPath` when the operator actually has it.
340
+ * The scope dir is freshly rebuilt by the caller, so no stale-link repair. */
341
+ function cortexLinkIfExists(realPath, overlayPath) {
342
+ if (!existsSync(realPath))
343
+ return;
344
+ try {
345
+ symlinkSync(realPath, overlayPath);
346
+ }
347
+ catch (error) {
348
+ console.error(`[entwurf:cortex-overlay] symlink failed for ${overlayPath}: ${error instanceof Error ? error.message : String(error)}`);
349
+ }
350
+ }
351
+ /**
352
+ * Materialize the session-scoped cortex dual-HOME overlay: tear down the scope
353
+ * dir and exact-rewrite it (auth symlinks + authored settings.json/mcp.json).
354
+ * Returns the isolated HOME/SNOWFLAKE_HOME the spawn env must carry.
355
+ */
356
+ export function ensureCortexDualHomeOverlay(params) {
357
+ if (!params.realHome || !params.realHome.startsWith("/")) {
358
+ throw new Error(`entwurf: cortex dual-HOME overlay requires an absolute realHome captured by the parent (got ${JSON.stringify(params.realHome)})`);
359
+ }
360
+ const overlaysRoot = params.overlaysRoot ?? CORTEX_OVERLAYS_ROOT;
361
+ const realSnowflake = params.realSnowflakeHome ?? join(params.realHome, ".snowflake");
362
+ sweepDeadCortexOverlays(overlaysRoot, params.isPidAlive ?? cortexPidAlive);
363
+ const scopeDir = join(overlaysRoot, cortexOverlayScopeId(params.scopeKey));
364
+ // Exact rewrite (never merge): the prior child for this scope key is already
365
+ // torn down (backend.ts closes it before a "new" decision spawns), so
366
+ // everything it wrote — conversations, logs, $HOME dotfiles — is discarded
367
+ // here. rmSync does not follow symlinks, so the real auth files are untouched.
368
+ rmSync(scopeDir, { recursive: true, force: true });
369
+ const home = join(scopeDir, "home");
370
+ const snowflakeHome = join(home, ".snowflake");
371
+ const cortexDir = join(snowflakeHome, "cortex");
372
+ mkdirSync(join(cortexDir, "cache"), { recursive: true });
373
+ // D5/F — measured-minimum auth passthrough (symlink-through only).
374
+ cortexLinkIfExists(join(realSnowflake, "connections.toml"), join(snowflakeHome, "connections.toml"));
375
+ cortexLinkIfExists(join(realSnowflake, "config.toml"), join(snowflakeHome, "config.toml"));
376
+ cortexLinkIfExists(join(realSnowflake, "cortex", "cache", "credential_cache"), join(cortexDir, "cache", "credential_cache"));
377
+ // D4 — runtime self-replacement off; D9 — explicit-server projection.
378
+ writeFileSync(join(cortexDir, "settings.json"), cortexOverlaySettingsJson(), "utf8");
379
+ writeFileSync(join(cortexDir, "mcp.json"), projectCortexMcpJson(params.mcpServers, params.realHome), "utf8");
380
+ return { home, snowflakeHome, scopeDir };
381
+ }