@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
@@ -119,7 +119,13 @@ export interface AcpChildLike {
119
119
  signalCode: NodeJS.Signals | null;
120
120
  stdin: { destroy(): void; unref?(): void };
121
121
  stdout: { destroy(): void; unref?(): void };
122
- stderr: { on(event: "data", listener: (chunk: Buffer) => void): void; destroy(): void; unref?(): void };
122
+ stderr: {
123
+ on(event: "data", listener: (chunk: Buffer) => void): void;
124
+ /** Optional: absent on minimal fakes; the flush is best-effort, never load-bearing for liveness. */
125
+ once?(event: "close", listener: () => void): void;
126
+ destroy(): void;
127
+ unref?(): void;
128
+ };
123
129
  kill(signal?: NodeJS.Signals | number): boolean;
124
130
  unref(): void;
125
131
  once(event: "exit" | "error", listener: (...args: unknown[]) => void): void;
@@ -210,6 +216,11 @@ interface BridgeSession {
210
216
  * nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
211
217
  */
212
218
  stderrTail: string[];
219
+ /**
220
+ * SESSION-scoped like `stderrTail`, and for the same reason: the launcher's
221
+ * frame can arrive on a turn later than the one that spawned the child.
222
+ */
223
+ launchObservation: AcpLaunchObservation;
213
224
  /** How the child ended, once it has — folded into the prompt-phase error. */
214
225
  exit?: { code: number | null; signal: NodeJS.Signals | null };
215
226
  /**
@@ -367,6 +378,85 @@ const CHILD_END_SETTLE_MS = 500;
367
378
  */
368
379
  const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
369
380
 
381
+ /**
382
+ * The launcher's control frame — see `claude-acp-launch.js`.
383
+ *
384
+ * Matched as an EXACT FULL LINE and nothing else. The vendor writes prose that
385
+ * mentions signals; a substring test would let vendor text manufacture an
386
+ * entwurf observation, which is precisely the confusion #72 cost three
387
+ * diagnosis passes. Our own frame is a fixed enum, so exact-line matching is
388
+ * sufficient AND necessary.
389
+ */
390
+ const LAUNCH_SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
391
+ const LAUNCH_SIGNAL_FRAME_VALUES: ReadonlySet<string> = new Set(["SIGTERM", "SIGINT"]);
392
+
393
+ /** A mutable, session-scoped record of a terminating signal the LAUNCHER caught. */
394
+ export type AcpLaunchObservation = { signal?: "SIGTERM" | "SIGINT" };
395
+
396
+ /**
397
+ * Split a stderr chunk stream into lines, consuming our own frames and passing
398
+ * everything else through to the tail untouched.
399
+ *
400
+ * Line-buffered because a chunk boundary can fall inside a frame; the trailing
401
+ * partial line is held, not emitted, so a frame split across two reads is still
402
+ * recognised exactly.
403
+ */
404
+ function makeLaunchFrameFilter(observation: AcpLaunchObservation, onText: (text: string) => void) {
405
+ let held = "";
406
+ return {
407
+ write(chunk: string): void {
408
+ held += chunk;
409
+ let nl = held.indexOf("\n");
410
+ let passed = "";
411
+ while (nl !== -1) {
412
+ const line = held.slice(0, nl);
413
+ if (
414
+ line.startsWith(LAUNCH_SIGNAL_FRAME_PREFIX) &&
415
+ LAUNCH_SIGNAL_FRAME_VALUES.has(line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length))
416
+ ) {
417
+ // FIRST caught signal wins: a later one is our own teardown racing
418
+ // the external kill, and reporting that would bury the cause.
419
+ observation.signal ??= line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length) as "SIGTERM" | "SIGINT";
420
+ } else {
421
+ passed += `${line}\n`;
422
+ }
423
+ held = held.slice(nl + 1);
424
+ nl = held.indexOf("\n");
425
+ }
426
+ if (passed) onText(passed);
427
+ },
428
+ /**
429
+ * The child's LAST words may arrive without a trailing newline — a process
430
+ * dying mid-write is exactly when that happens, and it is exactly when the
431
+ * tail matters most. Line buffering would otherwise hold that fragment
432
+ * forever, so the stream's close flushes it VERBATIM.
433
+ *
434
+ * No frame check here, deliberately: the launcher writes its frame with a
435
+ * single `writeSync` including the newline, well under PIPE_BUF, so a
436
+ * complete frame can never be the un-terminated remainder. Anything left
437
+ * without a newline is vendor text by construction.
438
+ */
439
+ flush(): void {
440
+ if (!held) return;
441
+ onText(held);
442
+ held = "";
443
+ },
444
+ };
445
+ }
446
+
447
+ /**
448
+ * What the operator is told about a caught signal — and what they are NOT told.
449
+ *
450
+ * Absence is reported as "not observed", never as "no signal": the launcher can
451
+ * only see what reached it, and an override launch (`CLAUDE_AGENT_ACP_COMMAND`)
452
+ * has no launcher at all. Attribution of the SENDER is not claimed here either —
453
+ * that needs the host's journal, which this process cannot read.
454
+ */
455
+ function launchSignalLine(observation: AcpLaunchObservation | undefined): string | undefined {
456
+ if (!observation?.signal) return undefined;
457
+ return `[acp] launch observed ${observation.signal} before child exit (sender not attributed)`;
458
+ }
459
+
370
460
  function isAcpConnectionClosure(err: unknown): boolean {
371
461
  const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
372
462
  return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
@@ -921,14 +1011,24 @@ export function streamAcpTurn(
921
1011
  stream.end();
922
1012
  }
923
1013
 
924
- function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
1014
+ function finishError(
1015
+ err: unknown,
1016
+ aborted: boolean,
1017
+ stderrTail?: string[],
1018
+ lifecycle?: string,
1019
+ launchObservation?: AcpLaunchObservation,
1020
+ ): void {
925
1021
  finalizeAcpStreamState(state);
926
1022
  state.output.stopReason = aborted ? "aborted" : "error";
927
1023
  const base = err instanceof Error ? err.message : String(err);
928
1024
  // The FIRST failure stays first and verbatim (it is what the backend
929
1025
  // actually said); the lifecycle line is added, never substituted, so a
930
1026
  // reader can still match the transport's own text.
931
- const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
1027
+ const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
1028
+ // Its OWN line, above the vendor tail: an entwurf-owned observation must not
1029
+ // have to be recovered by reading vendor prose.
1030
+ const observed = launchSignalLine(launchObservation);
1031
+ const diagnosed = observed ? `${withLifecycle}\n${observed}` : withLifecycle;
932
1032
  const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
933
1033
  const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
934
1034
  // A-c: a real failure (not an abort) that looks like a context-window
@@ -1124,6 +1224,8 @@ export function streamAcpTurn(
1124
1224
  /** Which phase a failure belongs to — flipped once the prompt is on the wire. */
1125
1225
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1126
1226
  const stderrTail: string[] = [];
1227
+ // Session-scoped alongside the tail — see the field's note on the session type.
1228
+ const launchObservation: AcpLaunchObservation = {};
1127
1229
  const sessionKey = resolveSessionKey(opts, cwd);
1128
1230
  try {
1129
1231
  if (signal?.aborted) throw new Error("aborted before launch");
@@ -1156,10 +1258,18 @@ export function streamAcpTurn(
1156
1258
  const spawned = child;
1157
1259
 
1158
1260
  // Drain stderr (an unconsumed pipe can backpressure-deadlock a long turn).
1159
- spawned.stderr.on("data", (c: Buffer) => {
1160
- stderrTail.push(c.toString());
1261
+ // The launcher's own control frames are consumed here and kept OUT of the
1262
+ // tail: the tail is vendor evidence, the observation is an entwurf fact,
1263
+ // and mixing the two is the overloading #72 was made of.
1264
+ const consumeStderr = makeLaunchFrameFilter(launchObservation, (text) => {
1265
+ stderrTail.push(text);
1161
1266
  if (stderrTail.length > 50) stderrTail.shift();
1162
1267
  });
1268
+ spawned.stderr.on("data", (c: Buffer) => consumeStderr.write(c.toString()));
1269
+ // `close` rather than `end`: it covers the destroy path our own teardown
1270
+ // takes, and on the EOF-first death it lands inside the post-mortem
1271
+ // settle window, so the flushed fragment is in the tail before we seal.
1272
+ spawned.stderr.once?.("close", () => consumeStderr.flush());
1163
1273
 
1164
1274
  // Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
1165
1275
  // there is no prompt turn for the agent to cancel yet, so the child is
@@ -1211,6 +1321,8 @@ export function streamAcpTurn(
1211
1321
  // this turn with the session, so a later reuse turn can still report
1212
1322
  // the child's dying words.
1213
1323
  stderrTail,
1324
+ // SAME object the frame filter writes into, for the same reason.
1325
+ launchObservation,
1214
1326
  // Armed at spawn, before ANY turn can fail on this child — the latch
1215
1327
  // must already exist when the `exit` listener below can fire.
1216
1328
  childEnd: makeChildEndLatch(),
@@ -1345,7 +1457,13 @@ export function streamAcpTurn(
1345
1457
  // own SIGTERM; the tail collected by then is sealed before our cleanup
1346
1458
  // touches the stderr pipe.
1347
1459
  const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
1348
- finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
1460
+ finishError(
1461
+ err,
1462
+ aborted,
1463
+ session?.stderrTail ?? stderrTail,
1464
+ lifecycle,
1465
+ session?.launchObservation ?? launchObservation,
1466
+ );
1349
1467
  // error/abort → drop the (uncertain) session and close its child; an
1350
1468
  // uncertain connection must never be reused (GPT ④).
1351
1469
  if (child) {
@@ -1412,7 +1530,7 @@ export function streamAcpTurn(
1412
1530
  // Without the session-scoped tail a mid-turn child death on a resident
1413
1531
  // session surfaced as a bare "ACP connection closed" with nothing to read
1414
1532
  // it by.
1415
- finishError(err, aborted, session.stderrTail, lifecycle);
1533
+ finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
1416
1534
  // error/abort on a reused session → drop it and close the child (GPT ④).
1417
1535
  session.retiring = true;
1418
1536
  retainedChildren.delete(session.child);
@@ -0,0 +1,100 @@
1
+ // entwurf-owned launcher for the Claude ACP backend (#72).
2
+ //
3
+ // WHY THIS FILE EXISTS — it is not a wrapper for its own sake.
4
+ //
5
+ // The vendor bin is named `claude-agent-acp`, and that name is not ours: any
6
+ // harness on the host that spawns the same package produces a process with the
7
+ // same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
8
+ // (openclaw's acpx, upstream PR #245) selects `claude-agent-acp` by argv
9
+ // SUBSTRING and SIGTERMs anything older than 900s. entwurf's child is retained
10
+ // across turns, so its age is the age of the SESSION, not of a turn — every
11
+ // session older than 15 minutes was shot at every 5 minutes. Measured: 12 of 12
12
+ // anomalous terminations across two boots, pid- and timestamp-locked (receipts
13
+ // in `scripts/raw-acp-child-exit-measure/README.md` §ANSWERED).
14
+ //
15
+ // So this launcher does two things, and deliberately nothing else:
16
+ //
17
+ // 1. NAMESPACE. It carries a name that is ours, so a name-matching janitor
18
+ // stops selecting our process. The vendor is `import`ed INTO this same
19
+ // process — not spawned as a child. That distinction is what keeps this
20
+ // inside #72's repair fence: with no child to restart, this cannot become
21
+ // the supervisor/watcher/retry the issue forbids. Any variant that spawns
22
+ // the vendor breaks the fence and must not be written.
23
+ //
24
+ // 2. OBSERVE A CAUGHT SIGNAL. The vendor's own handler turns SIGTERM/SIGINT
25
+ // into `dispose(); process.exit(0)`, so by the time entwurf sees the child
26
+ // end there is an exit code 0 and NO signal — a clean external kill and a
27
+ // vendor fault are indistinguishable. Registering first lets us record
28
+ // that a terminating signal was caught, before the vendor erases it.
29
+ //
30
+ // WHAT THIS COSTS, STATED SO NOBODY DISCOVERS IT LATER. Once the name split is
31
+ // in place, a host janitor that scans for the vendor name can no longer see
32
+ // entwurf's children AT ALL — including ones that genuinely leaked. If pi is
33
+ // SIGKILLed so `teardownChild` never runs, this adapter reparents to PID 1 and
34
+ // matches neither that janitor's name phase nor its orphan phase (which looks
35
+ // for a bare `claude`). That leak class now belongs to entwurf: after the split,
36
+ // we own our own cleanup story and cannot expect someone else's timer to cover
37
+ // it. This is a deliberate trade — being killed mid-turn is worse than leaking
38
+ // a process on an abnormal exit — but it is a trade, not a free win.
39
+ //
40
+ // ARGV IS NOT OURS TO TOUCH. The vendor reads `--cli` / `--version` /
41
+ // `--hide-claude-auth` from `process.argv`, and builds its own re-invocation
42
+ // command from `process.argv.slice(1)` for the terminal-auth advert. Under this
43
+ // launcher that advert becomes `node <this file> --cli auth login …`, which
44
+ // keeps working ONLY because we consume no flags and resolve the vendor
45
+ // ourselves. Never give this file an option of its own.
46
+
47
+ import { readFileSync, writeSync } from "node:fs";
48
+ import { createRequire } from "node:module";
49
+ import { dirname, join } from "node:path";
50
+ import { pathToFileURL } from "node:url";
51
+
52
+ /**
53
+ * The one line entwurf's stderr drain looks for, matched as an EXACT full line.
54
+ * A fixed enum, never interpolated from vendor text: the backend must be unable
55
+ * to mistake vendor prose (which mentions signals) for our own observation.
56
+ */
57
+ const SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
58
+
59
+ /** Signals the vendor normalizes to exit 0, and which are therefore invisible downstream. */
60
+ const OBSERVED_SIGNALS = /** @type {const} */ (["SIGTERM", "SIGINT"]);
61
+
62
+ for (const signal of OBSERVED_SIGNALS) {
63
+ process.on(signal, () => {
64
+ // writeSync, not console.error: a handler may run while the process is
65
+ // tearing down and an async write can be dropped.
66
+ try {
67
+ writeSync(2, `${SIGNAL_FRAME_PREFIX}${signal}\n`);
68
+ } catch {
69
+ // stderr already gone — the observation is best-effort, never fatal.
70
+ }
71
+ // THE SINK GUARD. Registering a handler SUPPRESSES node's default
72
+ // termination. If we are still the only listener — the vendor has not
73
+ // registered yet, or its import failed — this observer would make the
74
+ // process immune to the very signal it is observing, including entwurf's
75
+ // own teardown SIGTERM. Stand down and let the signal land for real.
76
+ if (process.listenerCount(signal) === 1) {
77
+ process.removeAllListeners(signal);
78
+ process.kill(process.pid, signal);
79
+ }
80
+ });
81
+ }
82
+
83
+ // Resolution lives INSIDE the same try as the import so both failures speak with
84
+ // one voice: a missing package and a broken package are the same event to an
85
+ // operator reading stderr, and only one of them would otherwise be legible.
86
+ try {
87
+ const require = createRequire(import.meta.url);
88
+ const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
89
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
90
+ const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
91
+ if (!binPath) throw new Error("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
92
+ // Same process, no argv touched. The vendor bin has no main-module guard, so
93
+ // importing it starts the agent exactly as executing it would.
94
+ await import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);
95
+ } catch (err) {
96
+ // Fail loud and DIE. No retry, no fallback: a launcher that survives its own
97
+ // failure is the hidden supervisor #72 forbids.
98
+ writeSync(2, `entwurf acp launcher: vendor import failed: ${err instanceof Error ? err.stack : String(err)}\n`);
99
+ process.exit(1);
100
+ }
package/run.sh CHANGED
@@ -119,7 +119,7 @@ run_vitest() {
119
119
  usage() {
120
120
  cat <<'EOF'
121
121
  Usage:
122
- ./run.sh setup [project-dir] # ONE presence-driven composition (#86): mode-first (source bootstrap only on a checkout), then per-component PASS/SKIP/FAIL for pi (presence+floor)/claude/agy/copilot (all four units: birth→MCP→receiver→footer, independently) + stable dev bins + v2 install smoke; absent harness = zero-state SKIP, detected-incomplete = named FAIL + nonzero exit. Never installs a harness or touches credentials
122
+ ./run.sh setup [project-dir] # ONE presence-driven composition (#86): mode-first (source bootstrap only on a checkout), then per-component PASS/SKIP/FAIL for pi (presence+floor)/claude/agy/copilot (all four units: birth→MCP→receiver→footer, independently)/omp (four units: birth→MCP→tools.xdev setting→receiver, independently) + stable dev bins + v2 install smoke; absent harness = zero-state SKIP, detected-incomplete = named FAIL + nonzero exit. Never installs a harness or touches credentials
123
123
  ./run.sh release-gate [project-dir] [--cut] [--allow-skip-gemini] # SINGLE release gate: full static (pnpm run check:full) + the v2-native live gates (v2 matrix-live, check-bridge, doctor-pi-provider, RGG) + the ACP plugin acceptance floor (12 LIVE smokes: socket-citizen/raw-turn/overlay/provider/session-reuse/carrier-augment/memory-containment/rgg/mcp/skill/bundled-mcp/v2-send) + the one surviving axis the aggregate used to omit silently (claude-native-resume; Cortex stays a documented on-demand direct call) + the cross-harness delivery chain (smoke-entwurf-chain-live). TWO-TIER summary: MUST (release-blocking, owns the exit code — "green" applies here) + BEHAVIOR (advisory, non-blocking: RGG positives model-in-loop turn). STEP OUTCOME protocol: every step is INVOKED and reports its own PASS / SKIP (exit 97, a prerequisite it does not have) / FAIL — a skip is never counted as a pass. Without --cut this is the unattended diagnostic (SKIPs reported, exit 0). WITH --cut it is read as release acceptance and ANY MUST SKIP is red, which is what makes "a CUT needs LIVE=1, SKIP=0" executable instead of prose. --allow-skip-gemini accepted-but-ignored (back-compat). final cut authorization is GLG's.
124
124
  ./run.sh check-bridge # entwurf-bridge direct MCP smoke + protocol/negative-path test.sh (live substrate = v2 live smokes)
125
125
  ./run.sh check-entwurf-bridge-boot # deterministic gate (5d-5-pre, G1a/G1b/G1e/G1f, IN pnpm run check:full): boot start.sh under strip-types + assert v2 fence graph loads + entwurf_v2 and entwurf_resume_call registered/schema + the tools/list surface is EXACTLY the seven shipped garden verbs; tools/list only, no auth/side-effect
@@ -218,6 +218,8 @@ Usage:
218
218
  ./run.sh install-copilot-mcp # #82 RAIL 5: register ONE entwurf-bridge server in ~/.copilot/mcp-config.json (adopt / create / REFUSE symlink), type:local, install-state under $XDG_DATA_HOME/entwurf/copilot-mcp/
219
219
  ./run.sh uninstall-copilot-mcp # honest inverse of install-copilot-mcp from install-state
220
220
  ./run.sh doctor-copilot-mcp # static ownership/config/boot doctor; RED only when install-state exists
221
+ ./run.sh install-omp-config # #87 follow-on: write the ONE operator setting omp's tool hand requires — `tools.xdev: false` in <omp agent dir>/config.yml. Owns exactly the line(s) it adds (recorded in install-state), refuses a symlinked config, refuses a config it cannot parse, and refuses an EXPLICIT operator `xdev: true` by name rather than overwriting a decision. Without it the vendor default wraps every MCP tool behind `xd://` and the doorbell announces a tool the model cannot call
222
+ ./run.sh uninstall-omp-config # honest inverse from install-state: takes back exactly the recorded line(s), removes the file only when entwurf created it, REFUSES when the config changed since install
221
223
  ./run.sh install-omp-bridge # #87: install the OMP BIRTH extension into <omp agent dir>/extensions/entwurf-meta-omp (index.ts|js + lib + capability registry). No launcher and no bake — an omp hook is an in-process extension. Refuses when an inherited PI_CODING_AGENT_DIR/PI_CONFIG_DIR/PI_PROFILE makes the target agent dir ambiguous (ledger M6), and refuses ANY pre-existing artifact at the unit path that entwurf holds no ownership state for — a shape is not a proof of ownership
222
224
  ./run.sh uninstall-omp-bridge # honest inverse from install-state (exact unit dir + recorded entry; no-state host REFUSES; state deleted LAST). Records already minted are preserved
223
225
  ./run.sh doctor-omp-bridge # #87: runtime axis (importable unit, writer/registry parity, mint vs sender-marker errors on SEPARATE axes, scope-fence receipts, a root-grammar preflight that goes RED on a relative ENTWURF_META_* override instead of reporting on some other directory, CERTIFIED omp record count via meta-facts under the omp root policy — never a text grep, live omp processes carrying inherited PI_SESSION_ID/PI_AGENT_ID) + ownership axis. PI_CODING_AGENT_DIR is the vendor's own agent dir here and is reported as ignored, never as contamination. Zero records = NOT-YET, never red
@@ -250,7 +252,7 @@ Usage:
250
252
  ./run.sh check-dep-versions # local deterministic check that the pi pin agrees across package.json (devDeps + peer range), run.sh (peer-install pins), and the baseline docs (AGENTS/README/ROADMAP/setup-clean-host/demo)
251
253
  ./run.sh check-node-floor-coherence # binds the Node floor (24+, single axis) across engines.node, run.sh setup preflight, meta-bridge install/doctor judgment logic, clean-host docs, the bridge launcher header, and the CI runner node-version — engines.node is the SSOT, everything else is derived; sweeps tracked contract text for an unregistered declaration
252
254
  ./run.sh check-pack # publish gate (dry-run): npm pack --dry-run + tarball invariants (runtime-critical present, dev residue absent)
253
- ./run.sh check-pack-pin-matcher # pure self-test of check-pack-install's pin-leak matcher against synthetic .pnpm lookalikes (version boundary: @0.84.30 must leak, @0.84.3 bare/peer-hash must pass); snapshot-safe qualification oracle, also run first inside check-pack-install
255
+ ./run.sh check-pack-pin-matcher # pure self-test of check-pack-install's pin-leak matcher against synthetic .pnpm lookalikes (version boundary: @0.84.40 must leak, @0.84.4 bare/peer-hash must pass); snapshot-safe qualification oracle, also run first inside check-pack-install
254
256
  ./run.sh check-fresh-cut-gate # SOURCE cell of the generation-boundary proof (IN pnpm run check:full): drives real install/setup/fresh-cut in a sandbox; certification refusal is pre-write, quiescence is fail-closed, archives preserve bytes, and the #54 exit matrix distinguishes complete / no-move / usage / incomplete transition / complete-with-cleanup-residue. No model/network/cost
255
257
  ./run.sh check-pack-install # heavy publish gate (prepublishOnly): actual npm pack + tar -tf + fresh-temp install smoke with the pinned pi peers (pins derived from the package.json devDep; check-dep-versions binds them) + the npm-installed bridge BOOTS (tools/list) and DELIVERS (tools/call entwurf_v2 → .msg lands) + the installed all-absent and copilot-present (four-unit fake-vendor) `entwurf setup` rows + the INSTALLED generation lifecycle on a seeded previous-generation host (REFUSE before activation writes / zero Claude invocations → installed fresh-cut archives + opens empty → install-meta-bridge PASSES) + the INSTALLED-PACKAGE branch of the Copilot and OMP birth installers actually RUN (compiled entry selected, no raw .ts, and a real birth edge mints a citizen — the half a required-artifact list can never stand in for)
256
258
  ./run.sh check-install-container # 0.12.8 (#51 C): Linux artifact-CONSUMER gate — one candidate .tgz handed read-only to a checkout-invisible node:<engines-major>-bookworm cell. Default packs once to temp; ENTWURF_CANDIDATE_TGZ=/absolute/preserved.tgz consumes those exact bytes with no re-pack and prints canonical path+sha256 for release. Non-root global PATH install, frozen package, MCP tools/list, fake-Claude install-meta-bridge, path+sha256 fence, strict doctor, and the GENERATION host-state matrix (clean / v3-only store bytes unchanged / previous-generation REFUSE→fresh-cut→retry PASS) seeded inline. Docker missing = honest SKIP; ENTWURF_REQUIRE_DOCKER=1 makes that RED (required CI)
@@ -1825,13 +1827,13 @@ assert.equal(peerTui, piAi,
1825
1827
  // floor tracks the devDep pin so a consumer can't install against a pi lacking
1826
1828
  // the public trust exports the bridge imports at the pinned minor, AND an upper
1827
1829
  // bound at the next minor stops a fresh install from silently pulling a future
1828
- // pi (past the declared ceiling — 0.85+ at the current 0.84.3 pin) whose
1830
+ // pi (past the declared ceiling — 0.85+ at the current 0.84.4 pin) whose
1829
1831
  // internal export surface has drifted from the one we typecheck against.
1830
1832
  // pi moves its public surface every minor (the 0.79→0.80 getModels→provider-
1831
1833
  // factory churn is exactly this), so an open `>=` floor is exactly how the next
1832
1834
  // installer re-acquires the drift. The floor is also the HARD MINIMUM a consumer
1833
- // install resolves: at `>=0.84.3` an existing 0.83.x host is upgraded, not kept.
1834
- // Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.3 <0.85`).
1835
+ // install resolves: at `>=0.84.4` an existing 0.84.3 host is upgraded, not kept.
1836
+ // Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.4 <0.85`).
1835
1837
  const [piMaj, piMin] = piAi.split('.').map(Number);
1836
1838
  assert.equal(piMaj, 0,
1837
1839
  `pi pin major must stay 0 for the next-minor ceiling rule (got ${piAi}); revisit check-dep-versions when pi reaches 1.x`);
@@ -2729,6 +2731,17 @@ check_acp_prompt_lifecycle() {
2729
2731
  run_ts scripts/check-acp-prompt-lifecycle.ts
2730
2732
  }
2731
2733
 
2734
+ check_acp_launch_namespace() {
2735
+ # #72: the Claude ACP child must launch under a name entwurf OWNS. A janitor
2736
+ # installed on the host for ANOTHER harness selects the vendor process name
2737
+ # `claude-agent-acp` by argv substring and SIGTERMs it by age; entwurf retains
2738
+ # its child across turns, so its age is the session's. This gate holds the
2739
+ # name split, holds the launcher transparent (the vendor still answers
2740
+ # --version through it), and keeps an explicit operator override verbatim.
2741
+ section "ACP launch namespace (#72 — no vendor process name in our argv)"
2742
+ run_ts scripts/check-acp-launch-namespace.ts
2743
+ }
2744
+
2732
2745
  check_acp_stream_hooks() {
2733
2746
  # Deterministic gate for the pi 0.84 streamSimple hook contract on the ACP rail
2734
2747
  # (#63; upstream pi-mono #7372 → doc-only #7576). before_provider_request
@@ -3067,15 +3080,15 @@ check_pack() {
3067
3080
  # The pin-leak filter for the install tree's .pnpm listing, shared by the real scan and its
3068
3081
  # self-test below. The version BOUNDARY is load-bearing: a pnpm .pnpm entry is
3069
3082
  # `<name>@<version>` followed by either `_<peer-hash>` or end-of-name (measured pnpm 11.20.0
3070
- # on this tree), so an unbounded substring match would bless a lookalike such as `@0.84.30`
3083
+ # on this tree), so an unbounded substring match would bless a lookalike such as `@0.84.40`
3071
3084
  # while announcing the pinned floor — a false-green oracle (found by independent review,
3072
3085
  # 2026-08-25).
3073
3086
  pack_install_leaked_pi() {
3074
- grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.3(_|$)' || true
3087
+ grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.4(_|$)' || true
3075
3088
  }
3076
3089
 
3077
3090
  # Matcher self-test on SYNTHETIC lookalikes: a healthy install tree cannot exercise the
3078
- # false-green shape (it contains no 0.84.30), so the oracle is proven against a fixture
3091
+ # false-green shape (it contains no 0.84.40), so the oracle is proven against a fixture
3079
3092
  # listing. Expected: the two lookalikes leak, the pinned version passes bare and with a
3080
3093
  # peer-hash suffix. Exposed as its own snapshot-safe subcommand because the heavy
3081
3094
  # check-pack-install cannot run inside the qualification snapshot (no install environment),
@@ -3084,13 +3097,13 @@ pack_install_leaked_pi() {
3084
3097
  check_pack_pin_matcher() {
3085
3098
  local matcher_probe
3086
3099
  matcher_probe=$(printf '%s\n' \
3087
- '@earendil-works+pi-ai@0.84.3' \
3088
- '@earendil-works+pi-ai@0.84.3_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3089
- '@earendil-works+pi-ai@0.84.30' \
3090
- '@earendil-works+pi-agent-core@0.84.2' | pack_install_leaked_pi)
3091
- if [ "$matcher_probe" != '@earendil-works+pi-ai@0.84.30
3092
- @earendil-works+pi-agent-core@0.84.2' ]; then
3093
- fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the 0.84.30/0.84.2 lookalikes and pass 0.84.3 bare or with a peer-hash — got: ${matcher_probe:-<nothing leaked>}"
3100
+ '@earendil-works+pi-ai@0.84.4' \
3101
+ '@earendil-works+pi-ai@0.84.4_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3102
+ '@earendil-works+pi-ai@0.84.40' \
3103
+ '@earendil-works+pi-agent-core@0.84.3' | pack_install_leaked_pi)
3104
+ if [ "$matcher_probe" != '@earendil-works+pi-ai@0.84.40
3105
+ @earendil-works+pi-agent-core@0.84.3' ]; then
3106
+ fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the 0.84.40/0.84.3 lookalikes and pass 0.84.4 bare or with a peer-hash — got: ${matcher_probe:-<nothing leaked>}"
3094
3107
  return 1
3095
3108
  fi
3096
3109
  echo "[check-pack-pin-matcher] ok — the pin-leak matcher is version-bounded (lookalikes leak, pinned version passes bare and with a peer-hash)"
@@ -3320,7 +3333,7 @@ _check_pack_install_impl() {
3320
3333
  printf '%s\n' '{ "name": "entwurf-install-smoke", "version": "0.0.0", "private": true }' > "$tmp/package.json"
3321
3334
 
3322
3335
  # pi-agent-core is pinned even though we never import it: pi-coding-agent depends
3323
- # on it by CARET (`^0.84.3`), so with no lockfile in this fresh temp project it
3336
+ # on it by CARET (`^0.84.x`), so with no lockfile in this fresh temp project it
3324
3337
  # floats to whatever pi published last — and that newer core then drags a NESTED
3325
3338
  # pi-ai of its own. Measured 2026-07-21: pinning only the three we import left
3326
3339
  # pi-agent-core@0.80.10 + pi-ai@0.80.10 in the tree while the gate still announced
@@ -3335,24 +3348,24 @@ _check_pack_install_impl() {
3335
3348
  # first time. Unpinned they would float exactly like pi-agent-core did in the
3336
3349
  # 2026-07-21 incident above. pi-telemetry joined the explicit pin list on
3337
3350
  # 2026-08-31: it arrives transitively (pi-agent-core and pi-ai both carry
3338
- # `^0.84.3` carets on it), and upstream's 0.84.4 patch publish (2026-08-28
3351
+ # `^0.84.x` carets on it), and upstream's 0.84.4 patch publish (2026-08-28
3339
3352
  # 22:04Z) floated that caret in this lockfile-less temp install, turning CI
3340
- # red through the leak assertion below. The bump itself stays a separate
3341
- # hard-cut lane (#87 thread / NEXT "Do not touch: Pi 0.84.4") pinning the
3342
- # drift shut is how the verified 0.84.3 runtime stays OURS to hold. The leak
3353
+ # red through the leak assertion below. That explicit pin is what held the
3354
+ # line until the bump lane ran; the verified floor is 0.84.4 as of
3355
+ # 2026-09-01, and the pin moved WITH it rather than being retired. The leak
3343
3356
  # assertion below still covers every other pi package, including any package
3344
3357
  # a future pi bump adds to the closure.
3345
3358
  echo "[check-pack-install] pnpm add into $tmp (with 0.84.x peers + typebox)"
3346
3359
  local install_log
3347
3360
  install_log=$(cd "$tmp" && pnpm add \
3348
3361
  "$tgz_path" \
3349
- "@earendil-works/pi-ai@0.84.3" \
3350
- "@earendil-works/pi-coding-agent@0.84.3" \
3351
- "@earendil-works/pi-tui@0.84.3" \
3352
- "@earendil-works/pi-agent-core@0.84.3" \
3353
- "@earendil-works/pi-client@0.84.3" \
3354
- "@earendil-works/pi-protocol@0.84.3" \
3355
- "@earendil-works/pi-telemetry@0.84.3" \
3362
+ "@earendil-works/pi-ai@0.84.4" \
3363
+ "@earendil-works/pi-coding-agent@0.84.4" \
3364
+ "@earendil-works/pi-tui@0.84.4" \
3365
+ "@earendil-works/pi-agent-core@0.84.4" \
3366
+ "@earendil-works/pi-client@0.84.4" \
3367
+ "@earendil-works/pi-protocol@0.84.4" \
3368
+ "@earendil-works/pi-telemetry@0.84.4" \
3356
3369
  "typebox@latest" \
3357
3370
  --ignore-workspace --ignore-scripts 2>&1) || {
3358
3371
  fail "[check-pack-install] pnpm add failed:"
@@ -3362,17 +3375,17 @@ _check_pack_install_impl() {
3362
3375
 
3363
3376
  # A pin is a wish until the resolved tree is read back. Assert it: EVERY
3364
3377
  # @earendil-works pi package present — direct or transitive, top level or nested —
3365
- # must be the pinned 0.84.3. Anything else means an unpinned caret floated and the
3378
+ # must be the pinned 0.84.4. Anything else means an unpinned caret floated and the
3366
3379
  # rest of this gate would be exercising a runtime nobody verified, while still
3367
- # printing "pinned pi 0.84.3". Fail loud instead of proving the wrong floor.
3380
+ # printing "pinned pi 0.84.4". Fail loud instead of proving the wrong floor.
3368
3381
  local leaked_pi
3369
3382
  leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | pack_install_leaked_pi)
3370
3383
  if [ -n "$leaked_pi" ]; then
3371
- fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.3):"
3384
+ fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.4):"
3372
3385
  printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
3373
3386
  return 1
3374
3387
  fi
3375
- echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.3"
3388
+ echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.4"
3376
3389
 
3377
3390
  # Resolve the installed package.json and confirm pi.extensions
3378
3391
  # arrived intact. If pi.extensions is empty or missing, the
@@ -4983,6 +4996,52 @@ setup_all() {
4983
4996
  fi
4984
4997
  fi
4985
4998
 
4999
+ # ── omp (oh-my-pi) ── presence-driven composition, same shape as copilot. OMP
5000
+ # was admitted as a garden citizen in v0.16.0 with installers, doctors and
5001
+ # inverses for every unit — but it was never composed HERE, so the one-command
5002
+ # surface left the fifth backend to a hand-run verb list and the operator setting
5003
+ # to a documentation step. That gap is what `docs/adding-a-harness.md` step 10
5004
+ # now closes for every future harness: an onboarding is not finished until setup
5005
+ # composes it. Four units, each independent: birth (who the citizen is) → MCP
5006
+ # hand (what it can call) → the tools.xdev operator setting (whether those calls
5007
+ # are REACHABLE) → receiver (whether a reply can land). OMP_BIN pins the PROBE
5008
+ # for hermetic gates; the unit scripts address `omp` on PATH.
5009
+ local omp_rc
5010
+ if ! command -v "${OMP_BIN:-omp}" >/dev/null 2>&1; then
5011
+ setup_result omp SKIP "omp not on PATH — zero OMP wiring written"
5012
+ else
5013
+ section "omp units (native harness detected: oh-my-pi)"
5014
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-bridge-install.sh) || omp_rc=$?
5015
+ if [ "$omp_rc" -eq 0 ]; then
5016
+ setup_result omp-birth PASS "birth extension installed — verify: ./run.sh doctor-omp-bridge"
5017
+ else
5018
+ setup_result omp-birth FAIL "detected omp, but the birth extension install did not complete (see above) — repair, then re-run setup"
5019
+ fi
5020
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-mcp-bridge.sh install) || omp_rc=$?
5021
+ if [ "$omp_rc" -eq 0 ]; then
5022
+ setup_result omp-mcp PASS "MCP server registered — verify: ./run.sh doctor-omp-mcp"
5023
+ else
5024
+ setup_result omp-mcp FAIL "detected omp, but the MCP registration did not complete (see above) — repair, then re-run setup"
5025
+ fi
5026
+ # The operator setting is a component of its own because its FAIL is a real
5027
+ # disagreement, not a broken install: an explicit `tools.xdev: true` is the
5028
+ # operator's decision and the writer refuses it by name. Naming that as a
5029
+ # component FAIL puts the choice in front of the operator instead of silently
5030
+ # shipping a citizen whose tools nobody can call.
5031
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install) || omp_rc=$?
5032
+ if [ "$omp_rc" -eq 0 ]; then
5033
+ setup_result omp-config PASS "tools.xdev: false written — verify: ./run.sh doctor-omp-mcp"
5034
+ else
5035
+ setup_result omp-config FAIL "detected omp, but the tools.xdev operator setting did not land (see above) — resolve it, then re-run setup"
5036
+ fi
5037
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-receive-install.sh) || omp_rc=$?
5038
+ if [ "$omp_rc" -eq 0 ]; then
5039
+ setup_result omp-receive PASS "receiver extension installed — verify: ./run.sh doctor-omp-receive"
5040
+ else
5041
+ setup_result omp-receive FAIL "detected omp, but the receiver extension install did not complete (see above) — repair, then re-run setup"
5042
+ fi
5043
+ fi
5044
+
4986
5045
  # ── core bridge boundary ── deterministic preflight lives in `pnpm run
4987
5046
  # check:full`; live substrate acceptance lives in `LIVE=1 ./run.sh
4988
5047
  # release-gate <scratch> --cut`. Setup is the install path, so it verifies the
@@ -5003,7 +5062,7 @@ setup_all() {
5003
5062
  local entry rest s_name s_verdict s_fails=""
5004
5063
  for entry in "${SETUP_RESULTS[@]}"; do
5005
5064
  s_name="${entry%%|*}"; rest="${entry#*|}"; s_verdict="${rest%%|*}"
5006
- printf ' %-14s %-4s %s\n' "$s_name" "$s_verdict" "${rest#*|}"
5065
+ printf ' %-18s %-4s %s\n' "$s_name" "$s_verdict" "${rest#*|}"
5007
5066
  if [ "$s_verdict" = "FAIL" ]; then s_fails="$s_fails $s_name"; fi
5008
5067
  done
5009
5068
  echo ""
@@ -6121,6 +6180,20 @@ case "$cmd" in
6121
6180
  smoke-omp-mcp-state)
6122
6181
  (cd "$REPO_DIR" && bash scripts/smoke-omp-mcp-state.sh)
6123
6182
  ;;
6183
+ install-omp-config)
6184
+ # #87 follow-on: the ONE operator setting omp's tool hand requires (`tools.xdev: false`).
6185
+ # A separate unit from install-omp-mcp because it answers a different question — the MCP
6186
+ # hand registers the server, this decides whether the registered tools are REACHABLE. It
6187
+ # owns exactly the line it adds and refuses an explicit operator `xdev: true` by name.
6188
+ shift || true
6189
+ (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install "$@")
6190
+ ;;
6191
+ uninstall-omp-config)
6192
+ # honest inverse from install-state: takes back exactly the recorded line(s), removes the
6193
+ # file only when entwurf created it, and REFUSES when the config changed since install.
6194
+ shift || true
6195
+ (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh uninstall "$@")
6196
+ ;;
6124
6197
  install-omp-bridge)
6125
6198
  # #87: the OMP BIRTH install. Not a mode of the Claude or Copilot installer, and for a
6126
6199
  # structural reason rather than a stylistic one: an omp "hook" IS an in-process
@@ -6453,6 +6526,9 @@ case "$cmd" in
6453
6526
  check-acp-prompt-lifecycle)
6454
6527
  check_acp_prompt_lifecycle
6455
6528
  ;;
6529
+ check-acp-launch-namespace)
6530
+ check_acp_launch_namespace
6531
+ ;;
6456
6532
  check-acp-stream-hooks)
6457
6533
  check_acp_stream_hooks
6458
6534
  ;;