@junghanacs/entwurf 0.16.0 → 0.17.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 (48) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +337 -0
  3. package/README.md +8 -11
  4. package/VERIFY.md +8 -1
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +25 -14
  7. package/docs/setup-clean-host.md +24 -10
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
  10. package/package.json +10 -10
  11. package/pi-extensions/lib/acp/acp-client.ts +57 -4
  12. package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
  13. package/pi-extensions/lib/acp/backend.ts +578 -18
  14. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  15. package/pi-extensions/lib/acp/event-mapper.ts +43 -6
  16. package/run.sh +129 -32
  17. package/scripts/check-acp-launch-namespace.ts +127 -0
  18. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  19. package/scripts/check-acp-stop-reason.ts +8 -2
  20. package/scripts/check-acp-usage-accounting.ts +1074 -0
  21. package/scripts/check-copilot-birth-hook.ts +28 -1
  22. package/scripts/check-gate-qualification.ts +4 -2
  23. package/scripts/check-omp-fresh-preflight.ts +27 -0
  24. package/scripts/check-setup-qualification.sh +40 -2
  25. package/scripts/copilot-bridge-oracle.sh +14 -6
  26. package/scripts/fake-copilot-vendor.sh +4 -2
  27. package/scripts/lib/pi-record-discovery.ts +47 -0
  28. package/scripts/mutants/acp-launch-namespace.json +34 -0
  29. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  30. package/scripts/mutants/acp-stream-hooks.json +4 -2
  31. package/scripts/mutants/acp-usage-accounting.json +181 -0
  32. package/scripts/mutants/copilot-birth.json +3 -5
  33. package/scripts/mutants/pack-install.json +2 -2
  34. package/scripts/mutants/setup-verdict.json +35 -0
  35. package/scripts/omp-config-xdev.py +310 -0
  36. package/scripts/omp-config-xdev.sh +76 -0
  37. package/scripts/omp-tool-surface.py +61 -10
  38. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  39. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  40. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  41. package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
  42. package/scripts/smoke-acp-cortex-live.ts +2 -2
  43. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  44. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  45. package/scripts/smoke-acp-v2-send-live.ts +2 -2
  46. package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
  47. package/scripts/smoke-mux-lifecycle-live.ts +46 -2
  48. package/scripts/smoke-setup-verdict.sh +48 -3
