@junghanacs/entwurf 0.24.0 → 0.25.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 (64) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/DELIVERY.md +24 -1
  3. package/README.md +2 -2
  4. package/VERIFY.md +19 -1
  5. package/demo/README.md +5 -2
  6. package/docs/acp-backend-rail.md +49 -2
  7. package/docs/setup-clean-host.md +4 -4
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
  17. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  18. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  19. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  20. package/mcp/entwurf-bridge/src/index.ts +5 -4
  21. package/package.json +10 -9
  22. package/pi-extensions/entwurf-control.ts +27 -154
  23. package/pi-extensions/lib/compaction-send-guard.ts +5 -3
  24. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  25. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  26. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  27. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  28. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  29. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  30. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  31. package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
  32. package/pi-extensions/lib/herdr-placement.ts +11 -0
  33. package/protocol.js +0 -8
  34. package/run.sh +197 -29
  35. package/scripts/check-entwurf-v2-surface.ts +289 -10
  36. package/scripts/check-gate-qualification.ts +7 -4
  37. package/scripts/check-herdr-fresh-call.ts +10 -2
  38. package/scripts/check-herdr-placement.ts +51 -0
  39. package/scripts/check-herdr-plugin-build.ts +11 -4
  40. package/scripts/check-herdr-plugin.ts +745 -38
  41. package/scripts/check-mux-launcher-fence.ts +37 -0
  42. package/scripts/check-pi-launch.ts +7 -4
  43. package/scripts/check-release-gate-outcomes.ts +165 -0
  44. package/scripts/doctor-pi-provider.ts +44 -4
  45. package/scripts/herdr-rails.mjs +75 -0
  46. package/scripts/meta-bridge-state.py +18 -1
  47. package/scripts/mutants/bridge-command-boot.json +38 -0
  48. package/scripts/mutants/control-send-receipt.json +235 -0
  49. package/scripts/mutants/herdr-fresh-call.json +4 -4
  50. package/scripts/mutants/herdr-plugin-build.json +7 -11
  51. package/scripts/mutants/herdr-plugin.json +177 -9
  52. package/scripts/mutants/meta-retire.json +22 -0
  53. package/scripts/mutants/pack-install.json +4 -4
  54. package/scripts/mutants/pi-floor.json +17 -0
  55. package/scripts/mutants/release-gate.json +48 -0
  56. package/scripts/mutants/retired-sent-surface.json +32 -0
  57. package/scripts/mutants/v2-surface.json +7 -7
  58. package/scripts/smoke-codex-fresh-live.ts +94 -13
  59. package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
  60. package/scripts/smoke-herdr-raw-install-live.sh +59 -2
  61. package/scripts/smoke-meta-install-state.sh +107 -1
  62. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  63. package/scripts/smoke-pi-provider-state.sh +106 -0
  64. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -282,6 +282,22 @@ const SUCCESS_RECEIPT = {
282
282
  observedLiveness: "alive" as const,
283
283
  };
284
284
 
