@junghanacs/entwurf 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +337 -0
  3. package/README.md +8 -11
  4. package/VERIFY.md +8 -1
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +25 -14
  7. package/docs/setup-clean-host.md +24 -10
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
  10. package/package.json +10 -10
  11. package/pi-extensions/lib/acp/acp-client.ts +57 -4
  12. package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
  13. package/pi-extensions/lib/acp/backend.ts +578 -18
  14. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  15. package/pi-extensions/lib/acp/event-mapper.ts +43 -6
  16. package/run.sh +129 -32
  17. package/scripts/check-acp-launch-namespace.ts +127 -0
  18. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  19. package/scripts/check-acp-stop-reason.ts +8 -2
  20. package/scripts/check-acp-usage-accounting.ts +1074 -0
  21. package/scripts/check-copilot-birth-hook.ts +28 -1
  22. package/scripts/check-gate-qualification.ts +4 -2
  23. package/scripts/check-omp-fresh-preflight.ts +27 -0
  24. package/scripts/check-setup-qualification.sh +40 -2
  25. package/scripts/copilot-bridge-oracle.sh +14 -6
  26. package/scripts/fake-copilot-vendor.sh +4 -2
  27. package/scripts/lib/pi-record-discovery.ts +47 -0
  28. package/scripts/mutants/acp-launch-namespace.json +34 -0
  29. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  30. package/scripts/mutants/acp-stream-hooks.json +4 -2
  31. package/scripts/mutants/acp-usage-accounting.json +181 -0
  32. package/scripts/mutants/copilot-birth.json +3 -5
  33. package/scripts/mutants/pack-install.json +2 -2
  34. package/scripts/mutants/setup-verdict.json +35 -0
  35. package/scripts/omp-config-xdev.py +310 -0
  36. package/scripts/omp-config-xdev.sh +76 -0
  37. package/scripts/omp-tool-surface.py +61 -10
  38. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  39. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  40. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  41. package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
  42. package/scripts/smoke-acp-cortex-live.ts +2 -2
  43. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  44. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  45. package/scripts/smoke-acp-v2-send-live.ts +2 -2
  46. package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
  47. package/scripts/smoke-mux-lifecycle-live.ts +46 -2
  48. package/scripts/smoke-setup-verdict.sh +48 -3
@@ -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
+ }
@@ -49,6 +49,22 @@ export type AcpPiStreamState = {
49
49
  /** When false, tool/permission notices are suppressed (kept terse for smokes). */
50
50
  showToolNotifications?: boolean;
51
51
  observedTools?: Map<string, ObservedToolState>;
52
+ /**
53
+ * RAW, UNINTERPRETED observations from this turn's `usage_update` notifications
54
+ * — carried beside the pi message, never folded into it here.
55
+ *
56
+ * The mapper is COMMON layer and deliberately assigns no meaning to either
57
+ * number: what `cost.amount` is CUMULATIVE OVER, and whether that even holds
58
+ * for a non-claude backend, is a per-backend measurement. backend.ts seals them
59
+ * only for a backend whose adapter declares `sealsTurnAccounting` (#93).
60
+ *
61
+ * Last write wins, never a sum: both are latest session-level observations, and
62
+ * one turn can legitimately see several (claude emits one per `result` message,
63
+ * including a sub-agent's own — read at claude-agent-acp 0.73.0
64
+ * `dist/acp-agent.js:2918-2933`), each carrying that result's current values.
65
+ */
66
+ observedSessionCostUsd?: number;
67
+ observedContextOccupancyTokens?: number;
52
68
  };
53
69
 
54
70
  /** A zeroed pi Usage block. */
@@ -328,13 +344,34 @@ export function applyAcpSessionUpdate(
328
344
  break;
329
345
  }
