@junghanacs/entwurf 0.21.0 → 0.23.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 (122) hide show
  1. package/AGENTS.md +3 -2
  2. package/BASELINE.md +3 -1
  3. package/CHANGELOG.md +658 -0
  4. package/DELIVERY.md +167 -29
  5. package/README.md +104 -14
  6. package/VERIFY.md +73 -11
  7. package/docs/external-mcp-host.md +16 -6
  8. package/docs/setup-clean-host.md +73 -22
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +289 -57
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +171 -263
  26. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +65 -31
  29. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  30. package/package.json +4 -4
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  33. package/pi-extensions/lib/codex-declaration.js +612 -0
  34. package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
  35. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  37. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  38. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  39. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  40. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  42. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  43. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  44. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  45. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  46. package/pi-extensions/lib/herdr-placement.ts +276 -0
  47. package/pi-extensions/lib/mux-fresh-call.ts +257 -304
  48. package/run.sh +522 -10
  49. package/scripts/check-codex-app-server-launch.ts +445 -0
  50. package/scripts/check-codex-birth-hook.ts +187 -0
  51. package/scripts/check-entwurf-control-rpc.ts +138 -0
  52. package/scripts/check-entwurf-fact-provider.ts +4 -2
  53. package/scripts/check-entwurf-facts.ts +12 -6
  54. package/scripts/check-entwurf-peers-surface.ts +3 -1
  55. package/scripts/check-entwurf-self-address.ts +68 -0
  56. package/scripts/check-entwurf-v2-production.ts +42 -1
  57. package/scripts/check-entwurf-v2-send.ts +26 -7
  58. package/scripts/check-fresh-call-dispatch.ts +301 -0
  59. package/scripts/check-gate-qualification.ts +19 -5
  60. package/scripts/check-herdr-activation.ts +1293 -0
  61. package/scripts/check-herdr-fresh-call.ts +1250 -0
  62. package/scripts/check-herdr-placement.ts +383 -0
  63. package/scripts/check-herdr-plugin-build.ts +842 -0
  64. package/scripts/check-herdr-plugin-profile.ts +371 -0
  65. package/scripts/check-herdr-plugin.ts +523 -0
  66. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  67. package/scripts/check-herdr-sandbox.ts +456 -0
  68. package/scripts/check-herdr-supply.ts +184 -0
  69. package/scripts/check-mux-launch-tmux.ts +331 -35
  70. package/scripts/check-mux-launcher-fence.ts +138 -0
  71. package/scripts/check-peer-facts.ts +331 -0
  72. package/scripts/check-release-gate-outcomes.ts +12 -0
  73. package/scripts/check-typing-call-fence.ts +296 -0
  74. package/scripts/codex-app-server-launch.sh +275 -0
  75. package/scripts/codex-birth-doctor.sh +159 -16
  76. package/scripts/codex-birth-install.sh +178 -72
  77. package/scripts/codex-birth-uninstall.sh +162 -7
  78. package/scripts/codex-socket-path.ts +33 -0
  79. package/scripts/codex-terminal-title-config.py +500 -0
  80. package/scripts/codex_toml_io.py +121 -0
  81. package/scripts/fixtures/herdr-supply.json +29 -0
  82. package/scripts/herdr-activation.mjs +536 -0
  83. package/scripts/herdr-plugin-activate.mjs +270 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  85. package/scripts/herdr-runtime.mjs +1203 -0
  86. package/scripts/install-herdr-ci.sh +96 -0
  87. package/scripts/lib/codex-fresh-live-protocol.ts +13 -5
  88. package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
  89. package/scripts/lib/tree-digest.ts +97 -0
  90. package/scripts/meta-bridge-install.sh +19 -2
  91. package/scripts/meta-bridge-state.py +167 -11
  92. package/scripts/mutants/codex-app-server-launch.json +157 -0
  93. package/scripts/mutants/codex-caller-seat.json +334 -0
  94. package/scripts/mutants/codex-native.json +195 -29
  95. package/scripts/mutants/control-socket-disconnect.json +57 -0
  96. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  97. package/scripts/mutants/herdr-activation.json +305 -0
  98. package/scripts/mutants/herdr-fresh-call.json +448 -0
  99. package/scripts/mutants/herdr-placement.json +161 -0
  100. package/scripts/mutants/herdr-plugin-build.json +160 -0
  101. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  102. package/scripts/mutants/herdr-plugin.json +133 -0
  103. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  104. package/scripts/mutants/herdr-supply.json +133 -0
  105. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  106. package/scripts/mutants/mux-fresh-call.json +184 -25
  107. package/scripts/mutants/mux-launcher-fence.json +13 -0
  108. package/scripts/mutants/omp-fresh.json +4 -4
  109. package/scripts/mutants/peer-facts.json +98 -0
  110. package/scripts/mutants/self-address.json +33 -0
  111. package/scripts/mutants/typing-call-fence.json +17 -0
  112. package/scripts/mutants/v2-surface.json +22 -0
  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-codex-birth.sh +281 -12
  117. package/scripts/smoke-codex-config-state.sh +192 -3
  118. package/scripts/smoke-codex-fresh-live.ts +277 -37
  119. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  120. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  121. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  122. package/scripts/smoke-setup-verdict.sh +13 -11
