@junghanacs/entwurf 0.13.1 → 0.14.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 (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -32,7 +32,7 @@
32
32
  // exactly what this gate must not make. They are cheap — each smoke declines
33
33
  // before it does any work.
34
34
  //
35
- // Pure + subprocess, no network/model — IN pnpm check.
35
+ // Pure + subprocess, no network/model — IN pnpm run check:full.
36
36
 
37
37
  import { strict as assert } from "node:assert";
38
38
  import { execFileSync } from "node:child_process";
@@ -243,8 +243,8 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
243
243
  viaWrapper.code,
244
244
  LIVE_SKIP_EXIT,
245
245
  `[QK:WRAPPER-SKIP-IS-PROTOCOL-EXIT] a run.sh smoke WRAPPER that declines a prerequisite must return the protocol ` +
246
- `SKIP code, not 0 — got exit ${viaWrapper.code}. The wrapper is a skip surface of its own (cortex, matrix, ` +
247
- "spawn-live and spawn-resume all decline before their .ts is ever reached), so fixing only the TypeScript " +
246
+ `SKIP code, not 0 — got exit ${viaWrapper.code}. The wrapper is a skip surface of its own (cortex and matrix ` +
247
+ "both decline before their .ts is ever reached), so fixing only the TypeScript " +
248
248
  `half would leave the aggregate counting those as PASS. Output: ${JSON.stringify(viaWrapper.output.slice(-300))}`,
249
249
  );
250
250
 
@@ -269,7 +269,9 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
269
269
  // the gate RUNS; it says nothing about steps the gate never lists. Three LIVE
270
270
  // smokes (cortex, spawn-live, claude-native-resume) sat outside the aggregate
271
271
  // with no stated reason until 2026-07-31, so a green cut was silent about the
272
- // second backend, the spawn substrate, and native resume.
272
+ // second backend, the spawn substrate, and native resume. (spawn-live was
273
+ // deleted with its transport in the visible-first cut; the incident is kept
274
+ // verbatim because it is what this rule was derived from.)
273
275
  //
274
276
  // So: every LIVE smoke is either WIRED into release_gate or EXCLUDED for a
275
277
  // reason an operator can read in the docs. The exclusion half is checked
@@ -284,13 +286,14 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
284
286
  // name → the sentence in the docs that carries its exclusion, and where.
285
287
  const DOCUMENTED_EXCLUSIONS: Record<string, [file: string, sentence: string]> = {
286
288
  "smoke-acp-long-turn-live": ["VERIFY.md", "on-demand, not part of `release-gate`"],
289
+ "smoke-mux-fresh-call-live": ["VERIFY.md", "Fresh-call LIVE is on-demand, not part of `release-gate`"],
287
290
  "smoke-agy-native-push-live": ["VERIFY.md", "Aggregate release-gate does not own an agy conversation id"],
288
291
  "smoke-acp-ordering-probe-live": ["docs/acp-backend-rail.md", "opt-in paired observation"],
289
292
  // Cortex needs an external Snowflake connection the HOST owns, so an aggregate
290
293
  // that required it would block every cut taken without that account. Excluded —
291
294
  // NOT waived: its direct call stays required for a Cortex-rail cut, and running
292
295
  // it without the connection still reports protocol SKIP rather than a pass.
293
- "smoke-acp-cortex-live": ["VERIFY.md", "The 0.13.1 aggregate does not re-certify Cortex"],
296
+ "smoke-acp-cortex-live": ["VERIFY.md", "The release aggregate does not re-certify Cortex"],
294
297
  };
295
298
 
296
299
  const allLive = globSync("scripts/smoke-*live*.{ts,sh}", { cwd: REPO_DIR })
@@ -309,7 +312,7 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
309
312
  excused,
310
313
  `[QK:NO-SILENT-AGGREGATE-OMISSION] ${name} is neither wired into release_gate nor excluded in the docs. ` +
311
314
  "A LIVE smoke that exists but is never listed makes a green cut silent about the axis it covers — exactly " +
312
- "how cortex (the second shipped backend), spawn-live and claude-native-resume went unrun for releases. " +
315
+ "how cortex (the second shipped backend) and claude-native-resume went unrun for releases. " +
313
316
  "Wire it, or state the exclusion where an operator reads it.",
314
317
  );
315
318
  const [file, sentence] = excused as [string, string];
