@junghanacs/entwurf 0.12.8 → 0.12.10

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 (96) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/DELIVERY.md +5 -4
  5. package/README.md +148 -23
  6. package/VERIFY.md +3 -2
  7. package/demo/README.md +2 -2
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +24 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
  26. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  27. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  28. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  29. package/mcp/entwurf-bridge/src/index.ts +200 -94
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +81 -39
  32. package/pi-extensions/lib/acp/models.ts +12 -12
  33. package/pi-extensions/lib/acp/overlay.ts +5 -3
  34. package/pi-extensions/lib/entwurf-core.ts +26 -9
  35. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
  37. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  38. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  39. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  40. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  41. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  42. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  43. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  44. package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
  45. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
  46. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  47. package/pi-extensions/lib/meta-session.ts +543 -58
  48. package/pi-extensions/meta-bridge-hook.ts +8 -2
  49. package/run.sh +78 -67
  50. package/scripts/agy-bridge-config.py +249 -49
  51. package/scripts/agy-bridge.sh +59 -14
  52. package/scripts/agy-imprint.ts +15 -1
  53. package/scripts/check-acp-carrier-augment.ts +34 -2
  54. package/scripts/check-acp-sdk-surface.ts +22 -11
  55. package/scripts/check-acp-session-reuse.ts +1 -1
  56. package/scripts/check-acp-session-store.ts +3 -3
  57. package/scripts/check-agy-permission-matrix.py +655 -0
  58. package/scripts/check-agy-sender-identity.ts +83 -0
  59. package/scripts/check-entwurf-control-rpc.ts +2 -2
  60. package/scripts/check-entwurf-deliverability.ts +9 -6
  61. package/scripts/check-entwurf-fact-provider.ts +16 -8
  62. package/scripts/check-entwurf-facts.ts +13 -13
  63. package/scripts/check-entwurf-resume-args.ts +25 -63
  64. package/scripts/check-entwurf-self-address.ts +187 -4
  65. package/scripts/check-entwurf-session-identity.ts +7 -6
  66. package/scripts/check-entwurf-v2-contract.ts +2 -2
  67. package/scripts/check-entwurf-v2-production.ts +9 -7
  68. package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
  69. package/scripts/check-entwurf-v2-surface.ts +327 -14
  70. package/scripts/check-fresh-cut-gate.sh +305 -4
  71. package/scripts/check-gate-qualification.ts +785 -0
  72. package/scripts/check-meta-identity-consumers.ts +501 -1
  73. package/scripts/check-meta-listing.ts +91 -9
  74. package/scripts/check-meta-receiver-marker.ts +54 -0
  75. package/scripts/check-model-lock.ts +1 -1
  76. package/scripts/check-shell-quote.ts +2 -1
  77. package/scripts/lib/mutation-qualify.ts +794 -0
  78. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  79. package/scripts/mutants/acp-augment.json +30 -0
  80. package/scripts/mutants/agy-permission.json +144 -0
  81. package/scripts/mutants/meta-identity.json +17 -0
  82. package/scripts/mutants/self-address.json +59 -0
  83. package/scripts/mutants/v2-surface.json +87 -0
  84. package/scripts/pi_settings_io.py +65 -0
  85. package/scripts/register-pi-package.py +183 -37
  86. package/scripts/register-pi-provider.py +68 -10
  87. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  88. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  89. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  90. package/scripts/smoke-agy-install-state.sh +205 -20
  91. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  92. package/scripts/smoke-meta-install-state.sh +1 -1
  93. package/scripts/smoke-pi-attach.ts +7 -2
  94. package/scripts/smoke-user-scope-citizen.sh +177 -0
  95. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  96. package/scripts/check-entwurf-mailbox-guard.ts +0 -262
@@ -61,7 +61,7 @@
61
61
  * Send-is-throw cleanup; see note above.)
62
62
  */
63
63
 
64
- import { existsSync, promises as fs, readFileSync } from "node:fs";
64
+ import { existsSync, promises as fs } from "node:fs";
65
65
  import * as net from "node:net";
66
66
  import * as os from "node:os";
67
67
  import * as path from "node:path";
@@ -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,12 +521,13 @@ 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
- from?: string; // sender agentId, e.g. "entwurf/claude-opus-4-8"
530
+ from?: string; // sender agentId, e.g. "entwurf/claude-opus-5"
529
531
  cwd?: string; // sender cwd (raw, abbreviateHome applied at render)