@@ -77,6 +77,7 @@ function deferred<T>(): Deferred<T> {
77
77
  function makeFakeChild() {
78
78
  const exitListeners: Array<(...args: unknown[]) => void> = [];
79
79
  const stderrListeners: Array<(chunk: Buffer) => void> = [];
80
+ const stderrCloseListeners: Array<() => void> = [];
80
81
  const kills: Array<NodeJS.Signals | number | undefined> = [];
81
82
  const pipe = () => ({ destroy() {}, unref() {} });
82
83
  const child = {
@@ -90,6 +91,9 @@ function makeFakeChild() {
90
91
  on(_event: "data", listener: (chunk: Buffer) => void) {
91
92
  stderrListeners.push(listener);
92
93
  },
94
+ once(_event: "close", listener: () => void) {
95
+ stderrCloseListeners.push(listener);
96
+ },
93
97
  destroy() {},
94
98
  unref() {},
95
99
  },
@@ -105,10 +109,11 @@ function makeFakeChild() {
105
109
  writeStderr(text: string) {
106
110
  for (const listener of [...stderrListeners]) listener(Buffer.from(text));
107
111
  },
108
- /** driver: the backend process ends. */
112
+ /** driver: the backend process ends. Its stderr pipe closes with it. */
109
113
  die(code: number | null, signal: NodeJS.Signals | null = null) {
110
114
  child.exitCode = code;
111
115
  child.signalCode = signal;
116
+ for (const listener of stderrCloseListeners.splice(0)) listener();
112
117
  for (const listener of exitListeners.splice(0)) listener(code, signal);
113
118
  },
114
119
  };
@@ -656,6 +661,141 @@ try {
656
661
  );
657
662
  }
658
663
 
664
+ // ----------------------------------------------------------------------
665
+ // CELL 12 — a signal the LAUNCHER caught survives the vendor erasing it.
666
+ //
667
+ // #72's whole difficulty: the vendor turns SIGTERM into `dispose(); exit(0)`,
668
+ // so an external kill and a clean vendor shutdown reach us as the SAME facts
669
+ // (code 0, signal null). CELL 10 can only separate a signal that was NOT
670
+ // caught. `claude-acp-launch.js` records the catch before the vendor erases
671
+ // it; this cell holds that the record reaches the operator, on its own line,
672
+ // and that the tail stays vendor-only.
673
+ // ----------------------------------------------------------------------
674
+ {
675
+ const h = makeHarness(recordDir);
676
+ const turn = startTurn(backend, userCtx("reaped from outside"), { sessionId: "life-eof-launchsig" }, h.deps);
677
+ await delay(30);
678
+ h.children[0].writeStderr("VENDOR-TAIL-MARK\n");
679
+ h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM\n");
680
+ h.transportClosed();
681
+ await delay(1);
682
+ h.children[0].die(0, null);
683
+ await turn.done;
684
+
685
+ const message = String(sealed(turn.events)[0].error.errorMessage);
686
+ assert.ok(
687
+ message.includes("launch observed SIGTERM before child exit"),
688
+ "[QK:LAUNCH-SIGNAL-EVIDENCE-STRUCTURED] a caught SIGTERM must reach the operator even though the vendor " +
689
+ `normalized it to exit 0 — otherwise an external kill is indistinguishable from a clean close. Got: ${JSON.stringify(message)}`,
690
+ );
691
+ assert.ok(
692
+ message.includes("sender not attributed"),
693
+ "the observation must NOT claim who sent the signal — attribution needs the host journal, which this " +
694
+ `process cannot read. Got: ${JSON.stringify(message)}`,
695
+ );
696
+ assert.ok(
697
+ message.includes("exit code 0") && message.includes("VENDOR-TAIL-MARK"),
698
+ `the exit fact and the vendor tail both survive alongside the observation. Got: ${JSON.stringify(message)}`,
699
+ );
700
+ assert.ok(
701
+ !message.includes("ENTWURF_ACP_LAUNCH_SIGNAL="),
702
+ "[QK:LAUNCH-FRAME-NOT-IN-TAIL] the raw control frame must be CONSUMED, not echoed into the vendor tail — " +
703
+ `the tail is vendor evidence and the observation is ours, and #72 was made of confusing the two. Got: ${JSON.stringify(message)}`,
704
+ );
705
+ }
706
+
707
+ // ----------------------------------------------------------------------
708
+ // CELL 13 — NEGATIVE SIBLING: vendor prose can never manufacture the fact.
709
+ //
710
+ // The oracle for CELL 12's exactness. Vendor stderr is free text and it does
711
+ // mention signals; if a loose test (`includes("SIGTERM")`, or a prefix match
712
+ // without the enum) fed the observation, entwurf would report an entwurf-owned
713
+ // fact it never observed — a worse failure than reporting nothing, because it
714
+ // would be trusted. Both a near-miss frame and prose must produce NOTHING.
715
+ // ----------------------------------------------------------------------
716
+ {
717
+ const h = makeHarness(recordDir);
718
+ const turn = startTurn(backend, userCtx("vendor mentions a signal"), { sessionId: "life-eof-nosig" }, h.deps);
719
+ await delay(30);
720
+ h.children[0].writeStderr("shutting down after SIGTERM; ENTWURF_ACP_LAUNCH_SIGNAL=SIGQUIT\n");
721
+ h.children[0].writeStderr(" ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM (quoted in prose, not a frame)\n");
722
+ h.transportClosed();
723
+ await delay(1);
724
+ h.children[0].die(0, null);
725
+ await turn.done;
726
+
727
+ const message = String(sealed(turn.events)[0].error.errorMessage);
728
+ assert.ok(
729
+ !message.includes("launch observed"),
730
+ "[QK:LAUNCH-SIGNAL-EXACT-FRAME-ONLY] neither vendor prose containing 'SIGTERM', an unknown signal value, nor " +
731
+ "an indented near-miss may produce a launch observation — the frame is an exact full line with a fixed " +
732
+ `enum, and anything looser lets vendor text forge our own evidence. Got: ${JSON.stringify(message)}`,
733
+ );
734
+ assert.ok(
735
+ message.includes("SIGQUIT") && message.includes("quoted in prose"),
736
+ `text that is not our frame stays in the vendor tail verbatim. Got: ${JSON.stringify(message)}`,
737
+ );
738
+ }
739
+
740
+ // ----------------------------------------------------------------------
741
+ // CELL 14 — the child's LAST words survive having no trailing newline.
742
+ //
743
+ // Filtering our control frame out of the tail means reading stderr by LINE,
744
+ // and a process dying mid-write does not finish its line. That fragment is
745
+ // exactly the dying words the tail exists for, so holding it in a line buffer
746
+ // forever would trade #72's diagnosis for a worse blindness. The stream's
747
+ // close must flush it VERBATIM.
748
+ // ----------------------------------------------------------------------
749
+ {
750
+ const h = makeHarness(recordDir);
751
+ const turn = startTurn(backend, userCtx("dies mid-write"), { sessionId: "life-eof-nonl" }, h.deps);
752
+ await delay(30);
753
+ h.children[0].writeStderr("FATAL dying words with no newline");
754
+ h.transportClosed();
755
+ await delay(1);
756
+ h.children[0].die(0, null);
757
+ await turn.done;
758
+
759
+ const message = String(sealed(turn.events)[0].error.errorMessage);
760
+ assert.ok(
761
+ message.includes("FATAL dying words with no newline"),
762
+ "[QK:STDERR-TAIL-FLUSHES-PARTIAL-LINE] a child that dies mid-write leaves its last line unterminated, and " +
763
+ "that fragment IS the dying words the tail exists for — line-buffering to strip our own frame must not " +
764
+ `swallow it. Got: ${JSON.stringify(message)}`,
765
+ );
766
+ }
767
+
768
+ // ----------------------------------------------------------------------
769
+ // CELL 15 — a frame split across two reads is still recognised exactly.
770
+ //
771
+ // Pipe chunk boundaries fall wherever the kernel put them, so the frame can
772
+ // arrive in pieces. This is the reason the filter buffers by line at all;
773
+ // without a cell for it, an implementation that matched per-CHUNK would pass
774
+ // every other test here and then miss the real signal in the field.
775
+ // ----------------------------------------------------------------------
776
+ {
777
+ const h = makeHarness(recordDir);
778
+ const turn = startTurn(backend, userCtx("frame arrives split"), { sessionId: "life-eof-split" }, h.deps);
779
+ await delay(30);
780
+ h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SI");
781
+ h.children[0].writeStderr("GNAL=SIGTERM\n");
782
+ h.transportClosed();
783
+ await delay(1);
784
+ h.children[0].die(0, null);
785
+ await turn.done;
786
+
787
+ const message = String(sealed(turn.events)[0].error.errorMessage);
788
+ assert.ok(
789
+ message.includes("launch observed SIGTERM before child exit"),
790
+ "[QK:LAUNCH-FRAME-SPANS-CHUNKS] the frame must be recognised across a chunk boundary — the kernel, not the " +
791
+ `writer, decides where a read ends. Got: ${JSON.stringify(message)}`,
792
+ );
793
+ assert.ok(
794
+ !message.includes("ENTWURF_ACP_LAUNCH_SI"),
795
+ `neither half of a split frame may leak into the vendor tail. Got: ${JSON.stringify(message)}`,
796
+ );
797
+ }
798
+
659
799
  // ----------------------------------------------------------------------
660
800
  // CELL 11 — a child that never reports an end says SO, bounded.
661
801
  //
@@ -771,7 +911,10 @@ console.log(
771
911
  "reported with its exit status AND stderr tail on BOTH the new and the reuse path; a death BETWEEN turns is " +
772
912
  "announced once by the next turn while a teardown WE performed stays silent; the child's end survives the " +
773
913
  "temporal order the field showed too (transport EOF first, exit one tick later, alongside the opposite order), " +
774
- "telling a clean exit apart from a signal and reporting silence AS silence within a bounded window; and pi's " +
914
+ "telling a clean exit apart from a signal and reporting silence AS silence within a bounded window; a signal the " +
915
+ "LAUNCHER caught survives the vendor normalizing it to exit 0 and is reported on its own line without claiming " +
916
+ "who sent it, while its raw control frame is consumed out of the vendor tail and vendor prose mentioning a " +
917
+ "signal can never forge that observation; and pi's " +
775
918
  "own isRetryableAssistantError refuses to classify any of those failures as transient while still matching " +
776
919
  "the retired 600s text",
777
920
  );
@@ -2,8 +2,14 @@
2
2
  //
3
3
  // WHAT THIS EXISTS TO STOP. `mapPromptStopReason` used to be a bare
4
4
  // `switch` returning a StopReason with `default: return "stop"`. The ACP terminal
5
- // set is closed — `@agentclientprotocol/sdk` 1.3.0 `schema/types.gen` declares
6
- // `end_turn | max_tokens | max_turn_requests | refusal | cancelled` — so that
5
+ // set is closed — `@agentclientprotocol/sdk` 1.4.0 `dist/schema/types.gen.d.ts:3001`
6
+ // declares `end_turn | max_tokens | max_turn_requests | refusal | cancelled`, with
7
+ // no `| string` arm. Re-measured at the 1.3.0 → 1.4.0 bump rather than inherited,
8
+ // because 1.4.0 also ships a SECOND, OPEN union at `dist/v2/schema/types.gen.d.ts:3607`
9
+ // (`… | "cancelled" | string`). That one is reachable only through the
10
+ // `./experimental/v2` export; entwurf imports the BARE specifier, which the package
11
+ // `exports` map sends to `./dist/acp.js` — the closed v1 surface. The claim holds
12
+ // only while that import stays bare, so a move to the v2 export retires it — so that
7
13
  // default silently collapsed THREE distinct non-success outcomes into a clean
8
14
  // successful turn:
9
15
  //