@junghanacs/entwurf 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +564 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/DELIVERY.md +24 -1
  4. package/README.md +2 -2
  5. package/VERIFY.md +20 -2
  6. package/demo/README.md +5 -2
  7. package/docs/acp-backend-rail.md +49 -2
  8. package/docs/setup-clean-host.md +4 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
  18. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  19. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  20. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  21. package/mcp/entwurf-bridge/src/index.ts +5 -4
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +27 -154
  24. package/pi-extensions/lib/compaction-send-guard.ts +5 -3
  25. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  26. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  27. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  30. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  32. package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
  33. package/pi-extensions/lib/herdr-placement.ts +11 -0
  34. package/protocol.js +0 -8
  35. package/run.sh +202 -33
  36. package/scripts/check-entwurf-v2-surface.ts +289 -10
  37. package/scripts/check-gate-qualification.ts +2354 -11
  38. package/scripts/check-herdr-fresh-call.ts +10 -2
  39. package/scripts/check-herdr-placement.ts +51 -0
  40. package/scripts/check-herdr-plugin-build.ts +11 -4
  41. package/scripts/check-herdr-plugin.ts +745 -38
  42. package/scripts/check-mux-launcher-fence.ts +37 -0
  43. package/scripts/check-pi-launch.ts +7 -4
  44. package/scripts/check-release-gate-outcomes.ts +165 -0
  45. package/scripts/doctor-pi-provider.ts +44 -4
  46. package/scripts/herdr-rails.mjs +75 -0
  47. package/scripts/lib/qualification-acceptance.ts +146 -0
  48. package/scripts/lib/qualification-artifact.ts +359 -0
  49. package/scripts/lib/qualification-collection.ts +384 -0
  50. package/scripts/lib/qualification-gh.ts +231 -0
  51. package/scripts/lib/qualification-ledger.ts +669 -0
  52. package/scripts/lib/qualification-oracle-core.ts +279 -0
  53. package/scripts/lib/qualification-receipt.ts +561 -0
  54. package/scripts/lib/qualification-release.ts +271 -0
  55. package/scripts/meta-bridge-state.py +18 -1
  56. package/scripts/mutants/bridge-command-boot.json +38 -0
  57. package/scripts/mutants/control-send-receipt.json +235 -0
  58. package/scripts/mutants/herdr-fresh-call.json +4 -4
  59. package/scripts/mutants/herdr-plugin-build.json +7 -11
  60. package/scripts/mutants/herdr-plugin.json +177 -9
  61. package/scripts/mutants/meta-retire.json +22 -0
  62. package/scripts/mutants/pack-install.json +4 -4
  63. package/scripts/mutants/pi-floor.json +17 -0
  64. package/scripts/mutants/qualification-ledger.json +489 -0
  65. package/scripts/mutants/release-gate.json +48 -0
  66. package/scripts/mutants/retired-sent-surface.json +32 -0
  67. package/scripts/mutants/v2-surface.json +7 -7
  68. package/scripts/qualification-oracle.ts +124 -0
  69. package/scripts/smoke-codex-fresh-live.ts +94 -13
  70. package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
  71. package/scripts/smoke-herdr-raw-install-live.sh +66 -4
  72. package/scripts/smoke-meta-install-state.sh +107 -1
  73. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  74. package/scripts/smoke-pi-provider-state.sh +106 -0
  75. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -0,0 +1,124 @@
