@junghanacs/entwurf 0.16.0 → 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 (31) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +8 -11
  4. package/demo/README.md +1 -1
  5. package/docs/setup-clean-host.md +24 -10
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
  7. package/package.json +8 -8
  8. package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
  9. package/pi-extensions/lib/acp/backend.ts +125 -7
  10. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  11. package/run.sh +108 -32
  12. package/scripts/check-acp-launch-namespace.ts +127 -0
  13. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  14. package/scripts/check-copilot-birth-hook.ts +28 -1
  15. package/scripts/check-gate-qualification.ts +3 -2
  16. package/scripts/check-omp-fresh-preflight.ts +27 -0
  17. package/scripts/check-setup-qualification.sh +40 -2
  18. package/scripts/copilot-bridge-oracle.sh +14 -6
  19. package/scripts/fake-copilot-vendor.sh +4 -2
  20. package/scripts/mutants/acp-launch-namespace.json +34 -0
  21. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  22. package/scripts/mutants/copilot-birth.json +3 -5
  23. package/scripts/mutants/pack-install.json +2 -2
  24. package/scripts/mutants/setup-verdict.json +35 -0
  25. package/scripts/omp-config-xdev.py +310 -0
  26. package/scripts/omp-config-xdev.sh +76 -0
  27. package/scripts/omp-tool-surface.py +61 -10
  28. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  29. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  30. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  31. package/scripts/smoke-setup-verdict.sh +48 -3
@@ -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
@@ -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,
@@ -829,7 +830,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
829
830
  "resume-args": 6,
830
831
  "resume-launch-identity": 6,
831
832
  "self-address": 5,
832
- "setup-verdict": 10,
833
+ "setup-verdict": 13,
833
834
  "source-install": 2,
834
835
  "v2-surface": 7,
835
836
  "v2-visible-resume": 17,
@@ -155,6 +155,16 @@ function pythonXdev(agentDir: string): "false" | "true" | "unreadable" {
155
155
  ["a top-level xdev outside tools", "xdev: false\ntools:\n approvalMode: yolo\n"],
156
156
  ["tab indentation (not YAML for the vendor)", "tools:\n\txdev: false\n"],
157
157
  ["broken flow scalar", "tools: [oops\n"],
158
+ // The shape the VENDOR's own settings writer produces: `key:` on one line with an
159
+ // indented flow collection under it. The python leaf's block-only reader returned
160
+ // None for the WHOLE file here, so an untouched operator config classified as
161
+ // `unreadable` and doctor-omp-mcp went RED for a reason unrelated to tools.xdev
162
+ // (measured on a real host, omp 18.0.0). Agreement alone could never catch it —
163
+ // both halves collapse `unreadable` and `true` into "not false" — so the direct
164
+ // assertion below is the one that holds the reader to the vendor's own output.
165
+ ["the vendor's own writer output (empty flow map sibling)", "modelRoles: \n {}\ntools: \n xdev: false\n"],
166
+ ["a populated flow map sibling", "modelRoles: \n {default: xai/grok}\ntools: \n xdev: false\n"],
167
+ ["a flow sequence sibling", "disabledProviders: \n [openrouter, google]\ntools: \n xdev: false\n"],
158
168
  ];
159
169
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-omp-xdev-"));
160
170
  try {
@@ -173,6 +183,23 @@ function pythonXdev(agentDir: string): "false" | "true" | "unreadable" {
173
183
  pythonSaysFalse === (ts === false),
174
184
  );
175
185
  }
186
+ // Agreement is not enough on the vendor's own shapes: two readers that BOTH fail
187
+ // closed agree perfectly and still leave the doctor red on a healthy host. These
188
+ // name the answer instead of comparing the halves.
189
+ for (const [label, yaml] of CONFIGS) {
190
+ if (
191
+ !label.startsWith("the vendor's own writer output") &&
192
+ !label.startsWith("a populated flow map") &&
193
+ !label.startsWith("a flow sequence")
194
+ )
195
+ continue;
196
+ const dir = fs.mkdtempSync(path.join(root, "vendor-"));
197
+ fs.writeFileSync(path.join(dir, "config.yml"), yaml as string);
198
+ ok(
199
+ `[QK:OMP-XDEV-VENDOR-SHAPE-READABLE] the python leaf READS tools.xdev on ${label} — a flow collection elsewhere in the file is not an unreadable config`,
200
+ pythonXdev(dir) === "false",
201
+ );
202
+ }
176
203
  } finally {
177
204
  fs.rmSync(root, { recursive: true, force: true });
178
205
  }
@@ -42,11 +42,23 @@ for _f in copilot-bridge-install.sh copilot-bridge-oracle.sh copilot-mcp-bridge.
42
42
  copilot-receive-bridge.sh copilot-statusline-bridge.sh copilot-statusline-config.py; do