330
346
  case "usage_update": {
331
- // S2c maps COARSE ACP usage only: `used` is occupancy-shaped and does
332
- // not split cleanly into pi's input/output/cache fields, so we fill
333
- // totalTokens + cost.total and leave the rest zero. Richer accounting
334
- // is a later lane (S2e/PR-polish).
335
- if (typeof update.used === "number") state.output.usage.totalTokens = update.used;
347
+ // `used` is OCCUPANCY-shaped the backend's post-turn context size, not
348
+ // the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
349
+ // as `used` (read at claude-agent-acp 0.73.0
350
+ // `dist/acp-agent.js:3290-3297`), after constructing that scalar from the
351
+ // latest assistant snapshot (`:3273-3289`). pi reads `usage.totalTokens` as
352
+ // exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
353
+ // || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
354
+ // `dist/core/compaction/compaction.js:86-88`). The assignment below is the
355
+ // raw observation; backend.ts seals the measured occupancy and relays the
356
+ // turn aggregate separately on `usage.acp`, never onto pi's four fields.
357
+ if (typeof update.used === "number") {
358
+ state.output.usage.totalTokens = update.used;
359
+ state.observedContextOccupancyTokens = update.used;
360
+ }
361
+ // `cost.amount` is a running SESSION total on the claude backend, so
362
+ // assigning it to a TURN field is wrong wherever an adapter has measured
363
+ // that (#93: pi sums per-message cost, which inflated a long session
364
+ // 10-18x). It stays here for a backend whose adapter has NOT measured its
365
+ // semantics — for those, this coarse assignment is the pre-#93 behaviour
366
+ // and changing it would be an unmeasured claim. backend.ts OVERWRITES
367
+ // this field authoritatively (from the baseline diff) for every turn of a
368
+ // backend that HAS sealsTurnAccounting, so the cumulative can never reach
369
+ // an operator's dashboard as a turn cost on that path.
336
370
  const cost = update.cost as { amount?: unknown } | undefined;
337
- if (typeof cost?.amount === "number") state.output.usage.cost.total = cost.amount;
371
+ if (typeof cost?.amount === "number") {
372
+ state.output.usage.cost.total = cost.amount;
373
+ state.observedSessionCostUsd = cost.amount;
374
+ }
338
375
  break;
339
376
  }
