@junghanacs/entwurf 0.23.1 → 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 (107) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +628 -0
  3. package/DELIVERY.md +24 -1
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +20 -2
  7. package/demo/README.md +5 -2
  8. package/docs/acp-backend-rail.md +49 -2
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +38 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +20 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +24 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  24. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  25. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  27. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  28. package/mcp/entwurf-bridge/src/index.ts +46 -5
  29. package/mcp/entwurf-bridge/test.sh +2 -2
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +83 -154
  32. package/pi-extensions/lib/acp/backend.ts +16 -6
  33. package/pi-extensions/lib/acp/context.ts +30 -10
  34. package/pi-extensions/lib/acp/session-store.ts +2 -2
  35. package/pi-extensions/lib/callback-env.ts +78 -0
  36. package/pi-extensions/lib/compaction-send-guard.ts +6 -1
  37. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  42. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  43. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  44. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  45. package/pi-extensions/lib/herdr-fresh-call.ts +25 -7
  46. package/pi-extensions/lib/herdr-placement.ts +24 -4
  47. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  48. package/pi-extensions/meta-bridge-omp.ts +35 -48
  49. package/protocol.js +0 -8
  50. package/run.sh +306 -53
  51. package/scripts/check-acp-backend-preflight.ts +117 -9
  52. package/scripts/check-acp-carrier-augment.ts +6 -4
  53. package/scripts/check-acp-event-mapper.ts +17 -6
  54. package/scripts/check-acp-prompt-builder.ts +17 -9
  55. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  56. package/scripts/check-acp-session-reuse.ts +64 -51
  57. package/scripts/check-acp-session-store.ts +31 -8
  58. package/scripts/check-acp-stop-reason.ts +20 -2
  59. package/scripts/check-acp-stream-hooks.ts +22 -5
  60. package/scripts/check-acp-usage-accounting.ts +33 -9
  61. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  62. package/scripts/check-entwurf-v2-surface.ts +289 -10
  63. package/scripts/check-fresh-call-dispatch.ts +4 -5
  64. package/scripts/check-gate-qualification.ts +51 -9
  65. package/scripts/check-herdr-activation.ts +73 -0
  66. package/scripts/check-herdr-fresh-call.ts +30 -6
  67. package/scripts/check-herdr-placement.ts +51 -0
  68. package/scripts/check-herdr-plugin-build.ts +11 -4
  69. package/scripts/check-herdr-plugin.ts +745 -38
  70. package/scripts/check-herdr-sandbox.ts +14 -2
  71. package/scripts/check-install-container.sh +1 -1
  72. package/scripts/check-mux-launcher-fence.ts +37 -0
  73. package/scripts/check-pi-launch.ts +8 -2
  74. package/scripts/check-release-gate-outcomes.ts +165 -0
  75. package/scripts/doctor-pi-provider.ts +44 -4
  76. package/scripts/herdr-rails.mjs +75 -0
  77. package/scripts/lib/mutation-qualify.ts +1 -1
  78. package/scripts/meta-bridge-state.py +18 -1
  79. package/scripts/mutants/acp-backend-preflight.json +20 -0
  80. package/scripts/mutants/bridge-boot-resume.json +26 -0
  81. package/scripts/mutants/bridge-command-boot.json +38 -0
  82. package/scripts/mutants/control-send-receipt.json +235 -0
  83. package/scripts/mutants/herdr-activation.json +16 -0
  84. package/scripts/mutants/herdr-fresh-call.json +15 -4
  85. package/scripts/mutants/herdr-plugin-build.json +7 -11
  86. package/scripts/mutants/herdr-plugin.json +177 -9
  87. package/scripts/mutants/meta-retire.json +22 -0
  88. package/scripts/mutants/mux-fresh-call.json +21 -8
  89. package/scripts/mutants/omp-fresh.json +5 -9
  90. package/scripts/mutants/pack-install.json +4 -4
  91. package/scripts/mutants/pi-floor.json +17 -0
  92. package/scripts/mutants/release-gate.json +48 -0
  93. package/scripts/mutants/retired-sent-surface.json +32 -0
  94. package/scripts/mutants/v2-surface.json +7 -7
  95. package/scripts/probe-bridge-command.ts +1 -0
  96. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  97. package/scripts/smoke-agy-install-state.sh +2 -2
  98. package/scripts/smoke-codex-fresh-live.ts +94 -13
  99. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  100. package/scripts/smoke-herdr-fresh-call-live.ts +71 -36
  101. package/scripts/smoke-herdr-raw-install-live.sh +90 -5
  102. package/scripts/smoke-meta-install-state.sh +107 -1
  103. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  104. package/scripts/smoke-omp-fresh-live.ts +4 -2
  105. package/scripts/smoke-omp-mcp-state.sh +1 -1
  106. package/scripts/smoke-pi-provider-state.sh +108 -2
  107. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -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();