285
+ /** Collect `const X = "literal"` bindings out of a parsed source, so a customType that travels
286
+ * through a named constant resolves to the same string a literal would. */
287
+ function visitConstsInto(
288
+ src: import("typescript").SourceFile,
289
+ into: Map<string, string>,
290
+ ts: typeof import("typescript"),
291
+ ): void {
292
+ const walk = (node: import("typescript").Node): void => {
293
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
294
+ if (ts.isStringLiteral(node.initializer)) into.set(node.name.text, node.initializer.text);
295
+ }
296
+ ts.forEachChild(node, walk);
297
+ };
298
+ walk(src);
299
+ }
300
+
285
301
  async function main(): Promise<void> {
286
302
  // ── 1: toDispatchInput mapping ────────────────────────────────────────────
287
303
  {
@@ -336,16 +352,50 @@ async function main(): Promise<void> {
336
352
  rr.isError && rr.text.includes("target-locked") && rr.text.includes("pid 999"),
337
353
  );
338
354
 
339
- // control sent → delivered
340
- const sent: EntwurfV2RunResult = {
355
+ // #120 P2 — THE BOUNDARY IS WHAT THIS LINE SAYS, and the route is an annotation beside it.
356
+ // The delivered branch renders what the RECEIVER observed, never which leg carried the
357
+ // bytes: for 78 minutes `control-socket → sent` was printed about messages lying in a
358
+ // queue, and the word `sent` is the whole reason nobody could tell.
359
+ const ctl = (
360
+ outcome: "sent" | "fallback-sent",
361
+ boundary?: "sent" | "queued-steer" | "queued-follow-up" | "accepted-unknown-boundary",
362
+ messagePath?: string,
363
+ ): EntwurfV2RunResult => ({
341
364
  kind: "executed",
342
365
  receipt: SUCCESS_RECEIPT,
343
366
  transport: "control-socket",
344
- outcome: { transport: "control-socket", outcome: "sent" },
345
- };
367
+ outcome: { transport: "control-socket", outcome, boundary, messagePath },
368
+ });
369
+ const idle = renderEntwurfV2Result(ctl("sent", "sent"));
370
+ ok("2: an idle direct trigger renders `sent`, and is not an error", !idle.isError && idle.text.endsWith("→ sent"));
371
+ const steered = renderEntwurfV2Result(ctl("sent", "queued-steer"));
372
+ const followed = renderEntwurfV2Result(ctl("sent", "queued-follow-up"));
373
+ ok(
374
+ "[QK:SEND-SURFACE-RENDERS-BOUNDARY] 2: a BUSY receiver renders its queue by name, as a success — and never the word `sent`. The route word answers WHICH LEG carried the bytes; this line has to answer what the receiver did with them, because for 78 minutes it said `sent` about messages lying in a queue",
375
+ !steered.isError &&
376
+ steered.text.endsWith("→ queued-steer") &&
377
+ !followed.isError &&
378
+ followed.text.endsWith("→ queued-follow-up") &&
379
+ !/→ sent/.test(steered.text) &&
380
+ !/→ sent/.test(followed.text),
381
+ );
382
+ const unknown = renderEntwurfV2Result(ctl("sent", "accepted-unknown-boundary"));
383
+ ok(
384
+ "[QK:SEND-SURFACE-UNKNOWN-NOT-SENT] 2: an answer we cannot classify renders `accepted-unknown-boundary` — a success (the receiver accepted; a retry would double-deliver) and NOT spelled with `sent`, which would put the removed false claim straight back",
385
+ !unknown.isError && unknown.text.endsWith("→ accepted-unknown-boundary") && !/sent/.test(unknown.text),
386
+ );
387
+ const fbQueued = renderEntwurfV2Result(ctl("fallback-sent", "queued-steer"));
346
388
  ok(
347
- "2: control sent → not error",
348
- !renderEntwurfV2Result(sent).isError && renderEntwurfV2Result(sent).text.includes("sent"),
389
+ "[QK:SEND-SURFACE-FALLBACK-KEEPS-BOUNDARY] 2: a re-resolved SOCKET keeps the boundary and annotates the route — a dead first socket re-resolving to a BUSY one must not come out reading as a plain delivery",
390
+ !fbQueued.isError && fbQueued.text.includes("queued-steer") && fbQueued.text.includes("re-resolved"),
391
+ );
392
+ const fbMail = renderEntwurfV2Result(ctl("fallback-sent", undefined, "/m/20260903T134455-e55e87/x.msg"));
393
+ ok(
394
+ "2: a re-resolved MAILBOX renders the durable receipt, not a bare route word",
395
+ !fbMail.isError &&
396
+ fbMail.text.includes("mailbox-enqueued") &&
397
+ fbMail.text.includes("enqueued x.msg") &&
398
+ !/fallback-sent/.test(fbMail.text),
349
399
  );
350
400
 
351
401
  // control in-band rejected with N3 rejectReason → non-delivery
@@ -375,7 +425,12 @@ async function main(): Promise<void> {
375
425
  },
376
426
  };
377
427
  const fb = renderEntwurfV2Result(fallback);
378
- ok("2: fallback-sent is a delivery (not an error)", !fb.isError && fb.text.includes("fallback-sent"));
428
+ // #120 P2: it is still a delivery, and it is now named by what it actually was — a durable
429
+ // mailbox enqueue reached through a re-resolved route — rather than by the internal leg word.
430
+ ok(
431
+ "2: a re-resolved mailbox delivery is not an error, and says mailbox-enqueued rather than the leg word",
432
+ !fb.isError && fb.text.includes("mailbox-enqueued") && fb.text.includes("re-resolved"),
433
+ );
379
434
  ok(
380
435
  "2: fallback-sent names the enqueued FILE (#98 R, fallback leg)",
381
436
  fb.text.includes("2026-09-03T09-58-02-114Z-ab12cd.msg"),
@@ -393,12 +448,12 @@ async function main(): Promise<void> {
393
448
  kind: "executed",
394
449
  receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
395
450
  transport: "control-socket",
396
- outcome: { transport: "control-socket", outcome: "fallback-sent" },
451
+ outcome: { transport: "control-socket", outcome: "fallback-sent", boundary: "sent" },
397
452
  };
398
453
  const fbs = renderEntwurfV2Result(fallbackSocket);
399
454
  ok(
400
- "2: socket-retry fallback-sent degrades to the bare outcome (no file to name)",
401
- fbs.text === "entwurf_v2 control-socket → fallback-sent" && !fbs.isError,
455
+ "2: a socket-to-socket retry names no file (none was written) and still carries its boundary",
456
+ fbs.text === "entwurf_v2 control-socket → sent (via a re-resolved route)" && !fbs.isError,
402
457
  );
403
458
 
404
459
  // The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
@@ -489,6 +544,53 @@ async function main(): Promise<void> {
489
544
  n1r.isError && n1r.text.includes("DIRTY") && n1r.text.includes("do NOT retry"),
490
545
  );
491
546
 
547
+ // #120 P2 — A DIRTY LOCK IS A REASON TO BE MORE PRECISE, NOT LESS. This line used to read
548
+ // `DELIVERED (<route>)` for all three shapes that reach it, and it was wrong about two: a
549
+ // `rejected` finalization delivered nothing at all, and a `queued-*` one is sitting in
550
+ // volatile receiver memory. The operator reading it is about to decide BY HAND whether
551
+ // anything still needs sending, which is the worst possible moment for the one word on
552
+ // screen to be the internal route.
553
+ const dirty = (
554
+ finalizedOutcome: "sent" | "fallback-sent" | "rejected",
555
+ finalizedBoundary?: "sent" | "queued-steer" | "queued-follow-up" | "accepted-unknown-boundary",
556
+ finalizedMessagePath?: string,
557
+ finalizedRejectReason?: string,
558
+ ): { text: string; isError: boolean } =>
559
+ renderEntwurfV2Result({
560
+ kind: "execution-failed",
561
+ receipt: SUCCESS_RECEIPT,
562
+ transport: "control-socket",
563
+ error: "release boom",
564
+ finalizedOutcome,
565
+ finalizedBoundary,
566
+ finalizedMessagePath,
567
+ finalizedRejectReason,
568
+ releaseFailed: true,
569
+ retrySafe: false,
570
+ });
571
+ const dirtyBusy = dirty("sent", "queued-steer");
572
+ const dirtyMailbox = dirty("fallback-sent", undefined, "/m/20260903T134455-e55e87/x.msg");
573
+ const dirtyRefused = dirty("rejected", undefined, undefined, "compacting");
574
+ ok(
575
+ "[QK:SEND-DIRTY-LOCK-NAMES-ACCEPTANCE] a release failure after a terminal send names the ACCEPTANCE it reached — the receiver's queue, the enqueued file, or the refusal — and never a bare `DELIVERED (<route>)`, while the retry-unsafe dirty-lock meaning stays intact on all three",
576
+ // the queue, by name, and not the word delivered
577
+ dirtyBusy.text.includes("queued-steer") &&
578
+ !/DELIVERED|\bdelivered\b/.test(dirtyBusy.text) &&
579
+ // the mailbox leg names its durable file rather than a route word
580
+ dirtyMailbox.text.includes("mailbox-enqueued") &&
581
+ dirtyMailbox.text.includes("enqueued x.msg") &&
582
+ !/DELIVERED|\bdelivered\b/.test(dirtyMailbox.text) &&
583
+ // a REFUSAL is never dressed as a delivery
584
+ dirtyRefused.text.includes("rejected") &&
585
+ // the receiver's OWN reason survives to the line the operator acts on
586
+ dirtyRefused.text.includes("compacting") &&
587
+ !/DELIVERED|\bdelivered\b/.test(dirtyRefused.text) &&
588
+ // and every one of them keeps the thing the operator must act on
589
+ [dirtyBusy, dirtyMailbox, dirtyRefused].every(
590
+ (r) => r.isError && r.text.includes("DIRTY") && r.text.includes("do NOT retry"),
591
+ ),
592
+ );
593
+
492
594
  // plain execution-failed → error
493
595
  const failed: EntwurfV2RunResult = {
494
596
  kind: "execution-failed",
@@ -624,6 +726,25 @@ async function main(): Promise<void> {
624
726
  "4: pi-native — NO static import of the self-address fence (TS5097 stays closed)",
625
727
  !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
626
728
  );
729
+ // #120 P2: the RECEIVER's own answer. The sender can only render a boundary the receiver
730
+ // actually names, so this is the far end of the same contract — and the bare `delivered`
731
+ // it replaced is the one DELIVERY.md refuses by name, because it was equally true of a
732
+ // direct trigger and of a message rotting in a queue nobody drains.
733
+ ok(
734
+ "[QK:SEND-RECEIVER-EMITS-BOUNDARY] the control-socket `send` answers with the typed boundary its own state produces, and with no bare `delivered` / unconsumed `deliveredAs` beside it — a field that is true of every outcome tells a sender nothing, and there is no sender left that reads those two",
735
+ (() => {
736
+ // Scoped to the RPC ANSWER, not the whole file: `deliveredAs` also appears in the
737
+ // (separately owned, #120 Part 2) sent-message renderer, and a whole-file ban here
738
+ // would make this claim fail for a reason it is not about.
739
+ const at = code.indexOf('respond(true, "send"');
740
+ const answer = at < 0 ? "" : code.slice(at, code.indexOf(");", at));
741
+ return (
742
+ /boundary:\s*sendReceiptBoundary\(\{\s*idle:\s*isIdle,\s*mode\s*\}\)/.test(answer) &&
743
+ !/\bdelivered:\s*true/.test(answer) &&
744
+ !/\bdeliveredAs\b/.test(answer)
745
+ );
746
+ })(),
747
+ );
627
748
  // Every SHIPPED rail must appear in the model-facing text. Measured 2026-07-27: both
628
749
  // surfaces described only control-socket/mailbox and told the model that an
629
750
  // `unsupported` citizen is reached "→ mailbox" — false for Antigravity, whose native-push
@@ -847,6 +968,164 @@ async function main(): Promise<void> {
847
968
  );
848
969
  }
849
970
 
971
+ // ── #120 P3: a renderer nobody can produce for, and the retired surface it drew ──
972
+ // PARSED, NOT GREPPED. The defect being closed here is a renderer registered for a customType
973
+ // that no code emits — and the thing that hid it for two releases was PROSE: `protocol.js` said
974
+ // "the bridge emits it", `entwurf-control.ts` named a producer in `streamShellAcp`, and neither
975
+ // was true. A regex oracle would have been satisfied by exactly those sentences. So the
976
+ // registration and the producer are both read out of the TypeScript AST: a `registerMessageRenderer`
977
+ // CALL, and an object literal property `customType` whose value resolves to the same string.
978
+ //
979
+ // SCOPE, stated: this is the pi-native file's own contract — registrations here must have a
980
+ // producer here. A future cross-file producer is a reason to widen this gate WITH the evidence,
981
+ // never a reason to let a comment satisfy it.
982
+ {
983
+ const ts = (await import("typescript")).default;
984
+ const file = path.join(REPO, "pi-extensions", "entwurf-control.ts");
985
+ const src = ts.createSourceFile(file, await fs.readFile(file, "utf8"), ts.ScriptTarget.Latest, true);
986
+
987
+ // `const X = "literal"` — the only indirection a customType is allowed to take here.
988
+ const consts = new Map<string, string>();
989
+ const visitConsts = (node: import("typescript").Node): void => {
990
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
991
+ if (ts.isStringLiteral(node.initializer)) consts.set(node.name.text, node.initializer.text);
992
+ }
993
+ ts.forEachChild(node, visitConsts);
994
+ };
995
+ visitConsts(src);
996
+ // Imported constants resolve through `protocol.js`, which is a string-literal module too.
997
+ const protocolSrc = ts.createSourceFile(
998
+ "protocol.js",
999
+ await fs.readFile(path.join(REPO, "protocol.js"), "utf8"),
1000
+ ts.ScriptTarget.Latest,
1001
+ true,
1002
+ );
1003
+ visitConstsInto(protocolSrc, consts, ts);
1004
+
1005
+ const resolve = (expr: import("typescript").Expression): string | null => {
1006
+ if (ts.isStringLiteral(expr)) return expr.text;
1007
+ if (ts.isIdentifier(expr)) return consts.get(expr.text) ?? null;
1008
+ return null;
1009
+ };
1010
+
1011
+ const registered: { type: string | null; text: string }[] = [];
1012
+ const produced = new Set<string>();
1013
+ const visit = (node: import("typescript").Node): void => {
1014
+ if (
1015
+ ts.isCallExpression(node) &&
1016
+ ts.isPropertyAccessExpression(node.expression) &&
1017
+ node.expression.name.text === "registerMessageRenderer" &&
1018
+ node.arguments.length > 0
1019
+ ) {
1020
+ registered.push({ type: resolve(node.arguments[0]), text: node.arguments[0].getText(src) });
1021
+ }
1022
+ if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && node.name.text === "customType") {
1023
+ const value = resolve(node.initializer);
1024
+ if (value !== null) produced.add(value);
1025
+ }
1026
+ ts.forEachChild(node, visit);
1027
+ };
1028
+ visit(src);
1029
+
1030
+ const orphans = registered.filter((r) => r.type === null || !produced.has(r.type));
1031
+ ok(
1032
+ `[QK:NO-RENDERER-WITHOUT-PRODUCER] every message renderer this extension registers is registered for a customType this extension actually EMITS — read from the AST, because the producer-less one that prompted this claim was covered by two comments naming producers that do not exist. A renderer nobody can reach is a box the operator will never see and a maintenance surface that answers no question (registered ${JSON.stringify(registered.map((r) => r.text))}, produced ${JSON.stringify([...produced])}, orphans ${JSON.stringify(orphans.map((o) => o.text))})`,
1033
+ registered.length > 0 && orphans.length === 0,
1034
+ );
1035
+
1036
+ // The retired surface itself, sealed in the two LIVE files it lived in. History keeps its
1037
+ // own record — CHANGELOG, ROADMAP and the demo note are deliberately NOT scanned.
1038
+ // The PHRASES too, not just the identifiers: what kept this surface alive through two
1039
+ // releases was prose — a comment promising "the [entwurf sent →] box exists for every
1040
+ // session that DOES send" outlived the code by one removal and would have been read as a
1041
+ // live guarantee. Sealing the identifiers alone leaves the lie behind.
1042
+ const retired =
1043
+ /ENTWURF_SENT_MESSAGE_TYPE|entwurf-sent|renderSentMessage|buildSentMessageBox|SentBoxData|entwurf sent|sender-side UI box/i;
1044
+ const live = [
1045
+ { rel: "protocol.js", text: await fs.readFile(path.join(REPO, "protocol.js"), "utf8") },
1046
+ { rel: "pi-extensions/entwurf-control.ts", text: await fs.readFile(file, "utf8") },
1047
+ ];
1048
+ const survivors = live.filter((f) => retired.test(f.text)).map((f) => f.rel);
1049
+ ok(
1050
+ `[QK:RETIRED-SENT-SURFACE-ABSENT] the sender-side echo surface is gone from the live bytes — no constant, no renderer, no box builder, no payload type — rather than left as a public export nothing produces. It was shipped in \`files\`, so an unreachable name there is a promise to a consumer that no code could ever keep (survivors: ${JSON.stringify(survivors)})`,
1051
+ survivors.length === 0,
1052
+ );
1053
+ }
1054
+
1055
+ // ── #120 P2 (D8): the OUTCOME list in both tool descriptions names every rail ──
1056
+ // A caller reads one of these two blurbs and nothing else before dispatching. The control
1057
+ // rail's four boundaries are the ONLY receiver-acceptance values; a mailbox answers with an
1058
+ // ENQUEUE receipt and native-push with an INJECTION, and folding those into the boundary union
1059
+ // would tell a caller that `mailbox-enqueued` is something a receiver observed about its own
1060
+ // queues. Missing either rail is red — the earlier draft listed `mailbox-enqueued` beside the
1061
+ // four and left native-push out entirely, and nothing noticed.
1062
+ {
1063
+ const outcomeSites = ["mcp/entwurf-bridge/src/index.ts", "pi-extensions/entwurf-control.ts"];
1064
+ const missing: string[] = [];
1065
+ for (const rel of outcomeSites) {
1066
+ const text = await fs.readFile(path.join(REPO, rel), "utf8");
1067
+ const at = text.indexOf("reports ONE");
1068
+ const blurb = at < 0 ? "" : text.slice(at, at + 900);
1069
+ if (!/acceptance boundary/i.test(blurb)) missing.push(`${rel}: control receiver acceptance boundary`);
1070
+ for (const b of ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"]) {
1071
+ if (!blurb.includes(b)) missing.push(`${rel}: boundary ${b}`);
1072
+ }
1073
+ if (!/those four only/i.test(blurb)) missing.push(`${rel}: the four-only scope of the boundary union`);
1074
+ if (!/mailbox ENQUEUE/i.test(blurb)) missing.push(`${rel}: mailbox enqueue receipt`);
1075
+ if (!/native-push INJECTION/i.test(blurb)) missing.push(`${rel}: native-push injection`);
1076
+ if (!/rejection|rejected/i.test(blurb)) missing.push(`${rel}: rejection`);
1077
+ if (!/lock-dirty/i.test(blurb)) missing.push(`${rel}: accepted-but-lock-dirty`);
1078
+ if (/mailbox-enqueued`? \/|\/ `?mailbox-enqueued/i.test(blurb)) {
1079
+ missing.push(`${rel}: mailbox-enqueued is listed INSIDE the receiver-boundary union`);
1080
+ }
1081
+ }
1082
+ ok(
1083
+ `[QK:SEND-OUTCOME-LIST-NAMES-EVERY-RAIL] both tool descriptions name each rail's own result — the control receiver's four acceptance boundaries (and that those four are the whole union), the mailbox ENQUEUE receipt, the native-push INJECTION, a rejection, and accepted-but-lock-dirty — so a caller is never told that one rail's vocabulary describes another's (missing: ${JSON.stringify(missing)})`,
1084
+ missing.length === 0,
1085
+ );
1086
+ }
1087
+
1088
+ // ── #120 P2: the four caller-facing prose sites, checked for what they SAY ──
1089
+ // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither mode interrupts anything: `agent-loop.js:186`
1090
+ // drains steering after a turn ENDS and `:191-197` drains follow-ups only once the inner loop
1091
+ // has ended. A caller who reads "interrupt the current turn" picks `steer` for urgency and gets
1092
+ // a queue with no order against the other one — the decision #120 was opened about.
1093
+ //
1094
+ // POSITIVE, not merely a banned-word sweep. The first cut of this claim only looked for the old
1095
+ // wording, which meant a site could pass by deleting its description entirely — the assertion
1096
+ // would have said four sites explain the queues while one of them explained nothing. Each site
1097
+ // now has to carry all four facts a caller needs before choosing a mode, and the old wording is
1098
+ // still banned beside them.
1099
+ {
1100
+ const sites = [
1101
+ "mcp/entwurf-bridge/src/index.ts",
1102
+ "pi-extensions/entwurf-control.ts",
1103
+ "pi-extensions/lib/entwurf-v2-contract.ts",
1104
+ "pi-extensions/lib/entwurf-v2-contract-schema.ts",
1105
+ ];
1106
+ const axes: { name: string; holds: (t: string) => boolean }[] = [
1107
+ { name: "names both queues", holds: (t) => /steering/i.test(t) && /follow-?ups?\b/i.test(t) },
1108
+ { name: "says neither is an interrupt", holds: (t) => /(neither|not)\s+(is\s+)?an?\s+interrupt/i.test(t) },
1109
+ { name: "says a later steer overtakes an earlier follow_up", holds: (t) => /overtakes/i.test(t) },
1110
+ {
1111
+ name: "says the queues are volatile and an abort drops them",
1112
+ holds: (t) => /volatile/i.test(t) && /abort/i.test(t),
1113
+ },
1114
+ ];
1115
+ const gaps: string[] = [];
1116
+ for (const rel of sites) {
1117
+ const text = await fs.readFile(path.join(REPO, rel), "utf8");
1118
+ for (const axis of axes) if (!axis.holds(text)) gaps.push(`${rel}: ${axis.name}`);
1119
+ if (/steer \((?:interrupt|immediate)|interrupt the current turn|steer = interrupt/i.test(text)) {
1120
+ gaps.push(`${rel}: still claims steer interrupts`);
1121
+ }
1122
+ }
1123
+ ok(
1124
+ `[QK:SEND-PROSE-NO-INTERRUPT] every caller-facing description of \`mode\` names BOTH queues, says neither is an interrupt, says a later steer overtakes an earlier follow_up, and says both are volatile so an abort drops them — and none of them still claims an interrupt. A caller chooses a mode from this text alone (gaps: ${JSON.stringify(gaps)})`,
1125
+ gaps.length === 0,
1126
+ );
1127
+ }
1128
+
850
1129
  console.log(`\ncheck-entwurf-v2-surface: ${passed} checks passed`);
851
1130
  }
