@junghanacs/entwurf 0.22.0 → 0.23.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 (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -72,7 +72,7 @@ interface WireUsage {
72
72
  * One `_meta.quota.token_count` row. The field NAMES deliberately differ from
73
73
  * `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
74
74
  * shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
75
- * sibling (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:6566-6575`).
75
+ * sibling (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6579-6588`).
76
76
  * Reading a quota row with the `usage` field names silently yields zeros, so the
77
77
  * fixture below spells the vendor's names out rather than reusing `WireUsage`.
78
78
  */
@@ -137,7 +137,7 @@ function makeFakeChild() {
137
137
  *
138
138
  * Each `prompt` call consumes the next TurnScript: it first pushes that turn's
139
139
  * `usage_update` notification (the wire the running cost total actually arrives
140
- * on — read at claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), then answers the
140
+ * on — read at claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), then answers the
141
141
  * prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
142
142
  * arrives on). Both orderings are the real one: the notification precedes the
143
143
  * response, because the SDK emits it from the `result` message that ENDS the turn.
@@ -393,8 +393,8 @@ try {
393
393
  // AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
394
394
  // also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
395
395
  // the vendor states its rows "can total more than `token_count`" and are "the
396
- // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.76.0
397
- // `dist/acp-agent.js:6541-6547`).
396
+ // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.79.0
397
+ // `dist/acp-agent.js:6554-6560`).
398
398
  //
399
399
  // The wide one is required, not merely nicer, because the DENOMINATOR already
400
400
  // has that scope: turn cost is the adjacent diff of the backend's running total,
@@ -608,8 +608,8 @@ try {
608
608
  // identity that produces the bound is a property of the MAIN AGENT LOOP's
609
609
  // cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
610
610
  // states those rows also count Task subagents, sidechains, and INTERNAL
611
- // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.76.0
612
- // `dist/acp-agent.js:6538-6558`). Mixing the two scopes inflates the bound
611
+ // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.79.0
612
+ // `dist/acp-agent.js:6551-6571`). Mixing the two scopes inflates the bound
613
613
  // through both remaining terms that mention cacheWrite:
614
614
  // max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
615
615
  // less is subtracted; min(rawBound, cacheWrite) rises with it.
@@ -749,9 +749,9 @@ try {
749
749
  // CELL 3 — a turn with NO cost notification holds the baseline.
750
750
  //
751
751
  // Measured upstream: the result-path `usage_update` carries cost (read at
752
- // claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), while other
752
+ // claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), while other
753
753
  // `usage_update` paths can carry `used` without cost (for example the
754
- // rate-limit path at `:4273-4281`). A live thinkpad ledger shows such turns
754
+ // rate-limit path at `:4336-4344`). A live thinkpad ledger shows such turns
755
755
  // really occur. The honest handling is to HOLD the baseline so the amount lands
756
756
  // in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
757
757
  // there would double-count the whole prefix.
@@ -831,7 +831,7 @@ try {
831
831
  // CELL 4 — a DECREASING session total is never silently absorbed.
832
832
  //
833
833
  // `conversation_reset` switches the session to a fresh transcript (read at
834
- // claude-agent-acp 0.76.0 `dist/acp-agent.js:4286-4293`), but whether that
834
+ // claude-agent-acp 0.79.0 `dist/acp-agent.js:4349-4356`), but whether that
835
835
  // changes `total_cost_usd` is an SDK-internal value we cannot observe here.
836
836
  // A diff can therefore go negative in a session we are still holding.
837
837
  // Absorbing it quietly would both misreport the turn and destroy the only
@@ -27,6 +27,17 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync
27
27
  import { tmpdir } from "node:os";
28
28
  import * as path from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
+ import {
31
+ appendSessionStartGroup,
32
+ canonicalJson,
33
+ certifySplice,
34
+ declarationDigest,
35
+ entwurfDeclarationGroup,
36
+ removeEntwurfDescription,
37
+ removeSessionStartGroup,
38
+ selectEntwurfDeclaration,
39
+ trustReceiptKey,
40
+ } from "../pi-extensions/lib/codex-declaration.js";
30
41
  import { listAllMetaIdentitiesDir, type MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
31
42
  import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
32
43
 
@@ -123,6 +134,182 @@ ok(
123
134
  );
124
135
  ok("the handler timeout is 30", handler.timeout === 30);
125
136
 
137
+ // ── 2. the declaration leaf: what entwurf owns inside a file it SHARES (#117) ─
138
+ // The installer, the inverse, the doctor and the fresh preflight all decide with this leaf, so
139
+ // its two mechanisms are proven here once rather than four times through their shells. The
140
+ // mechanisms are: a NORMALIZED digest (blind to whatever a neighbour's serializer does) and a
141
+ // SPAN SPLICE (so a neighbour's bytes are copied through rather than rewritten).
142
+ {
143
+ const fakeLauncher = "/opt/entwurf/codex-birth-launch.sh";
144
+ const ourGroup = entwurfDeclarationGroup(fakeLauncher);
145
+ const herdrGroup = {
146
+ hooks: [{ type: "command", command: "bash '/home/op/.codex/herdr-agent-state.sh' session", timeout: 10 }],
147
+ };
148
+
149
+ ok(
150
+ "[QK:CODEX-DECL-CANONICAL-KEY-ORDER] the canonical form sorts keys recursively, so two documents that differ only in key order digest identically",
151
+ canonicalJson({ b: 1, a: [{ y: 2, x: 3 }] }) === canonicalJson({ a: [{ x: 3, y: 2 }], b: 1 }),
152
+ );
153
+ ok(
154
+ "[QK:CODEX-DECL-DIGEST-BINDS-LAUNCHER] the digest is sensitive to the launcher path — the one string the vendor keys its trust receipt to",
155
+ declarationDigest(ourGroup) !== declarationDigest(entwurfDeclarationGroup(`${fakeLauncher}.bak`)),
156
+ );
157
+ ok(
158
+ "[QK:CODEX-DECL-DIGEST-BINDS-EVENT] the digest covers the EVENT as well as the group, so the same group under another event is another identity",
159
+ declarationDigest(ourGroup) !== declarationDigest(ourGroup, "SubagentStart"),
160
+ );
161
+
162
+ // SELECTION is by the launcher command, at whatever index that command happens to sit.
163
+ for (const [label, groups, wantIndex] of [
164
+ ["alone", [ourGroup], 0],
165
+ ["after a neighbour", [herdrGroup, ourGroup], 1],
166
+ ["before a neighbour", [ourGroup, herdrGroup], 0],
167
+ ["between neighbours", [herdrGroup, ourGroup, herdrGroup], 1],
168
+ ] as const) {
169
+ const picked = selectEntwurfDeclaration({ hooks: { SessionStart: groups } }, fakeLauncher);
170
+ ok(
171
+ `[QK:CODEX-DECL-SELECT-BY-COMMAND] entwurf's declaration is selected by its command, ${label} (index ${wantIndex})`,
172
+ picked.ok && picked.groupIndex === wantIndex && picked.handlerIndex === 0,
173
+ );
174
+ ok(
175
+ `the trust key names that measured position, ${label}`,
176
+ picked.ok &&
177
+ trustReceiptKey("/h/hooks.json", picked.groupIndex, picked.handlerIndex) ===
178
+ `/h/hooks.json:session_start:${wantIndex}:0`,
179
+ );
180
+ ok(
181
+ `every other group is reported FOREIGN and none of ours is, ${label}`,
182
+ picked.ok && picked.foreign.length === groups.length - 1 && !picked.foreign.some((g) => g.index === wantIndex),
183
+ );
184
+ }
185
+
186
+ // THE HANDLER KEY SET — and this claim is a DIAGNOSIS contract, not an acceptance one. Say it
187
+ // plainly, because the QK token predates the narrowing and an unchanged name over a changed
188
+ // meaning is how a mutant stops testing what its claim says (sol, 2026-09-18). ACCEPTANCE is
189
+ // owned by the normalized digest: a handler carrying `async` digests differently, so the
190
+ // preflight refuses it whether or not this predicate exists, and the mutant below cannot make
191
+ // a drifted declaration green. What ONLY this predicate buys is the NAME — "the extra key is
192
+ // `async`, and every extra key changes the trust identity" — instead of an anonymous digest
193
+ // mismatch, and an operator repairs those two with different hands. `async: true` is the case
194
+ // that earns the name: it would let the turn proceed before the record exists.
195
+ {
196
+ const extra = selectEntwurfDeclaration(
197
+ { hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } },
198
+ fakeLauncher,
199
+ );
200
+ ok(
201
+ "[QK:FRESHCALL-CODEX-HOOK-KEYS] a handler carrying `async` — or any key the installer never writes — is NAMED as a shape drift that quotes the extra key; acceptance is the digest's job either way, and what this claim owns is the diagnosis an operator reads",
202
+ !extra.ok &&
203
+ extra.code === "declaration-shape-drifted" &&
204
+ extra.detail.includes("exactly type+command+timeout") &&
205
+ extra.detail.includes("async"),
206
+ );
207
+ }
208
+
209
+ for (const [code, document] of [
210
+ ["declaration-absent", { hooks: { SessionStart: [herdrGroup] } }],
211
+ ["declaration-duplicated", { hooks: { SessionStart: [ourGroup, herdrGroup, ourGroup] } }],
212
+ ["declaration-shape-drifted", { hooks: { SessionStart: [{ matcher: "startup", ...ourGroup }] } }],
213
+ ["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } }],
214
+ ["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [ourGroup.hooks[0], herdrGroup.hooks[0]] }] } }],
215
+ ["hooks-unreadable", { hooks: { SessionStart: "not an array" } }],
216
+ ] as const) {
217
+ const picked = selectEntwurfDeclaration(document, fakeLauncher);
218
+ ok(
219
+ `[QK:CODEX-DECL-NAMED-REFUSALS] ${code} is returned by name rather than as a silent miss (${JSON.stringify(document).slice(0, 60)}…)`,
220
+ !picked.ok && picked.code === code,
221
+ );
222
+ }
223
+
224
+ // THE SPLICE. What is asserted is not "the result parses" but "every byte a neighbour owns is
225
+ // literally still there", because a re-serialize would also parse. So the fixtures below are
226
+ // deliberately formatted the way NOTHING in this repo serializes — tabs, inline groups, the
227
+ // neighbour's keys in the order serde emits them — and the assertion is a literal substring.
228
+ // A fixture written with `JSON.stringify(_, null, 2)` would let a whole-document rewrite pass
229
+ // unnoticed, which is the one failure this mechanism exists to prevent.
230
+ const herdrLine = `\t\t\t{"hooks": [{"command": ${JSON.stringify(herdrGroup.hooks[0].command)}, "timeout": 10, "type": "command"}]}`;
231
+ const ourLine = `\t\t\t{"hooks": [{"type": "command", "command": "'${fakeLauncher}'", "timeout": 30}]}`;
232
+ const shared = [
233
+ "{",
234
+ '\t"description": "entwurf codex-birth 9.9.9 — prose we authored",',
235
+ '\t"hooks": {',
236
+ '\t\t"SessionStart": [',
237
+ `${ourLine},`,
238
+ herdrLine,
239
+ "\t\t]",
240
+ "\t}",
241
+ "}",
242
+ ].join("\n");
243
+ ok(
244
+ "the shared fixture really does hold the neighbour's bytes verbatim, in formatting nothing here would reproduce",
245
+ shared.includes(herdrLine) && JSON.stringify(JSON.parse(shared), null, 2) !== shared,
246
+ );
247
+ const withoutOurs = removeEntwurfDescription(
248
+ certifySplice(removeSessionStartGroup(shared, 0), { ...JSON.parse(shared), hooks: { SessionStart: [herdrGroup] } }),
249
+ );
250
+ ok(
251
+ "[QK:CODEX-DECL-SPLICE-KEEPS-FOREIGN-BYTES] removing entwurf's group leaves the neighbour's bytes literally untouched",
252
+ withoutOurs.includes(herdrLine),
253
+ );
254
+ ok(
255
+ "removing entwurf's group also removes entwurf's own description and nothing else",
256
+ canonicalJson(JSON.parse(withoutOurs)) === canonicalJson({ hooks: { SessionStart: [herdrGroup] } }),
257
+ );
258
+ const foreignOnly = ["{", '\t"hooks": {', '\t\t"SessionStart": [', herdrLine, "\t\t]", "\t}", "}"].join("\n");
259
+ const rejoined = certifySplice(appendSessionStartGroup(foreignOnly, ourGroup), {
260
+ hooks: { SessionStart: [herdrGroup, ourGroup] },
261
+ });
262
+ ok(
263
+ "[QK:CODEX-DECL-SPLICE-APPENDS-LAST] appending entwurf's group leaves the neighbour at its own index — and therefore at its own trust receipt — with its bytes unchanged",
264
+ selectEntwurfDeclaration(JSON.parse(rejoined), fakeLauncher).groupIndex === 1 && rejoined.includes(herdrLine),
265
+ );
266
+
267
+ // THE POST-CONDITION IS THE SAFETY. The span reader is the only new way this unit can damage
268
+ // a file nobody asked it to touch, so no splice is ever trusted on the reader's word.
269
+ let refused = "";
270
+ try {
271
+ certifySplice(rejoined, { hooks: { SessionStart: [herdrGroup] } });
272
+ } catch (err) {
273
+ refused = err instanceof Error ? err.message : String(err);
274
+ }
275
+ ok(
276
+ "[QK:CODEX-DECL-SPLICE-CERTIFIED] a splice whose result is not the value the caller intended is REFUSED, never returned",
277
+ refused.includes("not the value this edit intended"),
278
+ );
279
+ refused = "";
280
+ try {
281
+ certifySplice("{not json", { hooks: {} });
282
+ } catch (err) {
283
+ refused = err instanceof Error ? err.message : String(err);
284
+ }
285
+ ok("a splice that does not parse is refused with its own reason", refused.includes("does not parse"));
286
+
287
+ // The span reader must find the SAME structure `JSON.parse` does, across the shapes a hooks
288
+ // file is actually written in. A disagreement here is the reader silently editing the wrong
289
+ // range, which is exactly what the post-condition above is guarding.
290
+ for (const [label, text] of [
291
+ ["compact", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } })],
292
+ ["2-space", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, 2)],
293
+ ["tab", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, "\t")],
294
+ [
295
+ "strings that contain braces and escaped quotes",
296
+ JSON.stringify({
297
+ description: 'a } b ] c \\" d',
298
+ hooks: { SessionStart: [ourGroup, { hooks: [{ type: "command", command: '] } "x"', timeout: 1 }] }] },
299
+ }),
300
+ ],
301
+ ] as const) {
302
+ const trimmed = certifySplice(removeSessionStartGroup(text, 0), {
303
+ ...(JSON.parse(text) as Record<string, unknown>),
304
+ hooks: { SessionStart: [(JSON.parse(text) as { hooks: { SessionStart: unknown[] } }).hooks.SessionStart[1]] },
305
+ });
306
+ ok(
307
+ `[QK:CODEX-DECL-SPAN-READER-EXACT] the span reader agrees with JSON.parse on ${label} formatting`,
308
+ JSON.parse(trimmed) !== null,
309
+ );
310
+ }
311
+ }
312
+
126
313
  // ── 3. FIRE the installed launcher, the way codex fires it ──────────────────