@@ -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);
@@ -36,6 +36,7 @@ import {
36
36
  isLivenessSupported,
37
37
  LIVENESS_DOMAIN_BACKENDS,
38
38
  } from "../pi-extensions/lib/entwurf-v2-contract.ts";
39
+ import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
39
40
  import {
40
41
  META_CITIZEN_BACKENDS,
41
42
  type MetaCitizenBackend,
@@ -138,7 +139,7 @@ for (const backend of META_CITIZEN_BACKENDS) {
138
139
  ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
139
140
  }
140
141
 
141
- // ── facts-only keyset: identity facts + liveness + the two observed axes, and
142
+ // ── facts-only keyset: identity facts + liveness + the three observed axes, and
142
143
  // still NO verb-routing / no transcript PATH ────────────────────────────────
143
144
  {
144
145
  const fact = resolvePeerFact(identity("pi"), "alive");
@@ -154,20 +155,25 @@ for (const backend of META_CITIZEN_BACKENDS) {
154
155
  "recordUpdatedAt",
155
156
  "receiver",
156
157
  "transcript",
158
+ "placement",
157
159
  ].sort();
158
160
  assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
159
- ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript)", true);
161
+ ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript/placement)", true);
160
162
 
161
163
  // #101: an unmeasured row says so. `unobserved` is the default for a caller that
162
164
  // injected no observer — never `none`/`absent`, which would be a fabricated fact.
163
165
  ok(
164
- "an observer-less composition reports both axes as unobserved (never a default fact)",
165
- fact.receiver === "unobserved" && fact.transcript === "unobserved",
166
+ "an observer-less composition reports all three axes as unobserved (never a default fact)",
167
+ fact.receiver === "unobserved" && fact.transcript === "unobserved" && fact.placement.kind === "unobserved",
166
168
  );
167
- const observed = resolvePeerFact(identity("claude-code"), null, { receiver: "inactive", transcript: "absent" });
169
+ const observed = resolvePeerFact(identity("claude-code"), null, {
170
+ receiver: "inactive",
171
+ transcript: "absent",
172
+ placement: UNOBSERVED_PLACEMENT,
173
+ });
168
174
  ok(
169
175
  "an injected observation rides onto the fact verbatim",
170
- observed.receiver === "inactive" && observed.transcript === "absent",
176
+ observed.receiver === "inactive" && observed.transcript === "absent" && observed.placement.kind === "unobserved",
171
177
  );
172
178
 