852
1131
 
@@ -832,23 +832,25 @@ let manifestCount: number;
832
832
  "acp-usage-accounting": 12,
833
833
  "agy-permission": 6,
834
834
  "bridge-boot-resume": 5,
835
- "bridge-command-boot": 9,
835
+ "bridge-command-boot": 12,
836
836
  "capability-cache": 3,
837
837
  "codex-app-server-launch": 9,
838
838
  "codex-caller-seat": 28,
839
+ "control-send-receipt": 19,
839
840
  "codex-native": 82,
840
841
  "compaction-send-guard": 7,
841
842
  "control-socket-disconnect": 4,
842
843
  "copilot-birth": 19,
843
844
  "copilot-launch": 14,
844
845
  "pi-launch": 9,
846
+ "pi-floor": 1,
845
847
  "copilot-receive": 20,
846
848
  "entwurf-peers": 1,
847
849
  "fresh-call-dispatch": 12,
848
850
  "fresh-cut": 3,
849
851
  "gate-qualification": 2,
850
852
  "herdr-placement": 13,
851
- "herdr-plugin": 11,
853
+ "herdr-plugin": 25,
852
854
  "herdr-plugin-profile": 14,
853
855
  "herdr-activation": 25,
854
856
  "herdr-plugin-build": 13,