340
377
  default:
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`);
@@ -2698,6 +2700,24 @@ check_acp_event_mapper() {
2698
2700
  run_ts scripts/check-acp-event-mapper.ts
2699
2701
  }
2700
2702
 
2703
+ check_acp_usage_accounting() {
2704
+ # Deterministic gate for the ACP USAGE ACCOUNTING contract (#93). A long-lived
2705
+ # Claude ACP session's dashboard read 10-18x high on three live ledgers,
2706
+ # because the per-turn token partition was dropped at the type boundary while
2707
+ # the backend's RUNNING SESSION TOTAL was assigned to a per-turn cost field pi
2708
+ # then summed. Drives streamAcpTurn against a fake ACP child + connection whose
2709
+ # turns are scripted (usage_update notification + PromptResponse.usage) for
2710
+ # five cells: the four-way token partition reaches the pi message; per-turn
2711
+ # costs are adjacent diffs of the running total and sum back to it across a
2712
+ # reused session; a turn with no cost notification attributes $0 and HOLDS the
2713
+ # baseline; a decreasing total rebaselines, attributes $0 and tells the
2714
+ # operator; totalTokens stays CONTEXT OCCUPANCY (asserted through pi's own
2715
+ # calculateContextTokens) and carries forward; and cortex — no measured
2716
+ # extractor — keeps its pre-#93 output untouched.
2717
+ section "ACP usage accounting (turn partition + adjacent-diff cost)"
2718
+ run_ts scripts/check-acp-usage-accounting.ts
2719
+ }
2720
+
2701
2721
  check_acp_stop_reason() {
2702
2722
  # Deterministic gate for the ACP stop-reason contract. Drives every member of
2703
2723
  # the closed ACP terminal set (end_turn / max_tokens / max_turn_requests /
@@ -2729,6 +2749,17 @@ check_acp_prompt_lifecycle() {
2729
2749
  run_ts scripts/check-acp-prompt-lifecycle.ts
2730
2750
  }
2731
2751
 
2752
+ check_acp_launch_namespace() {
2753
+ # #72: the Claude ACP child must launch under a name entwurf OWNS. A janitor
2754
+ # installed on the host for ANOTHER harness selects the vendor process name
2755
+ # `claude-agent-acp` by argv substring and SIGTERMs it by age; entwurf retains
2756
+ # its child across turns, so its age is the session's. This gate holds the
2757
+ # name split, holds the launcher transparent (the vendor still answers
2758
+ # --version through it), and keeps an explicit operator override verbatim.
2759
+ section "ACP launch namespace (#72 — no vendor process name in our argv)"
2760
+ run_ts scripts/check-acp-launch-namespace.ts
2761
+ }
2762
+
2732
2763
  check_acp_stream_hooks() {
2733
2764
  # Deterministic gate for the pi 0.84 streamSimple hook contract on the ACP rail
2734
2765
  # (#63; upstream pi-mono #7372 → doc-only #7576). before_provider_request
@@ -3067,15 +3098,15 @@ check_pack() {
3067
3098
  # The pin-leak filter for the install tree's .pnpm listing, shared by the real scan and its
3068
3099
  # self-test below. The version BOUNDARY is load-bearing: a pnpm .pnpm entry is
3069
3100
  # `<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`
3101
+ # on this tree), so an unbounded substring match would bless a lookalike such as `@0.84.40`
3071
3102
  # while announcing the pinned floor — a false-green oracle (found by independent review,
3072
3103
  # 2026-08-25).
3073
3104
  pack_install_leaked_pi() {
3074
- grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.3(_|$)' || true
3105
+ grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.4(_|$)' || true
3075
3106
  }
3076
3107
 
3077
3108
  # 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
3109
+ # false-green shape (it contains no 0.84.40), so the oracle is proven against a fixture
3079
3110
  # listing. Expected: the two lookalikes leak, the pinned version passes bare and with a
3080
3111
  # peer-hash suffix. Exposed as its own snapshot-safe subcommand because the heavy
3081
3112
  # check-pack-install cannot run inside the qualification snapshot (no install environment),
@@ -3084,13 +3115,13 @@ pack_install_leaked_pi() {
3084
3115
  check_pack_pin_matcher() {
3085
3116
  local matcher_probe
3086
3117
  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>}"
3118
+ '@earendil-works+pi-ai@0.84.4' \
3119
+ '@earendil-works+pi-ai@0.84.4_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3120
+ '@earendil-works+pi-ai@0.84.40' \
3121
+ '@earendil-works+pi-agent-core@0.84.3' | pack_install_leaked_pi)
3122
+ if [ "$matcher_probe" != '@earendil-works+pi-ai@0.84.40
3123
+ @earendil-works+pi-agent-core@0.84.3' ]; then
3124
+ 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
3125
  return 1
3095
3126
  fi
3096
3127
  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 +3351,7 @@ _check_pack_install_impl() {
3320
3351
  printf '%s\n' '{ "name": "entwurf-install-smoke", "version": "0.0.0", "private": true }' > "$tmp/package.json"
3321
3352
 
3322
3353
  # 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
3354
+ # on it by CARET (`^0.84.x`), so with no lockfile in this fresh temp project it
3324
3355
  # floats to whatever pi published last — and that newer core then drags a NESTED
3325
3356
  # pi-ai of its own. Measured 2026-07-21: pinning only the three we import left
3326
3357
  # pi-agent-core@0.80.10 + pi-ai@0.80.10 in the tree while the gate still announced
@@ -3335,24 +3366,24 @@ _check_pack_install_impl() {
3335
3366
  # first time. Unpinned they would float exactly like pi-agent-core did in the
3336
3367
  # 2026-07-21 incident above. pi-telemetry joined the explicit pin list on
3337
3368
  # 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
3369
+ # `^0.84.x` carets on it), and upstream's 0.84.4 patch publish (2026-08-28
3339
3370
  # 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
3371
+ # red through the leak assertion below. That explicit pin is what held the
3372
+ # line until the bump lane ran; the verified floor is 0.84.4 as of
3373
+ # 2026-09-01, and the pin moved WITH it rather than being retired. The leak
3343
3374
  # assertion below still covers every other pi package, including any package
3344
3375
  # a future pi bump adds to the closure.
3345
3376
  echo "[check-pack-install] pnpm add into $tmp (with 0.84.x peers + typebox)"
3346
3377
  local install_log
3347
3378
  install_log=$(cd "$tmp" && pnpm add \
3348
3379
  "$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" \
3380
+ "@earendil-works/pi-ai@0.84.4" \
3381
+ "@earendil-works/pi-coding-agent@0.84.4" \
3382
+ "@earendil-works/pi-tui@0.84.4" \
3383
+ "@earendil-works/pi-agent-core@0.84.4" \
3384
+ "@earendil-works/pi-client@0.84.4" \
3385
+ "@earendil-works/pi-protocol@0.84.4" \
3386
+ "@earendil-works/pi-telemetry@0.84.4" \
3356
3387
  "typebox@latest" \
3357
3388
  --ignore-workspace --ignore-scripts 2>&1) || {
3358
3389
  fail "[check-pack-install] pnpm add failed:"
@@ -3362,17 +3393,17 @@ _check_pack_install_impl() {
3362
3393
 
3363
3394
  # A pin is a wish until the resolved tree is read back. Assert it: EVERY
3364
3395
  # @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
3396
+ # must be the pinned 0.84.4. Anything else means an unpinned caret floated and the
3366
3397
  # 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.
3398
+ # printing "pinned pi 0.84.4". Fail loud instead of proving the wrong floor.
3368
3399
  local leaked_pi
3369
3400
  leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | pack_install_leaked_pi)
3370
3401
  if [ -n "$leaked_pi" ]; then
3371
- fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.3):"
3402
+ fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.4):"
3372
3403
  printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
3373
3404
  return 1
3374
3405
  fi
3375
- echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.3"
3406
+ echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.4"
3376
3407
 
3377
3408
  # Resolve the installed package.json and confirm pi.extensions
3378
3409
  # arrived intact. If pi.extensions is empty or missing, the
@@ -4983,6 +5014,52 @@ setup_all() {
4983
5014
  fi
4984
5015
  fi
4985
5016
 
5017
+ # ── omp (oh-my-pi) ── presence-driven composition, same shape as copilot. OMP
5018
+ # was admitted as a garden citizen in v0.16.0 with installers, doctors and
5019
+ # inverses for every unit — but it was never composed HERE, so the one-command
5020
+ # surface left the fifth backend to a hand-run verb list and the operator setting
5021
+ # to a documentation step. That gap is what `docs/adding-a-harness.md` step 10
5022
+ # now closes for every future harness: an onboarding is not finished until setup
5023
+ # composes it. Four units, each independent: birth (who the citizen is) → MCP
5024
+ # hand (what it can call) → the tools.xdev operator setting (whether those calls
5025
+ # are REACHABLE) → receiver (whether a reply can land). OMP_BIN pins the PROBE
5026
+ # for hermetic gates; the unit scripts address `omp` on PATH.
5027
+ local omp_rc
5028
+ if ! command -v "${OMP_BIN:-omp}" >/dev/null 2>&1; then
5029
+ setup_result omp SKIP "omp not on PATH — zero OMP wiring written"
5030
+ else
5031
+ section "omp units (native harness detected: oh-my-pi)"
5032
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-bridge-install.sh) || omp_rc=$?
5033
+ if [ "$omp_rc" -eq 0 ]; then
5034
+ setup_result omp-birth PASS "birth extension installed — verify: ./run.sh doctor-omp-bridge"
5035
+ else
5036
+ setup_result omp-birth FAIL "detected omp, but the birth extension install did not complete (see above) — repair, then re-run setup"
5037
+ fi
5038
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-mcp-bridge.sh install) || omp_rc=$?
5039
+ if [ "$omp_rc" -eq 0 ]; then
5040
+ setup_result omp-mcp PASS "MCP server registered — verify: ./run.sh doctor-omp-mcp"
5041
+ else
5042
+ setup_result omp-mcp FAIL "detected omp, but the MCP registration did not complete (see above) — repair, then re-run setup"
5043
+ fi
5044
+ # The operator setting is a component of its own because its FAIL is a real
5045
+ # disagreement, not a broken install: an explicit `tools.xdev: true` is the
5046
+ # operator's decision and the writer refuses it by name. Naming that as a
5047
+ # component FAIL puts the choice in front of the operator instead of silently
5048
+ # shipping a citizen whose tools nobody can call.
5049
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install) || omp_rc=$?
5050
+ if [ "$omp_rc" -eq 0 ]; then
5051
+ setup_result omp-config PASS "tools.xdev: false written — verify: ./run.sh doctor-omp-mcp"
5052
+ else
5053
+ setup_result omp-config FAIL "detected omp, but the tools.xdev operator setting did not land (see above) — resolve it, then re-run setup"
5054
+ fi
5055
+ omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-receive-install.sh) || omp_rc=$?
5056
+ if [ "$omp_rc" -eq 0 ]; then
5057
+ setup_result omp-receive PASS "receiver extension installed — verify: ./run.sh doctor-omp-receive"
5058
+ else
5059
+ setup_result omp-receive FAIL "detected omp, but the receiver extension install did not complete (see above) — repair, then re-run setup"
5060
+ fi
5061
+ fi
5062
+
4986
5063
  # ── core bridge boundary ── deterministic preflight lives in `pnpm run
