@junghanacs/entwurf 0.35.1 → 0.36.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 (42) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +209 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +9 -7
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +1 -1
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +19 -14
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +112 -38
  29. package/scripts/check-codex-app-server-launch.ts +55 -12
  30. package/scripts/check-codex-native-push.ts +231 -12
  31. package/scripts/check-gate-qualification.ts +3 -3
  32. package/scripts/codex-app-server-launch.sh +54 -29
  33. package/scripts/codex-socket-classify.ts +46 -0
  34. package/scripts/emit-pi-durable-carrier.ts +159 -168
  35. package/scripts/mutants/codex-app-server-launch.json +39 -12
  36. package/scripts/mutants/codex-native.json +15 -0
  37. package/scripts/mutants/control-send-receipt.json +1 -1
  38. package/scripts/mutants/pack-install.json +3 -3
  39. package/scripts/mutants/pi-durable.json +165 -16
  40. package/scripts/mutants/pi-floor.json +5 -5
  41. package/scripts/pi-durable-contact-host.mjs +1 -1
  42. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
5
  * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
- * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
- * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
6
+ * supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
7
+ * prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
8
+ * the whole Entwurf side of its contact:
8
9
  *
9
10
  * createPiDurableContact({ bridgeEntry })
10
11
  * .extension tools-only durable Extension, handed to the overlay's
@@ -49,14 +50,16 @@
49
50
  * bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
50
51
  *
51
52
  * RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
52
- * `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
+ * `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
54
  * then the watch, then a check that the record and this process's sender marker still name the
54
55
  * session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
55
56
  * announced to the ROOT conversation as input; the body stays in the mailbox until the model
56
57
  * calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
57
58
  * this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
58
- * vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
59
- * closed, our own marker removed, the reason kept by name — and nothing retries it.
59
+ * vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
60
+ * overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
61
+ * failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
62
+ * marker removed, the reason kept by name — and nothing retries it.
60
63
  *
61
64
  * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
62
65
  * unread body. So after a read the notices still queued for that mail would each cost a turn and an
@@ -295,9 +298,14 @@ export interface PiDurableRoots {
295
298
  // ---------------------------------------------------------------------------
296
299
  // Durable surface, typed NARROWLY and locally — read at pi-durable 1.0.2 @cd32f77 (#129,
297
300
  // `harness/types.ts`; historical coordinates, not re-anchored here). The current supply is the
298
- // carrier `pi/pi-durable/overlay/upstream-pin.json` names (1.1.0 at `abe508e`). `[read 2026-10-08]`
299
- // v1.0.4 → v1.1.0 `harness/types.ts` adds `models`, `contextRetentionMs` and a `context()` options
300
- // bag only; none of the shapes typed below moved.
301
+ // carrier emitted from Entwurf's owned source (pi/pi-durable/src, #141) against the 1.2.0 SDK set
302
+ // `pi/pi-durable/overlay/upstream-pin.json` names (origin `abe508e`). `[read 2026-10-13]` at
303
+ // v1.2.0 one shape typed below moved: `ToolExecutionResult.content` is now `output`, beside the new
304
+ // `structuredOutput` (`harness/types.ts`, its CHANGELOG's breaking changes). An omitted `output`
305
+ // gives the model the text a tool retained through `api.output()`; `content` is not read, so a
306
+ // result naming only `content` that retained no output reaches the model as nothing. The contact's
307
+ // tools retain none, so they answer in `output`.
308
+ // The submission draft, its `abort()` answers and the identity `define*` helpers did not move.
301
309
  // `defineExtension`/`defineTool` are identity functions (`harness/define.ts:6-17`), so a plain
302
310
  // object is the extension.
303
311
  // ---------------------------------------------------------------------------
@@ -314,8 +322,9 @@ export interface DurableCallContext {
314
322
  readonly abortSignal?: AbortSignal;
315
323
  }
316
324
 
325
+ /** `ToolExecutionResult`, narrowed: `output` is what the model reads. */
317
326
  export interface DurableToolResult {
318
- content: { type: "text"; text: string }[];
327
+ output: { type: "text"; text: string }[];
319
328
  }
320
329
 
321
330
  export interface DurableTool {
@@ -562,14 +571,19 @@ export interface DurableAdmittedSubmission {
562
571
  /** The overlay's `OpenDurableResult.submitToRoot`. */
563
572
  export type DurableSubmitToRoot = (draft: DurableDoorbellDraft) => Promise<DurableAdmittedSubmission>;
564
573
 
574
+ /** The overlay's `OpenDurableResult.onFailed`: one synchronous call when the session fails and closes itself. */
575
+ export type DurableOnFailed = (listener: () => void) => () => void;
576
+
565
577
  /** What `attach` needs to arm the doorbell. Without it the citizen only sends. */
566
578
  export interface DurableRootAdmission {
567
579
  submitToRoot: DurableSubmitToRoot;
580
+ onFailed: DurableOnFailed;
568
581
  }
569
582
 
570
583
  /** Why an armed doorbell was retired. Nothing re-arms it. */
571
584
  export type PiDurableReceiverRetirement =
572
585
  | "closed"
586
+ | "session-failed"
573
587
  | "watch-error"
574
588
  | "signal-vanished"
575
589
  | "doorbell-failed"
@@ -630,7 +644,7 @@ export interface PiDurableAttachment {
630
644
  /** The roots written — and the roots the bridge child was bound to. */
631
645
  roots: PiDurableRoots;
632
646
  bridgePid: number | null;
633
- /** The receiver marker this attach published, or null when no root admission was handed in. */
647
+ /** The receiver marker this attach published; null when no root admission was handed in, or the session had already failed. */
634
648
  receiverMarkerPath: string | null;
635
649
  }
636
650
 
@@ -682,6 +696,7 @@ interface ArmedDoorbell {
682
696
  markerPath: string;
683
697
  watcher: fs.FSWatcher;
684
698
  submitToRoot: DurableSubmitToRoot;
699
+ unsubscribeFailed: () => void;
685
700
  }
686
701
 
687
702
  /** One admitted notice: its native handle and the `.msg` names it announced. */
@@ -783,7 +798,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
783
798
  const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
784
799
  const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
785
800
  if (result.isError) throw new Error(result.text || `${name} failed`);
786
- return { content: [{ type: "text", text: result.text }] };
801
+ return { output: [{ type: "text", text: result.text }] };
787
802
  },
788
803
  });
