@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
@@ -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 §6/§11-8). Codex is already a native garden
13
+ // citizen and Gemini/major tools use native, so neither 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
 
14
17
  // pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
@@ -81,6 +84,65 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
81
84
  return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
82
85
  }
83
86
 
87
+ // ── Cortex (Snowflake Cortex Code) curated surface ──────────────────────────
88
+ //
89
+ // entwurf's ACP rail is backend-extensible: Cortex is the first NON-claude
90
+ // backend to land through it (docs/acp-backend-rail.md §6). pi-ai carries no
91
+ // snowflake/cortex model source, so this surface is HAND-CURATED. Every id
92
+ // carries the reserved `cortex-` prefix (§9-1): the prefix is the SINGLE routing
93
+ // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
94
+ // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
95
+ // `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
96
+ // recover the native id, which is enforced per-turn via
97
+ // session/set_config_option — never a launch `-m` pin (CP0-M).
98
+ //
99
+ // The 4-row set is the GLG-decided curation (2026-07-29) — a subset of the 11
100
+ // native ids cortex v1.1.52 exposed live when authenticated. A curated id that
101
+ // the running cortex no longer serves fails LOUD at set-model, before the
102
+ // prompt (measured: `Unsupported model: …` names the live set). Adding an id
103
+ // here is the same verify-both-axes commitment as the Claude set — do not
104
+ // extend casually.
105
+ export const CORTEX_MODEL_PREFIX = "cortex-";
106
+ export const SUPPORTED_CORTEX_MODEL_IDS = [
107
+ "cortex-auto",
108
+ "cortex-claude-opus-5",
109
+ "cortex-claude-sonnet-5",
110
+ "cortex-openai-gpt-5.4",
111
+ ] as const;
112
+
113
+ // Hand-set conservative context window. Cortex reports the LIVE window via its
114
+ // own ACP session config, not this curated metadata — this is a floor for the
115
+ // registry surface, deliberately not inflated to the Claude 1M.
116
+ const CORTEX_CONTEXT_DEFAULT = 200_000;
117
+
118
+ /**
119
+ * The curated Cortex model rows handed to the single `entwurf` provider via
120
+ * `allCuratedModels()`. The two Claude rows ride their OWN registry base
121
+ * (opus-5 / sonnet-5); `auto` (default family Claude) and the GPT row (no pi-ai
122
+ * source) ride the sonnet-5 base as a metadata floor. id / name / contextWindow
123
+ * are overridden; the `cortex-` prefix keeps them from colliding with the
124
+ * Claude curated ids. Same row shape as `curatedClaudeModels()` (AcpModelRow).
125
+ */
126
+ export function curatedCortexModels() {
127
+ const sonnetBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-sonnet-5");
128
+ const opusBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-5");
129
+ const row = (id: string, name: string, base: RegistryModel) => ({
130
+ id,
131
+ name,
132
+ reasoning: base.reasoning,
133
+ input: base.input,
134
+ cost: base.cost,
135
+ contextWindow: CORTEX_CONTEXT_DEFAULT,
136
+ maxTokens: base.maxTokens,
137
+ });
138
+ return [
139
+ row("cortex-auto", "Cortex · Auto", sonnetBase),
140
+ row("cortex-claude-opus-5", "Cortex · Claude Opus 5", opusBase),
141
+ row("cortex-claude-sonnet-5", "Cortex · Claude Sonnet 5", sonnetBase),
142
+ row("cortex-openai-gpt-5.4", "Cortex · OpenAI GPT-5.4", sonnetBase),
143
+ ];
144
+ }
145
+
84
146
  /**
85
147
  * The curated Claude model rows handed to `pi.registerProvider({ models })`.
86
148
  * Fail-loud if the anchor is absent from the pi-ai registry.
@@ -9,9 +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. Codex/Gemini
13
- // overlays (CODEX_HOME / admin.toml) are 0.11.0 behavior-oracle territory and
14
- // out of scope the bridge backends are not in v2 yet.
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 (§11-8 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.
15
21
  //
16
22
  // Two deliberate divergences from the literal 0.11.0 illustrative comment block
17
23
  // (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
@@ -27,6 +33,7 @@
27
33
  // which is exactly the "mailbox absence by design" the plugin commits to
28
34
  // (no meta-bridge hook on this child's settings surface → no mailbox).
29
35
 
36
+ import { createHash } from "node:crypto";
30
37
  import {
31
38
  existsSync,
32
39
  lstatSync,
@@ -41,6 +48,8 @@ import {
41
48
  import { homedir } from "node:os";
42
49
  import { join } from "node:path";
43
50
 
51
+ import type { AcpMcpServer } from "./config.js";
52
+
44
53
  /** Operator's real Claude config dir — the symlink-passthrough SOURCE. */