1
+ /**
2
+ * qualification-oracle — launcher for the exact-SHA composite release oracle (#124 P1).
3
+ *
4
+ * It JUDGES NOTHING. It clones the target SHA cleanly, then runs THAT SHA's own
5
+ * `scripts/lib/qualification-oracle-core.ts` inside the clone and relays its output and
6
+ * exit code. The operator's checkout — possibly dirty or stale — never evaluates a SHA
7
+ * with its own bytes.
8
+ *
9
+ * 1. tripwire the origin checkout: HEAD, porcelain, work-surface content hash;
10
+ * 2. `git clone --no-hardlinks --no-checkout <origin> <tmp>`; `checkout --detach <sha>`;
11
+ * the clone must be exactly <sha> and clean, its surface stable across two reads;
12
+ * 3. no core source at <sha> → ABORT oracle-source-missing (every SHA before the core
13
+ * landed, 8d508d3 included, is judged this way — by design, not by accident);
14
+ * 4. run `node --experimental-strip-types <clone>/scripts/lib/qualification-oracle-core.ts`
15
+ * with cwd = the clone;
16
+ * 5. ALWAYS (child failure, child signal, our own SIGINT/SIGTERM): remove the clone, then
17
+ * re-read the origin tripwire. A moved origin overrides any child result with ABORT.
18
+ *
19
+ * Nothing here writes the origin's .git, worktree or index (no `worktree add`).
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { originHead, originWorkSurfaceSha } from "./lib/mutation-qualify.ts";
28
+ import { ORACLE_CORE_PATH, parseOracleArgs } from "./lib/qualification-oracle-core.ts";
29
+ import { checkReceiptPath } from "./lib/qualification-receipt.ts";
30
+
31
+ const SELF_REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
32
+
33
+ function tripwire(dir: string): string {
34
+ const porcelain = spawnSync("git", ["-C", dir, "status", "--porcelain"], { encoding: "utf8" }).stdout;
35
+ return JSON.stringify({ head: originHead(dir), porcelain, surface: originWorkSurfaceSha(dir) });
36
+ }
37
+
38
+ export function launch(argv: string[], log = console.log, logError = console.error): number {
39
+ let origin: string;
40
+ let sha: string;
41
+ let ledgerOut: string | undefined;
42
+ try {
43
+ const args = parseOracleArgs(argv, true);
44
+ origin = path.resolve(args.origin ?? SELF_REPO);
45
+ sha = args.sha;
46
+ ledgerOut = args.ledgerOut;
47
+ if (ledgerOut) checkReceiptPath(ledgerOut, origin);
48
+ } catch (err) {
49
+ logError(`[qualification-oracle] ABORT: ${err instanceof Error ? err.message : String(err)}`);
50
+ return 2;
51
+ }
52
+ const passArgs = argv.filter((_, i) => argv[i] !== "--origin" && argv[i - 1] !== "--origin");
53
+
54
+ let interrupted: NodeJS.Signals | null = null;
55
+ const onSignal = (sig: NodeJS.Signals) => {
56
+ interrupted = sig;
57
+ };
58
+ process.on("SIGINT", onSignal);
59
+ process.on("SIGTERM", onSignal);
60
+ const before = tripwire(origin);
61
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-oracle-clone-"));
62
+ const clone = path.join(tmp, "repo");
63
+ let code = 2;
64
+ try {
65
+ const git = (...a: string[]) => spawnSync("git", a, { encoding: "utf8" });
66
+ if (git("clone", "-q", "--no-hardlinks", "--no-checkout", origin, clone).status !== 0) {
67
+ logError("[qualification-oracle] ABORT clone-failed");
68
+ return 2;
69
+ }
70
+ if (git("-C", clone, "-c", "advice.detachedHead=false", "checkout", "-q", "--detach", sha).status !== 0) {
71
+ logError(`[qualification-oracle] ABORT sha-unknown: ${sha} is not in ${origin}`);
72
+ return 2;
73
+ }
74
+ const head = git("-C", clone, "rev-parse", "HEAD").stdout.trim();
75
+ const clean = git("-C", clone, "status", "--porcelain").stdout === "";
76
+ const s1 = originWorkSurfaceSha(clone);
77
+ const s2 = originWorkSurfaceSha(clone);
78
+ if (head !== sha || !clean || s1 !== s2) {
79
+ logError(
80
+ `[qualification-oracle] ABORT clone-not-exact: head ${head}, clean ${clean}, surface stable ${s1 === s2}`,
81
+ );
82
+ return 2;
83
+ }
84
+ if (!fs.existsSync(path.join(clone, ORACLE_CORE_PATH))) {
85
+ logError(`[qualification-oracle] ABORT oracle-source-missing: ${sha} does not carry ${ORACLE_CORE_PATH}`);
86
+ return 2;
87
+ }
88
+ const child = spawnSync(process.execPath, ["--experimental-strip-types", ORACLE_CORE_PATH, ...passArgs], {
89
+ cwd: clone,
90
+ encoding: "utf8",
91
+ maxBuffer: 64 * 1024 * 1024,
92
+ });
93
+ if (child.stdout) log(child.stdout.replace(/\n$/, ""));
94
+ if (child.stderr) logError(child.stderr.replace(/\n$/, ""));
95
+ code = child.status === null ? 2 : child.status;
96
+ if (child.signal) {
97
+ logError(`[qualification-oracle] ABORT the oracle core ended by ${child.signal}`);
98
+ code = 2;
99
+ }
100
+ } finally {
101
+ fs.rmSync(tmp, { recursive: true, force: true });
102
+ process.off("SIGINT", onSignal);
103
+ process.off("SIGTERM", onSignal);
104
+ let after = "";
105
+ try {
106
+ after = tripwire(origin);
107
+ } catch (err) {
108
+ after = `unreadable: ${err instanceof Error ? err.message : String(err)}`;
109
+ }
110
+ if (after !== before) {
111
+ logError("[qualification-oracle] ABORT origin-moved: the origin checkout changed while the oracle ran");
112
+ code = 2;
113
+ }
114
+ if (interrupted) {
115
+ logError(`[qualification-oracle] ABORT interrupted by ${interrupted}`);
116
+ code = 2;
117
+ }
118
+ }
119
+ return code;
120
+ }
121
+
122
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
123
+ process.exit(launch(process.argv.slice(2)));
124
+ }
@@ -669,12 +669,75 @@ async function run(): Promise<void> {
669
669
  "set LIVE=1 plus ENTWURF_CODEX_APP_SERVER_PID, ENTWURF_CODEX_FRESH_MODEL, and ENTWURF_CODEX_FRESH_PI_MODEL to run; this opens real Codex and Pi windows and spends model turns.",
670
670
  );
671
671
  }
672
+
673
+ // ── THE OPERATOR-SUPPLIED PREREQUISITE TRIPLE, AND WHY ITS ABSENCE IS A SKIP ──
674
+ //
675
+ // Entwurf does not own the Codex app-server (VERIFY: "The A cell takes no inferred
676
+ // server or models"), so these three values can only come from the operator. That
677
+ // stays true. What was wrong is the CLASSIFICATION of their absence.
678
+ //
679
+ // `[측정 2026-09-23]` the skip line directly above promised four prerequisites in one
680
+ // breath — LIVE=1 *plus* the three env names — but only the first could ever produce a
681
+ // SKIP. The other three were `ok()` assertions, and `ok()` throws, so a missing
682
+ // operator input exited as FAIL. `release_gate()` supplies none of them, so this MUST
683
+ // step declined as a FAILURE inside every aggregate on every host: 0.25.0's own
684
+ // candidate receipt reads `MUST PASS=23 FAIL=1`, and the release record had to carry a
685
+ // prose classification explaining that the one red was "the shape of that step", and the
686
+ // cut stayed BLOCKED. A contract that manufactures a red every cut and then needs a human
687
+ // to excuse it is not a floor.
688
+ //
689
+ // `scripts/lib/step-outcome.sh` already wrote the rule this violated: "a step that RAN
690
+ // AND BROKE and a step that NEVER RAN are different facts, and a release record that
691
+ // blurs them is worthless: the first is a defect to fix, the second is a prerequisite to
692
+ // supply." An absent operator input is the second.
693
+ //
694
+ // So the axis is split, and the split is the whole repair:
695
+ // ABSENT (unset/empty) -> skipLive: nothing ran, the missing names are printed, and
696
+ // `--cut` still refuses it as BLOCKED (MUST SKIP). Nothing is
697
+ // excused — a cut without this receipt remains impossible.
698
+ // PRESENT BUT WRONG -> FAIL, unchanged: a malformed or dead PID, a process whose
699
+ // argv is not a Codex app-server, a foreign uid, A == S. The
700
+ // operator answered, and the answer is a defect.
701
+ // Nothing is inferred either way, and no default is invented for a value the operator
702
+ // did not give. The reproducible green is the invocation VERIFY and the release skill's
703
+ // P5 both now spell out, and `release_gate` needs no plumbing for it: measured on this
704
+ // source, that function exports no scrub and unsets nothing, so an exported triple
705
+ // already reaches this step.
706
+ //
707
+ // DELIBERATELY NOT MOVED: the installed-config preflights below (`codexFreshPreflight`,
708
+ // `codexCallerFreshPreflight`, `codexLaunchCwdFreshPreflight`). Those are host state
709
+ // `setup` establishes and the Codex doctors cover, and the file header's contract
710
+ // already decided them — "missing system birth, user MCP/status/terminal-title config,
711
+ // app-server, models, runtime, bridge, or tmux seat is a FAIL". A one-time answered
712
+ // launch directory is host setup, not a per-invocation input, and reclassifying it would
713
+ // be a second contract change riding this one.
714
+ const OPERATOR_INPUTS = [
715
+ ["ENTWURF_CODEX_APP_SERVER_PID", "the pid of the app-server YOU started (never inferred)"],
716
+ ["ENTWURF_CODEX_FRESH_MODEL", "the Codex-side model this cell may spend a turn on"],
717
+ ["ENTWURF_CODEX_FRESH_PI_MODEL", "the pi-side model of the callback sibling"],
718
+ ] as const;
719
+ const absentInputs = OPERATOR_INPUTS.filter(([name]) => (process.env[name]?.trim() ?? "") === "");
720
+ if (absentInputs.length > 0) {
721
+ skipLive(
722
+ LABEL,
723
+ `this cell takes no inferred server or models, and ${absentInputs.length} operator-supplied ` +
724
+ `prerequisite(s) are absent: ${absentInputs.map(([name, why]) => `${name} (${why})`).join("; ")}. ` +
725
+ "Supply them on the invocation — LIVE=1 ENTWURF_CODEX_APP_SERVER_PID=<pid> " +
726
+ "ENTWURF_CODEX_FRESH_MODEL=<codex-model> ENTWURF_CODEX_FRESH_PI_MODEL=<pi-model> — either for the " +
727
+ "standalone run or for `./run.sh release-gate <scratch> --cut`, which inherits them. " +
728
+ "A cut is still blocked without this receipt: --cut reads a MUST SKIP as red.",
729
+ );
730
+ }
731
+
672
732
  ok("the measured/certified host axis is Linux", process.platform === "linux", `host=${process.platform}`);
673
733
 
734
+ // Present, so a malformed value is the operator's answer being wrong — a FAIL, not a
735
+ // declined prerequisite. The absence arm above already left.
674
736
  const pidText = process.env.ENTWURF_CODEX_APP_SERVER_PID?.trim() ?? "";
675
737
  ok(
676
738
  "ENTWURF_CODEX_APP_SERVER_PID explicitly names the existing operator-owned app-server",
677
739
  /^[1-9]\d*$/.test(pidText),
740
+ `ENTWURF_CODEX_APP_SERVER_PID=${JSON.stringify(pidText)} is not a positive decimal pid`,
678
741
  );
679
742
  const appServerPid = Number(pidText);
680
743
  const procDir = `/proc/${appServerPid}`;
@@ -749,10 +812,13 @@ async function run(): Promise<void> {
749
812
  `fixture-session=${fixtureCoordinate.sessionId}\nfixture-name=${fixtureSessionName}\n` +
750
813
  `fixture-window=${fixtureCoordinate.windowId}`;
751
814
 
815
+ // Both are non-empty: the prerequisite arm at the top of `run` left otherwise. Asserting
816
+ // `.length > 0` again here would be a tautology dressed as a check, so the pair is
817
+ // RECORDED instead — the tier a live receipt is read on is a fact the evidence needs, and
818
+ // 0.25.0 already had to explain in prose that its pi leg ran on terra rather than luna.
752
819
  const codexModel = process.env.ENTWURF_CODEX_FRESH_MODEL?.trim() ?? "";
753
820
  const piModel = process.env.ENTWURF_CODEX_FRESH_PI_MODEL?.trim() ?? "";
754
- ok("ENTWURF_CODEX_FRESH_MODEL explicitly names the Codex runtime model", codexModel.length > 0);
755
- ok("ENTWURF_CODEX_FRESH_PI_MODEL explicitly names the Pi callback sibling model", piModel.length > 0);
821
+ receipts["1b-operator-supplied-models"] = `codex=${codexModel}\npi=${piModel}`;
756
822
  const codexRuntime = resolveExecutable("codex", appServerEnv);
757
823
  ok("the Codex runtime resolves on the app-server PATH", codexRuntime !== null);
758
824
  const bridgeRuntime = resolveExecutable("entwurf-bridge", appServerEnv);
@@ -1016,11 +1082,19 @@ async function run(): Promise<void> {
1016
1082
  message: initialPiInstruction,
1017
1083
  });
1018
1084
  receipts["5-fixture-to-initial-pi-v2"] = instructInitialPi.text;
1085
+ // #120 P2: the control-socket rail answers with the ACCEPTANCE BOUNDARY the receiver observed,
1086
+ // and every member of that closed set is a success. The initial Pi is mid-turn while the fixture
1087
+ // addresses it, so `queued-follow-up` is an ORDINARY answer here — measured 2026-09-23, this
1088
+ // exact step. The old `sent|delivered` pin passed only when the caller happened to be idle, and
1089
+ // before P2 it passed on the busy path too because the rail said `sent` either way; `delivered`
1090
+ // was never a member of the set at all. The sibling LIVE smokes took this widening in 6d90d73
1091
+ // (`smoke-herdr-fresh-call-live.ts`, `smoke-mux-lifecycle-live.ts`); this step was missed.
1019
1092
  ok(
1020
1093
  "the fixture addressed the real initial Pi caller through an actual control-socket entwurf_v2 receipt",
1021
1094
  !instructInitialPi.isError &&
1022
- /control-socket/i.test(instructInitialPi.text) &&
1023
- /(?:sent|delivered)/i.test(instructInitialPi.text),
1095
+ /entwurf_v2 control-socket → (?:sent|queued-steer|queued-follow-up|accepted-unknown-boundary)/.test(
1096
+ instructInitialPi.text,
1097
+ ),
1024
1098
  instructInitialPi.text,
1025
1099
  );
1026
1100
 
@@ -1325,15 +1399,22 @@ async function run(): Promise<void> {
1325
1399
  assertExactSourceToolCalls(
1326
1400
  piSourceToolReceipts(readInitialPiEntries()),
1327
1401
  [
1328
- {
1329
- toolName: "entwurf_v2",
1330
- arguments: {
1331
- target: callerGid,
1332
- intent: "fire-and-forget",
1333
- message: initialPiNonce,
1334
- wants_reply: false,
1335
- },
1336
- },
1402
+ // 0.24.0 moved a pi sibling's first action to the ZERO-ARGUMENT eighth verb, and this
1403
+ // smoke's own launch receipt says so in the same run: "its first action is the
1404
+ // zero-argument callback verb (codex is the one arm that still sends the nonce as
1405
+ // arguments)". The Codex audit below therefore keeps its `entwurf_v2` shape, and only
1406
+ // this one moves. `{}` is matched by deep-strict equality, so it pins the verb's actual
1407
+ // contract — a target or message typed here would be a second address axis and the verb
1408
+ // refuses it by name.
1409
+ //
1410
+ // Nothing is weakened by dropping the nonce from THIS expectation: the correlation is
1411
+ // proven earlier and from the delivered body, which is where a nonce can actually be
1412
+ // corrupted — `the initial Pi callback carries the exact launch nonce` (:970-973) reads
1413
+ // the fixture's own mailbox, requires exactly one exact match and refuses duplicates,
1414
+ // and the two cells beside it bind that callback's sender envelope to a real pi V3
1415
+ // citizen. This audit's job is the different one its name says: exactly these calls,
1416
+ // once each, and nothing else.
1417
+ { toolName: "entwurf_callback", arguments: {} },
1337
1418
  {
1338
1419
  toolName: "entwurf_fresh_call",
1339
1420
  arguments: {
@@ -83,6 +83,16 @@ import { joinKeyOf, parseHerdrPaneList } from "../pi-extensions/lib/herdr-placem
83
83
  import { assessLauncherCleanup, snapshotClaudeLauncher, verifyClaudeLauncher } from "./lib/claude-launcher-fence.ts";
84
84
  import { skipLive } from "./lib/live-skip.ts";
85
85
 
86
+ /**
87
+ * #120 P2: the control-socket rail answers with the ACCEPTANCE BOUNDARY the receiver observed, and
88
+ * every member of that set is a success. The caller here is mid-turn while its child calls back,
89
+ * so `queued-steer` is the ORDINARY answer — the old literal `→ sent` pin would have gone red on
90
+ * the common case and passed only when the caller happened to be idle. What must still be absent
91
+ * is a REFUSAL, which the negative beside it now names directly (`rejected` / `failed`) instead of
92
+ * as "anything that is not the word sent".
93
+ */
94
+ const CONTROL_ACCEPTED = /entwurf_v2 control-socket → (?:sent|queued-steer|queued-follow-up|accepted-unknown-boundary)/;
95
+
86
96
  const LABEL = "smoke-herdr-fresh-call-live";