43
43
  cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
44
44
  done
45
+ # OMP composition surface (Cells E/F): same discipline — every omp unit script is a real
46
+ # tracked file here, because the claims are about what the composition actually reaches.
47
+ for _f in omp-bridge-install.sh omp-bridge-oracle.sh omp-mcp-bridge.sh omp-mcp-config.py \
48
+ omp-tool-surface.py omp-config-xdev.sh omp-config-xdev.py omp-receive-install.sh; do
49
+ cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
50
+ done
45
51
  cp -r "$REPO_DIR/pi" "$PKG/pi"
46
52
  cp "$REPO_DIR/pi-extensions/lib/session-id.js" "$PKG/pi-extensions/lib/session-id.js"
47
53
  printf '%s\n' '// dist stub: copied+digested by the receive installer, never executed here' \
48
54
  > "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
49
55
  cp "$PKG/pi-extensions/lib/session-id.js" "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js"
56
+ # The omp units select the same compiled closure in installed mode. Same stub discipline:
57
+ # the installers copy and digest these bytes, they never execute them here.
58
+ for _e in meta-bridge-omp.js meta-bridge-receive-omp.js; do
59
+ printf '%s\n' '// dist stub: copied+digested by the omp installers, never executed here' \
60
+ > "$PKG/mcp/entwurf-bridge/dist/pi-extensions/$_e"
61
+ done
50
62
 
51
63
  # Fake pnpm: resolvable (so an unconditional require_cmd would pass), but any
52
64
  # INVOCATION writes a marker and exits uniquely — the bootstrap tripwire.
@@ -56,12 +68,12 @@ printf '#!/usr/bin/env bash\necho invoked > "%s"\nexit 97\n' "$MARKER" > "$SB/bi
56
68
  chmod +x "$SB/bin/pnpm"
57
69
 
58
70
  ABSENT="$SB/definitely-absent"
59
- run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(optional, default absent) → OUT/RC
71
+ run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(opt) $6=OMP_BIN(opt) → OUT/RC
60
72
  mkdir -p "$1/.pi/agent" "$2"
61
73
  set +e
62
74
  # ONE physical line by contract: check-install-surface S5c is a line-scoped static tripwire,
63
75
  # so the sandbox env assignments must ride the same line as the run.sh drive they guard.
64
- OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" bash "$PKG/run.sh" setup "$2" 2>&1)"
76
+ OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" OMP_BIN="${6:-$ABSENT}" ENTWURF_OMP_AGENT_DIR="$1/.omp/agent" bash "$PKG/run.sh" setup "$2" 2>&1)"
65
77
  RC=$?
66
78
  set -e
67
79
  }
@@ -79,6 +91,8 @@ want "A: no auth.json.bak and credential bytes identical [QK:SETUP-CREDENTIAL-FR
79
91
  "[ ! -e '$HOME_A/.pi/agent/auth.json.bak' ] && [ \"\$(sha256sum '$HOME_A/.pi/agent/auth.json' | cut -d' ' -f1)\" = '$AUTH_SHA' ]"
80
92
  want "A: an absent copilot is one zero-state SKIP — no unit composed, no .copilot written [QK:SETUP-COPILOT-ABSENT-SKIP]" \
81
93
  "printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && [ ! -e '$HOME_A/.copilot' ]"
94
+ want "A: an absent omp is one zero-state SKIP — no unit composed, no .omp written [QK:SETUP-OMP-ABSENT-SKIP]" \
95
+ "printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ ! -e '$HOME_A/.omp' ]"
82
96
  want "A control: mode named first, pi/claude/agy SKIP, bins PASS, core FAIL, NON-GREEN summary" \
83
97
  "printf '%s' \"\$OUT\" | head -n 1 | grep -q 'mode: installed package' && printf '%s' \"\$OUT\" | grep -q 'pi: SKIP' && printf '%s' \"\$OUT\" | grep -q 'claude: SKIP' && printf '%s' \"\$OUT\" | grep -q 'agy: SKIP' && printf '%s' \"\$OUT\" | grep -q 'bins: PASS' && printf '%s' \"\$OUT\" | grep -q 'core: FAIL' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
84
98
 
@@ -122,5 +136,29 @@ want "D: the failing-vendor birth is a named FAIL, never a cosmetic PASS [QK:SET
122
136
  want "D control: detected copilot never reads SKIP, and the summary names copilot-birth NON-GREEN" \
123
137
  "! printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN' && printf '%s' \"\$OUT\" | grep -q 'copilot-birth'"
124
138
 