@@ -859,7 +861,7 @@ let manifestCount: number;
859
861
  "peer-facts": 8,
860
862
  "meta-hook-session-switch": 17,
861
863
  "meta-identity": 4,
862
- "meta-retire": 3,
864
+ "meta-retire": 4,
863
865
  "mux-boundary": 16,
864
866
  "mux-fresh-call": 59,
865
867
  "mux-launcher-fence": 8,
@@ -871,7 +873,8 @@ let manifestCount: number;
871
873
  "omp-fresh": 24,
872
874
  "omp-receive": 11,
873
875
  "probe-ordering": 1,
874
- "release-gate": 19,
876
+ "release-gate": 22,
877
+ "retired-sent-surface": 2,
875
878
  "resume-args": 6,
876
879
  "resume-launch-identity": 6,
877
880
  "self-address": 8,
@@ -344,10 +344,18 @@ async function main(): Promise<void> {
344
344
  const code = MODULE_SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
345
345
  const specifiers = [...MODULE_SRC.matchAll(/^import\s[^;]*?from\s+"([^"]+)";$/gm)].map((m) => m[1]);
346
346
  ok(
347
- "[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins, the neutral composition leaf, and the callback-env pair — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
347
+ "[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins, the neutral composition leaf, the callback-env pair, and the dependency-free rails DECLARATION leaf — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
348
348
  specifiers.length > 0 &&
349
349
  specifiers.every(
350
- (s) => s.startsWith("node:") || s === "./fresh-call-composition.ts" || s === "./callback-env.ts",
350
+ (s) =>
351
+ s.startsWith("node:") ||
352
+ s === "./fresh-call-composition.ts" ||
353
+ s === "./callback-env.ts" ||
354
+ // #116 A7 — the ONE place the backend tuple and the per-backend model syntax
355
+ // example are written, shared with the status pane and the install narration.
356
+ // It imports nothing itself, so admitting it couples this rail to a constant
357
+ // rather than to another rail; the mux/entwurf ban below is untouched.
358
+ s === "../../scripts/herdr-rails.mjs",
351
359
  ) &&
352
360
  !/from\s+"\.\/(mux-|entwurf-)/.test(code),
353
361
  );
@@ -165,6 +165,57 @@ async function main(): Promise<void> {
165
165
  piNativeSessionIdFromPath("/s/2026-09-14T05-17-03-979Z_not-a-uuid.jsonl") === null &&
166
166
  piNativeSessionIdFromPath(`/s/${PI_NATIVE}.jsonl`) === null,
167
167
  );
168
+ {
169
+ // THE VENDOR HALF OF THAT CLAIM, FROM THE INSTALLED VENDOR — not a literal we typed.
170
+ // The key this parser recovers lives inside a filename pi builds, so the rule is only as
171
+ // true as pi's naming, and a source read cannot settle whether that naming is a contract
172
+ // or a convention. `[측정 2026-09-22, @earendil-works/pi-coding-agent 0.87.0]` the installed
173
+ // `SessionManager` writes a REAL session file here, in a temp dir, with zero model, zero
174
+ // network and zero child process: `create()` then one `appendMessage` flushes
175
+ // `<stamp>_<id>.jsonl` whose own `{"type":"session"}` header carries the same id.
176
+ //
177
+ // The 0.86.0 receipt this replaces needed a provider call and was therefore taken by hand
178
+ // once; it stays in the bump ledger as a 0.86.0 fact. This one re-takes itself on every
179
+ // run, so the vendor floor cannot drift under us silently again.
180
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-hp-vendor-"));
181
+ try {
182
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
183
+ const manager = await SessionManager.create(process.cwd(), dir);
184
+ await manager.appendMessage({
185
+ role: "assistant",
186
+ content: [{ type: "text", text: "placement vendor-floor probe" }],
187
+ api: "test",
188
+ provider: "test",
189
+ model: "test",
190
+ // Zero everything: this message exists to make the manager flush a file, and a
191
+ // fixture that invented usage numbers would be inventing vendor accounting too.
192
+ usage: {
193
+ input: 0,
194
+ output: 0,
195
+ cacheRead: 0,
196
+ cacheWrite: 0,
197
+ totalTokens: 0,
198
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
199
+ },
200
+ stopReason: "stop",
201
+ timestamp: Date.now(),
202
+ });
203
+ const written = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
204
+ const header = JSON.parse(readFileSync(path.join(dir, written[0]), "utf8").split("\n")[0]);
205
+ const recovered = piNativeSessionIdFromPath(path.join(dir, written[0]));
206
+ ok(
207
+ "the vendor floor is re-measured, not remembered: the installed pi writes a real session file whose " +
208
+ "basename this parser reads back to the same id its own header declares",
209
+ written.length === 1 &&
210
+ header.type === "session" &&
211
+ typeof header.id === "string" &&
212
+ recovered === header.id &&
213
+ recovered === manager.getSessionId(),
214
+ );
215
+ } finally {
216
+ fs.rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ }
168
219
  ok(
169
220
  "[QK:HP-JOIN-NEVER-GUESSES] an unmeasured agent_session kind and an empty value both yield NO key — an " +
170
221
  "unknown herdr shape is declined, never coerced into one of the two rules we actually measured",
@@ -33,6 +33,7 @@ import fs from "node:fs";
33
33
  import os from "node:os";
34
34
  import path from "node:path";
35
35
  import { pathToFileURL } from "node:url";
36
+ import * as HERDR_RAILS from "./herdr-rails.mjs";
36
37
  import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
37
38
  import { treeDigest } from "./lib/tree-digest.ts";
38
39
 
@@ -769,17 +770,23 @@ function drive(
769
770
  // contains `claude-sonnet-5`, so a containment test cannot tell the two grammars apart —
770
771
  // which is the whole claim.
771
772
  const idIn = (lines: string[], backend: string): string | null => {
772
- const line = lines.find((l) => l.startsWith(`note: ${backend}: `) && l.includes("model id looks like"));
773
+ const line = lines.find((l) => l.startsWith(`note: ${backend}: `) && l.includes("model string looks like"));
773
774
  return line === undefined ? null : (/`([^`]+)`/.exec(line)?.[1] ?? null);
774
775
  };
775
776
  const piId = idIn(notesOf(full), "pi");
776
777
  const claudeId = idIn(notesOf(full), "claude-code");
777
778
  return (
778
- piId === "openai-codex/gpt-5.6-terra" &&
779
+ // Compared against the SHARED leaf, not a literal copied into this gate: the install
780
+ // narration and the status pane's RAILS block now read one declaration
781
+ // (`scripts/herdr-rails.mjs`, #116 A7), and a gate holding its own third copy would
782
+ // be the very drift that leaf exists to stop. The GRAMMAR assertions stay literal —
783
+ // provider-qualified vs bare vendor id is the fact, and it must not become whatever
784
+ // the leaf happens to say.
785
+ piId === HERDR_RAILS.MODEL_SYNTAX_EXAMPLE.pi &&
779
786
  piId.includes("/") &&
780
- claudeId === "claude-sonnet-5" &&
787
+ claudeId === HERDR_RAILS.MODEL_SYNTAX_EXAMPLE["claude-code"] &&
781
788
  !claudeId.includes("/") &&
782
- idIn(notesOf(piOnly), "pi") === "openai-codex/gpt-5.6-terra" &&
789
+ idIn(notesOf(piOnly), "pi") === HERDR_RAILS.MODEL_SYNTAX_EXAMPLE.pi &&
783
790
  idIn(notesOf(piOnly), "claude-code") === null
784
791
  );
785
792
  })(),