789
804
 
@@ -836,6 +851,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
836
851
  const armed = doorbell;
837
852
  if (armed === null) return [];
838
853
  doorbell = null;
854
+ armed.unsubscribeFailed();
839
855
  const problems: string[] = [];
840
856
  try {
841
857
  armed.watcher.close();
@@ -985,15 +1001,16 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
985
1001
  * Mailbox, signal, watch — then the check that the record and this process's sender marker
986
1002
  * still name the session — and only then the receiver marker, the one fact a sender reads.
987
1003
  * Synchronous, so nothing can close the contact between the check and the publish. Throws,
988
- * after closing whatever it opened, when any step fails.
1004
+ * after closing whatever it opened, when any step fails. Returns null when the session had
1005
+ * already failed: the doorbell was retired as `session-failed` before this returned.
989
1006
  */
990
1007
  const arm = (
991
1008
  gardenId: string,
992
1009
  nativeSessionId: string,
993
1010
  ownerPid: number,
994
1011
  roots: PiDurableRoots,
995
- submitToRoot: DurableSubmitToRoot,
996
- ): string => {
1012
+ root: DurableRootAdmission,
1013
+ ): string | null => {
997
1014
  if (ownerPid !== process.pid) {
998
1015
  throw new Error(
999
1016
  `the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`,
@@ -1004,6 +1021,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1004
1021
  fs.mkdirSync(mailbox, { recursive: true });
1005
1022
  if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
1006
1023
  const watcher = watch(signal, () => void ring("signal"));
1024
+ let unsubscribeFailed = (): void => {};
1007
1025
  try {
1008
1026
  watcher.on("error", (error) => retire("watch-error", describeError(error)));
1009
1027
  const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
@@ -1020,6 +1038,21 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1020
1038
  : `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`,
1021
1039
  );
1022
1040
  }
1041
+ // A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
1042
+ // Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
1043
+ // failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
1044
+ // the retirement runs once the doorbell is published below. It is final; nothing re-arms.
1045
+ let published = false;
1046
+ let failedBeforePublished = false;
1047
+ const sessionFailed = (): void => {
1048
+ if (!published) {
1049
+ failedBeforePublished = true;
1050
+ return;
1051
+ }
1052
+ const problems = retire("session-failed", "the durable session failed and closed itself");
1053
+ if (problems.length > 0) throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
1054
+ };
1055
+ unsubscribeFailed = root.onFailed(sessionFailed);
1023
1056
  const markerPath = writeMetaReceiverMarker({
1024
1057
  gardenId,
1025
1058
  backend: PI_DURABLE_BACKEND,
@@ -1029,17 +1062,37 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1029
1062
  armProvenance: "session-start",
1030
1063
  receiversDir: roots.receiversDir,
1031
1064
  });
1032
- doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
1065
+ doorbell = {
1066
+ gardenId,
1067
+ ownerPid,
1068
+ mailbox,
1069
+ signal,
1070
+ markerPath,
1071
+ watcher,
1072
+ submitToRoot: root.submitToRoot,
1073
+ unsubscribeFailed,
1074
+ };
1033
1075
  inbox = mailbox;
1034
1076
  report = { status: "armed", gardenId, markerPath, admissions };
1077
+ published = true;
1078
+ if (failedBeforePublished) {
1079
+ sessionFailed();
1080
+ return null;
1081
+ }
1035
1082
  return markerPath;
1036
1083
  } catch (error) {
1037
- try {
1038
- watcher.close();
1039
- } catch (closeError) {
1084
+ const cleanup: unknown[] = [];
1085
+ for (const step of [unsubscribeFailed, () => watcher.close()]) {
1086
+ try {
1087
+ step();
1088
+ } catch (cleanupError) {
1089
+ cleanup.push(cleanupError);
1090
+ }
1091
+ }
1092
+ if (cleanup.length > 0) {
1040
1093
  throw new AggregateError(
1041
- [error, closeError],
1042
- `${describeError(error)} (cleanup also failed: ${describeError(closeError)})`,
1094
+ [error, ...cleanup],
1095
+ `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`,
1043
1096
  );
1044
1097
  }
1045
1098
  throw error;
@@ -1068,6 +1121,9 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1068
1121
  if (root !== undefined && typeof root.submitToRoot !== "function") {
1069
1122
  throw new Error("the root admission handed to attach has no submitToRoot function");
1070
1123
  }
1124
+ if (root !== undefined && typeof root.onFailed !== "function") {
1125
+ throw new Error("the root admission handed to attach has no onFailed function");
1126
+ }
1071
1127
  const ownerPid = options.ownerPid ?? process.pid;
1072
1128
  const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
1073
1129
  const upsert = upsertMetaSession({
@@ -1119,7 +1175,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1119
1175
  let receiverMarkerPath: string | null = null;
1120
1176
  if (root !== undefined) {
1121
1177
  try {
1122
- receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root.submitToRoot);
1178
+ receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
1123
1179
  } catch (error) {
1124
1180
  return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () =>
1125
1181
  client.close(),
package/run.sh CHANGED
@@ -181,7 +181,7 @@ Usage:
181
181
  ./run.sh check-pi-durable-contact # #129 L-identity gate, CHECKOUT-ONLY (an installed package refuses it in run_ts). Opens the REAL durable app from THIS checkout's verified carrier (pi/pi-durable/carrier/ under its resolver, against the checkout's pinned pi SDK set — refused by name first) with the pi-durable contact, births the citizen and runs its tools adapter-direct through a privately emitted branch bridge (ENTWURF_PI_DURABLE_BRIDGE_ENTRY; its registry must match this checkout). Matrix roots {default, explicit} x {fresh, --continue}: one record keyed by the durable session dir, sender marker on the spawned host pid, exactly one bridge child whose /proc PPID is that host, poisoned identity carriers kept from it, its four root carriers bound to the roots written, the receiver ARMED (raw marker owned by the host pid + start key as pi-durable-host, joined to the sender marker; removed on close), self = the record and replyable, peers from an outside bridge, same garden id on reopen; the bundle's compiled owner join must admit pi-durable-host. Reaps only the host/bridges it owns on any failure. Harness-bypassed: no model turn, no ToolTask. Missing inputs → SKIP (97), never a pass
182
182
  ./run.sh check-pi-durable-send # #129 L-send FIRST CELL, CHECKOUT-ONLY. Same input as check-pi-durable-contact. One native durable turn: the app's own controller input → GenerationTask → ToolTask → the contact's entwurf_v2 → the private bridge → a seeded self-fetch target (C2 recipe, owned by a separate idle child). S = a 127.0.0.1 scripted OpenAI-compatible endpoint configured through the app's documented models.json (dummy key, PI_OFFLINE); H = the native ToolTask path; V (a vendor turn) is NOT run. Oracles: the wire (tools offered, v2 schema = the bridge's tools/list, exactly two requests, the tool result + receipt in the second), the raw mailbox (.msg 1, signal, sender = the host record), the native view (one call, its settled result, root current). Not exactly-once, not recovery, not a recipient turn. Missing inputs → SKIP (97)
183
183
  ./run.sh check-demo-reproducibility # source-checkout-only: discover offline demo Node tests; no LIVE, model, render or publication
184
- ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver/emitter/task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
184
+ ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver, owned-source emission and typecheck fence, task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
185
185
  ./run.sh check-pi-durable-receive # #129 L-receive FIRST CELL, CHECKOUT-ONLY. Same input (the bundle must carry the pi-durable-host owner join). The host driver gives the app NO input; a seeded claude-code sender (marker owned by a separate idle child, handed to a gate-owned sending bridge through ENTWURF_META_SENDER_MARKER) enqueues one message, the host's doorbell admits its notice to the ROOT conversation, and the scripted model drains it with entwurf_inbox_read. S withholds response #1 until the ENQUEUE receipt, the .delivered bytes, the signal, the host-owned receiver marker and the notice are captured. Oracles: receiver() admission, native view, raw archive (.delivered.read + lastReadAt), wire (exactly two requests, drain result = view), and after a green reap a forensic read-only SQLite copy (submission row by request id = reported id, root, input, done, placed entry = the notice). V NOT run. Missing inputs → SKIP (97)
186
186
  ./run.sh check-copilot-receive-arm # #82 RAIL 5 gate: the REAL receiver installer + the REAL extension.mjs forked with a stubbed SDK. Arms only after birth, marker owned by the WATCHER pid, self-fetch dispatch answer, doorbell carries the garden id and NOT the body, id-drift/foreign-parent refusals. Hermetic; no Copilot, no model turn
187
187
  ./run.sh check-copilot-launch # #82 RAIL 7 gate: the MANAGED launch `entwurf copilot`, driven through its public address against a FAKE VENDOR on a sandbox PATH. Receiver precondition refusals, EXTENSIONS token + operator token preservation, injected defaults before the `--` terminator, byte-identical argv, the 11 explicit permission/surface policy overrides that suppress `--yolo`, exec (not fork) pid identity, exit passthrough, recursion refusal. Hermetic; no Copilot, no model turn
@@ -273,7 +273,7 @@ Usage:
273
273
  ./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
274
274
  ./run.sh doctor-meta-bridge # THE RELEASE ORACLE (#51, Linux-certified repair axis). exit 0 = every required layer was MEASURED on this Linux host: toolchain + state + plugin/MCP + resolved-artifact launch-form classification (all 3 owner hooks + doorbell static contract) + synthetic owner join + store scan + hook errors + SessionStart evidence + REQUIRED live MCP↔marker join + writer-version parity. Missing live evidence is NOT CERTIFIED (open a Claude session and re-run), never a pass; Darwin is NOT CERTIFIED and stays nonzero, on ONE narrowed reason — start-key and ppid are portable there (`ps` fallback, same string the TS core mints), so what holds the lane closed is per-process environment DISCOVERY alone, pending physical-host evidence. Detection power is held by check-meta-doctor-oracle
275
275
  ./run.sh copilot [args...] # #82 RAIL 7: the MANAGED Copilot launch. exec()s the vendor CLI in THIS terminal (cwd/pid/exit preserved) with COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS set for that one invocation — entwurf owns no part of your shell and writes nothing to it, but it owns the process it is about to become. Without that flag Copilot skips the extension scan SILENTLY. Refuses to launch unless the receiver unit is actually installed. Injects `--model auto` when no --model was given and `--yolo` when no explicit permission/surface policy flag was given, both BEFORE any `--`; every argument you pass is forwarded byte-identical. RUNNING THIS IS YOUR CONSENT to that profile — use plain `copilot` for stock vendor behaviour. Not tmux, not fresh-call, mints no citizen (birth is still the first prompt)
276
- ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, the pinned upstream app emitted by the maintainer) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue takes none of them. Nothing outside the package is located, installed or repaired
276
+ ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, emitted deterministically from Entwurf's owned source pi/pi-durable/src) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue takes none of them. Nothing outside the package is located, installed or repaired
277
277
  ./run.sh install-copilot-bridge # #82: GLOBAL install of the Copilot BIRTH plugin (own marketplace root; node+entry baked into the no-argv exec string). MCP wiring and the RECEIVER extension are separate install surfaces (install-copilot-mcp, install-copilot-receive). Also retires the stale Claude unit (--keep-stale-claude-unit opts out)
278
278
  ./run.sh uninstall-copilot-bridge # #86 C3a: honest inverse of install-copilot-bridge from the package-owned install-state (exact qualified id + exact marketplace name/path + recorded assembly only; state deleted LAST; a failing vendor list is UNKNOWN and refuses; never --force, never the stale Claude unit)
279
279
  ./run.sh doctor-copilot-bridge # #82/#86: fail-loud surface for that unit — runtime axis (red = a hook that RAN and failed, or a broken/unbaked artifact; "installed with zero records" is NOT red and is reported as NOT-YET: a Copilot session is born on its FIRST PROMPT, not when the window opens, measured) + ownership axis (install-state shape/binding, marketplace path drift, UNKNOWN vendor lists; legacy no-state install = named non-green, repair = install-copilot-bridge adoption). Either axis red = red
@@ -1606,7 +1606,7 @@ check_pi_durable_beside() {
1606
1606
  # from re-running every beside test. The six files are one contact: the adapter and its packaged
1607
1607
  # bootstrap, the installed carrier/SDK-set verifier (which also reads the three checkout gates as
1608
1608
  # SOURCE), the fresh preflight, the owner join that admits the durable host, the carrier's
1609
- # relocation grammar, resolver and maintainer emitter (#130) with the carrier's task footer (#134),
1609
+ # relocation grammar, resolver, owned-source emitter (#130, #141) and source fence with the carrier's task footer (#134),
1610
1610
  # and the bootstrap's explicit --native-module ingress (#130 P2) and its --native-module-dir (#134).
1611
1611
  run_vitest pi-extensions/meta-bridge-pi-durable.test.ts pi-extensions/lib/pi-durable-runtime.test.ts pi-extensions/lib/pi-durable-fresh-preflight.test.ts pi-extensions/lib/entwurf-deliverability.owner-join.test.ts pi-extensions/lib/pi-durable-carrier.test.ts pi-extensions/lib/pi-durable-native-module.test.ts
1612
1612
  }
@@ -2435,8 +2435,8 @@ const nextFloorRange = `>=${piAi} <${piMaj}.${piMin + 1}`;
2435
2435
  const floorGaps = [];
2436
2436
  const PI_CONSTELLATION = [
2437
2437
  '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', '@earendil-works/pi-tui',
2438
- '@earendil-works/pi-agent-core', '@earendil-works/pi-client', '@earendil-works/pi-protocol',
2439
- '@earendil-works/pi-telemetry', '@earendil-works/chord',
2438
+ '@earendil-works/pi-agent-core', '@earendil-works/pi-telemetry', '@earendil-works/chord',
2439
+ // `[측정 2026-10-13]` 1.2.0: pi-client and pi-protocol left — upstream removed both, E404 at 1.2.0.
2440
2440
  // `[측정 2026-09-30]` 0.99.1: pi-coding-agent's runtime deps gained the built-in extensions' packages.
2441
2441
  '@earendil-works/pi-codemode', '@earendil-works/pi-mcp',
2442
2442
  // #130: the durable library the pi-durable carrier imports — a production dependency since 0.32.
@@ -2577,6 +2577,65 @@ for (const [file, re, min, label] of SITES) {
2577
2577
  bound += hits.length;
2578
2578
  }
2579
2579
 
2580
+ // #141: the pi-durable carrier's emitter identity, the one place CI runs an EXACT Node. The check job
2581
+ // runs the Node the pin records as the carrier's emitter (CI-BOUND), and its check:full floor step runs
2582
+ // with ENTWURF_REQUIRE_EMITTER_NODE=1 (CI-REQUIRED), so that floor proves the emission under that
2583
+ // identity; every other job floats the major so the newest release of the lane stays covered. Both are
2584
+ // read from ACTIVE workflow lines only — the step's own `with:` / `env:` mapping, comments stripped —
2585
+ // so commented-out text or the same key on another step can never stand in for them.
2586
+ const EMITTER_BOUND_QK = '[QK:PI-DURABLE-EMITTER-NODE-CI-BOUND]';
2587
+ const EMITTER_REQUIRED_QK = '[QK:PI-DURABLE-EMITTER-NODE-CI-REQUIRED]';
2588
+ const emitterNode = JSON.parse(readFileSync('pi/pi-durable/overlay/upstream-pin.json', 'utf8')).carrier?.emitter?.node;
2589
+ assert.match(String(emitterNode), /^v\d+\.\d+\.\d+$/,
2590
+ `${EMITTER_BOUND_QK} pi/pi-durable/overlay/upstream-pin.json carrier.emitter.node must be an exact vX.Y.Z (got ${emitterNode})`);
2591
+ // jobs → steps → active lines. A full-line comment is dropped and a trailing ` # …` is cut; job heads are
2592
+ // the two-space keys under `jobs:`, a step starts at its six-space `- ` item.
2593
+ const ciJobs = new Map();
2594
+ {
2595
+ let inJobs = false;
2596
+ let steps;
2597
+ for (const raw of readFileSync('.github/workflows/ci.yml', 'utf8').split('\n')) {
2598
+ const line = raw.replace(/\s+#.*$/, '');
2599
+ if (/^\s*#/.test(raw) || line.trim() === '') continue;
2600
+ if (/^\S/.test(line)) { inJobs = line === 'jobs:'; steps = undefined; continue; }
2601
+ if (!inJobs) continue;
2602
+ const head = /^ ([a-z][a-z0-9-]*):$/.exec(line);
2603
+ if (head) { steps = []; ciJobs.set(head[1], steps); continue; }
2604
+ if (steps === undefined) continue;
2605
+ if (/^ - /.test(line)) steps.push([` ${line.slice(8)}`]);
2606
+ else if (steps.length > 0 && /^ /.test(line)) steps.at(-1).push(line);
2607
+ }
2608
+ }
2609
+ // A step's own value for `key` (`with:` / `env:` child at ten spaces), or a top-level step key at eight.
2610
+ const stepKey = (step, key) => step.find((l) => l.startsWith(` ${key}: `))?.slice(10 + key.length);
2611
+ const stepChild = (step, block, key) => {
2612
+ const at = step.indexOf(` ${block}:`);
2613
+ if (at === -1) return undefined;
2614
+ const children = [];
2615
+ for (const l of step.slice(at + 1)) { if (!l.startsWith(' ')) break; children.push(l); }
2616
+ return children.find((l) => l.startsWith(` ${key}: `))?.slice(12 + key.length);
2617
+ };
2618
+ const setupNodes = (job) => (ciJobs.get(job) ?? []).filter((s) => /^actions\/setup-node@/.test(stepKey(s, 'uses') ?? ''));
2619
+ const checkSetup = setupNodes('check');
2620
+ assert.equal(checkSetup.length, 1,
2621
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: the check job must have exactly one actions/setup-node step (found ${checkSetup.length})`);
2622
+ const checkNode = stepChild(checkSetup[0], 'with', 'node-version');
2623
+ assert.equal(`v${checkNode}`, emitterNode,
2624
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: the check job's setup-node runs Node ${checkNode}, but the carrier's pinned emitter is ${emitterNode} — its floor would prove the emission under another identity`);
2625
+ const floors = (ciJobs.get('check') ?? []).filter((s) => stepKey(s, 'run') === 'pnpm run check:full');
2626
+ assert.equal(floors.length, 1,
2627
+ `${EMITTER_REQUIRED_QK} .github/workflows/ci.yml: the check job must run \`pnpm run check:full\` in exactly one step (found ${floors.length})`);
2628
+ assert.equal(stepChild(floors[0], 'env', 'ENTWURF_REQUIRE_EMITTER_NODE'), '"1"',
2629
+ `${EMITTER_REQUIRED_QK} .github/workflows/ci.yml: the check job's check:full step does not set ENTWURF_REQUIRE_EMITTER_NODE: "1" in its own env, so an emitter-identity mismatch on that floor would be a skip, not red`);
2630
+ for (const [job] of ciJobs) {
2631
+ if (job === 'check') continue;
2632
+ for (const s of setupNodes(job)) {
2633
+ const value = stepChild(s, 'with', 'node-version');
2634
+ assert.match(String(value), /^\d+$/,
2635
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: job ${job} pins node-version ${value}; only the check job is exact, the others float the major`);
2636
+ }
2637
+ }
2638
+
2580
2639
  // Sweep the tracked first-party contract TEXT surfaces, NOT just the files
2581
2640
  // already registered above. The first cut of this gate swept only SITES' own
2582
2641
  // files and therefore could not see a declaration in an UNREGISTERED file —
@@ -2627,7 +2686,7 @@ assert.ok(swept >= 6,
2627
2686
  // `bound` is the real declaration count; SITES.length is the number of RULES
2628
2687
  // (the CI rule alone matches two node-version lines). Naming the rule count a
2629
2688
  // declaration count would be this gate telling a small lie about its own reach.
2630
- console.log(`[check-node-floor-coherence] ok — Node >=${MAJOR} is coherent across engines.node, ${bound} bound declarations from ${SITES.length} site rules, and ${swept} declarations swept over ${tracked.length} tracked first-party contract text surfaces (single supported axis, no legacy lane). Non-text carriers (e.g. a Dockerfile \`FROM node:*\`) are OUTSIDE this pattern and must be bound by their own gate.`);
2689
+ console.log(`[check-node-floor-coherence] ok — Node >=${MAJOR} is coherent across engines.node, ${bound} bound declarations from ${SITES.length} site rules, and ${swept} declarations swept over ${tracked.length} tracked first-party contract text surfaces (single supported axis, no legacy lane); the pi-durable emitter ${emitterNode} is the CI check job's exact Node. Non-text carriers (e.g. a Dockerfile \`FROM node:*\`) are OUTSIDE this pattern and must be bound by their own gate.`);
2631
2690
  EOF
2632
2691
  )
2633
2692
  }
@@ -2916,7 +2975,7 @@ check_pi_import_surface() {
2916
2975
  # resolve under the extension loader.
2917
2976
  #
2918
2977
  # #130 CARRIER FENCE (replaces the #129 bootstrap exception, retired with the source runtime):
2919
- # the pi-durable carrier (pi/pi-durable/carrier/*.js, upstream app files emitted by
2978
+ # the pi-durable carrier (pi/pi-durable/carrier/*.js, emitted from Entwurf's owned source by
2920
2979
  # scripts/emit-pi-durable-carrier.ts) is the ONE place pi-coding-agent internals are reached,
2921
2980
  # and only through the reserved `entwurf-pi-dist:` scheme its resolver answers. The fence is
2922
2981
  # declared and asserted here, not inferred from the root-only scan staying quiet:
@@ -3758,7 +3817,6 @@ check_pack() {
3758
3817
  "mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js"
3759
3818
  "pi/pi-durable/bootstrap.mjs"
3760
3819
  "pi/pi-durable/overlay/upstream-pin.json"
3761
- "pi/pi-durable/overlay/runtime-contacts.patch"
3762
3820
  "pi/pi-durable/carrier-resolver.mjs"
3763
3821
  "pi/pi-durable/carrier-relocation.mjs"
3764
3822
  "pi/pi-durable/carrier/runtime.js"
@@ -3845,6 +3903,11 @@ check_pack() {
3845
3903
  # Maintainer take/renderer/helper TS is source-only, including non-test implementations.
3846
3904
  '^demo/.*\.ts$'
3847
3905
  )
3906
+ # #141: the owned pi-durable source, its typecheck fence and the retired overlay patch are maintainer
3907
+ # input. An installed consumer runs the emitted carrier JS (and its LICENSE) only — Rule 11: compiled
3908
+ # JS for installed entrypoints. The files array lists pi/pi-durable per FILE, so nothing admits these
3909
+ # today; this tripwire is what fails loud the day an allowlist entry would.
3910
+ local durable_source_pattern='^pi/pi-durable/(src/|tsconfig\.json$|overlay/runtime-contacts\.patch$)'
3848
3911
 
3849
3912
  local pass=1 f pat hit
3850
3913
 
@@ -3864,6 +3927,13 @@ check_pack() {
3864
3927
  fi
3865
3928
  done
3866
3929
 
3930
+ hit=$(grep -E "$durable_source_pattern" <<<"$file_list" || true)
3931
+ if [ -n "$hit" ]; then
3932
+ fail "[check-pack] [QK:PI-DURABLE-SOURCE-NOT-SHIPPED] the owned pi-durable source rides the tarball — an installed consumer runs the carrier JS only:"
3933
+ echo "$hit" | sed 's/^/ /' >&2
3934
+ pass=0
3935
+ fi
3936
+
3867
3937
  local total
3868
3938
  total=$(printf '%s\n' "$file_list" | wc -l | tr -d ' ')
3869
3939
  echo "[check-pack] $total files in tarball"
@@ -3898,7 +3968,7 @@ check_pack() {
3898
3968
  # which is exactly what property (1)'s `(_|$)` absorbs; all three shapes are fixtures
3899
3969
  # below so a future suffix change cannot pass vacuously.)
3900
3970
  pack_install_leaked_pi() {
3901
- grep '^@earendil-works+' | grep -Ev '@1\.1\.0(_|$)' || true
3971
+ grep '^@earendil-works+' | grep -Ev '@1\.2\.0(_|$)' || true
3902
3972
  }
3903
3973
 
3904
3974
  # Matcher self-test on SYNTHETIC lookalikes: a healthy install tree cannot exercise either
@@ -3918,15 +3988,15 @@ check_pack_pin_matcher() {
3918
3988
  # None may leak; the two lookalikes must — a PREFIX-EXTENDED version (`0.86.0-beta.1`,
3919
3989
  # the prerelease shape that an unanchored match would bless) and an off-pin version.
3920
3990
  matcher_probe=$(printf '%s\n' \
3921
- '@earendil-works+pi-ai@1.1.0' \
3922
- '@earendil-works+pi-ai@1.1.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3923
- '@earendil-works+pi-ai@1.1.0_ws@8.21.3' \
3924
- '@earendil-works+pi-ai@1.1.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6__ws@8.21.3_zod@4.3.6' \
3925
- '@earendil-works+pi-ai@1.1.0-beta.1' \
3991
+ '@earendil-works+pi-ai@1.2.0' \
3992
+ '@earendil-works+pi-ai@1.2.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3993
+ '@earendil-works+pi-ai@1.2.0_ws@8.21.3' \
3994
+ '@earendil-works+pi-ai@1.2.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6__ws@8.21.3_zod@4.3.6' \
3995
+ '@earendil-works+pi-ai@1.2.0-beta.1' \
3926
3996
  '@earendil-works+pi-agent-core@0.87.1' | pack_install_leaked_pi)
3927
- if [ "$matcher_probe" != '@earendil-works+pi-ai@1.1.0-beta.1
3997
+ if [ "$matcher_probe" != '@earendil-works+pi-ai@1.2.0-beta.1
3928
3998
  @earendil-works+pi-agent-core@0.87.1' ]; then
3929
- fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the prefix-extended 1.1.0-beta.1 and the off-pin 0.87.1 lookalikes, and pass 1.1.0 bare or with any measured peer-hash — got: ${matcher_probe:-<nothing leaked>}"
3999
+ fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the prefix-extended 1.2.0-beta.1 and the off-pin 0.87.1 lookalikes, and pass 1.2.0 bare or with any measured peer-hash — got: ${matcher_probe:-<nothing leaked>}"
3930
4000
  return 1
3931
4001
  fi
3932
4002
 
@@ -3934,11 +4004,11 @@ check_pack_pin_matcher() {
3934
4004
  # (0.85.0 onward, still true at 0.99.1). An off-pin chord MUST leak; the pinned one must not. A matcher narrowed
3935
4005
  # back to `^@earendil-works+pi-` sees nothing here and dies at this signature.
3936
4006
  matcher_probe=$(printf '%s\n' \
3937
- '@earendil-works+chord@1.1.0' \
4007
+ '@earendil-works+chord@1.2.0' \
3938
4008
  '@earendil-works+chord@0.87.1' \
3939
- '@earendil-works+pi-ai@1.1.0' | pack_install_leaked_pi)
4009
+ '@earendil-works+pi-ai@1.2.0' | pack_install_leaked_pi)
3940
4010
  if [ "$matcher_probe" != '@earendil-works+chord@0.87.1' ]; then
3941
- fail "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE] the pin-leak matcher must cover every @earendil-works closure member, not just the pi-* families — an off-pin @earendil-works/chord has to leak (it is a runtime dependency of pi-coding-agent, pi-agent-core, pi-client and pi-protocol — `[측정 2026-09-20]` at 0.86.0, re-measured `[측정 2026-09-22]` at 0.87.0, `[측정 2026-09-23]` at 0.87.1 and `[측정 2026-09-30]` at 0.99.1, where pi-coding-agent adds pi-codemode and pi-mcp beside them) — got: ${matcher_probe:-<nothing leaked>}"
4011
+ fail "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE] the pin-leak matcher must cover every @earendil-works closure member, not just the pi-* families — an off-pin @earendil-works/chord has to leak (it was a runtime dependency of pi-coding-agent, pi-agent-core, pi-client and pi-protocol — `[측정 2026-09-20]` at 0.86.0, re-measured `[측정 2026-09-22]` at 0.87.0, `[측정 2026-09-23]` at 0.87.1 and `[측정 2026-09-30]` at 0.99.1, where pi-coding-agent adds pi-codemode and pi-mcp beside them; `[측정 2026-10-13]` at 1.2.0 pi-durable alone declares it) — got: ${matcher_probe:-<nothing leaked>}"
3942
4012
  return 1
3943
4013
  fi
3944
4014
 
@@ -4113,8 +4183,8 @@ ORACLE
4113
4183
  # copied from this checkout into the sandbox — it never ships (test/ is outside the package files).
4114
4184
  # Its initialization appends `init` to the receipt this cell names; the same public launch must then
4115
4185
  # reach the same carrier model refusal, so the installed bootstrap loaded the module before the
4116
- # runtime and the native registry installed its extension (carrier/runtime.js 148) before that
4117
- # refusal (runtime.js 164). A module claiming an extension name the app installs itself is refused by
4186
+ # runtime and the native registry installed its extension (carrier/runtime.js 159) before that
4187
+ # refusal (runtime.js 175). A module claiming an extension name the app installs itself is refused by
4118
4188
  # name before anything opens: no durable session directory, no citizen, in a fresh sandbox home.
4119
4189
  # Native SDK file opens here are ordinary (the module runs in the native process); none is judged.
4120
4190
  local mod_dir="$box/pd-module" mod_home="$box/pd-module-home" mod_env=()
@@ -4169,7 +4239,7 @@ ORACLE
4169
4239
  # aligned host binary (the pinned version, installed above for L1) must be inside the range.
4170
4240
  # The previous host version is a variable, not a quoted `@pkg@x.y.z` spec: check-dep-versions reads
4171
4241
  # those specs as this repo's install pins, and this one is deliberately off the floor.
4172
- local host_home host_proj host_old="$box/pd-host-old" host_env=() host_old_ver="1.0.4"
4242
+ local host_home host_proj host_old="$box/pd-host-old" host_env=() host_old_ver="1.1.0"
4173
4243
  mkdir -p "$host_old"
4174
4244
  for spec in "${host_specs[@]}"; do host_old_specs+=("${spec%@*}@$host_old_ver"); done
4175
4245
  out=$(cd "$host_old" && npm install "${host_old_specs[@]}" --no-audit --no-fund 2>&1) || {
@@ -4187,7 +4257,7 @@ ORACLE
4187
4257
  fail "[check-pack-install] host $cell: the native pi-durable row must stay PASS whatever the PATH host is:"; echo "$out" | tail -20 | sed 's/^/ /' >&2; return 1
4188
4258
  fi
4189
4259
  if [ "$cell" = old ]; then
4190
- if [ "$rc" -eq 0 ] || ! grep -q "pi: FAIL — detected pi $host_old_ver is outside the supported range >=$pin_ver <1.2" <<<"$out" || [ -e "$host_proj/.pi" ] || [ -e "$host_home/.pi/agent/settings.json" ]; then
4260
+ if [ "$rc" -eq 0 ] || ! grep -q "pi: FAIL — detected pi $host_old_ver is outside the supported range >=$pin_ver <1.3" <<<"$out" || [ -e "$host_proj/.pi" ] || [ -e "$host_home/.pi/agent/settings.json" ]; then
4191
4261
  fail "[QK:PACK-INSTALL-HOST-RANGE-SEPARATE] [check-pack-install] a real pi $host_old_ver host beside the $pin_ver native set must be a named pi FAIL with zero Pi wiring and a non-green setup (rc=$rc):"
4192
4262
  echo "$out" | tail -20 | sed 's/^/ /' >&2
4193
4263
  return 1
@@ -4395,7 +4465,6 @@ _check_pack_install_impl() {
4395
4465
  "mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js"
4396
4466
  "pi/pi-durable/bootstrap.mjs"
4397
4467
  "pi/pi-durable/overlay/upstream-pin.json"
4398
- "pi/pi-durable/overlay/runtime-contacts.patch"
4399
4468
  "pi/pi-durable/carrier-resolver.mjs"
4400
4469
  "pi/pi-durable/carrier-relocation.mjs"
4401
4470
  "pi/pi-durable/carrier/runtime.js"
@@ -4558,21 +4627,26 @@ _check_pack_install_impl() {
4558
4627
  # pi-ai, pi-codemode, pi-mcp, pi-tui}, pi-ai {pi-telemetry}, pi-agent-core {pi-ai}, pi-durable
4559
4628
  # {chord, pi-ai}, and `npm view` gives pi-client {chord, pi-protocol}, pi-protocol {chord}. All
4560
4629
  # eleven are published at 1.1.0.
4561
- echo "[check-pack-install] pnpm add into $tmp (with the aligned 1.1.x SDK + chord + codemode + mcp + durable + typebox)"
4630
+ # `[측정 2026-10-13]` at 1.2.0 the constellation SHRINKS to the nine-member SDK set: upstream
4631
+ # removed pi-client and pi-protocol (7472cda0d), and `npm view <pkg>@1.2.0` answers E404 for both,
4632
+ # so their pins cannot move with the floor and leave instead of staying redundant. The 1.2.0
4633
+ # registry manifests give pi-coding-agent {pi-agent-core, pi-ai, pi-codemode, pi-mcp, pi-tui}
4634
+ # (chord dropped), pi-ai {pi-telemetry}, pi-agent-core {pi-ai}, pi-durable {chord, pi-ai,
4635
+ # pi-codemode}; all nine are published at 1.2.0
4636
+ # (.tmp-verify/141-kickoff/s1a/map-receipts/01-tarballs.txt).
4637
+ echo "[check-pack-install] pnpm add into $tmp (with the aligned 1.2.x SDK + chord + codemode + mcp + durable + typebox)"
4562
4638
  local install_log
4563
4639
  install_log=$(cd "$tmp" && pnpm add \
4564
4640
  "$tgz_path" \
4565
- "@earendil-works/pi-ai@1.1.0" \
4566
- "@earendil-works/pi-coding-agent@1.1.0" \
4567
- "@earendil-works/pi-tui@1.1.0" \
4568
- "@earendil-works/pi-agent-core@1.1.0" \
4569
- "@earendil-works/pi-client@1.1.0" \
4570
- "@earendil-works/pi-protocol@1.1.0" \
4571
- "@earendil-works/pi-telemetry@1.1.0" \
4572
- "@earendil-works/chord@1.1.0" \
4573
- "@earendil-works/pi-codemode@1.1.0" \
4574
- "@earendil-works/pi-mcp@1.1.0" \
4575
- "@earendil-works/pi-durable@1.1.0" \
4641
+ "@earendil-works/pi-ai@1.2.0" \
4642
+ "@earendil-works/pi-coding-agent@1.2.0" \
4643
+ "@earendil-works/pi-tui@1.2.0" \
4644
+ "@earendil-works/pi-agent-core@1.2.0" \
4645
+ "@earendil-works/pi-telemetry@1.2.0" \
4646
+ "@earendil-works/chord@1.2.0" \
4647
+ "@earendil-works/pi-codemode@1.2.0" \
4648
+ "@earendil-works/pi-mcp@1.2.0" \
4649
+ "@earendil-works/pi-durable@1.2.0" \
4576
4650
  "typebox@latest" \
4577
4651
  --ignore-workspace --ignore-scripts 2>&1) || {
4578
4652
  fail "[check-pack-install] pnpm add failed:"
@@ -4588,11 +4662,11 @@ _check_pack_install_impl() {
4588
4662
  local leaked_pi
4589
4663
  leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | pack_install_leaked_pi)
4590
4664
  if [ -n "$leaked_pi" ]; then
4591
- fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 1.1.0):"
4665
+ fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 1.2.0):"
4592
4666
  printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
4593
4667
  return 1
4594
4668
  fi
4595
- echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works package is 1.1.0 (chord and pi-durable included)"
4669
+ echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works package is 1.2.0 (chord and pi-durable included)"
4596
4670
 
4597
4671
  # Resolve the installed package.json and confirm pi.extensions
4598
4672
  # arrived intact. If pi.extensions is empty or missing, the
@@ -322,25 +322,35 @@ exit "\${FAKE_CODEX_EXIT:-0}"
322
322
  );
323
323
  }
324
324
  {
325
+ // The classifier's INDETERMINATE domain, as one claim: two unidentifiable paths, both
326
+ // reaching the launcher through the one `indeterminate)` arm. A path that exists and is
327
+ // not a socket must read indeterminate, not stale, so the launcher refuses instead of
328
+ // clobbering it. A symlink that is NOT Codex's exact rendezvous alias must be refused with
329
+ // the product classifier's own reason, which is what pins the WIRING: a launcher that grew
330
+ // its own symlink rule again would refuse there too, but not in those words. Both runs are
331
+ // made before the one assertion, so a regression on that shared arm fails HERE, at this
332
+ // claim, and not at an earlier unnamed cell. The exact alias's accept path is certified on
333
+ // `checkCodexSocketFile` (check-codex-native-push), not here: this gate has no seam to put
334
+ // Codex's socket directory in a sandbox.
325
335
  const fileHome = path.join(root, "file-home");
326
336
  const fileSock = resolveCodexDefaultSocketPath({ CODEX_HOME: fileHome });
327
337
  mkdirSync(path.dirname(fileSock), { recursive: true });
328
338
  writeFileSync(fileSock, "");
329
- const r = launch([], { CODEX_HOME: fileHome });
330
- ok(
331
- "a path that exists and is not a socket is indeterminate, not stale — the launcher refuses instead of clobbering something it cannot identify",
332
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
333
- );
334
- }
335
- {
339
+ const regular = launch([], { CODEX_HOME: fileHome });
336
340
  const linkHome = path.join(root, "link-home");
337
341
  const linkSock = resolveCodexDefaultSocketPath({ CODEX_HOME: linkHome });
338
342
  mkdirSync(path.dirname(linkSock), { recursive: true });
339
343
  symlinkSync(path.join(root, "nowhere.sock"), linkSock);
340
- const r = launch([], { CODEX_HOME: linkHome });
344
+ const link = launch([], { CODEX_HOME: linkHome });
341
345
  ok(
342
- "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] a symlinked control socket is refused by name — the same classification the delivery rail's socket check uses",
343
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
346
+ "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] an unidentifiable path is refused, never launched over: a path that exists and is not a socket, and a symlink that is not Codex's rendezvous alias (refused with the classifier's own reason) — the same classification the delivery rail's socket check uses",
347
+ regular.status !== 0 &&
348
+ regular.out.includes("codex-app-server-socket-indeterminate") &&
349
+ regular.args.length === 0 &&
350
+ link.status !== 0 &&
351
+ link.out.includes("codex-app-server-socket-indeterminate") &&
352
+ link.out.includes("is not Codex's rendezvous alias") &&
353
+ link.args.length === 0,
344
354
  );
345
355
  }
346
356
  {
@@ -358,17 +368,50 @@ exit "\${FAKE_CODEX_EXIT:-0}"
358
368
  // Hard Rule 15: an unrecognised reading is the one case where proceeding is unsafe,
359
369
  // because every branch above is a decision about whether this launch would clobber a
360
370
  // running server. The stimulus is a sandbox `python3` that exits 0 while printing
361
- // something nobody wrote — the exact shape a silent fall-through needs.
371
+ // something nobody wrote — the exact shape a silent fall-through needs. The launcher runs
372
+ // that connect probe only on an endpoint the classifier verified as a socket, so the cell
373
+ // puts a (dead) socket file there; without one the probe would never be asked.
374
+ const oddHome = path.join(root, "odd-home");
375
+ const oddSock = resolveCodexDefaultSocketPath({ CODEX_HOME: oddHome });
376
+ mkdirSync(path.dirname(oddSock), { recursive: true });
377
+ spawnSync("python3", ["-c", "import socket,sys;s=socket.socket(socket.AF_UNIX);s.bind(sys.argv[1])", oddSock]);
362
378
  const oddBin = path.join(root, "odd-probe-bin");
363
379
  mkdirSync(oddBin, { recursive: true });
364
380
  const oddPython = path.join(oddBin, "python3");
365
381
  writeFileSync(oddPython, "#!/usr/bin/env bash\ncat >/dev/null\necho 'unexpected-probe-status'\nexit 0\n");
366
382
  chmodSync(oddPython, 0o755);
367
- const r = launch([], { PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
383
+ const r = launch([], { CODEX_HOME: oddHome, PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
384
+ // The second stimulus is the CLASSIFIER answering a word it never gives. `stale` is the
385
+ // discriminating one: it is a valid verdict of the connect probe, so a launcher that passed
386
+ // unknown classifier words through would read it as a dead socket and LAUNCH. A sandbox
387
+ // `node` answers for the classifier's basename only and hands every other invocation to
388
+ // the real Node — a test fixture on this gate's PATH, not a seam the product reads.
389
+ const oddClassifierHome = path.join(root, "odd-classifier-home");
390
+ const oddClassifierBin = path.join(root, "odd-classifier-bin");
391
+ mkdirSync(oddClassifierBin, { recursive: true });
392
+ const oddNode = path.join(oddClassifierBin, "node");
393
+ writeFileSync(
394
+ oddNode,
395
+ "#!/usr/bin/env bash\n" +
396
+ 'for a in "$@"; do case "$a" in */codex-socket-classify.js) printf \'stale\'; exit 0 ;; esac; done\n' +
397
+ `exec ${JSON.stringify(process.execPath)} "$@"\n`,
398
+ );
399
+ chmodSync(oddNode, 0o755);
400
+ const c = launch([], {
401
+ CODEX_HOME: oddClassifierHome,
402
+ PATH: `${oddClassifierBin}:${bin}:${process.env.PATH ?? ""}`,
403
+ });
368
404
  ok(
369
405
  "[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE] a socket classifier that exits 0 with a reading nobody wrote REFUSES instead of falling through to the exec",
370
406
  r.status !== 0 && r.out.includes("codex-app-server-socket-probe-unrecognised") && r.args.length === 0,
371
407
  );
408
+ ok(
409
+ `[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-CLASSIFIER] a classifier word the classifier never gives is refused before it can be read as one of the probe's own verdicts — 'stale' would otherwise launch (status ${c.status}, ${c.args.length} vendor args)`,
410
+ c.status !== 0 &&
411
+ c.out.includes("codex-app-server-socket-probe-unrecognised") &&
412
+ c.out.includes("the socket classifier answered\n 'stale'") &&
413
+ c.args.length === 0,
414
+ );
372
415
  }
373
416
 
374
417
  // ── 5. refusals that keep this from becoming something it is not ────────────