127
314
  const MEASURED = {
128
315
  session_id: "01a08147-fbad-78d2-b266-e058f322125e",
@@ -16,6 +16,11 @@
16
16
  * `sendRpcCommand` reject `connection closed before response` (the 2026-05-18
17
17
  * receiver-stuck backstop the settled-guard preserves).
18
18
  * 5. get_info runtime helper parses/formats cwd/model/idle once for every caller.
19
+ * 7. accepted-connection disconnect policy — a REAL peer that hangs up mid-exchange must not
20
+ * reach this process as an uncaught exception (the 2026-09-14 C4 incident: a resident pi died
21
+ * writing a late response to a sender that had already timed out), must not be diagnosed, and
22
+ * an error that is NOT a disconnect must be diagnosed exactly once without rethrowing.
23
+ * 8. ordering — the surface installs that policy before setEncoding and before the data handler.
19
24
  *
20
25
  * No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm run check:full`.
21
26
  */
@@ -27,6 +32,7 @@ import * as os from "node:os";
27
32
  import * as path from "node:path";
28
33
  import { fileURLToPath } from "node:url";
29
34
  import {
35
+ attachAcceptedSocketDisconnectPolicy,
30
36
  fetchControlSocketRuntimeInfo,
31
37
  formatRuntimeModel,
32
38
  formatSenderInfoBlock,
@@ -217,6 +223,138 @@ async function main(): Promise<void> {
217
223
  ok("6: undefined origin/replyable render nothing", !formatSenderInfoBlock(base).includes("origin"));
218
224
  }
219
225
 
226
+ // ── 7. accepted-connection disconnect policy (the server half of the wire) ──
227
+ // `[측정 2026-09-14]` a resident pi was killed by a late response written to a socket whose
228
+ // peer had already timed out and hung up. The EPIPE arrived asynchronously as an `error`
229
+ // event, and an `error` event with no listener is an uncaught exception. These cells drive a
230
+ // REAL unix socket to a real peer disconnect — no fake control server, no synthetic stand-in
231
+ // for the crash itself.
232
+ {
233
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rpc-disconnect-"));
234
+ const socketPath = path.join(dir, "s.sock");
235
+ const diagnostics: string[] = [];
236
+ const accepted: net.Socket[] = [];
237
+ const server = net.createServer((socket) => {
238
+ // Exactly what the surface does, and the ONLY error listener on this connection: if the
239
+ // policy stops absorbing, nothing else here is catching it.
240
+ attachAcceptedSocketDisconnectPolicy(socket, (line) => diagnostics.push(line));
241
+ accepted.push(socket);
242
+ });
243
+ await new Promise<void>((resolve) => server.listen(socketPath, resolve));
244
+
245
+ // Record instead of dying, so the crash becomes an assertion we can attribute rather than
246
+ // a stack trace that kills the gate run itself.
247
+ const uncaught: Error[] = [];
248
+ const onUncaught = (err: Error) => uncaught.push(err);
249
+ process.on("uncaughtException", onUncaught);
250
+ // Every socket this cell opens, torn down in `finally` — `server.close()` only calls back
251
+ // once the LAST connection is gone, so a client left open on a failing assertion would turn
252
+ // a red gate into a hang (which is not a kill: a mutant must fail bounded, with its QK).
253
+ const opened: net.Socket[] = [];
254
+ try {
255
+ const client = net.createConnection(socketPath);
256
+ opened.push(client);
257
+ await new Promise<void>((resolve, reject) => {
258
+ client.once("connect", resolve);
259
+ client.once("error", reject);
260
+ });
261
+ // The sender gives up and hangs up — the C4 shape exactly.
262
+ client.on("error", () => {});
263
+ client.destroy();
264
+
265
+ // The receiver answers LATE, into a peer that is gone. Bounded: writes until the stream
266
+ // records an error, never longer.
267
+ for (let i = 0; i < 200 && accepted[0] !== undefined && accepted[0].errored === null; i++) {
268
+ try {
269
+ accepted[0].write(`${"x".repeat(64 * 1024)}\n`);
270
+ } catch {
271
+ // A synchronous ERR_STREAM_DESTROYED is the OTHER half — writeResponse's own
272
+ // try/catch owns it. This loop is hunting the asynchronous one.
273
+ }
274
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
275
+ }
276
+
277
+ // Fixture integrity FIRST: if no peer-disconnect error ever landed, the two cells below
278
+ // would be vacuously green no matter what the policy did.
279
+ const errored = accepted[0]?.errored as NodeJS.ErrnoException | null | undefined;
280
+ ok(
281
+ "7: fixture — a real peer disconnect produced an async EPIPE/ECONNRESET on the accepted socket",
282
+ errored != null && (errored.code === "EPIPE" || errored.code === "ECONNRESET"),
283
+ );
284
+ ok(
285
+ "7: [QK:CONTROL-SOCKET-NO-UNCAUGHT] a vanished peer never reaches the process as an uncaught exception " +
286
+ `(would have killed this resident session) — uncaught=${JSON.stringify(uncaught.map((e) => String(e)))}`,
287
+ uncaught.length === 0,
288
+ );
289
+ ok(
290
+ "7: [QK:CONTROL-SOCKET-ABSORBS-PEER-DISCONNECT] a vanished peer is absorbed silently, never diagnosed " +
291
+ `(it describes the client, not our state) — diagnostics=${JSON.stringify(diagnostics)}`,
292
+ diagnostics.length === 0,
293
+ );
294
+
295
+ // Unexpected code on a REAL accepted socket: diagnosed exactly once, with code AND
296
+ // message, and the emit must not throw back out of the event callback.
297
+ const client2 = net.createConnection(socketPath);
298
+ opened.push(client2);
299
+ await new Promise<void>((resolve, reject) => {
300
+ client2.once("connect", resolve);
301
+ client2.once("error", reject);
302
+ });
303
+ client2.on("error", () => {});
304
+ // The SECOND accepted connection — live, never disconnected, so the diagnosis path is
305
+ // exercised on a socket in ordinary service rather than on the already-errored one.
306
+ for (let i = 0; i < 200 && accepted[1] === undefined; i++) {
307
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
308
+ }
309
+ const live = accepted[1];
310
+ ok("7: fixture — the second connection was accepted and carries the policy", live !== undefined);
311
+ const unexpected: NodeJS.ErrnoException = Object.assign(new Error("no space left on device"), {
312
+ code: "ENOSPC",
313
+ });
314
+ const before = diagnostics.length;
315
+ let threw = false;
316
+ try {
317
+ live?.emit("error", unexpected);
318
+ } catch {
319
+ threw = true;
320
+ }
321
+ const emitted = diagnostics.slice(before);
322
+ ok(
323
+ "7: [QK:CONTROL-SOCKET-DIAGNOSES-UNEXPECTED] a non-disconnect error diagnoses EXACTLY once with code and " +
324
+ `message, and never rethrows from the event callback — threw=${threw} emitted=${JSON.stringify(emitted)}`,
325
+ threw === false &&
326
+ emitted.length === 1 &&
327
+ emitted[0]!.includes("ENOSPC") &&
328
+ emitted[0]!.includes("no space left on device"),
329
+ );
330
+ } finally {
331
+ process.off("uncaughtException", onUncaught);
332
+ for (const socket of [...opened, ...accepted]) socket.destroy();
333
+ await new Promise<void>((resolve) => server.close(() => resolve()));
334
+ await fs.rm(dir, { recursive: true, force: true });
335
+ }
336
+ }
337
+
338
+ // ── 8. the surface installs the policy FIRST on every accepted connection ───
339
+ // Ordering is the contract, not merely presence: a listener attached after setEncoding or
340
+ // after the data handler leaves a window in which the very first write can still kill us.
341
+ {
342
+ const src = await fs.readFile(CONTROL_SRC, "utf8");
343
+ const accept = src.slice(src.indexOf("const server = net.createServer((socket) => {"));
344
+ const attachAt = accept.indexOf("attachAcceptedSocketDisconnectPolicy(socket)");
345
+ const encodingAt = accept.indexOf('socket.setEncoding("utf8")');
346
+ const dataAt = accept.indexOf('socket.on("data"');
347
+ ok(
348
+ "8: [QK:CONTROL-SOCKET-POLICY-FIRST] createServer attaches the disconnect policy before setEncoding and " +
349
+ `before the data handler — attach=${attachAt} setEncoding=${encodingAt} data=${dataAt}`,
350
+ attachAt !== -1 && encodingAt !== -1 && dataAt !== -1 && attachAt < encodingAt && attachAt < dataAt,
351
+ );
352
+ ok(
353
+ "8: the surface consumes the shared policy and defines no second error listener of its own",
354
+ /from "\.\/lib\/entwurf-control-rpc\.js"/.test(src) && !/socket\.on\("error"/.test(src),
355
+ );
356
+ }
357
+
220
358
  console.log(`\ncheck-entwurf-control-rpc: ${passed} checks passed`);
221
359
  }
222
360
 
@@ -30,6 +30,7 @@
30
30
  import assert from "node:assert/strict";
31
31
  import * as path from "node:path";
32
32
  import { type EntwurfFactsDeps, listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
33
+ import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
33
34
  import { type MetaCitizenBackend, serializeMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
34
35
  import { SOCKET_SUFFIX, type SocketDirEntry } from "../pi-extensions/lib/socket-discovery.ts";
35
36
  import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
@@ -83,6 +84,7 @@ function deps(
83
84
  observe: (identity) => ({
84
85
  receiver: identity.backend === "claude-code" ? "inactive" : "n/a",
85
86
  transcript: identity.gardenId === GID_CLAUDE ? "absent" : "exists",
87
+ placement: UNOBSERVED_PLACEMENT,
86
88
  }),
87
89
  // Kind-carrying entries, like the real bindings: the listing must be able to refuse a
88
90
  // symlinked record without following it. `irregularMeta` names the ones that are not
@@ -186,7 +188,7 @@ async function main(): Promise<void> {
186
188
  const all = deps(meta, {});
187
189
  all.observe = (identity) => {
188
190
  allObserved.push(identity.gardenId);
189
- return { receiver: "active", transcript: "exists" };
191
+ return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
190
192
  };
191
193
  await listEntwurfFacts(all);
192
194
  ok("Q112: generic provider without a presentation limit observes all 40 citizens", allObserved.length === 40);
@@ -196,7 +198,7 @@ async function main(): Promise<void> {
196
198
  d.observationLimit = 32;
197
199
  d.observe = (identity) => {
198
200
  observed.push(identity.gardenId);
199
- return { receiver: "active", transcript: "exists" };
201
+ return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
200
202
  };
201
203
  const r = await listEntwurfFacts(d);
202
204
  ok("Q112: full machine payload retains all 40 certified citizens", r.facts.peers.length === 40);
@@ -33,6 +33,7 @@ import { type EntwurfFactsResult, recordLessSocketMessage } from "../pi-extensio
33
33
  import type { PeerFact } from "../pi-extensions/lib/entwurf-facts.ts";
34
34
  import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
35
35
  import type { FactLiveness } from "../pi-extensions/lib/entwurf-v2-contract.ts";
36
+ import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
36
37
  import type { MetaCitizenBackend } from "../pi-extensions/lib/meta-session.ts";
37
38
  import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
38
39
 
@@ -51,7 +52,7 @@ function peer(
51
52
  gardenId: string,
52
53
  backend: MetaCitizenBackend,
53
54
  liveness: FactLiveness,
54
- observed: Partial<Pick<PeerFact, "receiver" | "transcript">> = {},
55
+ observed: Partial<Pick<PeerFact, "receiver" | "transcript" | "placement">> = {},
55
56
  ): PeerFact {
56
57
  return {
57
58
  gardenId,
@@ -64,6 +65,7 @@ function peer(
64
65
  liveness,
65
66
  receiver: observed.receiver ?? "n/a",
66
67
  transcript: observed.transcript ?? "exists",
68
+ placement: observed.placement ?? UNOBSERVED_PLACEMENT,
67
69
  };
68
70
  }
69
71
 
@@ -44,6 +44,7 @@ import * as path from "node:path";
44
44
  import { fileURLToPath } from "node:url";
45
45
  import {
46
46
  computeSelfAddressability,
47
+ decideUncitizenedNotice,
47
48
  type SelfAddressabilityFacts,
48
49
  type SocketState,
49
50
  } from "../pi-extensions/lib/entwurf-self-address.ts";
@@ -512,4 +513,71 @@ ok(
512
513
  !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(nativeSrc),
513
514
  );
514
515
 
516
+ // ── the other half of the same honesty: a session that is NOT a citizen says so ──
517
+ // This gate exists because a surface must not CLAIM an addressability it does not have. The mirror
518
+ // of that is a surface that has none and says nothing: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr
519
+ // plugin install wires this extension at USER scope, so it loads in every pi on the host while
520
+ // citizenship stays argv-gated — a plain `pi` after a green install is silent, tool-less and
521
+ // indistinguishable from an install that did nothing.
522
+ //
523
+ // WHY THESE THREE CELLS AND NOT ONE REGEX. The first cut pinned "told once, on the UI only" with a
524
+ // source regex that only proved a call existed somewhere. `[측정 2026-09-17, 독립 검수
525
+ // claude-opus-5 + 재현]` two mutants walked through it green: moving the call into the CITIZEN
526
+ // branch, and deleting the once-latch — 51/51 both times. That was a false success, so the
527
+ // conditions moved into a pure decision the truth table below exhausts, and what is left at the
528
+ // call site is narrow enough for a source pin to actually cover.
529
+ {
530
+ const decide = (controlEnabled: boolean, hasUI: boolean, alreadyShown: boolean): boolean =>
531
+ decideUncitizenedNotice({ controlEnabled, hasUI, alreadyShown });
532
+ const table = {
533
+ citizenFirstUI: decide(true, true, false),
534
+ citizenNoUI: decide(true, false, false),
535
+ citizenShown: decide(true, true, true),
536
+ uncitizenedFirstUI: decide(false, true, false),
537
+ uncitizenedNoUI: decide(false, false, false),
538
+ uncitizenedShown: decide(false, true, true),
539
+ uncitizenedShownNoUI: decide(false, false, true),
540
+ };
541
+ ok(
542
+ "a citizen is never told it is not one, a session with no UI is never told anything, and the " +
543
+ `answer is true in exactly ONE of the eight states [QK:SELFADDR-UNCITIZENED-DECISION] (${JSON.stringify(table)})`,
544
+ table.uncitizenedFirstUI === true &&
545
+ table.citizenFirstUI === false &&
546
+ table.citizenNoUI === false &&
547
+ table.citizenShown === false &&
548
+ table.uncitizenedNoUI === false &&
549
+ table.uncitizenedShown === false &&
550
+ table.uncitizenedShownNoUI === false,
551
+ );
552
+ }
553
+
554
+ // The call site carries the REAL flag as a fact and is unconditional — there is no branch left for
555
+ // a mutant to move it into, and passing a constant would make the decision above unreachable.
556
+ const noticeBody = nativeSrc.slice(
557
+ nativeSrc.indexOf("async function noticeUncitizenedSession"),
558
+ nativeSrc.indexOf("function shouldRegisterControlTools"),
559
+ );
560
+ ok(
561
+ "pi-native: the not-a-citizen notice is decided from the REAL flag, on the UI only, never on stderr " +
562
+ "[QK:SELFADDR-UNCITIZENED-NOTICED]",
563
+ /const enabled = pi\.getFlag\(ENTWURF_FLAG\) === true;\n\t\tawait noticeUncitizenedSession\(ctx, enabled\);/.test(
564
+ nativeSrc,
565
+ ) &&
566
+ /decideUncitizenedNotice\(\{ controlEnabled, hasUI: ctx\.hasUI, alreadyShown: uncitizenedNoticeShown \}\)/.test(
567
+ noticeBody,
568
+ ) &&
569
+ /--entwurf-control/.test(noticeBody) &&
570
+ !/process\.stderr/.test(noticeBody),
571
+ );
572
+
573
+ // The latch is the difference between a hint and a nag: `refreshServer` runs again on session
574
+ // switches, so the line that records "already said it" is load-bearing, not bookkeeping.
575
+ ok(
576
+ "pi-native: the notice records that it fired, so a session switch does not repeat it " +
577
+ "[QK:SELFADDR-UNCITIZENED-ONCE]",
578
+ /let uncitizenedNoticeShown = false;/.test(nativeSrc) &&
579
+ /uncitizenedNoticeShown = true;/.test(noticeBody) &&
580
+ noticeBody.indexOf("uncitizenedNoticeShown = true;") < noticeBody.indexOf("ctx.ui.notify("),
581
+ );
582
+
515
583
  console.log(`\ncheck-entwurf-self-address: ${passed} checks passed`);