@junghanacs/entwurf 0.15.1 → 0.16.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 (91) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +354 -0
  3. package/DELIVERY.md +3 -2
  4. package/README.md +68 -88
  5. package/VERIFY.md +4 -1
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +147 -9
  9. package/docs/setup-clean-host.md +125 -6
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  20. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  21. package/mcp/entwurf-bridge/src/index.ts +65 -19
  22. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  23. package/mcp/tsconfig.json +10 -0
  24. package/package.json +19 -9
  25. package/pi/entwurf-capabilities.json +1 -0
  26. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  27. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  28. package/pi-extensions/entwurf-control.ts +12 -9
  29. package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
  30. package/pi-extensions/lib/acp/backend.ts +125 -7
  31. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  32. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  33. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  34. package/pi-extensions/lib/meta-session.ts +219 -5
  35. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  36. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  37. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  38. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  39. package/run.sh +399 -31
  40. package/scripts/check-acp-launch-namespace.ts +127 -0
  41. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  42. package/scripts/check-copilot-birth-hook.ts +28 -1
  43. package/scripts/check-entwurf-self-address.ts +31 -0
  44. package/scripts/check-gate-qualification.ts +7 -3
  45. package/scripts/check-harness-admission-parity.ts +143 -0
  46. package/scripts/check-meta-doctor-oracle.sh +20 -0
  47. package/scripts/check-omp-birth-hook.ts +1049 -0
  48. package/scripts/check-omp-fresh-preflight.ts +208 -0
  49. package/scripts/check-omp-receive-arm.ts +516 -0
  50. package/scripts/check-setup-qualification.sh +40 -2
  51. package/scripts/copilot-bridge-oracle.sh +14 -6
  52. package/scripts/fake-copilot-vendor.sh +4 -2
  53. package/scripts/inventory-verification-surface.ts +1 -1
  54. package/scripts/meta-bridge-hook-log.sh +9 -1
  55. package/scripts/mutants/acp-launch-namespace.json +34 -0
  56. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  57. package/scripts/mutants/copilot-birth.json +10 -10
  58. package/scripts/mutants/mux-fresh-call.json +22 -22
  59. package/scripts/mutants/omp-birth.json +173 -0
  60. package/scripts/mutants/omp-fresh.json +300 -0
  61. package/scripts/mutants/omp-receive.json +135 -0
  62. package/scripts/mutants/pack-install.json +2 -2
  63. package/scripts/mutants/self-address.json +34 -0
  64. package/scripts/mutants/setup-verdict.json +35 -0
  65. package/scripts/omp-bridge-doctor.sh +315 -0
  66. package/scripts/omp-bridge-install.sh +221 -0
  67. package/scripts/omp-bridge-oracle.sh +154 -0
  68. package/scripts/omp-bridge-uninstall.sh +57 -0
  69. package/scripts/omp-config-xdev.py +310 -0
  70. package/scripts/omp-config-xdev.sh +76 -0
  71. package/scripts/omp-mcp-bridge.sh +320 -0
  72. package/scripts/omp-mcp-config.py +392 -0
  73. package/scripts/omp-receive-doctor.sh +246 -0
  74. package/scripts/omp-receive-facts.ts +106 -0
  75. package/scripts/omp-receive-install.sh +228 -0
  76. package/scripts/omp-receive-uninstall.sh +60 -0
  77. package/scripts/omp-tool-surface.py +400 -0
  78. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  79. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  80. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  81. package/scripts/raw-omp-measure/README.md +420 -0
  82. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  83. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  84. package/scripts/raw-omp-measure/source-audit.md +414 -0
  85. package/scripts/smoke-omp-bridge-state.sh +221 -0
  86. package/scripts/smoke-omp-fresh-live.ts +497 -0
  87. package/scripts/smoke-omp-mcp-state.sh +327 -0
  88. package/scripts/smoke-omp-receive-live.ts +340 -0
  89. package/scripts/smoke-omp-receive-state.sh +196 -0
  90. package/scripts/smoke-setup-verdict.sh +48 -3
  91. package/scripts/tsconfig.json +2 -0