173
179
  const FORBIDDEN = [
@@ -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`);
@@ -9,6 +9,10 @@
9
9
  * (a pi-alive citizen → control-socket execute; acquireLock spy saw {dir: lockDir}).
10
10
  * B. control `sendOverSocket` builds the RpcSendCommand (type/message/mode/wants_reply/
11
11
  * sender) and maps response.success→outcome; the hand releases under `lockDir`.
12
+ * B2. #115 — the SAME closure carries `response.error` onto the RpcSendResult, so a
13
+ * completed-but-refused RPC reaches the caller as `rejected` WITH the receiver's
14
+ * named reason. Dropping that one field renders a reasonless reject, which is the
15
+ * field symptom; no other deterministic gate holds this wiring.
12
16
  * D. the meta-mailbox hand enqueues onto the wired sessionsDir/mailboxDir.
13
17
  * E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
14
18
  * enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
@@ -60,6 +64,10 @@ const LOCK_DIR = "/fake/locks";
60
64
  const SESSIONS_DIR = "/fake/sessions";
61
65
  const MAILBOX_DIR = "/fake/mailbox";
62
66
  const CONTROL_DIR = "/fake/ctl";
67
+ /** The named in-band refusal a real receiver answers with while it compacts (#115).
68
+ * It is a plain opaque string here on purpose: the production seam must carry WHATEVER
69
+ * reason the receiver named, not a vocabulary this gate recognises. */
70
+ const IN_BAND_REJECT_REASON = "compacting";
63
71
 
64
72
  function identity(backend: MetaIdentity["backend"], gardenId = GID): MetaIdentity {
65
73
  return {
@@ -184,7 +192,10 @@ function makeSpiedFactory(over: {
184
192
  recordExists?: boolean;
185
193
  inspectKind?: TargetSocketInspection["kind"];
186
194
  probe?: "alive" | "dead" | "indeterminate";
187
- rpc?: "success" | "dead-throw";
195
+ /** `in-band-reject` (#115): the RPC COMPLETES and the receiver refuses in band
196
+ * (`{success:false,error:"compacting"}`) — distinct from `dead-throw`, which never
197
+ * reaches a response at all. It is the only arm that carries `response.error`. */
198
+ rpc?: "success" | "dead-throw" | "in-band-reject";
188
199
  classifyDead?: boolean;
189
200
  /** #50 C3 — a caller with no authoritative sender (senderProvider → undefined). */
190
201
  noSender?: boolean;
@@ -267,6 +278,11 @@ function makeSpiedFactory(over: {
267
278
  e.code = "ECONNREFUSED";
268
279
  throw e;
269
280
  }
281
+ if (over.rpc === "in-band-reject") {
282
+ return {
283
+ response: { type: "response", command: command.type, success: false, error: IN_BAND_REJECT_REASON },
284
+ };
285
+ }
270
286
  return { response: { type: "response", command: command.type, success: true } };
271
287
  },
272
288
  enqueue: (o) => {
@@ -531,6 +547,31 @@ async function main(): Promise<void> {
531
547
  );
532
548
  }
533
549
 
550
+ // ── B2: #115 — the production seam CARRIES the receiver's named in-band reason ─
551
+ // B above proves the success half of the map. This is the other half, and it is the
552
+ // half the field symptom lived in: a completed RPC answering `{success:false,
553
+ // error:"compacting"}`. The reason has to cross THREE production hops the fake cannot
554
+ // short-circuit — `response.error` → `RpcSendResult.error` (the factory's own
555
+ // `sendOverSocket` closure) → `driveSend`'s `inBandRejected` → the
556
+ // `executeControlSocketSend` result — so deleting the factory's `error:` wiring alone
557
+ // strands the reason and this cell goes red. `outcome` alone is NOT the assertion:
558
+ // a bare `rejected` is exactly what the defect rendered.
559
+ {
560
+ const { deps, spies } = makeSpiedFactory({ rpc: "in-band-reject" });
561
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
562
+ ok("B2: in-band {success:false} → outcome 'rejected' (not failed, not sent)", res.outcome === "rejected");
563
+ ok(
564
+ "B2: [QK:V2PROD-INBAND-ERROR-WIRED] response.error reaches the send result verbatim",
565
+ res.rejectReason === IN_BAND_REJECT_REASON,
566
+ );
567
+ ok("B2: an in-band reject is a COMPLETED rpc — the socket hand ran once", spies.rpc.length === 1);
568
+ ok(
569
+ "B2: a rejected send still releases under the wired lockDir",
570
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
571
+ );
572
+ ok("B2: an in-band reject never falls back to the mailbox", spies.enqueue.length === 0);
573
+ }
574
+
534
575
  // ── C2: #50 C3 — the dormant rail carries the caller edge (<sender_info>) ──
535
576
  // ── D: meta-mailbox hand enqueues onto the wired dirs ─────────────────────
536
577
  {
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * 1. ack success → outcome `sent`, release ×1, deadFallback NOT called.
7
7
  * 2. in-band reject (success:false) → outcome `rejected`, release ×1, NO fallback
8
- * (deadFallback + mailbox NOT called — the receiver was reached and refused); NO
9
- * rejectReason (an in-band refusal has no resolver taxonomy N3 boundary).
8
+ * (deadFallback + mailbox NOT called — the receiver was reached and refused); a
9
+ * supplied receiver error is carried verbatim as `rejectReason` (N3 boundary).
10
10
  * 3. dead → re-resolve(control-socket) → success → `fallback-sent`, release ×1,
11
11
  * deadFallback called EXACTLY once and UNDER the still-held lock (before release).
12
12
  * 4. dead → re-resolve reject → `rejected`, release ×1, and the resolver's reason is
@@ -193,16 +193,24 @@ async function main(): Promise<void> {
193
193
 
194
194
  // ── 2: in-band reject → rejected, release once, NO fallback ───────────────
195
195
  {
196
- const { result, trace } = await run({ firstSend: { result: { success: false, error: "refused" } } });
196
+ const { result, trace } = await run({ firstSend: { result: { success: false, error: "compacting" } } });
197
197
  ok("in-band reject → rejected", result.outcome === "rejected");
198
198
  ok("in-band reject → release ×1", trace.releases.length === 1);
199
- // N3 boundary: an in-band RPC refusal has NO resolver reason (only a dead-path
200
- // re-resolve reject carries one) the field stays undefined here.
201
- ok("in-band reject → no rejectReason (in-band has no resolver taxonomy)", result.rejectReason === undefined);
199
+ ok(
200
+ "[QK:V2SEND-INBAND-REJECT-REASON] in-band control reject carries its receiver error",
201
+ result.rejectReason === "compacting",
202
+ );
202
203
  ok(
203
204
  "in-band reject → no deadFallback, no mailbox",
204
205
  trace.deadFallbackCalls === 0 && trace.mailboxSends.length === 0,
205
206
  );
207
+ const unnamed = await run({ firstSend: { result: { success: false } } });
208
+ ok("in-band reject without an error does not invent rejectReason", unnamed.result.rejectReason === undefined);
209
+ const empty = await run({ firstSend: { result: { success: false, error: "" } } });
210
+ ok(
211
+ "in-band reject with an empty error does not expose an empty rejectReason",
212
+ empty.result.rejectReason === undefined,
213
+ );
206
214
  }
207
215
 
208
216
  // ── 3: dead → re-resolve(control) success → fallback-sent, fallback before release
@@ -223,6 +231,16 @@ async function main(): Promise<void> {
223
231
  "dead → deadFallback UNDER held lock (before release)",
224
232
  trace.order.indexOf("deadFallback") < trace.order.indexOf("releaseLock"),
225
233
  );
234
+
235
+ const refused = await run({
236
+ firstSend: { throwCode: "ECONNREFUSED" },
237
+ deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
238
+ fallbackSend: { result: { success: false, error: "retry-refused" } },
239
+ });
240
+ ok(
241
+ "dead → re-resolve(control) in-band reject carries the retry receiver error",
242
+ refused.result.outcome === "rejected" && refused.result.rejectReason === "retry-refused",
243
+ );
226
244
  }
227
245
 
228
246
  // ── 4: dead → re-resolve reject → rejected ────────────────────────────────
@@ -296,9 +314,10 @@ async function main(): Promise<void> {
296
314
  const refused = await run({
297
315
  firstSend: { throwCode: "ENOENT" },
298
316
  deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
299
- fallbackSend: { result: { success: false } },
317
+ fallbackSend: { result: { success: false, error: "mailbox-refused" } },
300
318
  });
301
319
  ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
320
+ ok("dead → mailbox enqueue reject carries its receiver error", refused.result.rejectReason === "mailbox-refused");
302
321
  // No file was written, so there is nothing to name — never echo a dep's stray path.
303
322
  ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
304
323
  }