87
97
  const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
88
98
  const REAL_HOME = process.env.HOME ?? os.homedir();
@@ -849,10 +859,7 @@ async function main(): Promise<void> {
849
859
  callId === null
850
860
  ? -1
851
861
  : childRecords.findIndex(
852
- (record, i) =>
853
- i > callAt &&
854
- record.includes("entwurf_v2 control-socket → sent") &&
855
- toolResultIdOf(record) === callId,
862
+ (record, i) => i > callAt && CONTROL_ACCEPTED.test(record) && toolResultIdOf(record) === callId,
856
863
  );
857
864
  // The LAST mention of the task token, so the birth prompt — which carries it, first —
858
865
  // cannot be what satisfies "the work came after".
@@ -864,14 +871,14 @@ async function main(): Promise<void> {
864
871
  }
865
872
  }
866
873
  ok(
867
- `${cell.label}: the child decoded the one-line birth argv and ran the task only AFTER its callback — the \`sent\` outcome belongs, by toolCallId, to the very call that carried the delivered nonce, and the task token appears in a record after it`,
874
+ `${cell.label}: the child decoded the one-line birth argv and ran the task only AFTER its callback — the ACCEPTED outcome belongs, by toolCallId, to the very call that carried the delivered nonce, and the task token appears in a record after it`,
868
875
  nonce.length > 0 && callAt >= 0 && sentAt > callAt && workedAt > sentAt,
869
876
  );