@@ -0,0 +1,127 @@
1
+ // Deterministic gate — the Claude ACP child launches under a name entwurf OWNS.
2
+ //
3
+ // WHY THIS IS A GATE AND NOT A COMMENT (#72). The vendor bin is called
4
+ // `claude-agent-acp`, and that name belongs to the package, not to us: any
5
+ // harness on the host spawning the same package produces a process with the
6
+ // same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
7
+ // selects `claude-agent-acp` by argv SUBSTRING and SIGTERMs anything older than
8
+ // 900s. Because entwurf RETAINS its child across turns, its age is the age of
9
+ // the session — 12 of 12 anomalous terminations across two boots were that
10
+ // janitor (receipts: scripts/raw-acp-child-exit-measure/README.md §ANSWERED).
11
+ //
12
+ // The defense is a launcher whose own name carries no vendor substring. That is
13
+ // a property of a STRING, which is exactly the kind of thing that rots silently
14
+ // under a rename or a "harmless" revert — hence a gate that fails loudly.
15
+ //
16
+ // The second half matters as much: the launcher must remain TRANSPARENT. The
17
+ // vendor reads `--cli` / `--version` from `process.argv` and builds its own
18
+ // re-invocation command from `process.argv.slice(1)`. A launcher that consumed
19
+ // a flag of its own, or failed to start the vendor at all, would trade one
20
+ // silent breakage for another — so we RUN it and require the vendor to answer.
21
+
22
+ import { strict as assert } from "node:assert";
23
+ import { execFileSync } from "node:child_process";
24
+ import { rmdirSync, rmSync } from "node:fs";
25
+ import { resolve } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+
28
+ /** The substring the janitor matches on — the thing our launch argv must not contain. */
29
+ const VENDOR_PROCESS_MATCHER = "claude-agent-acp";
30
+
31
+ const TMP_EMIT = ".tmp-verify/acp-launch-namespace";
32
+ rmSync(TMP_EMIT, { recursive: true, force: true });
33
+ try {
34
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
35
+ stdio: "pipe",
36
+ });
37
+ const adapterUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend-adapter.js")).href;
38
+ const mod = (await import(adapterUrl)) as {
39
+ claudeAdapter: {
40
+ resolveLaunch: (p: { cwd: string; modelId: string; nativeModelId: string; config: unknown }) => {
41
+ command: string;
42
+ args: string[];
43
+ };
44
+ };
45
+ };
46
+
47
+ const launch = mod.claudeAdapter.resolveLaunch({
48
+ cwd: process.cwd(),
49
+ modelId: "claude-sonnet-5",
50
+ nativeModelId: "claude-sonnet-5",
51
+ config: {},
52
+ });
53
+
54
+ // ----------------------------------------------------------------------
55
+ // The claim: nothing a name-matching janitor scans contains the vendor name.
56
+ // We check the WHOLE argv the way `ps` presents it, because that is what the
57
+ // janitor's awk actually reads — not just the basename.
58
+ // ----------------------------------------------------------------------
59
+ const psLine = [launch.command, ...launch.args].join(" ");
60
+ assert.ok(
61
+ !psLine.includes(VENDOR_PROCESS_MATCHER),
62
+ `[QK:CLAUDE-LAUNCH-IS-NAMESPACED] the default Claude ACP launch must not put "${VENDOR_PROCESS_MATCHER}" anywhere ` +
63
+ "in its argv: a janitor installed for another harness selects that substring by age and SIGTERMs it, which is " +
64
+ `the whole of #72. Got: ${JSON.stringify(psLine)}`,
65
+ );
66
+ assert.ok(
67
+ launch.args.length === 1 && launch.args[0].endsWith("claude-acp-launch.js"),
68
+ "the default launch is the entwurf-owned launcher and NOTHING else — an extra argv entry would be a flag of our " +
69
+ `own, which the vendor's argv.slice(1) self-reinvocation cannot survive. Got: ${JSON.stringify(launch.args)}`,
70
+ );
71
+
72
+ // ----------------------------------------------------------------------
73
+ // The launcher must still BE the vendor. A name split that stopped starting
74
+ // the agent would pass every string assertion above and ship a dead backend,
75
+ // so run it and make the vendor answer through it.
76
+ // ----------------------------------------------------------------------
77
+ const version = execFileSync(process.execPath, [...launch.args, "--version"], {
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "pipe"],
80
+ timeout: 60_000,
81
+ }).trim();
82
+ assert.match(
83
+ version,
84
+ /^\d+\.\d+\.\d+/,
85
+ "[QK:CLAUDE-LAUNCH-IS-TRANSPARENT] the launcher must pass argv through untouched and start the real vendor — " +
86
+ `\`--version\` has to reach it and answer. Got: ${JSON.stringify(version)}`,
87
+ );
88
+
89
+ // ----------------------------------------------------------------------
90
+ // The debug override is an EXPLICIT operator choice and must stay literal:
91
+ // an operator who names their own command owns the result, including losing
92
+ // the name split. Routing it through the launcher would silently overrule them.
93
+ // ----------------------------------------------------------------------
94
+ process.env.CLAUDE_AGENT_ACP_COMMAND = "echo overridden";
95
+ try {
96
+ const overridden = mod.claudeAdapter.resolveLaunch({
97
+ cwd: process.cwd(),
98
+ modelId: "claude-sonnet-5",
99
+ nativeModelId: "claude-sonnet-5",
100
+ config: {},
101
+ });
102
+ assert.deepEqual(
103
+ [overridden.command, ...overridden.args],
104
+ ["bash", "-lc", "echo overridden"],
105
+ "CLAUDE_AGENT_ACP_COMMAND must stay verbatim — the launcher is the DEFAULT, never an override of the operator",
106
+ );
107
+ } finally {
108
+ delete process.env.CLAUDE_AGENT_ACP_COMMAND;
109
+ }
110
+ } finally {
111
+ rmSync(TMP_EMIT, { recursive: true, force: true });
112
+ try {
113
+ // A leftover EMPTY parent dir reads as IMPURE tree drift in the
114
+ // qualification harness; a concurrent sibling gate's emit keeps it alive
115
+ // and this rmdir simply fails.
116
+ rmdirSync(".tmp-verify");
117
+ } catch {
118
+ // non-empty or already gone — fine either way
119
+ }
120
+ }
121
+
122
+ console.log(
123
+ "[check-acp-launch-namespace] ok — the default Claude ACP launch carries no vendor process name in its argv (so a " +
124
+ "name-matching janitor installed for another harness cannot select it), is exactly the entwurf-owned launcher " +
125
+ "with no flag of its own, still starts the real vendor through argv passed untouched, and leaves an explicit " +
126
+ "CLAUDE_AGENT_ACP_COMMAND override verbatim",
127
+ );
@@ -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
  );