530
532
  timestamp?: string; // ISO 8601 UTC; rendered in KST
531
533
  mode?: string; // "steer" | "follow_up" | string passed through
@@ -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,
@@ -1396,7 +1437,7 @@ The decider — not this surface — chooses the transport.`,
1396
1437
 
1397
1438
  interface EntwurfFactProviderModule {
1398
1439
  listEntwurfFacts(params: {
1399
- metaEntries: readonly string[];
1440
+ metaEntries: readonly { filename: string; regularFile: boolean }[];
1400
1441
  readRecord: (filename: string) => string;
1401
1442
  socket: { dir: string };
1402
1443
  }): Promise<unknown>;
@@ -1408,22 +1449,22 @@ interface EntwurfPeersRenderModule {
1408
1449
 
1409
1450
  interface MetaSessionModule {
1410
1451
  defaultMetaSessionsDir(): string;
1452
+ readActiveStoreEntries(dir: string): { filename: string; regularFile: boolean }[];
1453
+ makeStoreRecordReader(dir: string): (filename: string) => string;
1411
1454
  }
1412
1455
 
1413
1456
  async function renderEntwurfPeersForSurface(): Promise<{ text: string; payload: unknown }> {
1414
1457
  const meta = (await import(META_SESSION_MODULE)) as unknown as MetaSessionModule;
1415
1458
  const sessionsDir = meta.defaultMetaSessionsDir();
1416
- let metaEntries: string[] = [];
1417
- try {
1418
- metaEntries = (await fs.readdir(sessionsDir)).filter((name) => name.endsWith(".meta.json"));
1419
- } catch (err) {
1420
- if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
1421
- }
1422
-
1459
+ // Entries carry their KIND. The name-only readdir this used to do left `readRecord`
1460
+ // free to follow a symlinked `.meta.json` into bytes the store does not own — rule 1
1461
+ // held in the doctor and not on the surface operators actually read (pre-existing;
1462
+ // surfaced by the #52 duplicate pass, which would let such a symlink quarantine the
1463
+ // healthy record it shadowed).
1423
1464
  const provider = (await import(ENTWURF_FACT_PROVIDER_MODULE)) as unknown as EntwurfFactProviderModule;
1424
1465
  const result = await provider.listEntwurfFacts({
1425
- metaEntries,
1426
- readRecord: (filename) => readFileSync(path.join(sessionsDir, filename), "utf8"),
1466
+ metaEntries: meta.readActiveStoreEntries(sessionsDir),
1467
+ readRecord: meta.makeStoreRecordReader(sessionsDir),
1427
1468
  // Same socket axis as the legacy live-session scan, but merged with the
1428
1469
  // meta-record rail by listEntwurfFacts so meta-mailbox citizens are discoverable too.
1429
1470
  socket: { dir: ENTWURF_DIR },
@@ -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",
@@ -18,14 +18,14 @@
18
18
  // Why `/compat` and NOT the 0.80 provider-factory subpath (the pi-ai
19
19
  // providers/anthropic subpath): this file is loaded by pi's
20
20
  // EXTENSION loader (pi-coding-agent `core/extensions/loader.ts`), whose jiti
21
- // alias map resolves ONLY three pi-ai specifiers for extensions — the bare root,
22
- // `/compat`, and `/oauth` all to `ai/dist/compat.js`. A `providers/*` subpath
23
- // is NOT in that map: jiti prefix-matches the bare `@earendil-works/pi-ai` alias
24
- // and appends the remainder, yielding the unresolvable
25
- // `…/dist/compat.js/providers/anthropic` (verified live: extension load crash,
26
- // invisible to static typecheck which resolves against node_modules `exports`).
27
- // So `/compat` is the SINGLE sanctioned extension entrypoint for the old global
28
- // model-catalog API, and the SINGLE allowlisted exception in
21
+ // alias map resolves FOUR pi-ai specifiers for extensions — the bare root,
22
+ // `/compat`, `/oauth`, and (since pi 0.81) `/providers/all`. Other `providers/*`
23
+ // subpaths are NOT in that map: jiti prefix-matches the bare
24
+ // `@earendil-works/pi-ai` alias and appends the remainder, yielding the
25
+ // unresolvable `…/dist/compat.js/providers/anthropic` (verified live: extension
26
+ // load crash, invisible to static typecheck which resolves against node_modules
27
+ // `exports`). So `/compat` remains the SINGLE sanctioned extension entrypoint
28
+ // this repo uses for the old global model-catalog API, and the SINGLE allowlisted exception in
29
29
  // `run.sh check-pi-import-surface`. `getModels` here is compat's deprecated
30
30
  // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
31
31
  // the loader then exposes.
@@ -50,13 +50,13 @@ export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
50
50
  // both axes (protocol smoke + agent interview) — do not extend casually.
51
51
  // Exported so the claude backend adapter (backend-adapter.ts) can answer
52
52
  // `routeModel` without re-deriving the set from curatedClaudeModels().
53
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-4-8"] as const;
53
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5"] as const;
54
54
 
55
55
  /** The anchor model whose absence is a hard registry regression, not a soft skip. */