@@ -321,6 +324,121 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
321
324
  }
322
325
  }
323
326
 
327
+ // ===========================================================================
328
+ // 8. qualification scheduling topology — the subtraction has its own oracle.
329
+ // check-gate-qualification left the default check chains (operator
330
+ // inner-loop cost, 2026-08 subtraction). That move is a gate/release
331
+ // contract: the step must stay REACHABLE on the axes that now own it — the
332
+ // CI check job on every push and release_gate as its own MUST step — and
333
+ // must not silently return to the default chain. Without this cell,
334
+ // deleting the release_gate qualification block or the CI line leaves every
335
+ // focused gate green while a cut quietly loses its discriminating-power
336
+ // step.
337
+ //
338
+ // TWO claims, because #70 added a second independent contract here. 8a is
339
+ // REACHABILITY (absent from the default chain; present exactly once in CI and
340
+ // exactly once as a wired release_gate MUST step; named in VERIFY). 8b is what
341
+ // the CI step qualifies (the FULL floor, before the qualification run). Each
342
+ // carries its own replant — one mutant must never stand in for both.
343
+ // ===========================================================================
344
+ {
345
+ // The default chain is tiered (#70): `check` (core) and `check:full` compose the
346
+ // named group scripts, so the reachability scan covers EVERY check* script —
347
+ // qualification sneaking into any group re-doubles the closure floor.
348
+ const pkgScripts = (
349
+ JSON.parse(readFileSync(join(REPO_DIR, "package.json"), "utf8")) as { scripts: Record<string, string> }
350
+ ).scripts;
351
+ const pkgCheck = Object.entries(pkgScripts)
352
+ .filter(([name]) => name === "check" || name.startsWith("check:"))
353
+ .map(([, body]) => body)
354
+ .join(" && ");
355
+ const ciYml = readFileSync(join(REPO_DIR, ".github/workflows/ci.yml"), "utf8");
356
+ const ciHits = ciYml.split("- run: ./run.sh check-gate-qualification").length - 1;
357
+ const runShQ = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
358
+ const qualGateBody = runShQ.slice(runShQ.indexOf("release_gate() {"), runShQ.indexOf("# 5. Summary"));
359
+ const invocations = qualGateBody.split('bash "$self" check-gate-qualification').length - 1;
360
+ const verifyDoc = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
361
+
362
+ // One claim token, one assert (the qualification runner requires the exact-once
363
+ // signature); each broken axis names itself in the joined message.
364
+ const holes: string[] = [];
365
+ if (pkgCheck.includes("check-gate-qualification"))
366
+ holes.push(
367
+ "package.json's default `check` chain contains check-gate-qualification again (doubles every closure floor)",
368
+ );
369
+ if (ciHits !== 1) holes.push(`the CI check job runs check-gate-qualification ${ciHits}x (need exactly once)`);
370
+ if (invocations !== 1)
371
+ holes.push(`release_gate invokes check-gate-qualification ${invocations}x (need exactly one MUST step)`);
372
+ if (
373
+ !qualGateBody.includes('results+=("PASS check-gate-qualification")') ||
374
+ !qualGateBody.includes('results+=("FAIL check-gate-qualification")')
375
+ )
376
+ holes.push("the release_gate qualification step does not wire PASS/FAIL into the MUST counters");
377
+ if (!verifyDoc.includes("in the CI `check` job on every push, and as a release-gate MUST step"))
378
+ holes.push("VERIFY.md no longer names the owners of the moved qualification step");
379
+ assert.ok(
380
+ holes.length === 0,
381
+ "[QK:QUALIFICATION-SCHEDULING-REACHABLE] check-gate-qualification left the default check chains " +
382
+ "deliberately, so it must stay REACHABLE on the axes that own it now — absent from the default chain, " +
383
+ "exactly once in the CI check job, exactly once as a release_gate MUST step with its " +
384
+ `outcome wired, and named in VERIFY. Broken: ${holes.join("; ")}`,
385
+ );
386
+
387
+ // 8b. WHAT the CI qualification step qualifies — its own claim, not a branch
388
+ // folded into the one above. #70 split the deterministic floor into tiers,
389
+ // so "CI runs qualification once" stopped being sufficient evidence on its
390
+ // own: qualification on top of the ≤60s core would certify kill-power over
391
+ // a floor no candidate ships on. This is ONE contract with one condition —
392
+ // the full floor is present in the CI check job AND qualification follows
393
+ // it — because a floor that is absent and a floor that runs afterwards
394
+ // break the same promise identically. The committed replant qualifies the
395
+ // downgrade/omission axis (the tier rename downgrading CI back to core);
396
+ // ordering stays directly asserted here and has no incident-earned mutant.
397
+ const ciFloorAt = ciYml.indexOf("- run: pnpm run check:full");
398
+ const ciQualAt = ciYml.indexOf("- run: ./run.sh check-gate-qualification");
399
+ assert.ok(
400
+ ciFloorAt !== -1 && ciQualAt > ciFloorAt,
401
+ "[QK:CI-FULL-FLOOR-QUALIFIED] the CI check job owns the FULL deterministic floor (#70): it must run " +
402
+ "`pnpm run check:full` — not the ≤60s core — and check-gate-qualification must come AFTER it, so the " +
403
+ "kill-power proof covers the floor a candidate actually ships on. The committed replant qualifies the " +
404
+ "downgrade/omission axis; ordering is directly asserted by this same oracle. " +
405
+ `Broken: check:full at index ${ciFloorAt}, qualification at index ${ciQualAt}.`,
406
+ );
407
+ }
408
+
409
+ // ===========================================================================
410
+ // The integrated mux lifecycle is a MUST, and the focused fresh-call LIVE is not
411
+ //
412
+ // Two LIVE smokes now cover the mux rail and they are NOT interchangeable:
413
+ // `smoke-mux-fresh-call-live` drives the composition from SOURCE and is on-demand,
414
+ // while `smoke-mux-lifecycle-live` enters through the real MCP surface and follows a
415
+ // citizen through resume and recall. A cut that ran only the focused one would be
416
+ // green about the axis nobody proved. So the MUST wiring is pinned here, exactly
417
+ // once, together with the doc sentence that keeps the two apart for an operator.
418
+ // ===========================================================================
419
+ {
420
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
421
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
422
+ const verify = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
423
+ const mustSteps = gateBody.split("\n").filter((l) => l.includes('"$self" smoke-mux-lifecycle-live'));
424
+ const gaps: string[] = [];
425
+ if (mustSteps.length !== 1)
426
+ gaps.push(`release_gate runs smoke-mux-lifecycle-live ${mustSteps.length}x (need exactly one MUST step)`);
427
+ if (mustSteps.length === 1 && !mustSteps[0]?.includes("run_live_step"))
428
+ gaps.push("the lifecycle step does not go through run_live_step, so its SKIP would not reach the classifier");
429
+ if (!verify.includes("Fresh-call LIVE is on-demand, not part of `release-gate`"))
430
+ gaps.push("VERIFY.md no longer excuses the focused fresh-call LIVE from the aggregate");
431
+ if (!verify.includes("smoke-mux-lifecycle-live"))
432
+ gaps.push("VERIFY.md does not name the integrated lifecycle MUST at all");
433
+ assert.ok(
434
+ gaps.length === 0,
435
+ "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST] the integrated mux lifecycle LIVE must be a release-gate MUST exactly once " +
436
+ "and go through run_live_step (so a missing prerequisite is a SKIP the cut refuses, never a silent pass), " +
437
+ "while the focused fresh-call LIVE stays on-demand and VERIFY keeps the two distinguishable — a cut that " +
438
+ `ran only the source-level smoke would be green about the surface no one entered. Broken: ${gaps.join("; ")}`,
439
+ );
440
+ }
441
+
324
442
  console.log(
325
443
  "[check-release-gate-outcomes] ok — STEP OUTCOME protocol: one skip exit code shared by the shell and TS halves " +
326
444
  `(${LIVE_SKIP_EXIT}, clear of the per-tool 0..4 and shell 126+ bands), classifier maps 0→PASS / skip→SKIP / ` +
@@ -328,5 +446,7 @@ console.log(
328
446
  "exit 0, no LIVE smoke still carries the pre-P1 exit-0 skip shape, and both real skip surfaces were INVOKED and " +
329
447
  "observed to propagate the code: a smoke with LIVE unset (through run_ts, with its operator-readable marker) and " +
330
448
  "a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
331
- "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry",
449
+ "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
450
+ "check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
451
+ "exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it",
332
452
  );
@@ -0,0 +1,244 @@
1
+ /**
2
+ * check-resume-launch-identity — deterministic gate for `resume-launch-identity.ts`, the
3
+ * record-authoritative leaf preserved when the visible-first cut deleted `spawn-bg` and every
4
+ * caller it had.
5
+ *
6
+ * WHY A GATE FOR A CONSUMER-ZERO MODULE. Identity authority is a fail-closed risk class: this
7
+ * leaf's whole job is deciding WHICH being a resume turn lands in. A preserved leaf whose only
8
+ * guarantee is its own header comment is exactly the rot preservation was meant to prevent — it
9
+ * would be re-adopted, months later, by a visible-resume composition that trusts prose. The cells
10
+ * below are transplanted from the deleted `check-entwurf-v2-spawn-production` §9 (the plan
11
+ * parameter became a bare garden id; nothing else about the identity contract changed), plus one
12
+ * new cell for the #52 read the leaf's header claims and the deleted gate never isolated.
13
+ *
14
+ * What is certified, all through a temp `ENTWURF_META_SESSIONS_DIR` fixture store:
15
+ *
16
+ * 1. HAPPY — gardenId → record.transcriptPath → header id === record.nativeSessionId →
17
+ * LaunchIdentity carrying the header's own cwd/provider/model (#50 C2, #9).
18
+ * 2. C3 INTEGRITY — a transcript whose header id is NOT this citizen's `nativeSessionId` is
19
+ * refused, never resumed. This is the one that keeps a turn out of another being's session.
20
+ * 3. #52 ADDRESSABLE READ — a garden id that does not hold its `nativeSessionId` ALONE is
21
+ * refused. Two records sharing one native session would each pass their own per-record
22
+ * integrity check and resume one transcript twice under two per-garden-id locks, so the
23
+ * plain targeted read is not enough here and the leaf must not be "simplified" back to it.
24
+ * 4. CAUSE FIDELITY — each impossible resume names ITS OWN cause: absent record, out-of-domain
25
+ * backend, no recorded transcript, recorded-but-missing file, no model, headerless file. The
26
+ * missing-file cell is a regression pin: before the existence check that case fell through
27
+ * `readSessionIdentity`'s ENOENT swallow and lied "no recorded model".
28
+ * 4b. THE ONE EXPECTED REFUSAL — a backend with no same-id resume is not a defect at all: the
29
+ * record is fine and the operator reached past a capability boundary. It alone carries a
30
+ * TYPED reason (`target-not-pi`) so the visible-resume composition can match a FIELD instead
31
+ * of parsing a sentence or re-reading the record. Everything else here stays a bare throw.
32
+ * 4c. ABSOLUTE TRANSCRIPT — a relative recorded path would resolve against the WINDOW's own cwd
33
+ * at launch, not against this process's, so it names a different file than the one checked
34
+ * here. Refused before anything opens.
35
+ * 5. SSOT — the module header names THIS gate, so the preserved leaf cannot advertise a
36
+ * certification that does not exist.
37
+ *
38
+ * No spawn, no socket, no timer: the launch this identity feeds is deliberately not in scope.
39
+ */
40
+
41
+ import assert from "node:assert/strict";
42
+ import * as fs from "node:fs";
43
+ import * as os from "node:os";
44
+ import * as path from "node:path";
45
+ import { serializeMetaIdentity, upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
46
+ import {
47
+ ResumeBackendUnsupportedError,
48
+ resolveResumeLaunchIdentity,
49
+ } from "../pi-extensions/lib/resume-launch-identity.ts";
50
+
51
+ let passed = 0;
52
+ function ok(label: string, cond: boolean): void {
53
+ assert.ok(cond, label);
54
+ console.log(` ok ${label}`);
55
+ passed++;
56
+ }
57
+
58
+ const HERE = path.dirname(new URL(import.meta.url).pathname);
59
+ const REPO = path.join(HERE, "..");
60
+ const LEAF_SRC = path.join(REPO, "pi-extensions/lib/resume-launch-identity.ts");
61
+
62
+ function main(): void {
63
+ const world = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "resume-launch-id-"));
64
+ const storeDir = path.join(world, "meta-sessions");
65
+ fs.mkdirSync(storeDir, { recursive: true });
66
+ const prevStore = process.env.ENTWURF_META_SESSIONS_DIR;
67
+ process.env.ENTWURF_META_SESSIONS_DIR = storeDir;
68
+ try {
69
+ const cwd = path.join(world, "repo");
70
+ fs.mkdirSync(cwd, { recursive: true });
71
+ const sessionLine = (id: string) => `${JSON.stringify({ type: "session", id, cwd })}\n`;
72
+ const modelLine = `${JSON.stringify({ type: "model_change", provider: "openai-codex", modelId: "gpt-5.4" })}\n`;
73
+ const writeTranscript = (name: string, content: string): string => {
74
+ const p = path.join(world, name);
75
+ fs.writeFileSync(p, content);
76
+ return p;
77
+ };
78
+ const mintRecord = (nativeSessionId: string, transcriptPath: string | null, backend = "pi"): string =>
79
+ upsertMetaSession({
80
+ input: { backend: backend as "pi", nativeSessionId, cwd, model: "gpt-5.4", transcriptPath },
81
+ dir: storeDir,
82
+ }).record.gardenId;
83
+ const rejects = (gid: string, needle: string, label: string): void => {
84
+ let msg = "";
85
+ try {
86
+ resolveResumeLaunchIdentity(gid);
87
+ } catch (e) {
88
+ msg = e instanceof Error ? e.message : String(e);
89
+ }
90
+ ok(label, msg.includes(needle));
91
+ };
92
+
93
+ // ── 1. HAPPY ──────────────────────────────────────────────────────────────
94
+ const nativeOk = "0199aaaa-1111-4222-8333-444455556666";
95
+ const fileOk = writeTranscript("own.jsonl", sessionLine(nativeOk) + modelLine);
96
+ const gidOk = mintRecord(nativeOk, fileOk);
97
+ const launch = resolveResumeLaunchIdentity(gidOk);
98
+ ok("1: record-backed resume resolves the recorded transcript", launch.sessionFile === fileOk);
99
+ ok("1: resume cwd = transcript header authority (#9 cold-resume)", launch.cwd === cwd);
100
+ ok(
101
+ "1: resume model/provider = first model_change",
102
+ launch.model === "gpt-5.4" && launch.provider === "openai-codex",
103
+ );
104
+ // (No arity/shape assertion here on purpose: "the garden id is the only input" is carried
105
+ // by the type system and by every cell below actually resolving through the record.
106
+ // A `fn.length === 1` check would be a claim whose mutant could only be synthetic.)
107
+
108
+ // ── 2. C3 INTEGRITY — header id must be THIS citizen's ────────────────────
109
+ const nativeMine = "0199bbbb-1111-4222-8333-444455556666";
110
+ const foreignFile = writeTranscript(
111
+ "foreign.jsonl",
112
+ sessionLine("0199cccc-9999-4999-8999-999999999999") + modelLine,
113
+ );
114
+ const gidForeign = mintRecord(nativeMine, foreignFile);
115
+ rejects(
116
+ gidForeign,
117
+ "does not match the record's nativeSessionId",
118
+ "2: header ≠ nativeSessionId → refused, never resumed (C3) [QK:RESUME-ID-HEADER-INTEGRITY]",
119
+ );
120
+
121
+ // ── 4. CAUSE FIDELITY ─────────────────────────────────────────────────────
122
+ rejects("20260101T000000-facade", "not a garden citizen", "4: recordless gid → not a citizen");
123
+
124
+ const gidClaude = mintRecord("claude-native-1", null, "claude-code");
125
+ rejects(
126
+ gidClaude,
127
+ "is a claude-code citizen",
128
+ "4: out-of-domain citizen → refused as a capability-domain miss, not as citizen rank",
129
+ );
130
+ // This one refusal is EXPECTED, not a defect: the record is perfectly good and the operator
131
+ // has reached past a capability boundary. So it is the only failure here that carries a
132
+ // typed `reason` a caller can match on a FIELD — the visible-resume composition turns it
133
+ // into `target-not-pi`, and if it degraded to a bare Error that caller would either parse
134
+ // this sentence or read the record a second time to ask which backend it was.
135
+ {
136
+ let caught: Error | null = null;
137
+ try {
138
+ resolveResumeLaunchIdentity(gidClaude);
139
+ } catch (err) {
140
+ caught = err as Error;
141
+ }
142
+ ok(
143
+ "4: the backend miss is a TYPED refusal carrying reason + backend, not a bare Error [QK:RESUME-ID-BACKEND-TYPED-REFUSAL]",
144
+ caught instanceof ResumeBackendUnsupportedError &&
145
+ caught.reason === "target-not-pi" &&
146
+ caught.backend === "claude-code",
147
+ );
148
+ }
149
+
150
+ // A RELATIVE recorded path passes existsSync here whenever this process happens to sit in
151
+ // the right directory, but the launch resolves `--session` inside the window's own
152
+ // `-c <record cwd>`. Two directories, two files, one receipt that looks correct — so the
153
+ // leaf refuses before anything opens rather than resuming an unknown transcript.
154
+ {
155
+ const nativeRel = "0199ffff-1111-4222-8333-444455556666";
156
+ writeTranscript("relative.jsonl", sessionLine(nativeRel) + modelLine);
157
+ const gidRel = mintRecord(nativeRel, "relative.jsonl");
158
+ rejects(
159
+ gidRel,
160
+ "RELATIVE transcriptPath",
161
+ "4: a relative recorded transcriptPath → refused before launch, because --session would resolve against the WINDOW's cwd, not this one [QK:RESUME-ID-SESSION-ABSOLUTE]",
162
+ );
163
+ }
164
+
165
+ const gidNoFile = mintRecord("0199dddd-1111-4222-8333-444455556666", null);
166
+ rejects(gidNoFile, "no recorded transcriptPath", "4: transcriptPath null → nothing to resume");
167
+
168
+ // Regression pin (F7): before the existsSync guard this fell through readSessionIdentity's
169
+ // ENOENT swallow and reported "no recorded model" — a true refusal naming a false cause,
170
+ // which sends an operator to look for a model setting instead of a deleted transcript.
171
+ const gidGhost = mintRecord("0199abcd-1111-4222-8333-444455556666", path.join(world, "never-written.jsonl"));
172
+ rejects(
173
+ gidGhost,
174
+ "does not exist on disk",
175
+ "4: recorded transcript missing on disk → refused as MISSING, not as no-model [QK:RESUME-ID-MISSING-TRANSCRIPT-CAUSE]",
176
+ );
177
+
178
+ const nativeNoModel = "0199eeee-1111-4222-8333-444455556666";
179
+ const fileNoModel = writeTranscript("no-model.jsonl", sessionLine(nativeNoModel));
180
+ const gidNoModel = mintRecord(nativeNoModel, fileNoModel);
181
+ rejects(gidNoModel, "no recorded model", "4: no model_change → refused (model preservation)");
182
+
183
+ // A headerless transcript has an UNDEFINED header id, which can never equal the record's
184
+ // nativeSessionId — so the integrity check catches it and says "(none)" rather than
185
+ // resuming a file whose ownership was never stated.
186
+ const nativeNoHeader = "0199ffff-1111-4222-8333-444455556666";
187
+ const fileNoHeader = writeTranscript("no-header.jsonl", modelLine);
188
+ const gidNoHeader = mintRecord(nativeNoHeader, fileNoHeader);
189
+ rejects(gidNoHeader, '"(none)"', "4: headerless transcript → refused as (none), never resumed");
190
+
191
+ // ── 3. #52 ADDRESSABLE READ ───────────────────────────────────────────────
192
+ // Planted LAST so the duplicate cannot disturb the cells above: a store-wide uniqueness
193
+ // scan sees every record, and `upsertMetaSession` would attach to the existing garden id
194
+ // rather than mint a rival, so the rival record is written directly.
195
+ const rivalGid = "20260301T120009-a1b2c3";
196
+ fs.writeFileSync(
197
+ path.join(storeDir, `${rivalGid}.meta.json`),
198
+ serializeMetaIdentity({
199
+ schemaVersion: 3,
200
+ gardenId: rivalGid,
201
+ backend: "pi",
202
+ nativeSessionId: nativeOk, // the SAME native session as gidOk
203
+ cwd,
204
+ model: "gpt-5.4",
205
+ transcriptPath: fileOk,
206
+ createdAt: "2026-03-01T12:00:00.000Z",
207
+ recordUpdatedAt: "2026-03-01T12:30:00.000Z",
208
+ }),
209
+ );
210
+ // The happy-path id resolved a moment ago; the ONLY thing that changed is a second record
211
+ // claiming its native session. If this leaf used the plain targeted read, that resolve
212
+ // would still succeed and two garden ids would resume one transcript under two locks.
213
+ rejects(
214
+ gidOk,
215
+ "must be unique",
216
+ "3: a garden id that no longer holds its nativeSessionId alone → refused by the ADDRESSABLE read [QK:RESUME-ID-ADDRESSABLE-READ]",
217
+ );
218
+ rejects(
219
+ rivalGid,
220
+ "must be unique",
221
+ "3: …refused from EITHER side of the pair (neither rival is silently preferred)",
222
+ );
223
+ } finally {
224
+ if (prevStore === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
225
+ else process.env.ENTWURF_META_SESSIONS_DIR = prevStore;
226
+ fs.rmSync(world, { recursive: true, force: true });
227
+ }
228
+
229
+ // ── 5. SSOT — the leaf's header must name the gate that actually certifies it ──
230
+ {
231
+ const src = fs.readFileSync(LEAF_SRC, "utf8");
232
+ ok(
233
+ "5: the leaf's header names check-resume-launch-identity (no certification it does not have) [QK:RESUME-ID-GATE-SSOT]",
234
+ src.includes("`check-resume-launch-identity`"),
235
+ );
236
+ // The preservation is deliberate and stated: a reader who finds an uncalled export must
237
+ // be able to tell "kept on purpose, for the visible lane" from "forgotten".
238
+ ok("5: the leaf states it is preserved with no consumer, on purpose", /no consumer in the shipped tree/.test(src));
239
+ }
240
+
241
+ console.log(`\ncheck-resume-launch-identity: ${passed} checks passed`);
242
+ }
243
+
244
+ main();
@@ -291,7 +291,7 @@ async function main(): Promise<void> {
291
291
  }
292
292
 
293
293
  // ── inspectControlSocketPath (5c-3c R1 — the path-addressed SSOT) ────────────────
294
- // The 5c-3 spawn-bg watcher observes plan.expectedSocketPath and MUST inspect THAT exact
294
+ // A resume watcher observes an expected socket path and MUST inspect THAT exact
295
295
  // path (no gid re-derivation). Proves the path-taking core classifies every branch
296
296
  // identically to the gid wrapper, and that the wrapper just feeds it controlSocketPath(gid).
297
297
  {
@@ -0,0 +1,3 @@
1
+ {"type":"fixture-provenance","note":"Structure extracted from a fixture-only real Pi parent retake (2026-08-06) and then scrubbed: a real record-backed pi citizen called the native entwurf_fresh_call once, and the fresh sibling's nonce callback arrived in that parent's own transcript. MEASURED and preserved: pi records NO separate toolCall row — the call surfaces as a toolResult row carrying toolCallId + toolName, and the callback arrives later as a custom_message of customType entwurf-message whose body embeds a <sender_info> JSON envelope. Event order, roles, content types, toolName, the nonce flowing from the launch receipt into the callback body, and the sender_info FIELD NAMES are real. Every id, uuid, toolCallId, timestamp, model, path, cwd and message body is a fixture value. This is a sample of the PARENT-SIDE SHAPE — it is NOT placement evidence and says nothing about whether a window opened."}
2
+ {"type":"message","id":"0199f000-0000-7000-8000-000000000000-1","parentId":"0199f000-0000-7000-8000-000000000000-0","timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"toolResult","toolCallId":"fixture-toolcall-1","toolName":"entwurf_fresh_call","content":[{"type":"text","text":"[entwurf fresh call →]\n backend: pi (/fixture/bin/pi)\n model: fixture-provider/fixture-model (requested on the runtime CLI)\n window: @2 (index 2) in session $0\n pane: %2 pid 4242\n nonce: mux-fresh-call-000000000000000000000000\n\nThis is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\nThe sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its first action, and the sender envelope of THAT message is the address. Nothing is polling for it; if it never comes, the window is visible and can be read directly."}],"details":{"isError":false},"isError":false,"timestamp":1767225600000}}
3
+ {"type":"custom_message","customType":"entwurf-message","content":"mux-fresh-call-000000000000000000000000\n\n<sender_info>{\"sessionId\":\"20260101T000001-bbbbbb\",\"agentId\":\"fixture-provider/fixture-model\",\"cwd\":\"/fixture/project\",\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"origin\":\"pi-session\",\"replyable\":true}</sender_info>","display":true,"id":"0199f000-0000-7000-8000-000000000000-2","parentId":"0199f000-0000-7000-8000-000000000000-1","timestamp":"2026-01-01T00:00:00.000Z"}