@@ -495,7 +495,7 @@ function makeFakeHost(label: string, opts: FakeOpts): FakeHost {
495
495
  ? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
496
496
  : opts.pluginListRaw !== undefined
497
497
  ? ` "plugin list") echo "Installed plugins:"; cat ${JSON.stringify(rawList)}; exit 0 ;;`
498
- : ' "plugin list") echo "Installed plugins:"; sed "s/^/ • /;s/$/ (v$VER)/" "$STATE"; exit 0 ;;',
498
+ : ' "plugin list") echo "Live Plugins (loaded from a local marketplace directory, never copied):"; sed "s/^/ • /;s/$/ (v$VER) (enabled)/" "$STATE"; exit 0 ;;',
499
499
  ' "plugin uninstall")',
500
500
  opts.uninstallFails
501
501
  ? ' echo "boom" >&2; exit 1 ;;'
@@ -1065,6 +1065,33 @@ function writeBoundState(host: FakeHost): void {
1065
1065
  inst.status !== 0 && (inst.stderr ?? "").includes("malformed") && !existsSync(instHost.stateFile),
1066
1066
  );
1067
1067
  }
1068
+ {
1069
+ // Copilot CLI 1.0.81 (measured 2026-08-31) appends its own state token after the
1070
+ // version and an indented `from <path>` continuation line. The grammar admits that
1071
+ // ONE optional `(enabled)`/`(disabled)` token — reading it as part of the version
1072
+ // made every surface refuse a perfectly healthy host. Anything else in the tail
1073
+ // stays malformed, so the admission cannot widen into "ignore whatever follows".
1074
+ const liveRaw = `Live Plugins (loaded from a local marketplace directory, never copied):\n • ${OURS} (v${SHIPPED_VERSION}) (enabled)\n from /home/nobody/.assembled`;
1075
+ const docHost = makeFakeHost("doctor-state-token-row", { installed: [OURS], pluginListRaw: liveRaw });
1076
+ writeBoundState(docHost);
1077
+ writeFileSync(docHost.mktState, `${MKT}\t${docHost.asm}\n`);
1078
+ mkdirSync(path.join(docHost.asm, PLUGIN), { recursive: true });
1079
+ const doc = runVerb(docHost, "doctor-copilot-bridge");
1080
+ ok(
1081
+ "[QK:COPILOT-ROW-STATE-TOKEN-ADMITTED] a `(vX) (enabled)` row plus its `from` continuation line parses as the installed version — no malformed refusal on a healthy 1.0.81 host",
1082
+ doc.stdout.includes(`${OURS} (v${SHIPPED_VERSION}) is registered in Copilot`) &&
1083
+ !doc.stdout.includes("malformed") &&
1084
+ !(doc.stderr ?? "").includes("malformed"),
1085
+ );
1086
+ const foreignHost = makeFakeHost("install-foreign-tail-row", {
1087
+ pluginListRaw: ` • ${OURS} (v${SHIPPED_VERSION}) (whatever)`,
1088
+ });
1089
+ const foreign = runVerb(foreignHost, "install-copilot-bridge");
1090
+ ok(
1091
+ "an UNKNOWN trailing token is still malformed — the admission is exactly the two measured state words",
1092
+ foreign.status !== 0 && (foreign.stderr ?? "").includes("malformed") && !existsSync(foreignHost.stateFile),
1093
+ );
1094
+ }
1068
1095
  {
1069
1096
  // B defect 4: a pluginVersion carrying whitespace would be truncated by the
1070
1097
  // space-separated fact transport (`cut -d' ' -f3`) into a FABRICATED version and a
@@ -155,6 +155,26 @@ function row(facts: SelfAddressabilityFacts): { replyable: boolean; socketState:
155
155
  );
156
156
  }