139
+ # ── Cell E: omp PRESENT — the four units compose, and the SETTING is a real writer ──
140
+ # The omp unit scripts never spawn the vendor (they probe `omp` on PATH and write into the
141
+ # agent dir), so a stub binary is a faithful presence pin. Two claims live here: the
142
+ # composition reaches omp at all, and the tools.xdev writer refuses an EXPLICIT operator
143
+ # `true` by name rather than overwriting a decision it disagrees with.
144
+ STUB_OMP="$SB/stub-omp"; mkdir -p "$STUB_OMP"
145
+ printf '#!/usr/bin/env bash\necho "omp/18.0.0"\n' > "$STUB_OMP/omp"
146
+ chmod +x "$STUB_OMP/omp"
147
+ run_setup "$SB/home-e" "$SB/proj-e" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
148
+ # One assertion on purpose: the four PASS rows AND the artifacts behind them. Split in two,
149
+ # the row half alone passes a composition that reports PASS without running the unit.
150
+ want "E: a detected omp composes all four units — own rows, each backed by its ARTIFACT rather than an exit code [QK:SETUP-OMP-INDEPENDENT]" \
151
+ "printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-config: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && ! printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-meta-omp' ] && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-receive-omp' ] && [ -f '$SB/home-e/.omp/agent/mcp.json' ]"
152
+ want "E control: the setting reached the config the vendor reads (effective xdev-off)" \
153
+ "[ \"\$(python3 '$PKG/scripts/omp-tool-surface.py' '$SB/home-e/.omp/agent' | awk '/^verdict /{print \$2}')\" = 'xdev-off' ]"
154
+ # Now the disagreement branch: an operator who wrote xdev: true explicitly owns that value.
155
+ mkdir -p "$SB/home-f/.omp/agent"
156
+ printf 'tools: \n xdev: true\n' > "$SB/home-f/.omp/agent/config.yml"
157
+ run_setup "$SB/home-f" "$SB/proj-f" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
158
+ want "F: an EXPLICIT operator tools.xdev:true is refused by name, never overwritten [QK:SETUP-OMP-CONFIG-NO-OVERWRITE]" \
159
+ "printf '%s' \"\$OUT\" | grep -q 'omp-config: FAIL' && grep -q 'xdev: true' '$SB/home-f/.omp/agent/config.yml' && ! grep -q 'xdev: false' '$SB/home-f/.omp/agent/config.yml'"
160
+ want "F control: the disagreement is a component FAIL that leaves the other omp units composed" \
161
+ "printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
162
+
125
163
  echo ""
126
164
  echo "check-setup-qualification: $PASS checks passed (mutation-attribution oracle only — behavior evidence lives in smoke-setup-verdict and check-pack-install)"
@@ -77,13 +77,20 @@ PY
77
77
  # QUALIFIED is absent, present exactly once with a parsed nonempty version, or in a
78
78
  # shape nobody may act on. rc 0 prints `absent` or `one <version>`; rc 1 with the
79
79
  # reason on stderr for a MALFORMED exact row (claims our qualified id but does not
80
- # parse as `<qualified> (v<nonempty>)`) or MULTIPLE exact rows. Longer tokens that
81
- # merely contain the qualified id remain foreign and are ignored, same as above.
80
+ # parse as `<qualified> (v<nonempty>)`, optionally followed by the vendor's own
81
+ # `(enabled)`/`(disabled)` state token measured on Copilot CLI 1.0.81) or MULTIPLE
82
+ # exact rows. Longer tokens that merely contain the qualified id remain foreign and
83
+ # are ignored, same as above.
82
84
  copilot_exact_row_version() {
83
85
  local list_text="$1" qualified="$2"
84
86
  LIST_TEXT_ENV="$list_text" QUALIFIED_ENV="$qualified" python3 - <<'PY'
85
- import os, sys
87
+ import os, re, sys
86
88
  qualified = os.environ["QUALIFIED_ENV"]
89
+ # The measured row tail. Copilot CLI 1.0.81 appends its own state token after the
90
+ # version (`... (v0.1.0) (enabled)`), so the grammar carries that OPTIONAL trailing
91
+ # `(enabled)`/`(disabled)` explicitly instead of reading it as a garbled version.
92
+ # Anything else in the tail is still malformed.
93
+ ROW_TAIL = re.compile(r"\(v(?P<version>[^()]*)\)(?:[ \t]+\((?:enabled|disabled)\))?\Z")
87
94
  versions = []
88
95
  for raw in os.environ["LIST_TEXT_ENV"].splitlines():
89
96
  row = raw.strip()
@@ -100,10 +107,11 @@ for raw in os.environ["LIST_TEXT_ENV"].splitlines():
100
107
  if not parts or parts[0] != qualified:
101
108
  continue # foreign row, including longer ids that merely contain ours
102
109
  rest = parts[1] if len(parts) == 2 else ""
103
- if not (rest.startswith("(v") and rest.endswith(")")):
104
- print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)'", file=sys.stderr)
110
+ match = ROW_TAIL.fullmatch(rest)
111
+ if match is None:
112
+ print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)' with an optional '(enabled)'/'(disabled)' state token", file=sys.stderr)
105
113
  sys.exit(1)