56
- export const CURATED_ANCHOR_MODEL_ID = "claude-opus-4-8";
56
+ export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
57
57
 
58
- // Anthropic's registry reports 1M for both Sonnet 5 and Opus 4.8, and the
59
- // entwurf surface now exposes the full 1M for BOTH. Sonnet 5's 1M window is the
58
+ // Anthropic's registry reports 1M for both Sonnet 5 and Opus 5, and the
59
+ // entwurf surface exposes the full 1M for BOTH. Sonnet 5's 1M window is the
60
60
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
61
61
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
62
62
  // future registry value can't silently inflate the surface past what we verify.
@@ -9,9 +9,11 @@
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): 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.
15
17
  //
16
18
  // Two deliberate divergences from the literal 0.11.0 illustrative comment block
17
19
  // (the 0.11.0 CODE already does both — only its top doc-comment drew projects/
@@ -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,16 +20,21 @@
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
- import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, type MetaCitizenBackend } from "./meta-session.ts";
32
+ import {
33
+ type ActiveStoreEntry,
34
+ FRESH_CUT_PRESCRIPTION,
35
+ listAllMetaIdentities,
36
+ type MetaCitizenBackend,
37
+ } from "./meta-session.ts";
33
38
  import { type SocketScanDeps, scanSocketProbes } from "./socket-discovery.ts";
34
39
  import type { SocketLiveness } from "./socket-probe.ts";
35
40
 
@@ -82,7 +87,9 @@ export interface EntwurfFactsResult {
82
87
 
83
88
  export interface EntwurfFactsDeps {
84
89
  /** Meta-store axis: the `.meta.json` entry names + a record reader. */
85
- metaEntries: readonly string[];
90
+ /** Store entries WITH their kind (`readActiveStoreEntries`), never bare names: the
91
+ * listing must be able to refuse a symlinked record without following it. */
92
+ metaEntries: readonly ActiveStoreEntry[];
86
93
  readRecord: (filename: string) => string;
87
94
  /** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
88
95
  socket?: Partial<SocketScanDeps>;
@@ -123,8 +130,8 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
123
130
  // 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
124
131
  // Its three hazards (symlink forgery / malformed name / dir-read error) are
125
132
  // folded into diagnostics here so the listing survives but never lies.
126
- const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
127
- 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 ?? {});
128
135
  const probes = scan.probes;
129
136
  const socketGids = new Set(probes.map((p) => p.gardenId));
130
137
  const symlinkedGids = new Set(scan.symlinkedGardenIds);
@@ -151,24 +158,22 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
151
158
  });
152
159
  }
153
160
 
154
- // 3. pre-quarantine non-pi citizens that collide with a control socket. The
155
- // predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
156
- // listing and dispatch cannot drift, and it unions socketGids with the
157
- // symlinkedGids: a symlinked socket is never probed (absent from socketGids),
158
- // so the old socketGids-only check let a non-pi citizen with a forged
159
- // (symlinked) socket survive as a clean PeerFact while the legacy send path
160
- // 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.
161
166
  const conflictGids = new Set<string>();
162
167
  for (const id of identities) {
163
- if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
168
+ if (isOutOfSocketDomainGardenIdConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
164
169
  conflictGids.add(id.gardenId);
165
170
  diagnostics.push({
166
171
  kind: "garden-id-socket-conflict",
167
172
  gardenId: id.gardenId,
168
173
  backend: id.backend,
169
174
  message:
170
- `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
171
- "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.",
172
177
  });
173
178
  }
174
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;