45
54
  export const CLAUDE_REAL_CONFIG_DIR = join(homedir(), ".claude");
46
55
 
@@ -218,3 +227,225 @@ export function ensureClaudeConfigOverlay(
218
227
  }
219
228
  }
220
229
  }
230
+
231
+ // ============================================================================
232
+ // Cortex dual-HOME overlay — the as-measured containment for `cortex acp serve`
233
+ // (CP0 audit 2026-07-29, Cortex Code v1.1.52; docs/acp-backend-rail.md §4/§11-8).
234
+ // ============================================================================
235
+ //
236
+ // Why this is NOT the claude-shaped `SNOWFLAKE_HOME`-only overlay PR #40 shipped
237
+ // (each point is a measured defect, not a preference — §11-8 D-numbers):
238
+ //
239
+ // - D2: cortex reads `CONFIG_DIRS = [".claude", ".cortex"]` at `homedir()` and
240
+ // `~/.claude/skills` — a SNOWFLAKE_HOME redirect cannot move that axis. An
241
+ // overlay session carrying only auth symlinks still advertised the
242
+ // operator's 42 `~/.claude/skills` entries. Cortex has no
243
+ // `CLAUDE_CONFIG_DIR`-equivalent redirect knob (string occurs 0 times in the
244
+ // binary), so the only containment that closes the leak is an ISOLATED HOME:
245
+ // measured global-scope skill count 0, hook trace 0, bundled/project surface
246
+ // intact. Scope note: `homedir()`-anchored *operator-global* state is what
247
+ // the isolation closes; explicit cwd PROJECT scope (`<cwd>/.claude/*`) is
248
+ // retained by contract — a sibling working in a repo sees that repo's
249
+ // declared project surface.
250
+ // - D3: `CORTEX_HOME` beats `SNOWFLAKE_HOME` in cortex's own resolver, so one
251
+ // ambient operator variable would silently bypass the whole overlay. The
252
+ // adapter REFUSES to spawn when `CORTEX_HOME` is present at all (empty
253
+ // string included) — same presence-refusal family as the ordering probe's
254
+ // `CLAUDE_CODE_EXECUTABLE` precondition (backend-adapter.ts).
255
+ // - D9: cortex ACP `newSession` reads only `cwd` and `_meta` — the wire
256
+ // `mcpServers` param the backend-invariant turn loop passes is IGNORED. The
257
+ // explicit `entwurfProvider.mcpServers` (envelope-enriched) are therefore
258
+ // PROJECTED into the overlay-private `$SNOWFLAKE_HOME/cortex/mcp.json` (the
259
+ // door `cortex mcp add` writes). Exact-author every spawn; an entry type the
260
+ // file cannot represent fails loud BEFORE spawn (no silent drop).
261
+ // - D10: an isolated HOME also cuts `~/.pi/agent` (garden store, sockets,
262
+ // spawn surface) off the bundled entwurf-bridge — tools reach the model but
263
+ // see an EMPTY garden. Dual-HOME closes it: the `entwurf-bridge` mcp.json
264
+ // entry ALONE gets `HOME=<real operator home>` restored; every other MCP
265
+ // child stays in the isolated home. The real home is captured by the parent
266
+ // as an absolute path BEFORE spawn — never re-derived inside the child.
267
+ // - D4: cortex self-updates on launch by default, and `acp serve` accepts no
268
+ // `--no-auto-update` (the global flag position boots a TUI with exit 0 —
269
+ // protocol corruption, not a server). The one remaining door is the overlay
270
+ // writing `"autoUpdate": false` into its own `cortex/settings.json`. That is
271
+ // a mid-turn self-replacement OFF switch, not a version pin.
272
+ // - D5/F: auth passthrough is the measured MINIMUM: `connections.toml`,
273
+ // `config.toml` (optional — absent on the measured host),
274
+ // `cortex/cache/credential_cache` (auth succeeded with exactly this set).
275
+ // The WHOLE `cortex/cache` leaks operator tool_outputs/tip history; operator
276
+ // `cortex/skills` and operator `cortex/mcp.json` are denied outright.
277
+ // Symlink-through only — entwurf never copies/parses/mediates the Snowflake
278
+ // credential (AGENTS §ACP Plugin Boundary, Hard Rule 9).
279
+ //
280
+ // The overlay is SESSION/CHILD-SCOPED, never a static shared dir: two residents
281
+ // with different envelopes/configs would race one mcp.json. Scope id =
282
+ // `<host pid>-<sha256(scopeKey) 12 hex>`; the scope dir is torn down and
283
+ // exact-rewritten on every spawn (the prior child for the key is already dead —
284
+ // backend.ts tears it down before a "new" decision spawns), which is also the
285
+ // memory containment: nothing cortex wrote into the isolated home survives into
286
+ // the next session. Scope dirs whose host pid is gone are swept opportunistically.
287
+
288
+ /** Root under which per-session cortex dual-HOME overlays are materialized. */
289
+ export const CORTEX_OVERLAYS_ROOT = join(homedir(), ".pi", "agent", "cortex-overlays");
290
+
291
+ /** The name of the ONE mcp.json entry whose child gets the real operator HOME
292
+ * restored (D10 dual-HOME). Everything else stays in the isolated home. */
293
+ export const CORTEX_DUAL_HOME_BRIDGE_SERVER = "entwurf-bridge";
294
+
295
+ export interface CortexOverlayParams {
296
+ /** Session/child scope discriminator (PI_SESSION_ID, else a cwd-derived key).
297
+ * Identity for overlay-dir separation only — never an address authority. */
298
+ scopeKey: string;
299
+ /** Normalized + envelope-enriched MCP servers to project into mcp.json. */
300
+ mcpServers: readonly AcpMcpServer[];
301
+ /** Absolute real operator HOME captured by the parent BEFORE spawn (D10). */
302
+ realHome: string;
303
+ /** Real snowflake home (auth source). Defaults to `<realHome>/.snowflake`. */
304
+ realSnowflakeHome?: string;
305
+ /** Overlay root override (tests). Defaults to CORTEX_OVERLAYS_ROOT. */
306
+ overlaysRoot?: string;
307
+ /** pid-liveness probe override (tests) for the dead-scope sweep. */
308
+ isPidAlive?: (pid: number) => boolean;
309
+ }
310
+
311
+ export interface CortexOverlayResult {
312
+ /** Isolated HOME for the cortex child (spawn env HOME). */
313
+ home: string;
314
+ /** Isolated snowflake home (spawn env SNOWFLAKE_HOME) = `<home>/.snowflake`. */
315
+ snowflakeHome: string;
316
+ /** The scope dir owning both (for diagnostics/teardown). */
317
+ scopeDir: string;
318
+ }
319
+
320
+ /** Deterministic per-(host process, session key) overlay dir name. */
321
+ export function cortexOverlayScopeId(scopeKey: string, pid: number = process.pid): string {
322
+ const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 12);
323
+ return `${pid}-${digest}`;
324
+ }
325
+
326
+ /** Overlay-authored `$SNOWFLAKE_HOME/cortex/settings.json` — D4's one door. */
327
+ export function cortexOverlaySettingsJson(): string {
328
+ return `${JSON.stringify({ autoUpdate: false }, null, "\t")}\n`;
329
+ }
330
+
331
+ /**
332
+ * Project the (envelope-enriched) explicit server list into cortex's
333
+ * `$SNOWFLAKE_HOME/cortex/mcp.json` shape (D9). Only stdio entries are measured
334
+ * through this door — an http/sse entry fails loud BEFORE spawn rather than
335
+ * being silently dropped. The `entwurf-bridge` entry alone carries
336
+ * `HOME=<realHome>` (D10); a bridge-declared HOME env is overridden, not merged.
337
+ */
338
+ export function projectCortexMcpJson(servers: readonly AcpMcpServer[], realHome: string): string {
339
+ const out: Record<string, { type: "stdio"; command: string; args: string[]; env: Record<string, string> }> = {};
340
+ for (const server of servers) {
341
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
342
+ throw new Error(
343
+ `entwurf: cortex mcp.json projection cannot represent ${server.type} server ` +
344
+ `${JSON.stringify(server.name)} — only stdio entries are measured through ` +
345
+ `$SNOWFLAKE_HOME/cortex/mcp.json (CP0 D9). Remove it from entwurfProvider.mcpServers ` +
346
+ `for cortex models or front it with a stdio bridge.`,
347
+ );
348
+ }
349
+ const stdio = server as { name: string; command: string; args: string[]; env: { name: string; value: string }[] };
350
+ const env: Record<string, string> = {};
351
+ for (const kv of stdio.env) env[kv.name] = kv.value;
352
+ if (stdio.name === CORTEX_DUAL_HOME_BRIDGE_SERVER) env.HOME = realHome;
353
+ out[stdio.name] = { type: "stdio", command: stdio.command, args: [...stdio.args], env };
354
+ }
355
+ return `${JSON.stringify({ mcpServers: out }, null, "\t")}\n`;
356
+ }
357
+
358
+ function cortexPidAlive(pid: number): boolean {
359
+ try {
360
+ process.kill(pid, 0);
361
+ return true;
362
+ } catch (error) {
363
+ // EPERM = alive but not ours; anything else (ESRCH) = gone.
364
+ return (error as NodeJS.ErrnoException).code === "EPERM";
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Opportunistic GC of overlay scope dirs whose HOST pid is gone (process
370
+ * resources only — the overlay holds no records/transcripts worth preserving;
371
+ * conversations state is contained BY DESIGN, Hard Rule 8 does not apply to it).
372
+ * Entries not matching the scope-id grammar are left alone.
373
+ */
374
+ export function sweepDeadCortexOverlays(
375
+ overlaysRoot: string,
376
+ isPidAlive: (pid: number) => boolean = cortexPidAlive,
377
+ ): void {
378
+ let entries: string[];
379
+ try {
380
+ entries = readdirSync(overlaysRoot);
381
+ } catch {
382
+ return; // root absent — nothing to sweep
383
+ }
384
+ for (const entry of entries) {
385
+ const match = /^(\d+)-[0-9a-f]{12}$/.exec(entry);
386
+ if (!match) continue;
387
+ const pid = Number(match[1]);
388
+ if (pid === process.pid) continue;
389
+ if (isPidAlive(pid)) continue;
390
+ try {
391
+ rmSync(join(overlaysRoot, entry), { recursive: true, force: true });
392
+ } catch {
393
+ // Best-effort GC; a stuck dir is retried on the next spawn.
394
+ }
395
+ }
396
+ }
397
+
398
+ /** Symlink `realPath` at `overlayPath` when the operator actually has it.
399
+ * The scope dir is freshly rebuilt by the caller, so no stale-link repair. */
400
+ function cortexLinkIfExists(realPath: string, overlayPath: string): void {
401
+ if (!existsSync(realPath)) return;
402
+ try {
403
+ symlinkSync(realPath, overlayPath);
404
+ } catch (error) {
405
+ console.error(
406
+ `[entwurf:cortex-overlay] symlink failed for ${overlayPath}: ${error instanceof Error ? error.message : String(error)}`,
407
+ );
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Materialize the session-scoped cortex dual-HOME overlay: tear down the scope
413
+ * dir and exact-rewrite it (auth symlinks + authored settings.json/mcp.json).
414
+ * Returns the isolated HOME/SNOWFLAKE_HOME the spawn env must carry.
415
+ */
416
+ export function ensureCortexDualHomeOverlay(params: CortexOverlayParams): CortexOverlayResult {
417
+ if (!params.realHome || !params.realHome.startsWith("/")) {
418
+ throw new Error(
419
+ `entwurf: cortex dual-HOME overlay requires an absolute realHome captured by the parent (got ${JSON.stringify(params.realHome)})`,
420
+ );
421
+ }
422
+ const overlaysRoot = params.overlaysRoot ?? CORTEX_OVERLAYS_ROOT;
423
+ const realSnowflake = params.realSnowflakeHome ?? join(params.realHome, ".snowflake");
424
+ sweepDeadCortexOverlays(overlaysRoot, params.isPidAlive ?? cortexPidAlive);
425
+
426
+ const scopeDir = join(overlaysRoot, cortexOverlayScopeId(params.scopeKey));
427
+ // Exact rewrite (never merge): the prior child for this scope key is already
428
+ // torn down (backend.ts closes it before a "new" decision spawns), so
429
+ // everything it wrote — conversations, logs, $HOME dotfiles — is discarded
430
+ // here. rmSync does not follow symlinks, so the real auth files are untouched.
431
+ rmSync(scopeDir, { recursive: true, force: true });
432
+
433
+ const home = join(scopeDir, "home");
434
+ const snowflakeHome = join(home, ".snowflake");
435
+ const cortexDir = join(snowflakeHome, "cortex");
436
+ mkdirSync(join(cortexDir, "cache"), { recursive: true });
437
+
438
+ // D5/F — measured-minimum auth passthrough (symlink-through only).
439
+ cortexLinkIfExists(join(realSnowflake, "connections.toml"), join(snowflakeHome, "connections.toml"));
440
+ cortexLinkIfExists(join(realSnowflake, "config.toml"), join(snowflakeHome, "config.toml"));
441
+ cortexLinkIfExists(
442
+ join(realSnowflake, "cortex", "cache", "credential_cache"),
443
+ join(cortexDir, "cache", "credential_cache"),
444
+ );
445
+
446
+ // D4 — runtime self-replacement off; D9 — explicit-server projection.
447
+ writeFileSync(join(cortexDir, "settings.json"), cortexOverlaySettingsJson(), "utf8");
448
+ writeFileSync(join(cortexDir, "mcp.json"), projectCortexMcpJson(params.mcpServers, params.realHome), "utf8");
449
+
450
+ return { home, snowflakeHome, scopeDir };
451
+ }
@@ -10,10 +10,18 @@
10
10
  // if pi has excluded a built-in the backend will still expose, the declared
11
11
  // surface diverges from the actual one — we fail fast rather than lie.
12
12
  //
13
- // Claude-only scope (NEXT §스코프). The function keeps a `backend` field so the
14
- // honest-divergence logic stays explicit and the matrix gate can exercise both
15
- // the "claude narrows via tools" and "native always exposes" branches, but the
16
- // only backend this lane supplies is claude.
13
+ // The module's TWO jobs have different backend scopes do not collapse them:
14
+ // - `buildClaudeSessionMeta` is claude-only by CONSTRUCTION, with exactly one
15
+ // caller (`claudeAdapter.buildSessionMeta`). Cortex exposes no
16
+ // `_meta.systemPrompt` and no tools/permission declaration surface (native
17
+ // tools + its own overlay mcp.json projection), so its adapter returns
18
+ // `undefined` and that function is never reached for a cortex turn.
19
+ // - `assertExcludeToolsHonored` is BACKEND-INVARIANT: backend.ts runs it for
20
+ // every adapter before any spawn, passing `backend: adapter.backend` and the
21
+ // resolved `config.tools`. A cortex turn DOES enter this module through it.
22
+ // That split is why the `backend` field exists: the honest-divergence logic stays
23
+ // explicit and the matrix gate can exercise both the "claude narrows via tools"
24
+ // and "native always exposes" branches.
17
25
  //
18
26
  // Carrier guard (NEXT §S2-scout 핀1): `buildClaudeSessionMeta` only attaches
19
27
  // `_meta.systemPrompt` when a caller passes one. The shipped S2d path passes the
@@ -1,14 +1,29 @@
1
1
  /**
2
2
  * entwurf-core — sync entwurf execution, host-agnostic.
3
3
  *
4
- * Single implementation shared by:
5
- * - pi-extensions/entwurf.ts (pi native tool surface)
6
- * - mcp/entwurf-bridge/src/index.ts (MCP tool surface for ACP hosts)
4
+ * DIRECT importers, MEASURED from the import graph (2026-07-27) — not from memory:
5
+ * - pi-extensions/entwurf-control.ts (the pi adapter)
6
+ * - pi-extensions/lib/entwurf-v2-spawn-production.ts (v2 spawn-bg launch identity)
7
+ * - pi-extensions/lib/project-trust-handler.ts · lib/acp/augment.ts ·
8
+ * lib/native-push/adapter.ts
9
+ * - plus gates/smokes (check-shell-quote, check-package-source-routing, …)
10
+ *
11
+ * The MCP bridge is a TRANSITIVE consumer, not a direct one, and the distinction is the
12
+ * whole point: `mcp/entwurf-bridge/src/index.ts → entwurf-v2-surface.ts →
13
+ * entwurf-v2-production.ts → entwurf-v2-spawn-production.ts → entwurf-core.ts`. So the old
14
+ * header's claim — that the bridge imports this shared implementation the way a v1 tool
15
+ * surface did — was false, but so is "the bridge does not reach it": it reaches it at
16
+ * runtime, only for spawn-bg production.
17
+ *
18
+ * NOT consumers on any path: `pi-extensions/entwurf.ts` (the v1 pi native tool surface) and
19
+ * `pi-extensions/lib/entwurf-async.ts` were REMOVED in the 0.12 cutover. Do not reintroduce
20
+ * either as a live consumer.
7
21
  *
8
22
  * This module MUST NOT import anything from @earendil-works/pi-coding-agent or any
9
- * other pi runtime API. It is pure Node + @sinclair/typebox-free. Anything that
10
- * requires pi's ExtensionAPI (sendMessage, appendEntry, sessionManager) belongs
11
- * in the async entwurf path, which stays in pi-extensions/entwurf.ts for now.
23
+ * other pi runtime API. It is pure Node + @sinclair/typebox-free. Anything that
24
+ * requires pi's ExtensionAPI (sendMessage, appendEntry, sessionManager) belongs to a
25
+ * pi-adapter surface, never here — the v1 async entwurf path that used to hold that
26
+ * work went away with `pi-extensions/entwurf.ts`.
12
27
  *
13
28
  * Scope:
14
29
  * - sync execution (spawn pi, collect message_end events, return summary)
@@ -67,9 +82,11 @@ const PI_SETTINGS_PATH = process.env.PI_SETTINGS_PATH
67
82
  : path.join(AGENT_DIR, "settings.json");
68
83
  export const ENTWURF_CODEX_ACP_ENV = "ENTWURF_ACP_FOR_CODEX";
69
84
 
70
- // Currently unused: remote/SSH entwurf is fail-fast in 0.9.0 (garden-native
71
- // identity is local-FS only). Retained for #11 remote revival; parity-gated by
72
- // scripts/check-shell-quote.ts across entwurf.ts / entwurf-core.ts / entwurf-async.ts.
85
+ // Currently unused: remote/SSH entwurf is fail-fast (garden-native identity is
86
+ // local-FS only). Retained for #11 remote revival and pinned by
87
+ // scripts/check-shell-quote.ts, whose SOURCE_SITES is now THIS FILE ALONE — the
88
+ // entwurf.ts / entwurf-async.ts halves of that former parity check were removed
89
+ // with those files, so there is no cross-file parity left to assert.
73
90
  // biome-ignore lint/correctness/noUnusedVariables: retained for #11 remote revival; parity-gated.
74
91
  function shellQuote(value: string): string {
75
92
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -18,9 +18,10 @@
18
18
  * drains its own inbox on wake. A DIRECT-INJECT backend (pi / codex / antigravity)
19
19
  * has no mailbox drain at all — enqueuing for it is the SE-1 false success
20
20
  * ("✓ delivered" into a void). So deliverable = wakeMode === "self-fetch" AND the
21
- * receiver is active. This is the guard that the v1 fallback, MCP v1, pi-native v1,
22
- * and the v2 decider/send-fallback enqueue sites must all pass before writing a
23
- * .msg (slice 2d).
21
+ * receiver is active. Every enqueue site must pass this guard before writing a .msg
22
+ * (slice 2d). The shipped sites are the v2 decider and its send-fallback re-resolve;
23
+ * the v1 fallback / MCP v1 / pi-native v1 sites this once also listed were removed in
24
+ * the 0.12 cutover — do not read them as live.
24
25
  *
25
26
  * - nativePushDeliverable(facts): the SEPARATE deliverability predicate for a
26
27
  * NATIVE-PUSH backend (antigravity). A native-push citizen has no mailbox and no
@@ -89,9 +90,12 @@ export interface ReceiverIdentityFacts {
89
90
  * Does this presence marker actually belong to the target identity? A marker that is
90
91
  * absent, or whose garden id / backend / native session id has drifted from the record,
91
92
  * is NOT this receiver — fail-closed (a stale/foreign marker must never raise a dead
92
- * target to "active"). The single source of truth for "marker ↔ identity match" shared
93
- * by the v1 mailbox guard (gatherMailboxDeliverabilityFacts) and the v2 production
94
- * `mailboxDeliverabilityFor` seam, so the two paths cannot drift to different meanings.
93
+ * target to "active"). The single source of truth for "marker ↔ identity match". Its
94
+ * PRODUCTION consumers are the v2 `mailboxDeliverabilityFor` seam and the MCP bridge's
95
+ * `entwurf_self`. There is no second implementation: `entwurf-mailbox-guard.ts` used to
96
+ * wrap this atom with its own enqueue orchestration, had ZERO production importers (import
97
+ * graph measured 2026-07-27), and was DELETED rather than left as a green gate proving only
98
+ * retired behaviour. A new enqueue site consults this predicate through that seam.
95
99
  */
96
100
  export function receiverMarkerMatchesIdentity(
97
101
  marker: ReceiverIdentityFacts | null | undefined,
@@ -5,8 +5,8 @@
5
5
  * (one-way: provider → facts / socket-discovery / meta-session) — no import cycle
6
6
  * with `entwurf-facts.ts` (which owns `SocketProbe`/`resolveFactList`).
7
7
  *
8
- * listAllMetaIdentities → pi gid 추출 → scanSocketProbes(piGids)
9
- * → pre-quarantine non-pi/socket conflicts → resolveFactList(clean)
8
+ * listAllMetaIdentities → socket-domain gid extraction → scanSocketProbes
9
+ * → pre-quarantine out-of-domain/socket conflicts → resolveFactList(clean)
10
10
  * → { facts, diagnostics }
11
11
  *
12
12
  * Two throw-vs-diagnostics policies, kept distinct (GPT힣 C-원칙):
@@ -20,14 +20,14 @@
20
20
  * — that is not a re-implementation of the collision rule, it is input
21
21
  * sanitation that leaves the pure-core invariant intact.
22
22
  *
23
- * The non-pi+socket collision quarantines BOTH sides (the PeerFact AND the
24
- * socket): gardenId is the universal address and a send path reads the socket
23
+ * An out-of-socket-domain record/socket collision quarantines BOTH sides (the
24
+ * PeerFact AND the socket): gardenId is the universal address and a send path
25
25
  * first, so surfacing the record alone (as a clean `unsupported` PeerFact) while
26
26
  * a same-gid socket exists would be half a lie. Both leave the normal output;
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
29
 
30
- import { type FactList, isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.ts";
30
+ import { type FactList, isOutOfSocketDomainGardenIdConflict, resolveFactList } from "./entwurf-facts.ts";
31
31
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
32
32
  import {
33
33
  type ActiveStoreEntry,
@@ -130,8 +130,8 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
130
130
  // 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
131
131
  // Its three hazards (symlink forgery / malformed name / dir-read error) are
132
132
  // folded into diagnostics here so the listing survives but never lies.
133
- const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
134
- const scan = await scanSocketProbes(piGids, deps.socket ?? {});
133
+ const socketDomainGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
134
+ const scan = await scanSocketProbes(socketDomainGids, deps.socket ?? {});
135
135
  const probes = scan.probes;
136
136
  const socketGids = new Set(probes.map((p) => p.gardenId));
137
137
  const symlinkedGids = new Set(scan.symlinkedGardenIds);
@@ -158,24 +158,22 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
158
158
  });
159
159
  }
160
160
 
161
- // 3. pre-quarantine non-pi citizens that collide with a control socket. The
162
- // predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
163
- // listing and dispatch cannot drift, and it unions socketGids with the
164
- // symlinkedGids: a symlinked socket is never probed (absent from socketGids),
165
- // so the old socketGids-only check let a non-pi citizen with a forged
166
- // (symlinked) socket survive as a clean PeerFact while the legacy send path
167
- // still followed the symlink — the gap this closes.
161
+ // 3. Pre-quarantine citizens outside the control-socket capability domain that
162
+ // collide with a control socket. The predicate is SHARED with v2 dispatch so
163
+ // listing and dispatch cannot drift. It unions real and symlinked socket gids:
164
+ // the earlier real-socket-only check let an out-of-domain record survive as a
165
+ // clean PeerFact beside a forged symlink receiver.
168
166
  const conflictGids = new Set<string>();
169
167
  for (const id of identities) {
170
- if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
168
+ if (isOutOfSocketDomainGardenIdConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
171
169
  conflictGids.add(id.gardenId);
172
170
  diagnostics.push({
173
171
  kind: "garden-id-socket-conflict",
174
172
  gardenId: id.gardenId,
175
173
  backend: id.backend,
176
174
  message:
177
- `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
178
- "ambiguity; both the citizen and the socket are quarantined from the listing.",
175
+ `out-of-socket-domain citizen (${id.backend}) shares its gardenId with a control socket ` +
176
+ "(real or symlinked) address ambiguity; both sides are quarantined from the listing.",
179
177
  });
180
178
  }
181
179
  }
@@ -75,27 +75,27 @@ export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness |
75
75
  }
76
76
 
77
77
  /**
78
- * A non-pi RECORD whose gardenId collides with a control socket — a real (probed)
79
- * one OR a symlinked/forged one. The gardenId is the universal address (동결결정3),
80
- * so a non-pi citizen sharing it with a socket means a send-path that reaches the
81
- * socket first hits a DIFFERENT receiver than the record names — an address split.
78
+ * An OUT-OF-SOCKET-DOMAIN record whose gardenId collides with a control socket —
79
+ * a real (probed) one OR a symlinked/forged one. The gardenId is the universal
80
+ * address, so a citizen without socket capability sharing it with a socket means
81
+ * the socket reaches a DIFFERENT receiver than the record names — an address split.
82
82
  * Both the citizen and the socket are quarantined from the facts listing.
83
83
  *
84
84
  * The union `socketGids ∪ symlinkedGardenIds` is load-bearing: `socketGids` are
85
85
  * gids with a real probed `*.sock`, but `symlinkedGardenIds` are NEVER probed (P1)
86
86
  * and so are absent from `socketGids`. Looking at `socketGids` alone (the
87
- * fact-provider:125 gap this closes) let a non-pi citizen with a *symlinked* socket
87
+ * fact-provider:125 gap this closes) let an out-of-domain citizen with a *symlinked* socket
88
88
  * survive as a clean PeerFact while the legacy send path still followed the symlink
89
89
  * to a forged receiver. Both axes claim the gid → both must quarantine it.
90
90
  *
91
- * SCOPE: this is the RECORD-side, non-pi conflict only — shared by the fact-provider
92
- * (listing) and the v2 decider (dispatch) so the two cannot drift (4c "재유도 금지"
93
- * 동형; only the observation-bit source is parameterized). A pi citizen whose own
94
- * canonical socket is a symlink is NOT this predicate's concern — that is a
91
+ * SCOPE: this is the record-side, out-of-socket-domain conflict only — shared by
92
+ * listing and dispatch so they cannot drift; only the observation-bit source is
93
+ * parameterized. A citizen IN the socket domain whose own canonical socket is a
94
+ * symlink is NOT this predicate's concern — that is a
95
95
  * target-specific lstat conflict the decider's `inspectTargetControlSocket` raises
96
96
  * as `address-conflict`, kept deliberately separate (GPT 1차 검수 C).
97
97
  */
98
- export function isNonPiGardenIdSocketConflict(
98
+ export function isOutOfSocketDomainGardenIdConflict(
99
99
  backend: string,
100
100
  gardenId: string,
101
101
  socketGids: ReadonlySet<string>,
@@ -163,8 +163,8 @@ export interface FactList {
163
163
  * `indeterminate` and strand a dormant citizen as un-resumable); a dormant
164
164
  * citizen's absent socket file is probed to `dead` (ENOENT) by the wiring and
165
165
  * arrives here AS `dead` → dormant → resumable.
166
- * - out-of-domain citizen WITH a control socket at its gardenId → fail-loud
167
- * (address ambiguity; a non-pi citizen must not own a pi control socket).
166
+ * - out-of-socket-domain citizen WITH a control socket at its gardenId → fail-loud
167
+ * (address ambiguity; a backend without socket capability cannot own that rail).
168
168
  * - out-of-domain citizen without a socket → `unsupported` (via resolvePeerFact).
169
169
  * - a probed gardenId with NO citizen → `RecordLessSocketFact` (#50 C4: a
170
170
  * diagnostic subject the provider folds into a `record-less-socket`
@@ -202,7 +202,7 @@ export function resolveFactList(identities: MetaIdentity[], socketProbes: Socket
202
202
  if (probeMap.has(gid)) {
203
203
  throw new Error(
204
204
  `resolveFactList: out-of-domain citizen ${gid} (${identity.backend}) has a control socket — ` +
205
- "address ambiguity (a non-pi citizen must not own a pi control socket)",
205
+ "address ambiguity (an out-of-socket-domain citizen cannot own a control socket)",
206
206
  );
207
207
  }
208
208
  socket = null;