106
- version = rest[2:-1]
114
+ version = match.group("version")
107
115
  if not version:
108
116
  print(f"malformed exact row for {qualified}: empty version in {row!r}", file=sys.stderr)
109
117
  sys.exit(1)
@@ -4,7 +4,9 @@
4
4
  # cannot drift: smoke-setup-verdict's copilot-present cell and check-pack-install's
5
5
  # installed copilot-present consumer row. The answer shapes are the MEASURED CLI's
6
6
  # (copilot 1.0.80, 2026-08-27) — the same shapes check-copilot-birth-hook.ts bakes
7
- # into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix,
7
+ # into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix
8
+ # followed by the vendor's `(enabled)` state token and an indented `from <path>`
9
+ # continuation line (measured on copilot 1.0.81, 2026-08-31),
8
10
  # `plugin marketplace list` prints `<name> (Local: <abs path>)`, and `--force`
9
11
  # anywhere is refused loudly (the real `marketplace remove --force` uninstalls that
10
12
  # marketplace's plugins as a side effect; no entwurf surface may reach for it).
@@ -51,7 +53,7 @@ case "\$1 \$2 \$3" in
51
53
  "plugin marketplace remove") awk -F"\t" -v n="\$4" '\$1 != n' "\$MKTS" > "\$MKTS.tmp"; mv "\$MKTS.tmp" "\$MKTS"; exit 0 ;;
52
54
  esac
53
55
  case "\$1 \$2" in
54
- "plugin list") echo "Installed plugins:"; sed "s/^//;s/\$/ (v\$VER)/" "\$STATE"; exit 0 ;;
56
+ "plugin list") echo "Live Plugins (loaded from a local marketplace directory, never copied):"; while read -r id; do [ -n "\$id" ] && { echo "\$id (v\$VER) (enabled)"; echo " from $dir"; }; done < "\$STATE"; exit 0 ;;
55
57
  "plugin uninstall") grep -Fvx "\$3" "\$STATE" > "\$STATE.tmp"; mv "\$STATE.tmp" "\$STATE"; exit 0 ;;
56
58
  "plugin install") echo "\$3" >> "\$STATE"; exit 0 ;;
57
59
  esac
@@ -0,0 +1,34 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-launch-namespace",
4
+ "mutants": [
5
+ {
6
+ "claim": "CLAUDE-LAUNCH-IS-NAMESPACED",
7
+ "title": "the default launch names the vendor bin directly again — the child re-enters the process-name space a janitor for another harness scans by substring, which is the whole of #72",
8
+ "subject": "pi-extensions/lib/acp/backend-adapter.ts",
9
+ "find": ["\tconst launcher = fileURLToPath(new URL(\"./claude-acp-launch.js\", import.meta.url));"],
10
+ "replace": ["\tconst launcher = require.resolve(\"@agentclientprotocol/claude-agent-acp/dist/index.js\");"],
11
+ "gate": ["bash", "run.sh", "check-acp-launch-namespace"],
12
+ "timeoutSeconds": 240,
13
+ "signature": "[QK:CLAUDE-LAUNCH-IS-NAMESPACED]",
14
+ "signatureSource": "scripts/check-acp-launch-namespace.ts"
15
+ },
16
+ {
17
+ "claim": "CLAUDE-LAUNCH-IS-TRANSPARENT",
18
+ "title": "the launcher consumes an argv slot of its own — the vendor's argv.slice(1) self-reinvocation and its own flags stop working behind a name split that still looks correct",
19
+ "subject": "pi-extensions/lib/acp/claude-acp-launch.js",
20
+ "find": ["\tawait import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);"],
21
+ "replace": [
22
+ "\tif (process.argv.includes(\"--version\")) {",
23
+ "\t\tconsole.log(\"entwurf launcher\");",
24
+ "\t\tprocess.exit(0);",
25
+ "\t}",
26
+ "\tawait import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);"
27
+ ],
28
+ "gate": ["bash", "run.sh", "check-acp-launch-namespace"],
29
+ "timeoutSeconds": 240,
30
+ "signature": "[QK:CLAUDE-LAUNCH-IS-TRANSPARENT]",
31
+ "signatureSource": "scripts/check-acp-launch-namespace.ts"
32
+ }
33
+ ]
34
+ }