157
157
 
158
+ // meta-session / NONE rail: no inbound transport at all. Mailbox facts and a live probe
159
+ // cannot buy replyability — there is nothing to land on. Distinct from the unsupplied
160
+ // (undefined) domain row below, which stays fail-closed for a caller that forgot the axis.
161
+ {
162
+ const noneFacts: SelfAddressabilityFacts = {
163
+ origin: "meta-session",
164
+ metaDeliveryDomain: "none",
165
+ recordBacked: true,
166
+ ownerAlive: true,
167
+ watchArmed: true,
168
+ probeAlive: true,
169
+ };
170
+ const noneRail = computeSelfAddressability(noneFacts);
171
+ ok("meta/none + all facts true → NOT replyable", noneRail.replyable === false);
172
+ ok(
173
+ "meta/none names the missing inbound rail (mailbox/probe facts cannot rescue it)",
174
+ noneRail.reason.includes("no inbound rail"),
175
+ );
176
+ }
177
+
158
178
  // meta-session with NO rail declared: we cannot say how a reply would travel, so we do not
159
179
  // claim it would arrive. The domain is derived from nativePushSupported(backend) — a caller
160
180
  // that forgets it gets a refusal, not an optimistic guess.
@@ -246,6 +266,17 @@ ok(
246
266
  /existsSync\s*\(/.test(piBody) && /controlSocketPathIn\s*\(\s*ENTWURF_DIR\s*,/.test(piBody),
247
267
  );
248
268
 
269
+ const metaBuilder = functionBody("buildTrustedMetaSenderEnvelope");
270
+ // FIRST of the two derivation claims so a binary-fallback mutant dies here, not at the seam pin.
271
+ ok(
272
+ "buildTrustedMetaSenderEnvelope does not fall back every non-native-push backend to self-fetch [QK:SELFADDR-NO-FALLBACK-SELF-FETCH]",
273
+ !/\?\s*"native-push"\s*:\s*"self-fetch"/.test(metaBuilder),
274
+ );
275
+ ok(
276
+ "buildTrustedMetaSenderEnvelope derives self-fetch through resolveMailboxWakeModeCapability(identity), not a backend-name list [QK:SELFADDR-MAILBOX-WAKE-SEAM]",
277
+ /resolveMailboxWakeModeCapability\s*\(\s*identity\s*\)/.test(metaBuilder),
278
+ );
279
+
249
280
  const selfRegion = toolRegion("entwurf_self");
250
281
  ok(
251
282
  "entwurf_self existsSync-probes the pi socket (alive vs expected, no synthesized path lie)",
@@ -799,8 +799,9 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
799
799
  const EXPECTED_LANE_MUTANTS: Record<string, number> = {
800
800
  "acp-augment": 10,
801
801
  "acp-cortex": 12,
802
+ "acp-launch-namespace": 2,
802
803
  "acp-overlay": 1,
803
- "acp-prompt-lifecycle": 10,
804
+ "acp-prompt-lifecycle": 15,
804
805
  "acp-stop-reason": 6,
805
806
  "acp-stream-hooks": 10,
806
807
  "agy-permission": 6,
@@ -821,12 +822,15 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
821
822
  "pack-install": 1,
822
823
  "pi-package-ownership": 6,
823
824
  "mux-resume-call": 12,
825
+ "omp-birth": 11,
826
+ "omp-fresh": 24,
827
+ "omp-receive": 11,
824
828
  "probe-ordering": 1,
825
829
  "release-gate": 12,
826
830
  "resume-args": 6,
827
831
  "resume-launch-identity": 6,
828
- "self-address": 3,
829
- "setup-verdict": 10,
832
+ "self-address": 5,
833
+ "setup-verdict": 13,
830
834
  "source-install": 2,
831
835
  "v2-surface": 7,
832
836
  "v2-visible-resume": 17,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * check-harness-admission-parity — the edge between two parity loops that were each closed and
3
+ * had nothing between them (#87 Bundle C).
4
+ *
5
+ * ── The defect this owns ──
6
+ *
7
+ * Two gates already held their own halves tight:
8
+ *
9
+ * `check-entwurf-capabilities` registry == META_CITIZEN_BACKENDS
10
+ * `fresh-call-surfaces.contract.test.ts` surfaces == FRESH_CALL_BACKENDS
11
+ *
12
+ * and NO file imported both constants. So a harness could be admitted as a full D6 citizen —
13
+ * birth, MCP hand, receive, a garden id on its own status line — while `entwurf_fresh_call`
14
+ * could not open it, and every gate in the repo stayed green. `docs/adding-a-harness.md` step 9
15
+ * says a backend is **supported** only when it can be opened as one visible fresh sibling, but
16
+ * that sentence had no consumer: the honest prose in `DELIVERY.md` ("Visible fresh is NOT
17
+ * implemented, so OMP is not a supported harness under step 9") sat there being true while a
18
+ * release package containing that backend passed the whole floor.
19
+ *
20
+ * `[측정]` 2026-08-30, on the Bundle A+B candidate: registry carried six backends, all D6;
21
+ * `FRESH_CALL_BACKENDS` carried three. The omp row read `unsupported` in the docs and shipped
22
+ * anyway. GLG's finding: **an `unsupported` label is not a partial-release permit.**
23
+ *
24
+ * ── The rule ──
25
+ *
26
+ * Every backend a meta-record may name is either
27
+ * (a) openable by `entwurf_fresh_call` — it walked step 9; or
28
+ * (b) a PRE-#82 admission, named here AND described as such in the delivery matrix.
29
+ *
30
+ * There is no third state. A post-#82 harness that is a citizen but not fresh-openable makes
31
+ * this gate red, which is what stops the release package rather than only the docs.
32
+ *
33
+ * ── Why the exception is a literal here plus a sentence there ──
34
+ *
35
+ * Same shape `check-release-gate-outcomes` cell 7 already uses for aggregate omissions, and for
36
+ * the same reason it states: *an exclusion that only this gate believes in is how the omission
37
+ * would come back.* The pair is deliberately redundant — the literal makes the exception a
38
+ * decision someone had to type, and the doc sentence makes it a thing an operator can read
39
+ * without opening this file. Neither half alone is the contract.
40
+ *
41
+ * This introduces NO new authority: the capability registry keeps its schema (there is no
42
+ * `supported` field and there must not be one — a self-declared grade is exactly what step 9
43
+ * refuses), and the fresh set stays the composition's own constant.
44
+ *
45
+ * Pure parse over shipped source + docs. No backend, no network, no subprocess.
46
+ */
47
+
48
+ import assert from "node:assert/strict";
49
+ import fs from "node:fs";
50
+ import path from "node:path";
51
+ import { fileURLToPath } from "node:url";
52
+ import { META_CITIZEN_BACKENDS, type MetaCitizenBackend } from "../pi-extensions/lib/meta-session.ts";
53
+ import { FRESH_CALL_BACKENDS } from "../pi-extensions/lib/mux-fresh-call.ts";
54
+
55
+ const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
56
+
57
+ let passed = 0;
58
+ function ok(label: string, cond: boolean): void {
59
+ assert.ok(cond, label);
60
+ console.log(` ok ${label}`);
61
+ passed++;
62
+ }
63
+
64
+ /**
65
+ * Backends admitted BEFORE the #82 step 9 contract, which `docs/adding-a-harness.md` step 9
66
+ * preserves explicitly: "a pre-contract backend that has not walked this step remains
67
+ * legacy/probe evidence and must not be described as supported until it is re-evaluated here."
68
+ * Copilot is the first admission made UNDER that contract (step 9's worked example), so nothing
69
+ * admitted after it qualifies for this list.
70
+ *
71
+ * `claude-code` and `pi` are also pre-contract and are deliberately absent: they are fresh-
72
+ * openable, so they need no exception. An exception is for a backend that CANNOT be opened —
73
+ * never a convenience for one that simply has not been wired yet.
74
+ *
75
+ * Each value is the file and the sentence in it that carries the exception for a reader. The
76
+ * sentence must be the one that says WHY the backend is not step-9 supported; a row that merely
77
+ * mentions the backend is not an exception.
78
+ */
79
+ const PRE_82_LEGACY: Partial<Record<MetaCitizenBackend, [file: string, sentence: string]>> = {
80
+ antigravity: [
81
+ "DELIVERY.md",
82
+ "Admitted before the #82 step 9 contract and not re-evaluated under it, so it is legacy citizen evidence, not a step-9 supported harness",
83
+ ],
84
+ codex: ["DELIVERY.md", "No owned native-citizen install/invocation lane."],
85
+ };
86
+
87
+ const fresh = new Set<string>(FRESH_CALL_BACKENDS);
88
+ const legacy = Object.keys(PRE_82_LEGACY);
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // 1. The join itself. This assertion is the entire point of the file: it is the
92
+ // first line in the repo that reads both constants at once.
93
+ // ---------------------------------------------------------------------------
94
+ const unaccounted = META_CITIZEN_BACKENDS.filter((b) => !fresh.has(b) && !(b in PRE_82_LEGACY));
95
+ ok(
96
+ "[QK:ADMISSION-FRESH-PARITY] every citizen backend is either openable by entwurf_fresh_call or a declared pre-#82 legacy admission — a post-contract harness that mints records but cannot be opened is not admissible, and an `unsupported` note is not a partial-release permit (docs/adding-a-harness.md step 9). Unaccounted: " +
97
+ (unaccounted.length === 0 ? "none" : unaccounted.join(", ")),
98
+ unaccounted.length === 0,
99
+ );
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // 2. The exception must be readable OUTSIDE this file. A literal nobody else
103
+ // carries is how the omission comes back (check-release-gate-outcomes cell 7).
104
+ // ---------------------------------------------------------------------------
105
+ for (const backend of legacy) {
106
+ const [file, sentence] = PRE_82_LEGACY[backend as MetaCitizenBackend] as [string, string];
107
+ const body = fs.readFileSync(path.join(REPO_DIR, file), "utf8");
108
+ ok(
109
+ `[QK:ADMISSION-LEGACY-DOCUMENTED] ${backend}'s pre-#82 exception is carried by a sentence an operator can read in ${file}, not only by this gate's literal`,
110
+ body.includes(sentence),
111
+ );
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // 3. The two states are exclusive, and the exception list has no dead entries.
116
+ // A backend that got wired for fresh while keeping its legacy excuse would
117
+ // leave a stale "cannot be opened" sentence in the matrix pointing at a
118
+ // capability that now exists.
119
+ // ---------------------------------------------------------------------------
120
+ for (const backend of legacy) {
121
+ ok(
122
+ `[QK:ADMISSION-LEGACY-EXCLUSIVE] ${backend} is excused OR fresh-openable, never both — a backend that walked step 9 must lose its exception and its matrix sentence together`,
123
+ !fresh.has(backend),
124
+ );
125
+ ok(
126
+ `[QK:ADMISSION-LEGACY-SCOPE] ${backend} is still a citizen backend — an exception for a backend no record may name is dead weight that hides the next real one`,
127
+ (META_CITIZEN_BACKENDS as readonly string[]).includes(backend),
128
+ );
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // 4. The reverse direction. The fresh set naming something that can never mint a
133
+ // record would be a launchable window with no address to call home from —
134
+ // `freshCall` would open it and the callback could carry no garden id.
135
+ // ---------------------------------------------------------------------------
136
+ const openableNonCitizens = [...fresh].filter((b) => !(META_CITIZEN_BACKENDS as readonly string[]).includes(b));
137
+ ok(
138
+ "[QK:ADMISSION-FRESH-IS-CITIZEN] every fresh-openable backend is also a citizen backend — opening a harness that cannot mint a record would produce a window with no address to call back from. Offenders: " +
139
+ (openableNonCitizens.length === 0 ? "none" : openableNonCitizens.join(", ")),
140
+ openableNonCitizens.length === 0,
141
+ );
142
+
143
+ console.log(`[check-harness-admission-parity] ${passed} assertions ok`);
@@ -526,6 +526,26 @@ else
526
526
  fi
527
527
  cp "$TMP/hook-log-precopilot.bak" "$AGENT/meta-bridge-hook.log"
528
528
 
529
+ # M9c — an OMP line in the shared hook log must not move this doctor either (#87). The
530
+ # omp birth unit is an in-process EXTENSION, and its scope fence writes a line for every
531
+ # task subagent it refuses, so this file now carries a second rail's routine traffic. Its
532
+ # ERROR lines are mint/marker faults on that rail and its recovery token is
533
+ # `create`/`attach`, never `armed watch` — so counting them here would redden the CLAUDE
534
+ # doctor for a fault it does not own and cannot prescribe a fix for. Same expect-GREEN
535
+ # shape as M9b: the defect this guards makes a HEALTHY host go red.
536
+ cp "$AGENT/meta-bridge-hook.log" "$TMP/hook-log-preomp.bak"
537
+ {
538
+ echo "2026-08-27T00:00:02.000Z ERROR [omp] upsert failed (edge=session_start, native=01a042da-537a-7770-a275-7b8162eecca4): store refused"
539
+ echo "2026-08-27T00:00:03.000Z INFO [omp] scope-refused edge=session_start mode=print: not the visible tui host, no record minted"
540
+ } >> "$AGENT/meta-bridge-hook.log"
541
+ run_doctor
542
+ if [ "$DOC_RC" -eq 0 ]; then
543
+ ok "an omp ERROR in the shared hook log leaves the CLAUDE doctor green (rails stay separate)"
544
+ else
545
+ bad "[QK:HOOK-LOG-OMP-RAIL-SCOPED] an omp ERROR in the shared hook log turned the CLAUDE doctor red — the hook-log judgement does not scope out the omp rail:"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ (FAIL|WARN)' | sed 's/^/ /')"
546
+ fi
547
+ cp "$TMP/hook-log-preomp.bak" "$AGENT/meta-bridge-hook.log"
548
+
529
549
  # M10 — deployed writer bundle missing: staleness becomes unknowable.
530
550
  mv "$PLANTED/lib/meta-session.ts" "$TMP/meta-session.bak"
531
551
  expect_red "installed writer bundle missing" "deployed writer version is UNKNOWN"