4987
5064
  # check:full`; live substrate acceptance lives in `LIVE=1 ./run.sh
4988
5065
  # release-gate <scratch> --cut`. Setup is the install path, so it verifies the
@@ -5003,7 +5080,7 @@ setup_all() {
5003
5080
  local entry rest s_name s_verdict s_fails=""
5004
5081
  for entry in "${SETUP_RESULTS[@]}"; do
5005
5082
  s_name="${entry%%|*}"; rest="${entry#*|}"; s_verdict="${rest%%|*}"
5006
- printf ' %-14s %-4s %s\n' "$s_name" "$s_verdict" "${rest#*|}"
5083
+ printf ' %-18s %-4s %s\n' "$s_name" "$s_verdict" "${rest#*|}"
5007
5084
  if [ "$s_verdict" = "FAIL" ]; then s_fails="$s_fails $s_name"; fi
5008
5085
  done
5009
5086
  echo ""
@@ -6121,6 +6198,20 @@ case "$cmd" in
6121
6198
  smoke-omp-mcp-state)
6122
6199
  (cd "$REPO_DIR" && bash scripts/smoke-omp-mcp-state.sh)
6123
6200
  ;;
6201
+ install-omp-config)
6202
+ # #87 follow-on: the ONE operator setting omp's tool hand requires (`tools.xdev: false`).
6203
+ # A separate unit from install-omp-mcp because it answers a different question — the MCP
6204
+ # hand registers the server, this decides whether the registered tools are REACHABLE. It
6205
+ # owns exactly the line it adds and refuses an explicit operator `xdev: true` by name.
6206
+ shift || true
6207
+ (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install "$@")
6208
+ ;;
6209
+ uninstall-omp-config)
6210
+ # honest inverse from install-state: takes back exactly the recorded line(s), removes the
6211
+ # file only when entwurf created it, and REFUSES when the config changed since install.
6212
+ shift || true
6213
+ (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh uninstall "$@")
6214
+ ;;
6124
6215
  install-omp-bridge)
6125
6216
  # #87: the OMP BIRTH install. Not a mode of the Claude or Copilot installer, and for a
6126
6217
  # structural reason rather than a stylistic one: an omp "hook" IS an in-process
@@ -6447,12 +6538,18 @@ case "$cmd" in
6447
6538
  check-acp-event-mapper)
6448
6539
  check_acp_event_mapper
6449
6540
  ;;
6541
+ check-acp-usage-accounting)
6542
+ check_acp_usage_accounting
6543
+ ;;
6450
6544
  check-acp-stop-reason)
6451
6545
  check_acp_stop_reason
6452
6546
  ;;
6453
6547
  check-acp-prompt-lifecycle)
6454
6548
  check_acp_prompt_lifecycle
6455
6549
  ;;
6550
+ check-acp-launch-namespace)
6551
+ check_acp_launch_namespace
6552
+ ;;
6456
6553
  check-acp-stream-hooks)
6457
6554
  check_acp_stream_hooks
6458
6555
  ;;
@@ -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
+ );