@@ -186,17 +196,38 @@ function transcriptRecords(file: string): string[] {
186
196
  }
187
197
 
188
198
  /**
189
- * The id of the `entwurf_v2` toolCall in this record that IS the callback, or null. Pi writes a call
190
- * and its result as two records; this is one half of the join that replaces a same-record read.
199
+ * The BARE verb names, as the two surfaces this smoke reads actually spell them — NOT the
200
+ * model-facing dialect. `[측정 2026-09-20, this smoke's own red fixture]` the claude MCP activity
201
+ * log carries `Calling MCP tool: entwurf_callback` / `Tool 'entwurf_callback' completed`, and pi
202
+ * writes `"type":"toolCall","name":"entwurf_callback"`; neither records
203
+ * `mcp__entwurf-bridge__entwurf_callback`. The per-host dialect is owned by
204
+ * `FRESH_CALL_CALLBACK_TOOL` / `FRESH_CALL_DELIVERY_TOOL` in `fresh-call-composition.ts` and is
205
+ * what the FRAMING says; these two are what an OBSERVER sees, and conflating them is what this
206
+ * pair of constants exists to stop.
207
+ */
208
+ const CALLBACK_VERB = "entwurf_callback";
209
+ const DELIVERY_VERB = "entwurf_v2";
210
+
211
+ /**
212
+ * The id of the CALLBACK toolCall in this record, or null. Pi writes a call and its result as two
213
+ * records; this is one half of the join that replaces a same-record read.
214
+ *
215
+ * WHAT MAKES IT THE CALLBACK `[ff09522]`. It used to be the ARGUMENTS: message === nonce, target
216
+ * === the caller, intent === fire-and-forget. That predicate is gone because the thing it read is
217
+ * gone — the birth verb takes ZERO arguments and reads the caller and the nonce out of its own
218
+ * process env, so there is nothing in the call for a model to get wrong and nothing here to
219
+ * compare. What identifies the callback now is the verb plus the ABSENCE of an address: a call
220
+ * carrying a target or a message is a model supplying an address, which is the second address axis
221
+ * Hard Rule 2 refuses, and it is not this act.
191
222
  *
192
- * WHAT MAKES IT THE CALLBACK, READ STRUCTURALLY `[sol 재검 2026-09-18]`. An earlier version only
193
- * asked whether the serialised arguments CONTAINED the nonce, which a call carrying `prefix+nonce`,
194
- * or the right nonce to the wrong target, satisfies just as well — and then the later, correct call
195
- * is the one that produces the artifact, so the predicate could join a wrong call to a right
196
- * delivery. The arguments are an object, so they are read as one: the message must BE the nonce,
197
- * the target must be the caller that minted it, and the intent must be the one the framing names.
223
+ * The nonce↔sender correlation the old predicate carried did not disappear with it — it moved to
224
+ * the DELIVERED ARTIFACT, which this smoke checks independently and earlier ("the child's callback
225
+ * ARRIVED … its body is a production nonce and its sender is the child the direct witness resolved
226
+ * to"). Two records, one event, same as before; only the half that names the call has changed.
198
227
  */