870
877
  ok(
871
- `${cell.label}: the child's OWN tool result says the callback was DELIVERED on the rail its caller answers on — \`entwurf_v2 control-socket → sent\` — not a timeout, not a reject, not a dirty lock`,
872
- childText.includes("entwurf_v2 control-socket → sent") &&
878
+ `${cell.label}: the child's OWN tool result says the callback was ACCEPTED on the rail its caller answers on — one of \`sent|queued-steer|queued-follow-up|accepted-unknown-boundary\` — not a timeout, not a reject, not a dirty lock`,
879
+ CONTROL_ACCEPTED.test(childText) &&
873
880
  !/entwurf_v2 [a-z-]+ (?:execution failed:|DELIVERED \()/.test(childText) &&
874
- !/entwurf_v2 control-socket → (?!sent)[a-z-]+/.test(childText),
881
+ !/entwurf_v2 control-socket → (?:rejected|failed)/.test(childText),
875
882
  );
876
883
  } else {
877
884
  const childActivity = mcpActivityBySession(root).get(childNative) ?? [];
@@ -490,7 +490,7 @@ for (const name of ["entwurf", "entwurf-bridge", "entwurf-statusline"]) {
490
490
  }
491
491
 
492
492
  // The REAL subcommand, from the installed bin, under node_modules — which is the branch of
493
- // start.sh that runs the prebuilt dist. Its own oracle is an EXACT set (run.sh:5187), so a
493
+ // start.sh that runs the prebuilt dist. Its own oracle is an EXACT set (`EXPECT_TOOLS` in run.sh check-bridge), so a
494
494
  // zero exit here is the artifact listing all seven garden verbs and no eighth.
495
495
  const run = spawnSync(path.join(binDir, "entwurf"), ["check-bridge"], { encoding: "utf8" });
496
496
  const said = `${run.stdout ?? ""}${run.stderr ?? ""}`;
@@ -507,6 +507,7 @@ const verbs = (said.match(/entwurf_[a-z0-9_]+/g) ?? []).filter((v, i, a) => a.in
507
507
  // one; the two disagreeing is the signal, and a verb genuinely added upstream reddens this
508
508
  // smoke until somebody says so here too.
509
509
  const EXPECTED_VERBS = [
510
+ "entwurf_callback",
510
511
  "entwurf_fresh_call",
511
512
  "entwurf_inbox_read",
512
513
  "entwurf_peers",
@@ -519,7 +520,7 @@ console.log(` VERBS ${verbs.join(",") || "<none>"}`);
519
520
  if (run.status === 0) ok(`entwurf check-bridge exit 0 from the installed bin (${verbs.length} verbs listed)`);
520
521
  else no(`entwurf check-bridge exit ${run.status}: ${said.trim().slice(0, 400)}`);
521
522
  JSON.stringify(verbs) === JSON.stringify(EXPECTED_VERBS)
522
- ? ok(`the receipt names EXACTLY the seven garden verbs (${EXPECTED_VERBS.length}), parsed from what the installed bin answered`)
523
+ ? ok(`the receipt names EXACTLY the eight garden verbs (${EXPECTED_VERBS.length}), parsed from what the installed bin answered`)
523
524
  : no(`verb set mismatch — want ${JSON.stringify(EXPECTED_VERBS)} got ${JSON.stringify(verbs)}`);
524
525
  process.exit(bad);
525
526
  ' "$ACTIVE" "$REGISTRY" "$PLUGIN_ID" || fail=1
@@ -713,8 +714,13 @@ run_fan() {
713
714
  fan_no_server="$(run_fan)"; fan_no_server_rc=$?
714
715
  echo "$fan_no_server" | sed 's/^/ /'
715
716
  echo " → exit $fan_no_server_rc"
716
- if [ "$fan_no_server_rc" -ne 0 ] && printf '%s' "$fan_no_server" | grep -q '^herdr-agent-list-failed'; then
717
- ok "with no herdr server the fan names herdr-agent-list-failed and goes red (not an empty table)"
717
+ # Since #120, each block owns its own verdict: a failed `agent list` is indented inside
718
+ # [4] CITIZENS, not at column zero above the report. Anchor both the owner block and its
719
+ # exact failure line; a mention in the closing guidance or a different block is no receipt.
720
+ if [ "$fan_no_server_rc" -ne 0 ] &&
721
+ printf '%s\n' "$fan_no_server" | grep -q '^\[4\] CITIZENS — owner: entwurf; read now, this open$' &&
722
+ printf '%s\n' "$fan_no_server" | grep -q '^ herdr-agent-list-failed: exit [1-9]'; then
723
+ ok "with no herdr server the CITIZENS block names herdr-agent-list-failed and goes red (not an empty table)"
718
724
  else
719
725
  bad "with no herdr server the fan answered rc=$fan_no_server_rc without naming herdr-agent-list-failed"
720
726
  fi
@@ -740,6 +746,62 @@ if printf '%s' "$fan_with_server" | grep -qx '1 citizen(s) not shown: nobody cou
740
746
  else
741
747
  bad "the fan drew no '1 citizen(s) not shown ... (unobserved)' accounting for the citizen born in [7]"
742
748
  fi
749
+
750
+ # ── 8b. the SAME fan with the ENTWURF_BIN crutch REMOVED (#116 A-D3=C) ──────
751
+ # The two runs above hand the pane `ENTWURF_BIN`, which is exactly the operator crutch D3=C exists
752
+ # to delete: with it, this cell stays green whether or not the ledger fallback exists, so it can
753
+ # certify nothing about the first screen a real user sees. `plugins/herdr/README.md` says the plugin
754
+ # puts NOTHING on PATH, so pathless is the state a correct install actually leaves behind.
755
+ # The herdr server from [8] is still up — the fan's second read needs it, and [9] stops it below.
756
+ PATHLESS="$(printf '%s' "$PATH" | tr ':' '\n' | while IFS= read -r d; do [ -n "$d" ] && [ -x "$d/entwurf" ] || printf '%s\n' "$d"; done | paste -sd:)"
757
+ run_fan_pathless() {
758
+ env -u ENTWURF_BIN PATH="$PATHLESS" HERDR_BIN_PATH="$(command -v herdr)" node "$FAN" </dev/null 2>&1
759
+ }
760
+ if command -v entwurf >/dev/null 2>&1 && PATH="$PATHLESS" command -v entwurf >/dev/null 2>&1; then
761
+ bad "the pathless PATH still resolves an entwurf — this cell would prove nothing"
762
+ else
763
+ ok "the pathless PATH resolves no entwurf (the crutch is really gone, not merely unset)"
764
+ fi
765
+
766
+ fan_pathless="$(run_fan_pathless)"; fan_pathless_rc=$?
767
+ echo "$fan_pathless" | sed 's/^/ /'
768
+ echo " → exit $fan_pathless_rc"
769
+ # The IDENTICAL literal the crutched run asserts at the cell above, so the two runs are comparable
770
+ # rather than two different claims.
771
+ if [ "$fan_pathless_rc" -eq 0 ] && printf '%s' "$fan_pathless" | grep -qx '1 citizen(s) not shown: nobody could observe placement for them (unobserved)\.'; then
772
+ ok "[QK:HRIL-FAN-LEDGER-FALLBACK] with NO ENTWURF_BIN and no entwurf on PATH, the certified activation ledger supplies the runtime and the citizen read completes — the same accounting sentence, from a binary nobody put on PATH"
773
+ else
774
+ bad "the ledger-fallback claim failed: pathless fan rc=$fan_pathless_rc did not reproduce the crutched run's citizen accounting"
775
+ fi
776
+ if printf '%s' "$fan_pathless" | grep -q 'via activation-ledger'; then
777
+ ok "the pathless run says WHICH source supplied the binary, so the fallback cannot be mistaken for a PATH hit"
778
+ else
779
+ bad "the pathless run did not name activation-ledger as the binary's source"
780
+ fi
781
+
782
+ # ── 8c. pathless AND no ledger: the CITIZEN axis alone skips, and it is not red ──
783
+ # The other half of the same contract. A host that never activated must not read as broken, and the
784
+ # three blocks that need no entwurf binary must still answer.
785
+ # XDG_STATE_HOME is redirected TOO, and that is the whole isolation: the ledger lives under XDG
786
+ # **state** ($LEDGER above) while the runtime lives under XDG **data**. Moving only the second would
787
+ # leave this cell reading the real host's ledger, and "no ledger here" would be unfalsifiable.
788
+ HOMELESS="$(mktemp -d)"
789
+ fan_no_ledger="$(env -u ENTWURF_BIN PATH="$PATHLESS" HOME="$HOMELESS" XDG_DATA_HOME="$HOMELESS/.local/share" \
790
+ XDG_STATE_HOME="$HOMELESS/.local/state" \
791
+ HERDR_BIN_PATH="$(command -v herdr)" node "$FAN" </dev/null 2>&1)"; fan_no_ledger_rc=$?
792
+ echo "$fan_no_ledger" | sed 's/^/ /'
793
+ echo " → exit $fan_no_ledger_rc"
794
+ if [ "$fan_no_ledger_rc" -eq 0 ] &&
795
+ printf '%s' "$fan_no_ledger" | grep -q 'entwurf-not-found' &&
796
+ printf '%s' "$fan_no_ledger" | grep -q '\[1\] HERDR INTEGRATION' &&
797
+ printf '%s' "$fan_no_ledger" | grep -q '\[2\] ENTWURF ACTIVATION' &&
798
+ printf '%s' "$fan_no_ledger" | grep -q '\[3\] RAILS'; then
799
+ ok "[QK:HRIL-FAN-NO-LEDGER-SKIPS-CITIZENS-ONLY] with no ledger and no runtime the CITIZEN block alone skips by name at exit 0, and blocks 1-3 still render — never activating is a state, not a defect, and this is what stops D3=C drifting into 'install it from the pane'"
800
+ else
801
+ bad "the no-ledger citizen-skip claim failed: fan rc=$fan_no_ledger_rc did not render blocks 1-3 with a citizen-only skip"
802
+ fi
803
+ rm -rf "$HOMELESS"
804
+
743
805
  herdr server stop >/dev/null 2>&1 || true
744
806
 
745
807
  # ── 8a. the SHIPPED LAUNCHER, from the bin the user actually received ───────
@@ -380,6 +380,74 @@ cell(
380
380
  "#94 compaction keys relinquish across nested/string axes: restore, delete, operator-changed, fresh, type-strict",
381
381
  compaction_cells,
382
382
  )
383
+
384
+ TURN_DURATION = "showTurnDuration"
385
+
386
+ def turn_duration_cells():
387
+ # GLG's ruling: showTurnDuration reports how long a turn took. That is
388
+ # operator INFORMATION, not a single-driver policy surface, so entwurf must
389
+ # neither own it nor force it to false.
390
+ #
391
+ # The claim is measured against the two constants and the real leaf, never
392
+ # quoted from a comment: a drift that puts the key back under management has
393
+ # to redden HERE and not only at the drive level, because the drive level
394
+ # runs one install shape while the constant governs every host.
395
+ managed = m.managed_keys()["settings"]["scalar"]
396
+ assert TURN_DURATION not in managed, managed
397
+ assert TURN_DURATION not in m._managed_scalar_names
398
+ assert TURN_DURATION in m._retired_scalar_names
399
+ retired = {name: last for name, _path, last in m.RETIRED_SETTINGS_SCALARS}
400
+ # The last managed value is the comparison relinquishment turns on. A wrong
401
+ # one here would either skip a value we really wrote or overwrite one we
402
+ # never did, so it is pinned by type as well as equality.
403
+ assert retired[TURN_DURATION] is False, retired[TURN_DURATION]
404
+
405
+ path = [TURN_DURATION]
406
+
407
+ def call(current=MISSING, old=MISSING):
408
+ settings = {} if current is MISSING else {TURN_DURATION: current}
409
+ keys = {} if old is MISSING else {TURN_DURATION: copy.deepcopy(old)}
410
+ state = {"files": {"settings": {"keys": keys}}}
411
+ m.relinquish_retired_scalar(state, settings, TURN_DURATION, path, False)
412
+ return state, settings
413
+
414
+ def led(existed, value):
415
+ return {"kind": "scalar", "path": path, "original": {"existed": existed, "value": value}}
416
+
417
+ # THE MEASURED HOST SHAPE (2026-09-23): ledger says we wrote false over a
418
+ # pre-install false, and the operator has since set true. Their true must
419
+ # survive, and the entry must be consumed so uninstall cannot reach back.
420
+ state, settings = call(True, led(True, False))
421
+ assert settings[TURN_DURATION] is True, settings
422
+ assert TURN_DURATION not in state["files"]["settings"]["keys"]
423
+ # Consumption is the inverse: restore_entry is the only writer uninstall has,
424
+ # and with the row gone there is no entry left to hand it.
425
+ before = copy.deepcopy((state, settings))
426
+ m.relinquish_retired_scalar(state, settings, TURN_DURATION, path, False)
427
+ assert (state, settings) == before
428
+
429
+ # Still carrying OUR false, with provenance -> undo our own write honestly.
430
+ state, settings = call(False, led(True, False))
431
+ assert settings[TURN_DURATION] is False, settings
432
+ state, settings = call(False, led(False, None))
433
+ assert TURN_DURATION not in settings, settings
434
+
435
+ # No provenance at all -> the value is the operator's whatever it says.
436
+ for current in [True, False, MISSING]:
437
+ state, settings = call(current)
438
+ assert settings == ({} if current is MISSING else {TURN_DURATION: current})
439
+
440
+ # Type strictness on the bool axis: `0 == False` while `type(0) is not bool`,
441
+ # so an untyped comparison would rewrite a value that was never ours.
442
+ state, settings = call(0, led(True, False))
443
+ assert settings[TURN_DURATION] == 0 and type(settings[TURN_DURATION]) is int
444
+
445
+ cell(
446
+ "[QK:META-RETIRE-TURN-DURATION-OPERATOR-OWNED]",
447
+ "showTurnDuration is operator-owned: unmanaged in the SSOT, retired at false, "
448
+ "and relinquishment preserves the measured live true while consuming the ledger entry",
449
+ turn_duration_cells,
450
+ )
383
451
  sys.exit(failed)
384
452
  PY
385
453
  then ok "#71 retirement leaf: provenance, bool strictness, malformed refusal, one-shot convergence"
@@ -409,6 +477,10 @@ assert s['files']['settings']['keys']['promptSuggestionEnabled']['original']['va
409
477
  assert 'autoCompactEnabled' not in s['files']['settings']['keys']
410
478
  assert 'env.DISABLE_AUTOCOMPACT' not in s['files']['settings']['keys']
411
479
  assert 'skipDangerousModePermissionPrompt' not in s['files']['settings']['keys']
480
+ # Same for showTurnDuration: a fresh install takes no ownership evidence for it,
481
+ # so on a host entwurf meets for the first time there is nothing to relinquish
482
+ # and nothing uninstall could ever restore.
483
+ assert 'showTurnDuration' not in s['files']['settings']['keys']
412
484
  assert s['files']['claudeRoot']['keys']['mcpServers.entwurf-bridge']['original']['value']['command'] == 'old'
413
485
  PY
414
486
  then ok "state captures original scalar/map values, excludes the retired scalar, and is mode 0600"; else bad "state did not capture original values / retirement / mode 0600"; fi
@@ -440,6 +512,18 @@ state['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']={
440
512
  'kind':'scalar', 'path':['env','DISABLE_AUTOCOMPACT'],
441
513
  'original':{'existed':True, 'value':'0'}
442
514
  }
515
+ # showTurnDuration: the upgrade shape MEASURED on the source host 2026-09-23,
516
+ # planted verbatim so apply() is judged against the state a real prior version
517
+ # left behind. The ledger proves entwurf wrote `false` over a pre-install
518
+ # `false`; the live value is the `true` the operator set afterwards. This is the
519
+ # only branch where relinquishment could plausibly damage an operator choice —
520
+ # restoring the snapshot here would hand `false` back to somebody who had since
521
+ # turned the display on — so it is the branch the drive level must carry.
522
+ settings['showTurnDuration']=True
523
+ state['files']['settings']['keys']['showTurnDuration']={
524
+ 'kind':'scalar', 'path':['showTurnDuration'],
525
+ 'original':{'existed':True, 'value':False}
526
+ }
443
527
  json.dump(settings, open(sp,'w'), indent=2); open(sp,'a').write('\n')
444
528
  json.dump(state, open(stp,'w'), indent=2); open(stp,'a').write('\n')
445
529
  PY
@@ -459,9 +543,16 @@ assert settings['env']['DISABLE_AUTOCOMPACT'] == '0'
459
543
  assert 'autoCompactEnabled' not in settings
460
544
  assert settings['env']['KEEP_ME'] == 'yes'
461
545
  assert settings['statusLine']['command'] == os.environ['REPO'] + '/scripts/meta-bridge-statusline.sh'
462
- for key in ['promptSuggestionEnabled','awaySummaryEnabled','autoMemoryEnabled','verbose','showTurnDuration','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
546
+ for key in ['promptSuggestionEnabled','awaySummaryEnabled','autoMemoryEnabled','verbose','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
463
547
  assert settings[key] is False, key
464
548
  assert settings['skipDangerousModePermissionPrompt'] is False
549
+ # showTurnDuration retirement, drive level. Two facts that must not be read off
550
+ # one assertion: the operator's PRE-install `true` (fixture) is still `true`
551
+ # AFTER apply, and it is still `true` because apply never wrote the key rather
552
+ # than because it happened to write the same value. The upgrade shape planted
553
+ # above supplies the second half — a ledger entry proving entwurf wrote `false`
554
+ # here, over a live `true` the operator set afterwards.
555
+ assert settings['showTurnDuration'] is True
465
556
  # #94 drive-level relinquishment, both branches:
466
557
  # nested path -> the operator's pre-entwurf '0' is back, and the env map that
467
558
  # carries it survives with the neighbour key untouched.
@@ -473,6 +564,9 @@ state=json.load(open(cfg + '/entwurf.install-state.json'))
473
564
  assert 'skipDangerousModePermissionPrompt' not in state['files']['settings']['keys']
474
565
  assert 'autoCompactEnabled' not in state['files']['settings']['keys']
475
566
  assert 'env.DISABLE_AUTOCOMPACT' not in state['files']['settings']['keys']
567
+ # The consumed entry is the whole inverse: with no ledger row left, uninstall()
568
+ # has no authority over this key and can never write our `false` back.
569
+ assert 'showTurnDuration' not in state['files']['settings']['keys']
476
570
  for item in ['Bash','Read','Write','Edit','Grep','Glob','WebFetch','WebSearch','Skill','mcp__entwurf-bridge__*']:
477
571
  assert item in settings['permissions']['allow'], item
478
572
  assert settings['permissions']['allow'].count('Read') == 1
@@ -493,6 +587,13 @@ then ok "apply installs managed keyset without clobbering unrelated keys"; else
493
587
  if py check >/dev/null 2>&1; then ok "survival check passes after relinquishing the retired scalar"; else bad "survival check failed right after retirement apply"; fi
494
588
  MANAGED_JSON="$(py managed-keys)"
495
589
  if printf '%s' "$MANAGED_JSON" | grep -q 'skipDangerousModePermissionPrompt'; then bad "managed-keys still claims the retired operator key"; else ok "managed-keys returns skipDangerousModePermissionPrompt to the operator"; fi
590
+ # managed-keys is the CROSS-REPO contract: check-keyset-overlap and the
591
+ # agent-config fragment read it to stay disjoint. Measured 2026-09-23 —
592
+ # `check-keyset-overlap agent-config/claude/settings.fragment.json` failed on
593
+ # exactly one key, showTurnDuration, because both sides claimed it. Dropping our
594
+ # claim is what resolves that collision, so the SSOT output is asserted here and
595
+ # not only in the Python constants.
596
+ if printf '%s' "$MANAGED_JSON" | grep -q 'showTurnDuration'; then bad "managed-keys still claims showTurnDuration — a consumer fragment setting the operator's own value would keep colliding"; else ok "managed-keys returns showTurnDuration to the operator (cross-repo collision resolved at the SSOT)"; fi
496
597
  # A provenance-less true cannot be changed safely, but doctor/check must make it visible.
497
598
  python3 - <<'PY'
498
599
  import json, os
@@ -577,6 +678,11 @@ assert settings['env']['KEEP_ME'] == 'yes'
577
678
  assert settings['statusLine']['command'] == '/old/user/statusline.sh'
578
679
  assert settings['promptSuggestionEnabled'] is True
579
680
  assert settings['skipDangerousModePermissionPrompt'] is False
681
+ # showTurnDuration survives uninstall at the operator's value. The install above
682
+ # ran against a ledger row saying entwurf had written `false` here; relinquish
683
+ # consumed that row, so uninstall reaches this key with no authority at all and
684
+ # the `true` the operator set is still `true`. The key must also be ABSENT from
685
+ # the removal list below — a retired key is neither restored nor deleted.
580
686
  assert settings['showTurnDuration'] is True
581
687
  # One list, one meaning: these are the keys UNINSTALL removed. `autoCompactEnabled`
582
688
  # is not among them post-#94 — that entwurf never introduced it is a different
@@ -786,9 +786,16 @@ async function main(): Promise<void> {
786
786
  mode: "follow_up",
787
787
  message: `Scene 2 (${liveNeedle}): you were told a color. Reply with that color only, then stop.`,
788
788
  });
789
+ // #120 P2: the rail answers with the ACCEPTANCE BOUNDARY, and this send names `follow_up`
790
+ // against a citizen that may well be mid-turn — `queued-follow-up` is a correct, normal
791
+ // answer here. Pinning the word `sent` would have demanded an idle receiver the scene
792
+ // never promises. What is asserted is the rail plus an accepted boundary; a refusal
793
+ // still fails through `isError`.
789
794
  ok(
790
- `${cell}: tools/call entwurf_v2 fire-and-forget delivered on the CONTROL-SOCKET rail to the fresh citizen`,
791
- !send.isError && send.text.includes("control-socket") && send.text.includes("sent"),
795
+ `${cell}: tools/call entwurf_v2 fire-and-forget was ACCEPTED on the CONTROL-SOCKET rail by the fresh citizen`,
796
+ !send.isError &&
797
+ send.text.includes("control-socket") &&
798
+ /→ (?:sent|queued-steer|queued-follow-up|accepted-unknown-boundary)/.test(send.text),
792
799
  `--- response ---\n${send.text}\n--- bridge stderr ---\n${bridge.stderrTail()}`,
793
800
  );
794
801
  receipts[`${cell}/3-live-send`] = send.text;