199
228
  function entwurfCallIdFor(record: string, nonce: string, callerGid: string): string | null {
229
+ void nonce;
230
+ void callerGid;
200
231
  let parsed: unknown;
201
232
  try {
202
233
  parsed = JSON.parse(record);
@@ -207,16 +238,14 @@ function entwurfCallIdFor(record: string, nonce: string, callerGid: string): str
207
238
  if (!Array.isArray(content)) return null;
208
239
  for (const part of content) {
209
240
  const call = part as { type?: unknown; name?: unknown; id?: unknown; arguments?: unknown };
210
- if (call.type !== "toolCall" || call.name !== "entwurf_v2") continue;
211
- const args = call.arguments as
212
- | { message?: unknown; target?: unknown; intent?: unknown; wants_reply?: unknown }
213
- | undefined;
214
- if (typeof args !== "object" || args === null) continue;
215
- if (args.message !== nonce) continue;
216
- if (args.target !== callerGid) continue;
217
- if (args.intent !== "fire-and-forget") continue;
218
- // The framing asks for no reply; a call that asked for one is a different act.
219
- if (args.wants_reply === true) continue;
241
+ if (call.type !== "toolCall" || call.name !== CALLBACK_VERB) continue;
242
+ // Zero-argument verb: absent, null and `{}` are all the shape it is called with. A supplied
243
+ // target or message is a model naming an address the env already owns — not this act.
244
+ const args = call.arguments as { message?: unknown; target?: unknown } | undefined | null;
245
+ if (args !== undefined && args !== null) {
246
+ if (typeof args !== "object" || Array.isArray(args)) continue;
247
+ if (args.target !== undefined || args.message !== undefined) continue;
248
+ }
220
249
  return typeof call.id === "string" ? call.id : null;
221
250
  }
222
251
  return null;
@@ -830,10 +859,7 @@ async function main(): Promise<void> {
830
859
  callId === null
831
860
  ? -1
832
861
  : childRecords.findIndex(
833
- (record, i) =>
834
- i > callAt &&
835
- record.includes("entwurf_v2 control-socket → sent") &&
836
- toolResultIdOf(record) === callId,
862
+ (record, i) => i > callAt && CONTROL_ACCEPTED.test(record) && toolResultIdOf(record) === callId,
837
863
  );
838
864
  // The LAST mention of the task token, so the birth prompt — which carries it, first —
839
865
  // cannot be what satisfies "the work came after".
@@ -845,14 +871,14 @@ async function main(): Promise<void> {
845
871
  }
846
872
  }
847
873
  ok(
848
- `${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`,
849
875
  nonce.length > 0 && callAt >= 0 && sentAt > callAt && workedAt > sentAt,
850
876
  );
851
877
  ok(
852
- `${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`,
853
- 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) &&
854
880
  !/entwurf_v2 [a-z-]+ (?:execution failed:|DELIVERED \()/.test(childText) &&
855
- !/entwurf_v2 control-socket → (?!sent)[a-z-]+/.test(childText),
881
+ !/entwurf_v2 control-socket → (?:rejected|failed)/.test(childText),
856
882
  );
857
883
  } else {
858
884
  const childActivity = mcpActivityBySession(root).get(childNative) ?? [];
@@ -864,23 +890,27 @@ async function main(): Promise<void> {
864
890
  // OFFERS exactly that corroboration ("you can corroborate the caller first if you
865
891
  // want to"), so the old oracle contradicted our own prompt and would have forbidden
866
892
  // the behaviour we asked for. What still must hold is everything the claim was
867
- // actually about — the callback lands before any work, it completes, and no
868
- // entwurf_v2 in the log failed or timed out.
893
+ // actually about — the callback lands before any work, it completes, and neither the
894
+ // callback verb nor the delivery verb failed or timed out anywhere in the log.
895
+ // `[ff09522]` the verb this joins on MOVED: the birth callback is `entwurf_callback`
896
+ // and `entwurf_v2` is now only the DELIVERY the closing line asks for. Joining on the
897
+ // old name does not merely miss — it mis-attributes, finding the result-delivery call
898
+ // and then reporting the real callback as forbidden work before it.
869
899
  // ONLY what the framing actually offers. `entwurf_self` used to sit in this set and
870
900
  // nothing ever proposed it to the child — an allowance for a tool we do not mention
871
901
  // widens the oracle without widening the contract (sol D1, 2026-09-18).
872
902
  const READ_ONLY_FIRST = new Set(["entwurf_peers"]);
873
- const beforeCallback = order.slice(0, Math.max(order.indexOf("entwurf_v2"), 0));
903
+ const beforeCallback = order.slice(0, Math.max(order.indexOf(CALLBACK_VERB), 0));
874
904
  // THE JOIN THIS AXIS CAN ACTUALLY MAKE. The claude MCP log records WHICH tool was
875
905
  // called and whether it completed — never its arguments or its result body — so
876
- // "the first entwurf_v2 completed" alone would also be true of a call that delivered
906
+ // "the first callback completed" alone would also be true of a call that delivered
877
907
  // somebody else's nonce or came back as a semantic reject over a successful
878
908
  // transport. The second artifact closes it: the caller's own delivered message
879
909
  // carries the EXACT nonce and the child as its sender, and its enqueue timestamp has
880
910
  // to fall inside the window of that first call. Two independent records, one event.
881
- const callAt = indexOfEntry(childActivity, (e) => (e.debug ?? "") === "Calling MCP tool: entwurf_v2");
911
+ const callAt = indexOfEntry(childActivity, (e) => (e.debug ?? "") === `Calling MCP tool: ${CALLBACK_VERB}`);
882
912
  const doneAt = indexOfEntry(childActivity, (e) =>
883
- (e.debug ?? "").startsWith("Tool 'entwurf_v2' completed successfully"),
913
+ (e.debug ?? "").startsWith(`Tool '${CALLBACK_VERB}' completed successfully`),
884
914
  );
885
915
  const callbackStamp = deliveredMessages(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid)[0]?.file ?? "";
886
916
  const stampedAt = Date.parse(
@@ -902,14 +932,19 @@ async function main(): Promise<void> {
902
932
  // here, and a claim about "any work" is wider than the oracle. What IS observed, and
903
933
  // is the thing the framing actually asks for, is the ORDER OF TOOLS: the first
904
934
  // non-read-only tool this child called was the callback.
905
- `${cell.label}: the child's FIRST non-read-only tool call was the callback — the FIRST entwurf_v2 completed successfully and the delivered callback carrying this exact nonce was enqueued inside that call's own window, preceded only by the read-only corroboration the framing offers, with no failed or timed-out entwurf_v2 anywhere in its log (this cell reads tool activity only; assistant text is not observable on this rail)`,
935
+ `${cell.label}: the child's FIRST non-read-only tool call was the zero-argument ${CALLBACK_VERB} — it completed successfully and the delivered callback carrying this exact nonce was enqueued inside that call's own window, preceded only by the read-only corroboration the framing offers, with neither ${CALLBACK_VERB} nor ${DELIVERY_VERB} failing or timing out anywhere in its log (this cell reads tool activity only; assistant text is not observable on this rail)`,
906
936
  childActivity.length > 0 &&
907
- order.includes("entwurf_v2") &&
937
+ order.includes(CALLBACK_VERB) &&
908
938
  beforeCallback.every((name) => READ_ONLY_FIRST.has(name)) &&
909
939
  within &&
910
940
  callbackArrived &&
911
- !childActivity.some(
912
- (e) => (e.debug ?? "").startsWith("Tool 'entwurf_v2' failed") || (e.error ?? "").includes("entwurf_v2"),
941
+ // BOTH verbs, because the child uses both: the birth callback and, after the
942
+ // work, the delivery verb the framing's closing line names. A failed delivery
943
+ // is as fatal to this rail as a failed callback.
944
+ ![CALLBACK_VERB, DELIVERY_VERB].some((verb) =>
945
+ childActivity.some(
946
+ (e) => (e.debug ?? "").startsWith(`Tool '${verb}' failed`) || (e.error ?? "").includes(verb),
947
+ ),
913
948
  ),
914
949
  );
915
950
  }
@@ -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
@@ -553,13 +554,19 @@ const HOME = process.env.HOME;
553
554
  const SOCKET_DIR = path.join(HOME, ".pi", "entwurf-control");
554
555
  const META_DIR = path.join(HOME, ".pi", "agent", "meta-sessions");
555
556
  const ENTWURF_BIN = process.argv[2];
557
+ // The ENTRY POINT is an argument, because there are two of them and they are different
558
+ // claims. `pi --entwurf-control` proves the WIRING; `entwurf pi` proves the LAUNCHER a user
559
+ // received from npm. One program, so neither claim drifts away from the other.
560
+ const LABEL = process.argv[3];
561
+ const [COMMAND, ...COMMAND_ARGS] = process.argv.slice(4);
556
562
 
557
563
  let bad = 0;
558
564
  const ok = (m) => console.log(" ok " + m);
559
565
  const no = (m) => { console.log(" FAIL " + m); bad = 1; };
560
566
  const ls = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
561
567
 
562
- const child = spawn("pi", ["--entwurf-control", "--mode", "rpc"], { stdio: ["pipe", "pipe", "pipe"] });
568
+ console.log(` ENTRY ${LABEL}: ${COMMAND} ${COMMAND_ARGS.join(" ")}`);
569
+ const child = spawn(COMMAND, COMMAND_ARGS, { stdio: ["pipe", "pipe", "pipe"] });
563
570
  let stderr = "";
564
571
  child.stderr.on("data", (d) => { stderr += d.toString(); });
565
572
 
@@ -655,14 +662,14 @@ child.stdout.on("data", (d) => {
655
662
  child.on("error", (err) => { no(`pi failed to spawn: ${err.message}`); done(1); });
656
663
  child.on("exit", (code) => {
657
664
  if (settled) return;
658
- no(`pi exited ${code} before answering get_state — stderr: ${stderr.trim().slice(0, 600)}`);
665
+ no(`${LABEL}: the session exited ${code} before answering get_state — stderr: ${stderr.trim().slice(0, 600)}`);
659
666
  done(1);
660
667
  });
661
668
 
662
669
  setTimeout(() => { child.stdin.write(`${JSON.stringify({ type: "get_state", id: "g1" })}\n`); }, 500);
663
670
  setTimeout(() => { if (!settled) { no("pi did not answer get_state within 60s"); done(1); } }, 60_000);
664
671
  DRIVER_EOF
665
- node /tmp/use-path-drive.mjs "$ACTIVE/node_modules/.bin/entwurf" || fail=1
672
+ node /tmp/use-path-drive.mjs "$ACTIVE/node_modules/.bin/entwurf" "the plugin wiring" pi --entwurf-control --mode rpc || fail=1
666
673
 
667
674
  # ── 8. the status fan, driven by the REAL binaries this install produced ─────
668
675
  # `check-herdr-plugin` already drives `lib/status.mjs` against stub `entwurf` and `herdr`
@@ -734,8 +741,86 @@ if printf '%s' "$fan_with_server" | grep -qx '1 citizen(s) not shown: nobody cou
734
741
  else
735
742
  bad "the fan drew no '1 citizen(s) not shown ... (unobserved)' accounting for the citizen born in [7]"
736
743
  fi
744
+
745
+ # ── 8b. the SAME fan with the ENTWURF_BIN crutch REMOVED (#116 A-D3=C) ──────
746
+ # The two runs above hand the pane `ENTWURF_BIN`, which is exactly the operator crutch D3=C exists
747
+ # to delete: with it, this cell stays green whether or not the ledger fallback exists, so it can
748
+ # certify nothing about the first screen a real user sees. `plugins/herdr/README.md` says the plugin
749
+ # puts NOTHING on PATH, so pathless is the state a correct install actually leaves behind.
750
+ # The herdr server from [8] is still up — the fan's second read needs it, and [9] stops it below.
751
+ PATHLESS="$(printf '%s' "$PATH" | tr ':' '\n' | while IFS= read -r d; do [ -n "$d" ] && [ -x "$d/entwurf" ] || printf '%s\n' "$d"; done | paste -sd:)"
752
+ run_fan_pathless() {
753
+ env -u ENTWURF_BIN PATH="$PATHLESS" HERDR_BIN_PATH="$(command -v herdr)" node "$FAN" </dev/null 2>&1
754
+ }
755
+ if command -v entwurf >/dev/null 2>&1 && PATH="$PATHLESS" command -v entwurf >/dev/null 2>&1; then
756
+ bad "the pathless PATH still resolves an entwurf — this cell would prove nothing"
757
+ else
758
+ ok "the pathless PATH resolves no entwurf (the crutch is really gone, not merely unset)"
759
+ fi
760
+
761
+ fan_pathless="$(run_fan_pathless)"; fan_pathless_rc=$?
762
+ echo "$fan_pathless" | sed 's/^/ /'
763
+ echo " → exit $fan_pathless_rc"
764
+ # The IDENTICAL literal the crutched run asserts at the cell above, so the two runs are comparable
765
+ # rather than two different claims.
766
+ 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
767
+ 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"
768
+ else
769
+ bad "the ledger-fallback claim failed: pathless fan rc=$fan_pathless_rc did not reproduce the crutched run's citizen accounting"
770
+ fi
771
+ if printf '%s' "$fan_pathless" | grep -q 'via activation-ledger'; then
772
+ ok "the pathless run says WHICH source supplied the binary, so the fallback cannot be mistaken for a PATH hit"
773
+ else
774
+ bad "the pathless run did not name activation-ledger as the binary's source"
775
+ fi
776
+
777
+ # ── 8c. pathless AND no ledger: the CITIZEN axis alone skips, and it is not red ──
778
+ # The other half of the same contract. A host that never activated must not read as broken, and the
779
+ # three blocks that need no entwurf binary must still answer.
780
+ # XDG_STATE_HOME is redirected TOO, and that is the whole isolation: the ledger lives under XDG
781
+ # **state** ($LEDGER above) while the runtime lives under XDG **data**. Moving only the second would
782
+ # leave this cell reading the real host's ledger, and "no ledger here" would be unfalsifiable.
783
+ HOMELESS="$(mktemp -d)"
784
+ fan_no_ledger="$(env -u ENTWURF_BIN PATH="$PATHLESS" HOME="$HOMELESS" XDG_DATA_HOME="$HOMELESS/.local/share" \
785
+ XDG_STATE_HOME="$HOMELESS/.local/state" \
786
+ HERDR_BIN_PATH="$(command -v herdr)" node "$FAN" </dev/null 2>&1)"; fan_no_ledger_rc=$?
787
+ echo "$fan_no_ledger" | sed 's/^/ /'
788
+ echo " → exit $fan_no_ledger_rc"
789
+ if [ "$fan_no_ledger_rc" -eq 0 ] &&
790
+ printf '%s' "$fan_no_ledger" | grep -q 'entwurf-not-found' &&
791
+ printf '%s' "$fan_no_ledger" | grep -q '\[1\] HERDR INTEGRATION' &&
792
+ printf '%s' "$fan_no_ledger" | grep -q '\[2\] ENTWURF ACTIVATION' &&
793
+ printf '%s' "$fan_no_ledger" | grep -q '\[3\] RAILS'; then
794
+ 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'"
795
+ else
796
+ 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"
797
+ fi
798
+ rm -rf "$HOMELESS"
799
+
737
800
  herdr server stop >/dev/null 2>&1 || true
738
801
 
802
+ # ── 8a. the SHIPPED LAUNCHER, from the bin the user actually received ───────
803
+ # [7] proves the wiring by running `pi` off PATH. That is not the same claim as the one #118
804
+ # hop 2 makes, and astra named the gap: a user who installed this plugin does not have this
805
+ # checkout, so the only launcher that means anything to them is the one inside the npm
806
+ # artifact the lock names. Measured on the published tarball before this cell existed, the
807
+ # `pi)` case IS in `run.sh` at 0.23.1 — so the claim is now testable, and this cell is what
808
+ # tests it: the same 0-token birth, driven through `<active>/node_modules/.bin/entwurf pi`.
809
+ #
810
+ # The proof that it EXEC'd pi is the birth itself. A launcher that silently did nothing, or
811
+ # resolved to something else, cannot produce a V3 record and a gardenId-keyed socket.
812
+ #
813
+ # IT SITS AFTER [8] ON PURPOSE: the fan cell asserts the unobserved count as an exact
814
+ # literal, and a second citizen born before it would change that number. Order here is the
815
+ # cheaper of the two ways to keep that oracle exact.
816
+ echo; echo "[8a] entwurf pi — the launcher inside the npm artifact, same 0-token birth"
817
+ ENTWURF_INSTALLED_BIN="$ACTIVE/node_modules/.bin/entwurf"
818
+ if [ -x "$ENTWURF_INSTALLED_BIN" ]; then
819
+ node /tmp/use-path-drive.mjs "$ENTWURF_INSTALLED_BIN" "the shipped launcher" "$ENTWURF_INSTALLED_BIN" pi --mode rpc || fail=1
820
+ else
821
+ bad "no installed entwurf bin at $ENTWURF_INSTALLED_BIN — the launcher claim cannot be measured"
822
+ fi
823
+
739
824
  # ── 9. deactivate: the roundtrip, on the npm source (#118 H1-5) ──────────────
740
825
  # `check-herdr-activation` cell 16 proves this against fixtures: [QK:HAC-DEACTIVATE-ROUNDTRIP]
741
826
  # asserts the pi settings return to their ORIGINAL MEANING (JSON equality, not bytes — the
@@ -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;
@@ -434,7 +434,9 @@ ok(
434
434
  // callback-only prompt is delivered first, and the task is released only by the exact
435
435
  // successful callback result. The transcript is where both are visible, in order, in one
436
436
  // native session.
437
- const bootstrapLine = buildOmpCallbackOnlyPrompt({ target: callerGid, nonce: nonce as string })
437
+ // Since the callback moved out of band (ENTWURF_CALLBACK_TARGET/NONCE in the sibling env), the
438
+ // callback-only prompt carries no address; the receipt is the no-arg first action itself.
439
+ const bootstrapLine = buildOmpCallbackOnlyPrompt()
438
440
  .split("\n")
439
441
  .find((l) => l.includes("FIRST AND ONLY ACTION")) as string;
440
442
  const transcript = fs.readFileSync(transcriptPath as string, "utf8");
@@ -442,7 +444,7 @@ const callbackAt = transcript.indexOf(bootstrapLine);
442
444
  const taskAt = transcript.indexOf(sceneFact);
443
445
  receipts["5-two-stage-order"] = `callback-only prompt @${callbackAt}, task scene fact @${taskAt}`;
444
446
  ok(
445
- "stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt, carrying this call's target and nonce",
447
+ "stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg first action; the address rides the sibling env, never this prompt)",
446
448
  callbackAt >= 0,
447
449
  ` expected line: ${bootstrapLine}`,
448
450
  );
@@ -53,7 +53,7 @@ cat > "$SB/bin/entwurf-bridge" <<'FAKE'
53
53
  while IFS= read -r line; do
54
54
  case "$line" in
55
55
  *'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
56
- *'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
56
+ *'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"},{"name":"entwurf_callback"}]}}' ;;
57
57
  esac
58
58
  done
59
59
  FAKE