diff --git a/MAINTAINER.md b/MAINTAINER.md index 37decc3..f7db82d 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -95,7 +95,9 @@ docs/ `docs/INSTALLATION.md`, `docs/MODELS.md`, `docs/PROVIDERS.md`, `docs/SETUP.md`, `docs/TROUBLESHOOTING.md`, `docs/UPGRADING.md`, `docs/CODEX-STATUSLINE.md`, `docs/adr/0015-managed-codex-native-statusline.md`, -`docs/adr/0032-model-lifecycle-intelligence.md`, and +`docs/adr/0032-model-lifecycle-intelligence.md`, +`docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md`, +`tests/live/qe-court-participant-transport.test.mjs`, and `docs/ddd/model-lifecycle-intelligence.md`. Generated workspace state under the shipped source trees is explicitly excluded. Nothing else ships — verify with `npm pack --dry-run` before a release if you touch `files`. @@ -105,7 +107,8 @@ split between `integrations.hosts` (which hosts are enabled) and top-level `rout (`version`, `primaryHost`, and the per-activity `routes`). `routing.mjs` is pure (defaults, primary-host mirroring, validation, and projections to AQE `agentOverrides` and `ak run`); `providers.mjs` does the I/O (host/auth detection, -environment wiring, both MCP-bridge directions, AQE router file). Seeded/healed by +environment wiring, Codex's independent Ruflo integration, legacy MCP retirement, and the AQE +router file). Seeded/healed by `setup` + `sync` + `x host pick`, surfaced by `status` + `dashboard`. Design records: ADRs [0001–0006](docs/adr/); user guide: `docs/PROVIDERS.md`. diff --git a/claude/dual-mode-reference.md b/claude/dual-mode-reference.md index e05d12f..e91b6a4 100644 --- a/claude/dual-mode-reference.md +++ b/claude/dual-mode-reference.md @@ -9,10 +9,9 @@ ## Ambidextrous dual-host mode (claude + codex) -Both frontier CLIs are enabled, so `ak` runs **ambidextrous**: the same tools, memory, -and quality gates are available whichever agent is in the driver's seat, and each host can -reach the other. Work flows complementarily — Claude and Codex are peers, not primary and -fallback. +Both frontier CLIs are enabled, so `ak` runs **ambidextrous**: the same Ruflo/AQE tools, +memory, and quality gates are available whichever agent is in the driver's seat. Work flows +complementarily — Claude and Codex are peers, not primary and fallback. ### `ak run` — canonical activity pipelines @@ -37,17 +36,21 @@ ak run refactor "extract the payment module" --dry-run - **`--json`** — emit machine-readable output while executing; combine it with `--dry-run` for a machine-readable preview. -### The Claude ↔ Codex bridge (bidirectional MCP) +### Cross-host integration -The two hosts see each other as MCP servers, so either can delegate to the other mid-task: +Managed cross-host work uses one bounded path: -- **Claude → Codex** — Codex is exposed as an MCP server (`codex mcp-server`); Claude - reaches it through the **`mcp__codex__codex`** tool to hand a subtask to Codex. -- **Codex → ruflo** — Codex registers ruflo's MCP via `[mcp_servers.ruflo]` in +- **Claude-led or Codex-led execution** — use `ak run`; every worker has one absolute + deadline and process-tree cleanup. +- **Codex → Ruflo/AQE tools** — Codex registers Ruflo's MCP via `[mcp_servers.ruflo]` in `~/.codex/config.toml`, so Codex-driven sessions get the same memory, routing, and swarm - tools Claude has. + tools Claude has. Agentic-QE owns its separate Codex MCP/platform integration. +- **Optional Claude → Codex interactive delegation** — OpenAI's Claude Code plugin uses + Codex App Server and remains user-owned. Agentic-kit never silently installs it. -Register (or repair) both directions with `ak sync`; inspect with `ak status`. +OpenAI deprecated `codex mcp-server` on 2026-08-24. `ak sync` removes only the legacy +project registration that agentic-kit owns; user-owned entries are preserved and warned. +Inspect the effective topology with `ak status`. ### Per-activity routing + escalation ladders @@ -68,6 +71,8 @@ ambidextrous experience with the roles flipped. `ak status` marks the primary an Two enabled hosts do not by themselves prove two inference vendors. qe-court diversity must be grounded in the providers that actually serve the selected roles. Prefer independently evidenced providers for review/security activities and treat configured host/model routes as intent, not -vendor proof. +vendor proof. `primaryHost` mirrors `ak run` activity routes; it does not reverse QE-Court seats. +Until Agentic-QE ships a host-neutral court runner and complete Codex projection, the reciprocal +live check is a **participant-transport regression**, not a court verdict. diff --git a/claude/providers-reference.md b/claude/providers-reference.md index 21d7bfc..4eacb8d 100644 --- a/claude/providers-reference.md +++ b/claude/providers-reference.md @@ -62,17 +62,17 @@ ak host off # reset to claude-only default; strip managed env keys `pick` persists your choice to `kit.json` and applies it: it writes the ruflo backend flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`) and `AQE_LLM_PROVIDER` into -`.claude/settings.local.json` `env` (merge-not-clobber, backup-first), maintains the -Claude↔Codex bridge when both hosts are enabled, and registers any API-key providers with ruflo. +`.claude/settings.local.json` `env` (merge-not-clobber, backup-first), maintains Codex's +independent Ruflo integration, and registers any API-key providers with ruflo. `ak sync` reapplies the same choice idempotently; `ak status` shows **hosts** and **providers** rows and flags drift. At the claude-only default nothing is written — behavior is unchanged until you opt in. -When **both** hosts are enabled, `ak` also seeds a **per-activity routing policy** and wires a -**two-way Claude↔Codex MCP bridge** (Claude reaches Codex via `mcp__codex__codex`; Codex reaches -ruflo via `[mcp_servers.ruflo]`). `--primary-host claude|codex` chooses which host leads. See the -ambidextrous dual-host reference block and `docs/PROVIDERS.md` §3.5 for the routing table and -canonical `ak run` execution. +When **both** hosts are enabled, `ak` also seeds a **per-activity routing policy** and gives Codex +Ruflo access via `[mcp_servers.ruflo]`. `--primary-host claude|codex` chooses which host leads +bounded `ak run` pipelines. OpenAI deprecated the former Claude→`codex mcp-server` projection; +its Claude Code plugin is an optional user-owned interactive path. See the ambidextrous dual-host +reference block and `docs/PROVIDERS.md` §3.5. ### Install & update (install-method-aware) diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index 0beff94..00bc601 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -53,7 +53,7 @@ served it. See [Providers and hosts](PROVIDERS.md) for those axes in detail. | agentic-kit standing | Default-enabled; primary-eligible | Opt-in; primary-eligible | Opt-in; explicitly non-primary | | `ak run` execution | Native CLI adapter | Native CLI adapter | Managed supervised-server adapter | | Automatic activity routes | Yes | Yes | No; explicit routes only | -| Ruflo support | Reference/native surface | Strong, with bridge and parity gaps | Managed compatibility layer | +| Ruflo support | Reference/native surface | Strong, with integration and parity gaps | Managed compatibility layer | | AQE support | Default and fullest path | Strong platform path; one direct-provider gap in `ak` | Upstream platform assets, not an AQE inference provider | | RuvNet Brain | Native plugin, hooks, MCP, console | Native plugin, hooks, MCP, skills | Managed search MCP and guidance; no native Brain plugin | | Managed command status line | Yes | No; Codex's native built-in fields only | No | @@ -106,14 +106,15 @@ Official extension references: [Claude hooks](https://code.claude.com/docs/en/ho | Ruflo capability | Claude Code | Codex | OpenCode | | --- | --- | --- | --- | -| Upstream host orientation | **Native:** primary/reference CLI surface | **Native + managed:** upstream backend/plugin pieces plus agentic-kit bridge | **Managed:** no equivalent upstream backend flag | +| Upstream host orientation | **Native:** primary/reference CLI surface | **Native + managed:** upstream backend/plugin pieces plus agentic-kit integration | **Managed:** no equivalent upstream backend flag | | Ruflo MCP tools | Native registration | Managed Ruflo MCP registration | Connected managed MCP; compact lazy `ak_ruflo_*` provider projection | | Shared Ruflo memory | Same project store | Same project store | Same project store when pointed at the same Ruflo server | | Agents and skills | Upstream Claude assets | Codex-compatible skills/plugin assets and generated guidance | Receipt-owned lazy profile catalogue through one stock `ak-specialist`; stock skills loaded on demand | | Lifecycle hooks | Native Claude hooks | Codex hooks/plugin surfaces | OpenCode events translated by `ruflo-hooks.js` | | Inference-backend flag | `ENABLE_CLAUDE_CODE` | `ENABLE_CODEX` | None | -| Cross-host bridge | Claude can call the Codex MCP server | Codex can call Ruflo MCP | No equivalent peer bridge | -| Upgrade convergence | `ak sync` heals managed assets | `ak sync` heals bridge/guidance | `ak sync` regenerates the embedded catalogue and repairs exact-receipted plugins/config | +| Cross-host execution | `ak run` can lead bounded Codex workers | `ak run` can lead bounded Claude workers | Explicit `ak run` routes only | +| Interactive peer path | Optional user-owned OpenAI Codex plugin/App Server | No supported inverse Claude plugin | None | +| Upgrade convergence | `ak sync` heals managed assets | `ak sync` heals Ruflo/AQE access and retires owned legacy MCP | `ak sync` regenerates the embedded catalogue and repairs exact-receipted plugins/config | | Teardown | Managed blocks and registrations | Receipt-based managed teardown | Value- and hash-receipt teardown; user-owned values survive | Ruflo MCP access and Ruflo-backed inference are different contracts. In diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f82bd30..379a86a 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -266,7 +266,7 @@ An interactive picker (or flags for scripts). Enable `codex` and `ak`: - installs it if it's missing (`npm i -g @openai/codex`) — but leaves an existing mise/brew/native install alone, -- maintains the Claude↔Codex bridge and generated host guidance, +- maintains shared Ruflo/AQE access and generated host guidance, - writes `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` into `.claude/settings.local.json`. > [!NOTE] @@ -383,13 +383,30 @@ is aborted and reported as `permission_required`; `ak` never auto-approves it. O not written to AQE `agentOverrides`, cannot become `primaryHost`, and do not count as a separate AQE vendor. `ak run` is the only execution surface for an OpenCode route. -**QE-Court validation stays upstream-owned.** `agentic-qe` 3.13.3 corrected its shipped -QE-Court panel and now enforces the configured anti-collusion policy before convening. -`ak status` and `ak host status` surface that result read-only; `ak sync` never rewrites +**QE-Court validation stays upstream-owned.** Agentic-kit's local check proves only configured +vendor diversity and writer/jury separation; it does not prove that provider seats or the court +runtime are executable. `ak status` and `ak host status` state that boundary explicitly, and +`ak sync` never rewrites `.claude/skills/qe-court/config.json`. If a config created by 3.13.2 or earlier still seats both `defense` and `jury` on Cognitum tiers, regenerate it with 3.13.3+ or change `defense` to `claude-code` so the jury and defense use distinct vendors. +`primaryHost` controls the mirrored `ak run` activity policy, not QE-Court roles. The source-tree +live regression exercises bounded participant transport from both directions without claiming a +court verdict: + +```bash +pnpm test:qe-court-live # one Claude-led + one Codex-led trial +AK_QE_COURT_TRIALS=5 pnpm test:qe-court-live # POSIX soak +``` + +Each seat performs an MCP-native Ruflo memory store→retrieve round trip, emits the exact returned +value in a validated bounded handoff, and must terminate within its absolute deadline. The check +independently confirms the stored project-memory value and fails on repository mutation or +orphaned state. +Full court parity remains blocked until Agentic-QE ships a supported host-neutral runner and a +self-contained Codex QE-Court projection. + Defaults (all overridable; your edits are marked `custom` and never re-seeded): | Activity | Host | Default model | @@ -447,9 +464,9 @@ weigh, cleared with `ak x host refresh` if you want the newer default. set as authoritative. Excluding a routing host removes it from persisted routes and escalation ladders before AQE is reprojected; seeded entries are removed silently, while a user-pinned route prints a warning naming the disabled host. It also removes stale agentic-kit-curated AQE overrides -while preserving foreign override keys. Excluding Codex additionally retires only the two -marker-owned Codex MCP bridges; user-registered MCP servers are left alone. Re-enabling Codex -converges those bridges again. +while preserving foreign override keys. Excluding Codex retires only marker-owned integrations; +user-registered MCP servers are left alone. The deprecated Claude→Codex MCP projection is retired +even while Codex remains enabled, while the independent Ruflo-in-Codex registration converges. `routing.routes` intentionally names a host and model, not an inference provider. Provider resolution is a separate binding lookup; absent grounded evidence remains unknown or explicitly inferred. @@ -467,7 +484,7 @@ The native config stores each knob below lives in — and their precedence — a | You want to… | `ak` way | The raw ruflo/aqe way it maps to | | ------------------------------------ | --------------------------------- | --------------------------------------------------- | -| Enable claude/codex hosts | `ak host pick` | `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` env + managed bridge/guidance | +| Enable claude/codex hosts | `ak host pick` | `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` env + shared Ruflo/AQE access and guidance | | Register a Ruflo LLM provider | `--provider ollama:qwen3.6:27b` | `ruflo providers configure -p ollama -m qwen3.6:27b -e http://127.0.0.1:11434` | | Select a direct Ruflo provider | per-agent/raw setting | agent `--provider` or `RUFLO_PROVIDER=ollama` / `openrouter` | | Set which LLM runs QE | `--aqe-provider gemini` | `AQE_LLM_PROVIDER=gemini` (env) | diff --git a/docs/SETUP.md b/docs/SETUP.md index a8d50af..02e58e5 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -74,8 +74,9 @@ versions. The table below describes the current contract. | Project `AGENTS.md` | Arbitrary project content is preserved. Agentic-kit only reconciles its own sentinel-delimited managed blocks, including removing stale blocks that no longer apply. | Created only when a selected integration needs managed guidance. | Project setup also reapplies enabled host/provider wiring and seeds the default -per-activity routing policy in dual-host mode. With Codex enabled, it repairs -both directions of the Claude/Codex–Ruflo bridge. +per-activity routing policy in dual-host mode. With Codex enabled, it registers +the workspace-aware Ruflo MCP in Codex and retires only agentic-kit-owned legacy +`codex mcp-server` project entries. ## Setup trust manifest @@ -117,11 +118,14 @@ is removed and setup fails instead of silently expanding project trust. ### Codex and OpenCode -Codex does not need an agentic-kit auto-approve list. The manifest instead names -the project Claude-to-Codex MCP bridge, the user-scope Codex-to-Ruflo MCP -registration, and the AQE Codex integration that project setup will create. +Codex does not need an agentic-kit auto-approve list. The manifest names the +user-scope Codex-to-Ruflo MCP registration and the AQE Codex integration that +project setup will create. Agentic-kit does not alter Codex's sandbox or approval policy. +OpenAI's Claude Code plugin for Codex is an optional App Server-based interactive +path. It is user-owned: setup and sync never install, enable, update, or remove it. + Codex also retains exclusive ownership of third-party plugins. Agentic-kit never installs or enables a Codex plugin (including `security-guidance`), and setup/sync never rewrites Codex's plugin tables or cache. `ak status` only reads enabled diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 2f70aab..0766dff 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -83,7 +83,7 @@ open in other terminals, here is what can actually reach them, worst first: What does **not** break, by design: running binaries keep executing their old code (replaced files don't affect a running process's open inodes). Agentic-kit's managed settings and guidance writers are atomic and fail closed when the one-time backup cannot -be created or validated. Settings env keys, `~/.codex/config.toml` edits (MCP bridge, +be created or validated. Settings env keys, `~/.codex/config.toml` edits (Ruflo/AQE MCP, `[tui]` status line), OpenCode wiring, `.agentic-qe/llm-config.json`, and the managed guidance blocks are all **read at session start** — a live session simply doesn't see them until its next launch. The kit's own self-update runs last and applies from the next @@ -158,6 +158,11 @@ an agentic-qe or `ak` upgrade. If `ak status` reports `writerIsNeverJuror`, rege config with agentic-qe 3.13.3+ or change `routing.defense.provider` from `cognitum-low` to `claude-code`. `ak` reports this state read-only; `ak sync` no longer changes QE-Court roles. +The local anti-collusion check is not a runtime-readiness proof. Current consumer projections can +reference source-only referee/oracle assets, and `primaryHost` does not reverse court seats. Use +`pnpm test:qe-court-live` in a source checkout for one bounded Claude-led and one bounded +Codex-led **participant-transport** trial; do not record it as a court verdict. + If you already have `ak` working, you almost never need `ak setup` again — it's the installer. Enabling a shipped-but-opt-in host feature is a `host pick` (or an `x mcp pick`, etc.), not a re-`setup`. Project setup calls `ruflo init --full --force`, so review the @@ -184,7 +189,8 @@ ak host status # 3. verify: hosts "enabled, wired" + routing tab Step 1 gets the newer code onto disk. Step 2 is what actually turns dual-host on — it records `codex` in `kit.json` and does the wiring: writes `ENABLE_CODEX` into `.claude/settings.local.json`, seeds the per-activity routing policy, registers the -Codex↔ruflo MCP bridge both ways, and generates the dual-host guidance. +workspace-aware Ruflo MCP in Codex, retires any agentic-kit-owned legacy `codex mcp-server` +project entry, and generates the dual-host guidance. Add `--primary-host codex` if you want Codex to lead (Claude becomes the alternate). > [!NOTE] @@ -208,12 +214,12 @@ Every `ak` command ends with a best-effort, never-blocking drift nudge. It has t - **Version drift** (npm-managed tools; TTL-cached network check): `↑ ruflo 4.1.0 available (installed 4.0.0) — run: ak sync` - **Local artifact drift** (spawn-light file compares, evaluated on every run): - `↻ drifted: 2 CLAUDE.md block(s) · codex MCP unregistered — run: ak sync` + `↻ drifted: 2 CLAUDE.md block(s) · deprecated codex MCP registered — run: ak sync` The second half covers the artifacts `ak` *renders*: managed guidance blocks in the machine-wide guidance files (`~/.claude/CLAUDE.md`, and `~/.codex/AGENTS.md` on codex -machines), the Claude↔Codex MCP bridge (both -directions), and the statusline footer. These can drift with **no version change at all** — +machines), Codex's independent Ruflo/AQE access, legacy MCP retirement, and the statusline +footer. These can drift with **no version change at all** — a kit update (or, on an npm-linked dev checkout, merely merging a PR that edits a `claude/*.md` template) revises the source of truth, and the rendered copies lag until the next `ak sync`. The nudge closes that window, using the exact drift definitions `ak status` uses (the two diff --git a/docs/adr/0001-one-routing-policy-many-projections.md b/docs/adr/0001-one-routing-policy-many-projections.md index cebb0ca..da2ab2b 100644 --- a/docs/adr/0001-one-routing-policy-many-projections.md +++ b/docs/adr/0001-one-routing-policy-many-projections.md @@ -2,13 +2,15 @@ - **Status:** Superseded in part by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-23 -- **Updated:** 2026-07-30 +- **Updated:** 2026-08-25 - **Update note:** Retained the one-policy decision while retiring the compatibility projection, command, and persisted names for GA. - **Deciders:** agentic-kit maintainers > **Historical scope:** the one-policy principle remains, but the compatibility command, > adapter projection, and persisted field names recorded below were removed for 4.0 GA. +> OpenAI deprecated the recorded `codex mcp-server` projection on 2026-08-24; +> [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) retires it. ## Context diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index bb42ed8..9600a70 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -34,6 +34,10 @@ project-memory pin is now projected through host-specific launch context: Claude project env, Codex's workspace-aware MCP launcher, and OpenCode's project-aware MCP/lifecycle processes. + 2026-08-25: [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) + retires the deprecated Claude→Codex MCP projection while retaining this ADR's + value-precise ownership boundary. OpenAI's Claude Code plugin remains external and + user-owned; Ruflo and Agentic-QE registrations are independent Codex integrations. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), diff --git a/docs/adr/0018-generalized-host-worker-execution.md b/docs/adr/0018-generalized-host-worker-execution.md index 115c607..f23c691 100644 --- a/docs/adr/0018-generalized-host-worker-execution.md +++ b/docs/adr/0018-generalized-host-worker-execution.md @@ -1,11 +1,13 @@ # ADR-0018 — Generalized host-worker execution; `ak run` canonical -- **Status:** Accepted; compatibility retention superseded by +- **Status:** Implemented; compatibility retention superseded by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-29 -- **Updated:** 2026-07-30 +- **Updated:** 2026-08-25 - **Update note:** Added private bounded dependency handoffs and one absolute lifecycle deadline per attempt; removed the temporary command, adapter, and persisted-schema compatibility clauses. + ADR-0033 extends cleanup to POSIX process groups so host-spawned MCP descendants share the + worker's bounded termination contract. - **Deciders:** agentic-kit maintainers > **GA amendment:** the host-neutral lifecycle, `ak run`, and OpenCode safety decisions remain. diff --git a/docs/adr/0026-about-component-directory.md b/docs/adr/0026-about-component-directory.md index 6a97bd6..6e441c9 100644 --- a/docs/adr/0026-about-component-directory.md +++ b/docs/adr/0026-about-component-directory.md @@ -59,7 +59,7 @@ About (no secondary rail — one scrolling page with category anchors): Knowledge RuvNet Brain — the offline KB that grounds answers about this stack. The kit agentic-kit itself — the caretaker that installs, heals, and explains the rest. Configured for you Non-package surfaces: MCP registrations, guidance blocks, - statuslines, dual-host routing & bridge, background daemon, permission + statuslines, dual-host routing & shared tools, background daemon, permission allowlists — each with "configured by setup/sync · yours to change". ``` @@ -159,7 +159,7 @@ The draft left three points open. All three are decided; this section is the rec usage scorecard and `ak system` already follow. It also answers the question in the place the question is usually asked — right after `ak setup` finishes printing. 3. **Six separate "Configured for you" cards, not one card with a list.** Each configured - surface — MCP registrations, guidance blocks, statuslines, dual-host routing and bridge, the + surface — MCP registrations, guidance blocks, statuslines, dual-host routing and shared tools, the background daemon, permission allowlists — has its own state chip and its own managing command. Collapsing them into one card would force a single chip to summarize six independent health facts, which is exactly the kind of averaging the honest-degradation contract forbids, diff --git a/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md b/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md new file mode 100644 index 0000000..3d44850 --- /dev/null +++ b/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md @@ -0,0 +1,95 @@ +# ADR-0033 — Retire Codex MCP and bound QE-Court participant transport + +- **Status:** Implemented; handoff transport amended by + [ADR-0034](0034-schema-native-handoffs-and-hermetic-seats.md) +- **Date:** 2026-08-25 +- **Updated:** 2026-08-26 +- **Update note:** Initial implementation retires only receipt-owned legacy MCP state, + diagnoses effective Codex MCP topology, extends POSIX cleanup to process groups, and adds + fail-closed QE-Court readiness plus a reciprocal live participant-transport regression. + 2026-08-26: the soak exposed the free-text handoff as the weak link; ADR-0034 moves + handoff-bearing seats to schema-native output and hermetic isolation. +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), + [ADR-0006](0006-primary-host-and-ambidextrous-mirroring.md), + [ADR-0016](0016-capability-driven-integration-adapters.md), + [ADR-0018](0018-generalized-host-worker-execution.md), + [ADR-0020](0020-ga-stable-surfaces.md), + [issue #108](https://github.com/pacphi/agentic-kit/issues/108) + +## Context + +Agentic-kit previously registered `codex mcp-server` as a project-scoped Claude Code MCP +server and described it together with Codex's Ruflo MCP registration as a bidirectional bridge. +That model was inaccurate: one leg exposed Codex as a nested host while the other exposed Ruflo +tools to Codex. It also put another long-lived transport inside a multi-participant workflow. + +OpenAI deprecated `codex mcp-server` on 2026-08-24. OpenAI recommends App Server for custom +integrations and its Claude Code plugin for interactive Claude→Codex work. The plugin is a +user-scoped external integration and does not provide the inverse Codex→Claude path. Agentic-kit's +existing `ak run` supervisor already provides host-neutral routing, one absolute deadline per +attempt, bounded cancellation, and normalized terminal evidence. + +The QE-Court investigation exposed a separate proof gap. The current Agentic-QE consumer skill +can pass local role/vendor checks while its generated package references schema, referee, and +oracle assets that are absent from the consumer. Agentic-QE does not currently expose a supported +host-neutral court runner, and `routing.primaryHost` mirrors activity routes rather than court +seats. A successful Claude/Codex transport check therefore cannot be called a court verdict. + +## Decision + +1. `ak run` remains the sole agentic-kit-managed cross-host executor. Claude-primary and + Codex-primary policies select leadership without changing the transport contract. +2. Setup, sync, and host selection stop creating `codex mcp-server`. They remove a legacy project + registration only when `integrations.ownership.codex.mcp === "ak"`, confirm its absence, and + clear the receipt only after confirmation. User-owned entries are preserved with a deprecation + warning and an exact manual remedy. +3. Codex keeps one independent, workspace-aware Ruflo MCP registration. Agentic-QE continues to + own its Codex platform/MCP integration. Agentic-kit detects recursive Codex self-registration, + missing concrete Agentic-QE registration, and duplicate Ruflo transports without rewriting + unowned Codex TOML. +4. OpenAI's Claude Code plugin is optional and user-owned. Agentic-kit may document and detect it, + but does not silently install, enable, update, or remove it. A future managed App Server/plugin + adapter requires a separate lifecycle, ownership, cancellation, and teardown decision. +5. POSIX host workers run as process-group leaders. Timeout, cancellation, and cleanup signal the + whole group (TERM then bounded KILL), matching Windows whole-tree cleanup and preventing a host's + MCP descendants from surviving the direct worker. +6. Status distinguishes three levels of QE-Court evidence: + + - local anti-collusion routing validation; + - self-contained consumer artifacts and concrete provider-seat readiness; + - a completed upstream court verdict. + + A lower level never claims a higher one. +7. The opt-in reciprocal live harness is named a **QE-Court participant-transport regression**. + It runs bounded Claude-led and Codex-led direct-host DAGs, requires MCP-native Ruflo memory + store→retrieve proofs in validated handoffs, independently confirms the project-memory values, + rejects repository mutation and orphaned state, and never substitutes for Agentic-QE's court + protocol. + +## Consequences + +- Existing agentic-kit-owned legacy MCP state converges safely on the next setup, sync, or host + selection. Foreign state remains visible and untouched. +- Claude→Codex interactive delegation is no longer implied by enabling both hosts. Users who want + it can install OpenAI's plugin explicitly; production automation should use `ak run`. +- Codex startup no longer needs to load a recursive `codex → codex mcp-server` topology. +- A transport regression can prove bounded participant lifecycle in both leadership directions, + but full QE-Court parity remains blocked on an upstream Agentic-QE execution surface and complete + Codex projection. +- ADR-0001's MCP projection is historical. ADR-0006's leadership decision, ADR-0016's ownership + boundary, ADR-0018's worker lifecycle, and ADR-0020's stable execution surface remain in force. + +## Verification + +- Unit tests cover receipt-owned retirement, preservation of user-owned state, concrete Codex MCP + topology, fail-closed court artifact readiness, POSIX descendant cleanup, and orphan reporting. +- `pnpm test:qe-court-live` runs one Claude-led and one Codex-led participant-transport trial. + `AK_QE_COURT_TRIALS=5` raises this to a reciprocal soak test on POSIX shells. +- `ak status` fails recursive self-MCP and reports missing Agentic-QE or duplicate Ruflo MCP state. + +## References + +- [OpenAI Codex changelog](https://developers.openai.com/codex/changelog) +- [OpenAI Codex App Server](https://developers.openai.com/codex/app-server) +- [OpenAI Codex Claude Code plugin](https://github.com/openai/codex-plugin-cc) diff --git a/docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md b/docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md new file mode 100644 index 0000000..07c12af --- /dev/null +++ b/docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md @@ -0,0 +1,110 @@ +# ADR-0034 — Schema-native worker handoffs and hermetic qe-court seats + +- **Status:** Implemented +- **Date:** 2026-08-26 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0018](0018-generalized-host-worker-execution.md), + [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md), + [issue #108](https://github.com/pacphi/agentic-kit/issues/108) + +## Context + +ADR-0033's reciprocal live regression passed its baseline but failed a +five-trial soak: a Codex-hosted seat in a Claude-led DAG exited cleanly in 39s +yet produced `protocol_error` — "worker emitted a malformed or duplicate +handoff block". The v1 handoff rode the FINAL FREE-TEXT message and required +the tagged block to end it, tolerating only one whitelisted receipt grammar. +A supervised seat, however, is a full agent session loaded with standing +instructions ak does not control: machine guidance files, plugin hooks and +receipts (Claude plugins and Codex plugins both mandate trailing output), and +whatever the model appends stochastically. Hard-failing on final-text SHAPE +made those collisions a per-seat coin flip; the raw offending text was also +redacted, so failures arrived without evidence. + +Probes (2026-08-26) grounded the remedies: + +- `codex exec --output-schema` held 5/5 with MCP servers active at codex + 0.149.1 (openai/codex#15451 did not reproduce); `-o` writes exactly the + final message. +- `claude -p --json-schema` returns the validated object as + `structured_output` on the stream-json `result` event; `--strict-mcp-config` + mounts exactly one server; `--settings '{"disableAllHooks":true}'` silences + plugin hooks. `--bare` is NOT usable: it disables subscription OAuth. +- Codex 0.149.x offers no per-invocation MCP-roster control: `-c mcp_servers` + overrides do not change the effective roster, and plugins mount servers and + instruction files regardless. Cleanroom `--ignore-user-config` also drops + the project's `trust_level`, which blocks MCP tool approvals in exec mode. +- agentic-qe 3.13.12 ships a subscription Codex CLI provider + (`dist/shared/llm/providers/codex.js`, built for qe-court cross-vendor + seats), but a provider is reachable only via `defaultProvider`, the + `fallbackChain`, or a `FALLBACK_PRIORITY` list that contains neither + `codex` nor `claude-code` — so ak's projected codex `agentOverrides` sat + dead behind "not enabled" warnings. + +## Decision + +1. **Schema-native handoff transport.** A worker whose summary a dependent + needs (`worker.requiresHandoff`, set by the runner) carries the handoff as + the host-enforced final object: `--json-schema` on claude, + `--output-schema` on codex, one shipped schema + (`src/lib/execution/handoff.schema.json`, kept inside OpenAI's + structured-output keyword subset). Adapters supply the matching prompt + instruction through a new optional `handoffRequestFor` hook; the tagged + request stays the host-neutral default for hosts without a schema surface. + `parseHandoffText` accepts bare objects (with benign wrapping) and routes + tagged text through the strict extractor; `normalizeHandoff` remains the + only authority on accepted shape. +2. **Tolerant-but-strict extractor.** `extractHandoff` tolerates text before + and after ONE well-formed block; duplicate delimiters stay fatal (the + shadowing/injection-suspicious case), and raw output is never a fallback. + The RuvNet-Brain receipt whitelist is deleted as subsumed. +3. **Hermetic seats (opt-in `worker.hermetic`).** Claude seats disable all + hooks, mount exactly the ruflo MCP server under `--strict-mcp-config`, and + self-carry `--allowedTools mcp__ruflo` so seat permissions never depend on + machine settings; never `--bare`. Codex seats take the bounded trims that + exist: `--ephemeral`, medium reasoning effort, `project_doc_max_bytes=0`; + their roster-independence comes from the schema transport, not isolation. + The qe-court live regression runs every seat hermetic and persists a + redacted raw final-message tail whenever a protocol failure recurs. +4. **Make the projected codex provider real.** The agentOverrides projection + also enables exactly the providers it references in `llm-config.json` + (merge-not-clobber, nothing beyond `enabled`), and the chain gate admits + `codex` rungs (`AQE_CHAIN_PROVIDER_TYPES`) so the subscription Codex + provider is reachable. `AQE_PROVIDER_TYPES` stays narrow — it also gates + `AQE_LLM_PROVIDER`, whose accepted values are aqe's own list. + +## Consequences + +- Final-text shape is no longer load-bearing on claude/codex handoff workers; + instruction collisions (receipts, trailing prose) cannot malform a handoff. + Cross-host escalation stays sound: both instructions request the same four + fields and `parseHandoffText` accepts both forms on every host. +- Seats are reproducible across machines: a hermetic claude seat carries its + own MCP mount and permission; codex seats still inherit the machine's MCP + roster and global AGENTS.md (both ak-managed here) — accepted and + documented, not hidden. +- aqe's qe-court cross-vendor engine (codex, subscription-billed) actually + participates; its startup no longer warns about dead overrides. +- Upstream asks recorded: aqe FALLBACK_PRIORITY omits host-CLI providers; + aqe's court referee/oracle assets remain unshipped (the ADR-0033 + three-level evidence ladder is unchanged — transport proof still never + claims a court verdict); codex `-c mcp_servers` silently no-ops. + +## Verification + +- Unit: extractor tolerance/duplicates, parseHandoffText forms, schema-flag + argv per host, hermetic argv per host, structured_output preference, + provider-enable + codex-rung projection (`tests/kit/execution-handoff`, + `subprocess-execution`, `routing-projection`). +- Live: `pnpm test:qe-court-live` baseline plus `AK_QE_COURT_TRIALS=5` soak + must pass with every participant terminal, MCP-native memory proofs + matched, no timeout/orphan/malformed handoff, and no repository mutation. + +## References + +- [Claude Code headless mode](https://code.claude.com/docs/en/headless) — + `--json-schema`/`structured_output`, `--bare` OAuth caveat +- [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) + — `--output-schema`, `-o` +- [openai/codex#15451](https://github.com/openai/codex/issues/15451) — not + reproduced at 0.149.1 (5/5) diff --git a/docs/adr/README.md b/docs/adr/README.md index 86d5c5c..3436810 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,7 +16,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0005](0005-dashboard-in-page-routing-reveal.md) | Dashboard surfaces routing via in-page reveal | Implemented | | [0006](0006-primary-host-and-ambidextrous-mirroring.md) | Primary host & ambidextrous mirroring (which host leads) | Amended by 0020 | | [0007](0007-maintainer-admin-local-telemetry.md) | Maintainer admin: a loopback telemetry page with deliberate egress | Accepted | -| [0008](0008-guidance-target-scope-split.md) | Machine-scoped guidance blocks land in machine files, not a repo's AGENTS.md | Accepted | +| [0008](0008-guidance-target-scope-split.md) | Machine-scoped guidance blocks land in machine files, not a repo's AGENTS.md | Implemented | | [0009](0009-usage-scorecard-local-transcript-analytics.md) | Usage scorecard: local transcript analytics with graded evidence | Implemented | | [0010](0010-provider-mediated-quota-reads.md) | Provider-mediated quota reads (the only honest denominators) | Accepted | | [0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md) | Local models: provenance out-of-band, $0 per model, stated transcript fidelity | Proposed | @@ -26,7 +26,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0015](0015-managed-codex-native-statusline.md) | Manage Codex's native user-wide status line without claiming rich-renderer parity | Accepted | | [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted; compatibility amended; closed-registry clause superseded by 0029 | | [0017](0017-opencode-host.md) | OpenCode as a managed, observable host through native surfaces | Accepted; compatibility amended | -| [0018](0018-generalized-host-worker-execution.md) | Generalized host-worker execution; `ak run` canonical | Accepted; compatibility amended | +| [0018](0018-generalized-host-worker-execution.md) | Generalized host-worker execution; `ak run` canonical | Implemented; compatibility amended | | [0019](0019-escalation-in-ak-run.md) | Bounded per-worker escalation in `ak run` | Accepted; historical context closed | | [0020](0020-ga-stable-surfaces.md) | One stable GA surface per capability | Implemented | | [0021](0021-inference-provider-provenance.md) | Inference-provider provenance for live sessions | Accepted | @@ -40,6 +40,8 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0029](0029-host-adapter-extension-point.md) | External host adapters: declarative manifest, subprocess hooks | Accepted (experimental contract) | | [0031](0031-capability-graduation-and-upstream-requests.md) | Capability graduation: earned parity for external adapters, and the upstream request path | Accepted (governance; implementation staged) | | [0032](0032-model-lifecycle-intelligence.md) | Model lifecycle intelligence from provenance-aware local evidence | Implemented | +| [0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) | Retire Codex MCP; bound reciprocal QE-Court participant transport | Implemented; handoff transport amended by 0034 | +| [0034](0034-schema-native-handoffs-and-hermetic-seats.md) | Schema-native worker handoffs and hermetic qe-court seats | Implemented | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md index 200f565..70f06d1 100644 --- a/docs/ddd/component-directory.md +++ b/docs/ddd/component-directory.md @@ -118,7 +118,7 @@ under the dashboard's CSP. ### Configured surfaces Non-package things ak sets up — MCP registrations, managed guidance blocks -(CLAUDE.md/AGENTS.md), statuslines, the dual-host routing policy and Claude↔Codex bridge, the +(CLAUDE.md/AGENTS.md), statuslines, the dual-host routing policy and shared Ruflo/AQE access, the background daemon, permission allowlists — are directory entries too, in the `configured` category. Their chip reads `configured` (joined from the relevant status subsystem row), their paragraph explains what was configured and why it helps, and each names the command that diff --git a/docs/ddd/routing-and-orchestration.md b/docs/ddd/routing-and-orchestration.md index 1e178db..5632faf 100644 --- a/docs/ddd/routing-and-orchestration.md +++ b/docs/ddd/routing-and-orchestration.md @@ -20,7 +20,7 @@ RoutingPolicy provenance escalation rungs[] -RoutingPolicy -> eligible configuration projections -> AQE / MCP surfaces +RoutingPolicy -> eligible configuration projections -> AQE / host guidance RoutingPolicy + template + task -> materialized workers -> ak run ``` @@ -84,12 +84,15 @@ change the persisted base route. ladder. Pure projectors materialize that policy into: - AQE `agentOverrides` for AQE-eligible Claude/Codex routes; -- `ak run`'s host-neutral worker plan; -- Codex MCP availability/configuration. +- `ak run`'s host-neutral worker plan. These are configuration projections, not independent routing policies. Native surfaces must not be edited back into domain truth without an explicit import or reconciliation design. +Codex's Ruflo/AQE MCP registrations are independent host integrations, not routing projections. +OpenAI deprecated the former Claude→Codex `codex mcp-server` projection; bounded cross-host work +uses `ak run`. + ## Canonical execution `ak run` is the canonical executor for materialized activity plans. It can select every host whose diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index e01b236..72993c8 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -160,7 +160,7 @@ transcript and never substitute for each other. See [Machine footprint](machine- | Monogram tile | The honest icon for a component with no official mark: initials on a category-hued tile | | Register contract | The editorial writing rules (one ~50-word paragraph, plain language, active voice, no runtime claims, no superlatives) | | Parity gate | The test asserting managed-tools registry ↔ directory completeness in both directions | -| Configured surface | A non-package thing ak sets up — MCP registrations, guidance blocks, statuslines, routing and bridge, the daemon, permission allowlists — carrying a managing command instead of package links | +| Configured surface | A non-package thing ak sets up — MCP registrations, guidance blocks, statuslines, routing and shared tool access, the daemon, permission allowlists — carrying a managing command instead of package links | Editorial content states purpose and reads true on a machine where the component is absent; runtime state is a chip word, never a prose word. See diff --git a/package.json b/package.json index b15ee7a..061edf2 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,15 @@ "docs/CODEX-STATUSLINE.md", "docs/adr/0015-managed-codex-native-statusline.md", "docs/adr/0032-model-lifecycle-intelligence.md", + "docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md", + "tests/live/qe-court-participant-transport.test.mjs", "docs/ddd/model-lifecycle-intelligence.md" ], "scripts": { "test": "node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 \"tests/kit/*.test.mjs\" && node tests/statusline-segments.test.cjs && node tests/statusline-brain.test.cjs && node tests/agentdb.test.cjs && node tests/health-history.test.cjs && node tests/harvest.test.cjs && node tests/dashboard.test.cjs && node tests/admin-model.test.cjs && node tests/admin.test.cjs", "test:ui": "node tests/ui/dashboard-ui.mjs", "test:surface": "node --test tests/kit/dispatch-surface.test.mjs", + "test:qe-court-live": "node --test tests/live/qe-court-participant-transport.test.mjs", "typecheck": "tsc -p tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 8409bb5..7b06704 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -17,7 +17,7 @@ import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; +import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, retireCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; @@ -74,8 +74,8 @@ Options: --no-security skip the security-surface verification --codex enable + install the OpenAI Codex host during setup (dual-mode; default is claude-only, codex opt-in). Installs @openai/codex - if absent (prompted; external installs untouched) and wires - the Claude↔Codex bridges + per-activity routing. + if absent (prompted; external installs untouched), wires + shared Ruflo/AQE access, and seeds per-activity routing. --opencode enable the opencode host during setup: wires opencode.json (claude-flow + ruvnet-brain MCP, skills paths, permissions), deploys the lifecycle plugin + platform skill, and converts @@ -351,7 +351,7 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { } // 7. Write-verification (store → actual on-disk row, then clean up). Native - // bridges may select agentdb-memory.db beside the pinned compatibility DB. + // Native memory integrations may select agentdb-memory.db beside the pinned compatibility DB. const probeKey = `_setup/verify-${process.pid}-${Date.now()}`; const stored = (await runCmd('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env })).code === 0; const landed = stored ? findMemoryEntry(root, '_setup', probeKey) : null; @@ -407,11 +407,10 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { if (rt.changed) (rt.ok ? ok : warn)(`aqe router: ${rt.detail}`); if (Object.keys(cfg.routing?.routes ?? {}).length) printActivityRoutingTable(cfg); if (cfg.integrations?.hosts?.codex) { - const mcp = await ensureCodexMcp(cfg, root); - if (mcp.changed) saveKitConfig(cfg); // persist the codexMcp ownership marker - if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`codex MCP: ${mcp.detail}`); - // reverse bridge: register ruflo MCP into codex (codex→ruflo) so the bridge is - // two-way — parity with `ak sync` / `ak host pick`. + const mcp = await retireCodexMcp(cfg, root); + if (mcp.changed) saveKitConfig(cfg); + if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`legacy codex MCP: ${mcp.detail}`); + // Independently register Ruflo in Codex for shared routing/swarm/memory tools. const rmcp = await ensureRufloMcpInCodex(cfg, root); if (rmcp.changed) saveKitConfig(cfg); // persist reverse MCP ownership if (rmcp.changed || !rmcp.ok) (rmcp.ok ? ok : warn)(`ruflo→codex MCP: ${rmcp.detail}`); @@ -504,7 +503,7 @@ export async function run({ flags, pkgRoot, confirm = ask }) { info('not inside a project (no .git here) — run `ak setup` from a repo to set one up'); } // Final reconcile pass — deliberately AFTER the hosts branch (which installs - // the claude/codex/opencode CLIs) and the project phase (whose codex bridge + // the claude/codex/opencode CLIs) and the project phase (whose Codex integration // creates ~/.codex): the user-scope MCP registration needs the claude CLI on // disk, and several guidance blocks gate on freshly-installed hosts // (command:codex, flag:dualMode). Shares blocks.mjs reconcileGuidance with diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 7458907..ac32255 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -8,7 +8,7 @@ import { loadRing, detectRegression } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; import { scanNpxStale } from '../lib/npx.mjs'; -import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus, ruvectorRegistered } from '../lib/mcp.mjs'; +import { registrationStatus, codexMcpStatus, codexMcpTopology, rufloCodexMcpStatus, ruvectorRegistered } from '../lib/mcp.mjs'; import { opencodeMcpStatus, catalogSource, createOpencodeLifecycleAdapter, opencodeArtifactReceiptState, @@ -27,7 +27,7 @@ import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostIns import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../lib/adapters/lifecycle-registry.mjs'; import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; -import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; +import { qeCourtShipped, readQeCourtConfig, validateCourtConfig, qeCourtReadiness } from '../lib/qeCourt.mjs'; import { drift as ruvectorDrift } from '../lib/ruvector.mjs'; import { statuslineDrift } from '../lib/codex-statusline.mjs'; import { inspectCodexPlugins } from '../lib/codex-plugins.mjs'; @@ -544,28 +544,24 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('mcp', 'warn', "legacy 'ruflo'-keyed MCP registration present", 'sync migrates it to claude-flow')); } - // codex MCP backend (mcp__codex__codex) — the dual-host swarm's inline Claude→Codex - // path (ADR-0001 projection #3). Only surfaces when the codex host is enabled; - // setup/sync register it project-scoped via ensureCodexMcp. Spawn-free: reads the - // project .mcp.json (== `claude mcp get codex`) plus the kit.json ownership marker. + // Retired Claude→Codex `codex mcp-server` projection (ADR-0033). Its absence + // is healthy; setup/sync remove only the prior agentic-kit-owned entry. + // User-owned entries are preserved and receive an explicit manual remedy. if (cfg.integrations?.hosts?.codex) { try { const { registered, owned } = codexMcpStatus(cfg, cwd); if (registered) { - rows.push(row('codex-mcp', 'ok', - `codex MCP registered (mcp__codex__codex)${owned ? '' : ' — pre-existing (not ak-managed)'}`)); - } else if (await have('codex')) { - rows.push(row('codex-mcp', 'warn', 'codex enabled but codex MCP not registered', - 'sync registers the codex MCP server')); + rows.push(row('codex-mcp', 'warn', + `deprecated codex mcp-server registered${owned ? ' — agentic-kit-owned' : ' — user-owned; preserved'}`, + owned ? 'sync retires the legacy MCP entry' : 'remove manually: claude mcp remove codex -s project')); } else { - rows.push(row('codex-mcp', 'warn', 'codex enabled but codex CLI not installed', - 'sync installs codex, then registers the codex MCP')); + rows.push(row('codex-mcp', 'ok', 'legacy codex mcp-server absent; supervised cross-host execution uses ak run')); } } catch (e) { rows.push(row('codex-mcp', 'warn', `codex MCP check unavailable: ${e.message}`)); } - // reverse bridge: ruflo MCP → codex (a codex-driven session reaches ruflo's - // tools). The mirror of the claude→codex row above; makes the bridge two-way. + // Independent Ruflo MCP integration lets a Codex-driven session reach the + // same routing, swarm, and memory tools as Claude. try { const { registered, owned, command, args } = rufloCodexMcpStatus(cfg); const workspacePinned = command === 'ak' @@ -584,6 +580,32 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } catch (e) { rows.push(row('codex-mcp', 'warn', `ruflo→codex MCP check unavailable: ${e.message}`)); } + + // Effective project+user topology. These checks are independent of the + // agentic-kit ownership receipt because recursive/duplicate transports can + // stall a Codex-driven worker even when another tool created them. + try { + const topology = codexMcpTopology({ cwd }); + if (topology.selfRegistrations.length) { + const scopes = topology.selfRegistrations.map((entry) => entry.scope).join(', '); + rows.push(row('codex-mcp', 'fail', + `recursive codex → codex mcp-server registration detected (${scopes})`, + 'remove the [mcp_servers.codex] table from the reported Codex config before live multi-host runs')); + } + if (!topology.agenticQeRegistrations.length) { + rows.push(row('codex-mcp', 'warn', 'agentic-qe MCP is not concretely registered in Codex', + 'run: aqe platform setup codex --overwrite --with-ruflo')); + } else { + rows.push(row('codex-mcp', 'ok', 'agentic-qe MCP concretely registered in Codex')); + } + if (topology.duplicateRuflo) { + rows.push(row('codex-mcp', 'warn', + `duplicate Ruflo MCP registrations in Codex: ${topology.rufloRegistrations.map((entry) => entry.name).join(', ')}`, + 'keep the workspace-aware [mcp_servers.ruflo] entry and remove legacy duplicates after reviewing ownership')); + } + } catch (e) { + rows.push(row('codex-mcp', 'warn', `Codex MCP topology check unavailable: ${e.message}`)); + } } // Codex owns plugin installation, enablement, and refresh. Inspect every @@ -923,7 +945,13 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('qe-court', 'warn', `qe-court panel invalid: ${violations.join(', ')} — regenerate with agentic-qe >=3.13.3 or choose different defense/jury vendors`)); } else { - rows.push(row('qe-court', 'ok', 'qe-court panel valid (vendor-diverse, jury independent of writer)')); + const readiness = qeCourtReadiness(qcRoot); + if (readiness.ready) { + rows.push(row('qe-court', 'ok', 'qe-court routing and consumer artifacts are ready; provider-seat readiness still requires a live proof')); + } else { + rows.push(row('qe-court', 'warn', + `qe-court routing config passes the local anti-collusion check, but executability is not proven (${readiness.artifactIssues.join('; ')})`)); + } } } } diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 03fbea3..a2da7ef 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -13,7 +13,7 @@ import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, det import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; +import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, retireCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs'; import { pruneNpxStale } from '../lib/npx.mjs'; @@ -267,10 +267,10 @@ export async function run({ flags, pkgRoot, fetchLatest }) { } const router = applyAqeRouter(cfg, cwd); if (router.changed || !router.ok) report('aqe router', router); - const mcp = await ensureCodexMcp(cfg, cwd); + const mcp = await retireCodexMcp(cfg, cwd); if (mcp.changed) saveKitConfig(cfg); - if (mcp.changed || !mcp.ok) report('codex MCP', mcp); - // reverse bridge: register ruflo MCP into codex (codex→ruflo half) + if (mcp.changed || !mcp.ok) report('legacy codex MCP', mcp); + // Independent Ruflo integration for Codex-driven sessions. const rmcp = await ensureRufloMcpInCodex(cfg, cwd); if (rmcp.changed) saveKitConfig(cfg); if (rmcp.changed || !rmcp.ok) report('ruflo→codex MCP', rmcp); diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 962af7b..bf39dc8 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -6,11 +6,11 @@ // Two independent axes: ruflo host CLIs (claude/codex) and the LLM the routers use. import readline from 'node:readline/promises'; import { - HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, detectHosts, + HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, AQE_CHAIN_PROVIDER_TYPES, detectHosts, settingsTarget, isDefault, applyHosts, applyProviders, undoProviders, hostInstallState, hostAuthState, installHost, applyAqeRouter, undoAqeRouter, bothHostsEnabled, DUAL_ROLE_TIP, JUDGE_BIAS_TIP, QE_COURT_TIP, suggestedFallbackFor, - seedActivityRoutesIfMultiHost, printActivityRoutingTable, ensureCodexMcp, undoCodexMcp, + seedActivityRoutesIfMultiHost, printActivityRoutingTable, retireCodexMcp, undoCodexMcp, ensureRufloMcpInCodex, undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource, collectIntegrationFacts, } from '../../lib/providers.mjs'; @@ -296,7 +296,7 @@ function printQeCourtStatus(cwd) { console.log(` ${role.padEnd(28)} ${provider ?? dim('(unset)')}`); } if (violations.length) warn(`qe-court panel invalid: ${violations.join(', ')}`); - else ok('qe-court panel valid (vendor-diverse, jury independent of writer)'); + else ok('qe-court routing config passes the local anti-collusion check (runtime court readiness not proven)'); } /** Opt-in, per-activity re-seed of routes whose seeded pin diverges from the @@ -370,12 +370,6 @@ async function off({ cwd, pkgRoot }) { cfg.integrations.hosts = defaultHostMap(); cfg.routing.primaryHost = DEFAULT_PRIMARY_HOST; cfg.routing.routes = {}; - cfg.integrations.ownership ??= {}; - cfg.integrations.ownership.codex = { - ...(cfg.integrations.ownership.codex ?? {}), - mcp: null, - reverseMcp: null, - }; if (ret.ok && cfg.integrations.ownership.opencode) { delete cfg.integrations.ownership.opencode.catalogDir; } @@ -386,10 +380,18 @@ async function off({ cwd, pkgRoot }) { const router = undoAqeRouter(cwd); const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); + cfg.integrations.ownership ??= {}; + cfg.integrations.ownership.codex = { + ...(cfg.integrations.ownership.codex ?? {}), + ...(mcp.ok ? { mcp: null } : {}), + ...(rmcp.ok ? { reverseMcp: null } : {}), + }; + saveKitConfig(cfg); const ocLine = ret.ok ? `opencode: ${ret.undo.detail}; ${ret.artifacts.detail}` : `opencode teardown incomplete — ${ret.undo.detail}`; - (ret.ok ? ok : warn)(`reset to claude-only default — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}; ${ocLine}`); - return ret.ok ? 0 : 1; + const complete = ret.ok && mcp.ok && rmcp.ok; + (complete ? ok : warn)(`reset to claude-only default${complete ? '' : ' with teardown receipts retained'} — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}; ${ocLine}`); + return complete ? 0 : 1; } export const parseModels = (csv) => csv.split(',').map((s) => s.trim()).filter(Boolean).map((tok) => { @@ -551,11 +553,11 @@ async function pick({ flags, cwd, pkgRoot }) { if (AQE_PROVIDER_TYPES.includes(norm)) aqeProvider = norm; else { warn(`unknown aqe provider '${aqeProvider}' — leaving aqe on its default (valid: ${AQE_PROVIDER_TYPES.join(', ')})`); aqeProvider = null; } } - // validate fallback chain providers + // validate fallback chain providers (chain gate admits codex — #108 phase 3) aqeFallback = aqeFallback .map((e) => ({ ...e, provider: e.provider === 'anthropic' ? 'claude' : e.provider })) .filter((e) => { - const okp = AQE_PROVIDER_TYPES.includes(e.provider); + const okp = AQE_CHAIN_PROVIDER_TYPES.includes(e.provider); if (!okp) warn(`dropping unknown fallback provider '${e.provider}'`); else if (!e.models.length) warn(`fallback entry '${e.provider}' has no models — aqe may skip it; add e.g. ${e.provider}:`); return okp; @@ -618,8 +620,9 @@ async function pick({ flags, cwd, pkgRoot }) { } } - // Codex owns two directional MCP bridges. Disable only the marker-owned - // bridges, matching OpenCode's receipt-based teardown semantics. + // Disable only marker-owned integrations, matching OpenCode's receipt-based + // teardown semantics. The legacy Claude→Codex MCP receipt may still exist on + // machines upgrading across ADR-0033. let codexRetired = null; if (prevCodex && !cfg.integrations.hosts.codex) { const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); @@ -627,8 +630,8 @@ async function pick({ flags, cwd, pkgRoot }) { cfg.integrations.ownership ??= {}; cfg.integrations.ownership.codex = { ...(cfg.integrations.ownership.codex ?? {}), - mcp: null, - reverseMcp: null, + ...(mcp.ok ? { mcp: null } : {}), + ...(rmcp.ok ? { reverseMcp: null } : {}), }; codexRetired = { mcp, rmcp }; } @@ -695,7 +698,10 @@ async function pick({ flags, cwd, pkgRoot }) { const h = applyHosts(cfg, cwd); (h.ok ? ok : fail)(`hosts: ${h.detail}`); - if (codexRetired) ok(`codex disabled: ${codexRetired.mcp.detail}; ${codexRetired.rmcp.detail}`); + if (codexRetired) { + const complete = codexRetired.mcp.ok && codexRetired.rmcp.ok; + (complete ? ok : warn)(`codex disabled${complete ? '' : ' with teardown receipts retained'}: ${codexRetired.mcp.detail}; ${codexRetired.rmcp.detail}`); + } if (primaryHost !== DEFAULT_PRIMARY_HOST) { const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none'; ok(`primary host: ${primaryHost} (alternate: ${alt})`); @@ -703,11 +709,11 @@ async function pick({ flags, cwd, pkgRoot }) { if (aqeProvider) ok(`aqe provider: AQE_LLM_PROVIDER=${aqeProvider}`); const router = applyAqeRouter(cfg, cwd); if (router.changed || !router.ok) (router.ok ? ok : warn)(`aqe router: ${router.detail}`); - const mcp = await ensureCodexMcp(cfg, cwd); - if (mcp.changed) saveKitConfig(cfg); // persist forward MCP ownership - if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`codex MCP: ${mcp.detail}`); - // reverse bridge — register ruflo MCP into codex (codex→ruflo) so the bridge is - // two-way. aqe's codex MCP is handled by `aqe init --with-codex` (setup runs it). + const mcp = await retireCodexMcp(cfg, cwd); + if (mcp.changed) saveKitConfig(cfg); + if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`legacy codex MCP: ${mcp.detail}`); + // Register Ruflo independently in Codex. Agentic-QE's Codex integration is + // handled by `aqe init --with-codex` during setup. const rmcp = await ensureRufloMcpInCodex(cfg, cwd); if (rmcp.changed) saveKitConfig(cfg); // persist reverse MCP ownership if (rmcp.changed || !rmcp.ok) (rmcp.ok ? ok : warn)(`ruflo→codex MCP: ${rmcp.detail}`); diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index ee010f2..9de7d19 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -231,7 +231,6 @@ const hostEntries = [ trust: { approvalPolicy: 'unchanged', changes: [ - { id: 'claude-to-codex-mcp', kind: 'mcp-registration', scope: 'project', owner: 'agentic-kit', value: 'codex mcp-server', effect: 'expose Codex to Claude Code as mcp__codex__codex in this project', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, { id: 'codex-to-ruflo-mcp', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'ak x ruflo-mcp', effect: 'register the Ruflo MCP server in Codex with workspace-pinned project memory', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, { id: 'aqe-codex-integration', kind: 'host-integration', scope: 'project', owner: 'agentic-qe', value: 'aqe init --with-codex', effect: 'project Agentic-QE Codex skills and configuration', operations: ['setup'], features: ['project', 'aqe'] }, ], diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index af6fa92..713c581 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -97,7 +97,7 @@ export const BUILTIN_BLOCKS = [ // so it lands in the two MACHINE-scoped guidance files — ~/.claude/CLAUDE.md // (claude) and ~/.codex/AGENTS.md (agents-user) — never a repo's checked-in // AGENTS.md, which would leak machine truths into shared git history (ADR-0008). - // Documents `ak run`, the Claude↔Codex bridge, and per-activity routing. + // Documents `ak run`, shared Ruflo/AQE access, and per-activity routing. slug: 'ruflo-dual-mode-reference', template: 'dual-mode-reference.md', position: 'append', diff --git a/src/lib/dashboard/about-directory.mjs b/src/lib/dashboard/about-directory.mjs index fd7c321..3e01e72 100644 --- a/src/lib/dashboard/about-directory.mjs +++ b/src/lib/dashboard/about-directory.mjs @@ -278,12 +278,12 @@ const ENTRIES = Object.freeze([ Object.freeze({ id: 'dual-host-routing', category: 'configured', - name: 'Dual-host routing & bridge', + name: 'Dual-host routing & shared tools', tagline: 'Each kind of work goes to the better host.', paragraph: 'With Claude Code and Codex both enabled, ak records which host handles which kind ' - + 'of work — coding, testing, review, security — and wires each one to reach the ' - + 'other as a tool, so a job can be handed across mid-flight. You pick the table and ' + + 'of work — coding, testing, review, security — and gives both hosts the same ' + + 'Ruflo/AQE tools. Bounded `ak run` workers hand work across hosts. You pick the table and ' + 'which host leads: `ak host pick`.', links: Object.freeze([]), icon: MONOGRAM('R', '--info'), diff --git a/src/lib/execution/claude.mjs b/src/lib/execution/claude.mjs index 34e4bd4..d5acbf9 100644 --- a/src/lib/execution/claude.mjs +++ b/src/lib/execution/claude.mjs @@ -1,8 +1,28 @@ import { createJsonlSummaryCapture, createSubprocessExecutionAdapter } from './subprocess.mjs'; -import { extractHandoff } from './handoff.mjs'; +import { HANDOFF_REQUEST_STRUCTURED, HANDOFF_SCHEMA_TEXT, parseHandoffText } from './handoff.mjs'; + +// Hermetic seat isolation (ADR-0034, opt-in via worker.hermetic): disable ALL +// hooks (plugin hooks included — the RuvNet Brain receipt mandate is a plugin +// hook), mount exactly one MCP server (ruflo, the coordination/evidence bus) +// ignoring every other registration, and self-carry the permission for it so +// the seat does not depend on this machine's settings allowlists. Deliberately +// NOT `--bare`: bare mode disables subscription OAuth (API-key only), which +// would silently change who pays. Settings sources still load (model default, +// trust); only their hooks are neutralized. +// Order is load-bearing: `--mcp-config ` and +// `--allowedTools ` are VARIADIC and would swallow a directly +// following prompt argument ("Input must be provided..." — observed live on +// the one seat with no flag after them). Every variadic flag is therefore +// followed by another flag token, and the block ends on single-value +// `--settings`, which consumes exactly one argument and so guards the prompt. +const HERMETIC_FLAGS = Object.freeze([ + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{"ruflo":{"command":"ak","args":["x","ruflo-mcp"]}}}', + '--allowedTools', 'mcp__ruflo', + '--settings', '{"disableAllHooks":true}', +]); /** Claude Code's documented print/json mode. No permission bypass is passed. */ -/** @param {Omit[0], 'id'|'host'|'command'|'argumentsFor'|'summaryFor'|'summaryCaptureFor'>} [options] */ +/** @param {Omit[0], 'id'|'host'|'command'|'argumentsFor'|'summaryFor'|'summaryCaptureFor'|'handoffRequestFor'>} [options] */ export function createClaudeExecutionAdapter(options = {}) { return createSubprocessExecutionAdapter({ id: 'claude-print-json', host: 'claude', command: 'claude', @@ -12,13 +32,30 @@ export function createClaudeExecutionAdapter(options = {}) { // Templates carry per-node turn caps (#88) — honored where the CLI has a // surface; codex exec and opencode serve have none (documented there). ...(Number.isInteger(worker.maxTurns) && worker.maxTurns > 0 ? ['--max-turns', String(worker.maxTurns)] : []), + ...(worker.hermetic === true ? HERMETIC_FLAGS : []), + // Schema-native handoff (ADR-0034): --json-schema makes the CLI enforce + // the handoff shape and return it as `structured_output` on the result + // event — the final-text SHAPE stops being load-bearing (#108). + ...(worker.requiresHandoff === true ? ['--json-schema', HANDOFF_SCHEMA_TEXT] : []), worker.prompt, ], summaryCaptureFor: () => createJsonlSummaryCapture( - (event) => event?.type === 'result' && event?.subtype === 'success' ? event.result : null, + (event) => { + if (event?.type !== 'result' || event?.subtype !== 'success') return null; + // --json-schema delivers the validated object in structured_output; + // the text result remains the path for legacy tagged handoffs. + if (event.structured_output !== undefined && event.structured_output !== null) { + return JSON.stringify(event.structured_output); + } + return event.result; + }, 'Claude', ), - summaryFor: (_observation, finalText) => extractHandoff(finalText), + summaryFor: (_observation, finalText) => parseHandoffText(finalText), + // Structured phrasing, not the bare-object demand: --json-schema derives + // structured_output out of band, and a "final message must be the object" + // instruction is a contradiction the model may obey by refusing the task. + handoffRequestFor: () => HANDOFF_REQUEST_STRUCTURED, ...options, }); } diff --git a/src/lib/execution/codex.mjs b/src/lib/execution/codex.mjs index 43a428b..9d74fce 100644 --- a/src/lib/execution/codex.mjs +++ b/src/lib/execution/codex.mjs @@ -1,16 +1,35 @@ import { createJsonlSummaryCapture, createSubprocessExecutionAdapter } from './subprocess.mjs'; -import { extractHandoff } from './handoff.mjs'; +import { HANDOFF_REQUEST_JSON, HANDOFF_SCHEMA_PATH, parseHandoffText } from './handoff.mjs'; + +// Hermetic seat trims (ADR-0034, opt-in via worker.hermetic). Codex 0.149.x +// offers no per-invocation control of the MCP roster (plugins mount servers +// and instructions regardless — probed 2026-08-26), so isolation is bounded: +// no session persistence, right-sized reasoning effort, and no project docs. +// The machine-wide ~/.codex/AGENTS.md still loads; ak manages that content. +// Roster-independence comes from the schema-native transport below instead. +const HERMETIC_FLAGS = Object.freeze([ + '--ephemeral', + '-c', 'model_reasoning_effort="medium"', + '-c', 'project_doc_max_bytes=0', +]); /** Codex's documented exec/json mode. Its configured sandbox policy is retained. * worker.maxTurns is deliberately NOT forwarded: codex exec has no turn-cap * flag (verified against its help) — the bound rides on the runner timeout. */ -/** @param {Omit[0], 'id'|'host'|'command'|'argumentsFor'|'summaryFor'|'summaryCaptureFor'>} [options] */ +/** @param {Omit[0], 'id'|'host'|'command'|'argumentsFor'|'summaryFor'|'summaryCaptureFor'|'handoffRequestFor'>} [options] */ export function createCodexExecutionAdapter(options = {}) { return createSubprocessExecutionAdapter({ id: 'codex-exec-json', host: 'codex', command: 'codex', argumentsFor: (worker, cwd) => [ 'exec', '--json', '--cd', cwd, ...(worker.configuredModel ? ['--model', worker.configuredModel] : []), + ...(worker.hermetic === true ? HERMETIC_FLAGS : []), + // Schema-native handoff (ADR-0034): --output-schema constrains the final + // agent message to the handoff shape, so trailing-instruction collisions + // (#108's stochastic protocol_error) cannot malform it. Held 5/5 with + // MCP servers active at codex 0.149.1 (openai/codex#15451 not + // reproduced there — re-probe on codex upgrades). + ...(worker.requiresHandoff === true ? ['--output-schema', HANDOFF_SCHEMA_PATH] : []), worker.prompt, ], summaryCaptureFor: () => createJsonlSummaryCapture( @@ -19,7 +38,8 @@ export function createCodexExecutionAdapter(options = {}) { : null, 'Codex', ), - summaryFor: (_observation, finalText) => extractHandoff(finalText), + summaryFor: (_observation, finalText) => parseHandoffText(finalText), + handoffRequestFor: () => HANDOFF_REQUEST_JSON, ...options, }); } diff --git a/src/lib/execution/handoff.mjs b/src/lib/execution/handoff.mjs index 3a20070..6826e69 100644 --- a/src/lib/execution/handoff.mjs +++ b/src/lib/execution/handoff.mjs @@ -1,12 +1,24 @@ // Runtime-only worker handoffs (#76). These summaries are an internal // coordination channel: they never enter WorkerResult, dry-run materialization, // or `ak run --json`. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; export const HANDOFF_START = ''; export const HANDOFF_END = ''; export const HANDOFF_MAX_BYTES = 2 * 1024; export const HANDOFF_AGGREGATE_MAX_BYTES = 8 * 1024; +// Schema-native transport (ADR-0034): hosts whose CLIs can enforce a JSON +// Schema on the final message (`claude --json-schema`, `codex exec +// --output-schema`) carry the handoff as that bare object instead of a +// tag-delimited block. One schema document serves both flags; the keyword set +// deliberately stays inside OpenAI's structured-output subset (no minLength — +// normalizeHandoff enforces non-empty), and normalizeHandoff remains the only +// authority on what is accepted. +export const HANDOFF_SCHEMA_PATH = fileURLToPath(new URL('./handoff.schema.json', import.meta.url)); +export const HANDOFF_SCHEMA_TEXT = JSON.stringify(JSON.parse(readFileSync(HANDOFF_SCHEMA_PATH, 'utf8'))); + const FIELDS = Object.freeze(['outcome', 'artifacts', 'decisions', 'risks']); // eslint-disable-next-line no-control-regex const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; @@ -21,7 +33,41 @@ ${HANDOFF_START} {"outcome":"concise result","artifacts":["paths or outputs"],"decisions":["important choices"],"risks":["remaining risks"]} ${HANDOFF_END} All four fields are required. Keep the JSON concise and valid. This block is -runtime coordination data and will not be included in the public run result.`; +runtime coordination data and will not be included in the public run result. +Emit the block exactly once.`; + +// Schema-native variants (ADR-0034). parseHandoffText accepts every form on +// every host, so a cross-host escalation rung never strands an instruction. +// +// JSON variant: for hosts whose schema flag constrains the FINAL MESSAGE +// itself (codex --output-schema) — the model is asked for the bare object. +export const HANDOFF_REQUEST_JSON = ` + +Internal dependency handoff required. +This summary may be forwarded to a different host or inference vendor. Never +include secrets, credentials, tokens, raw logs, or transcript excerpts. +Your final message must be exactly one JSON object of this shape and nothing else: +{"outcome":"concise result","artifacts":["paths or outputs"],"decisions":["important choices"],"risks":["remaining risks"]} +All four fields are required; the arrays may be empty. No prose, no code +fences, no text before or after the object. This object is runtime +coordination data and will not be included in the public run result.`; + +// Structured variant: for hosts where the schema is enforced OUT OF BAND of +// the message text (claude --json-schema derives structured_output from the +// turn). Telling that model its "final message must be the bare object" is a +// contradiction it may obey by refusing the task (observed live: an outcome +// of "not applicable — this turn required a raw JSON handoff object"). So the +// model is told what to REPORT, never how to shape its message. +export const HANDOFF_REQUEST_STRUCTURED = ` + +Internal dependency handoff required. +After completing the work above, report the handoff in your structured output: +outcome (concise result), artifacts (paths or outputs), decisions (important +choices), risks (remaining risks). All four fields are required; the arrays +may be empty. This summary may be forwarded to a different host or inference +vendor — never include secrets, credentials, tokens, raw logs, or transcript +excerpts. It is runtime coordination data and will not be included in the +public run result.`; function bytes(value) { return Buffer.byteLength(value, 'utf8'); @@ -91,7 +137,12 @@ export function normalizeHandoff(value, { maxBytes = HANDOFF_MAX_BYTES } = {}) { return compact(value, maxBytes); } -/** Extract exactly one tagged JSON handoff. Raw host output is never a fallback. */ +/** Extract exactly one tagged JSON handoff. Raw host output is never a + * fallback. Text before or after the single block is tolerated (ADR-0034): + * a full agent session carries standing instructions ak does not control + * (machine guidance, plugin receipts), and hard-failing on benign trailing + * prose was issue #108's stochastic protocol_error. Duplicate delimiters stay + * fatal — a second block is the shadowing/injection-suspicious case. */ export function extractHandoff(raw) { if (typeof raw !== 'string') return null; const firstStart = raw.indexOf(HANDOFF_START); @@ -99,8 +150,7 @@ export function extractHandoff(raw) { if (firstStart === -1 && firstEnd === -1) return null; if (firstStart === -1 || firstEnd === -1 || firstEnd < firstStart || raw.indexOf(HANDOFF_START, firstStart + HANDOFF_START.length) !== -1 - || raw.indexOf(HANDOFF_END, firstEnd + HANDOFF_END.length) !== -1 - || raw.slice(firstEnd + HANDOFF_END.length).trim() !== '') { + || raw.indexOf(HANDOFF_END, firstEnd + HANDOFF_END.length) !== -1) { throw new TypeError('worker emitted a malformed or duplicate handoff block'); } const body = raw.slice(firstStart + HANDOFF_START.length, firstEnd).trim(); @@ -109,6 +159,29 @@ export function extractHandoff(raw) { return normalizeHandoff(value); } +/** Parse a final message under the schema-native transport (ADR-0034): + * tagged block if the delimiters appear (strict, above); otherwise the bare + * JSON object the host's schema flag enforced — accepted whole, or as the + * outermost brace span when benign wrapping (a code fence, an appended + * receipt line) surrounds it. A parsed non-object candidate is not a handoff + * (null); a parsed object with the wrong shape IS protocol evidence and + * throws via normalizeHandoff. Raw prose is never a fallback. */ +export function parseHandoffText(raw) { + if (typeof raw !== 'string' || !raw.trim()) return null; + if (raw.includes(HANDOFF_START) || raw.includes(HANDOFF_END)) return extractHandoff(raw); + const trimmed = raw.trim(); + const candidates = [trimmed]; + const open = trimmed.indexOf('{'); + const close = trimmed.lastIndexOf('}'); + if (open !== -1 && close > open) candidates.push(trimmed.slice(open, close + 1)); + for (const candidate of candidates) { + let value; + try { value = JSON.parse(candidate); } catch { continue; } + if (value && typeof value === 'object' && !Array.isArray(value)) return normalizeHandoff(value); + } + return null; +} + /** Remove the private protocol payload before host diagnostics reach a public * WorkerResult. Once either delimiter appears, the remainder is withheld: * malformed/truncated blocks must not create a disclosure bypass. */ diff --git a/src/lib/execution/handoff.schema.json b/src/lib/execution/handoff.schema.json new file mode 100644 index 0000000..87b63f9 --- /dev/null +++ b/src/lib/execution/handoff.schema.json @@ -0,0 +1,12 @@ +{ + "title": "AK worker handoff", + "type": "object", + "properties": { + "outcome": { "type": "string" }, + "artifacts": { "type": "array", "items": { "type": "string" } }, + "decisions": { "type": "array", "items": { "type": "string" } }, + "risks": { "type": "array", "items": { "type": "string" } } + }, + "required": ["outcome", "artifacts", "decisions", "risks"], + "additionalProperties": false +} diff --git a/src/lib/execution/process-tree.mjs b/src/lib/execution/process-tree.mjs index dafac0b..1c24150 100644 --- a/src/lib/execution/process-tree.mjs +++ b/src/lib/execution/process-tree.mjs @@ -7,13 +7,25 @@ import { isWindows } from '../paths.mjs'; /** * @param {any} child * @param {'SIGTERM'|'SIGKILL'} signal - * @param {{windows?:boolean,runFn?:typeof run}} [options] + * @param {{windows?:boolean,runFn?:typeof run,processKill?:typeof process.kill}} [options] */ export async function signalProcessTree(child, signal, { windows = isWindows, runFn = run, + processKill = process.kill, } = {}) { - if (!windows) return !!child?.kill?.(signal); + if (!windows) { + // Subprocess workers are spawned detached on POSIX, making the direct + // child a process-group leader. A negative pid reaches that whole group, + // including MCP servers or other descendants started by the host CLI. + if (Number.isInteger(child?.pid) && child.pid > 0) { + try { + processKill(-child.pid, signal); + return true; + } catch { /* non-detached/custom child — fall through to direct signal */ } + } + return !!child?.kill?.(signal); + } if (!Number.isInteger(child?.pid) || child.pid < 1) return false; // Windows has no portable TERM for arbitrary console trees. /F is used for // both rungs; /T is the required descendant-ownership guarantee. diff --git a/src/lib/execution/runner.mjs b/src/lib/execution/runner.mjs index 9108b4b..5036546 100644 --- a/src/lib/execution/runner.mjs +++ b/src/lib/execution/runner.mjs @@ -288,9 +288,21 @@ export async function executeRunPlan(plan, { const start = (worker) => { const dependencies = (worker.dependsOn ?? []).map((id) => ({ id, handoff: summaries.get(id) })); let runtimePrompt = worker.prompt; + const wantsHandoff = mustSummarize.has(worker.id); try { runtimePrompt += renderDependencyHandoffs(dependencies); - if (mustSummarize.has(worker.id)) runtimePrompt += HANDOFF_REQUEST; + if (wantsHandoff) { + // ADR-0034: an adapter whose transport enforces the handoff schema on + // the final message supplies its own instruction; the tagged-block + // request stays the host-neutral default. An escalation rung that + // switches hosts keeps the original instruction — parseHandoffText + // accepts both forms on every schema-native host, so the summary + // still validates. + const adapter = adapterFor(adapters, worker.host); + runtimePrompt += typeof adapter?.handoffRequestFor === 'function' + ? adapter.handoffRequestFor(worker) + : HANDOFF_REQUEST; + } } catch (error) { results.set(worker.id, workerFailure(worker, { status: 'blocked', @@ -301,13 +313,15 @@ export async function executeRunPlan(plan, { pending.delete(worker.id); return; } - const runtimeWorker = { ...worker, prompt: runtimePrompt }; + // `requiresHandoff` rides the runtime worker so an adapter can add its + // schema-output flags only for workers whose summary a dependent needs. + const runtimeWorker = { ...worker, prompt: runtimePrompt, requiresHandoff: wantsHandoff }; const promise = executeWorkerWithEscalation(runtimeWorker, adapters, { cwd, timeoutMs, clock, escalate, - requireHandoff: mustSummarize.has(worker.id), + requireHandoff: wantsHandoff, }); running.set(worker.id, promise.then((outcome) => ({ id: worker.id, ...outcome }))); pending.delete(worker.id); diff --git a/src/lib/execution/schema.mjs b/src/lib/execution/schema.mjs index bef6c02..786df84 100644 --- a/src/lib/execution/schema.mjs +++ b/src/lib/execution/schema.mjs @@ -17,6 +17,13 @@ const REQUIRED_METHODS = Object.freeze([ 'readiness', 'prepare', 'launch', 'observe', 'interpret', 'summarize', 'cancel', 'cleanup', ]); +// Optional per-adapter hooks (ADR-0034). `handoffRequestFor(worker)` lets an +// adapter supply the handoff instruction matching its transport (schema-native +// hosts ask for the bare JSON object); absent, the runner appends the generic +// tagged-block request. Optional and validated only when present, so existing +// adapters — including externally-admitted ones — keep their exact shape. +const OPTIONAL_METHODS = Object.freeze(['handoffRequestFor']); + /** Validate the host-neutral execution-adapter shape without invoking it. */ export function validateExecutionAdapter(value) { assertRecord(value, 'executionAdapter'); @@ -26,7 +33,19 @@ export function validateExecutionAdapter(value) { throw new TypeError(`executionAdapter.${method} must be a function`); } } - return immutable({ id: value.id, ...Object.fromEntries(REQUIRED_METHODS.map((method) => [method, value[method]])) }); + const optional = {}; + for (const method of OPTIONAL_METHODS) { + if (value[method] === undefined) continue; + if (typeof value[method] !== 'function') { + throw new TypeError(`executionAdapter.${method} must be a function when present`); + } + optional[method] = value[method]; + } + return immutable({ + id: value.id, + ...Object.fromEntries(REQUIRED_METHODS.map((method) => [method, value[method]])), + ...optional, + }); } /** Validate the privacy-safe, normalized terminal result every adapter returns. */ diff --git a/src/lib/execution/subprocess.mjs b/src/lib/execution/subprocess.mjs index 890f1c8..a431557 100644 --- a/src/lib/execution/subprocess.mjs +++ b/src/lib/execution/subprocess.mjs @@ -220,14 +220,18 @@ async function terminate(state, { terminationGraceMs, forceGraceMs, signalFn }) /** Build a lifecycle adapter for one host's structured non-interactive CLI. * `argumentsFor` must return a fixed argv vector; prompts never pass through a * shell. Permission modes are deliberately absent from this generic layer. + * `handoffRequestFor` (optional, ADR-0034) supplies the runner's handoff + * instruction when this host's transport enforces the schema on the final + * message; absent, the runner appends the generic tagged-block request. * @param {{id:string, host:string, command:string, argumentsFor:(worker:any, cwd:string)=>string[], * summaryFor:(observation:any,summaryText:string|null)=>any, * summaryCaptureFor?:()=>{write:(chunk:string)=>void,read:()=>string|null}, + * handoffRequestFor?:(worker:any)=>string, * spawnFn?:typeof nodeSpawn, haveFn?:typeof have, resolveFn?:typeof resolveShim, * signalFn?:typeof signalProcessTree, * clock?:()=>string, terminationGraceMs?:number, forceGraceMs?:number}} options */ export function createSubprocessExecutionAdapter({ - id, host, command, argumentsFor, summaryFor, summaryCaptureFor, + id, host, command, argumentsFor, summaryFor, summaryCaptureFor, handoffRequestFor, spawnFn = nodeSpawn, haveFn = have, resolveFn = resolveShim, signalFn = signalProcessTree, clock = nowIso, terminationGraceMs = 1_500, forceGraceMs = 1_500, @@ -261,7 +265,14 @@ export function createSubprocessExecutionAdapter({ if (invocation.resolved === false) { throw new Error(`${host} command has no safe Windows invocation`); } - const child = spawnFn(invocation.command, invocation.args, { cwd: state.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawnFn(invocation.command, invocation.args, { + cwd: state.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + // POSIX process groups let timeout/cancel reach host-spawned MCP + // descendants. Windows uses taskkill /T in signalProcessTree. + detached: process.platform !== 'win32', + }); if (!child?.once) throw new Error(`${host} process did not expose child lifecycle events`); // Register the acquired child on runner-owned state before any future // await so a launch deadline can still cancel and clean it up. @@ -284,6 +295,7 @@ export function createSubprocessExecutionAdapter({ summarize(state, observation) { return summaryFor(observation, state.summaryCapture?.read() ?? null); }, async cancel(state) { return terminate(state, { terminationGraceMs, forceGraceMs, signalFn }); }, async cleanup(state) { return terminate(state, { terminationGraceMs, forceGraceMs, signalFn }); }, + ...(typeof handoffRequestFor === 'function' ? { handoffRequestFor } : {}), }; return validateExecutionAdapter(adapter); } diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index 8d6bc5c..06736c9 100644 --- a/src/lib/hosts.mjs +++ b/src/lib/hosts.mjs @@ -1,8 +1,8 @@ // Host-adapter core — the host-neutral spine of ak's ambidextrous experience. // // why: ak models two frontier hosts (claude, codex) but the *experience* used to -// be claude-shaped (guidance → CLAUDE.md, statusline = claude's, MCP bridge one -// way). This module makes "which host is driving" a first-class, detected axis and +// be claude-shaped (guidance → CLAUDE.md, statusline = claude's). This module +// makes "which host is driving" a first-class, detected axis and // puts every host-specific artifact behind a per-host descriptor, so the commands // become host-loops instead of claude-hardcoded paths. // @@ -12,10 +12,9 @@ // versions) lives in providers.mjs, which imports THIS (one direction only). // // Grounded: -// - codex is exposed as an MCP server via `codex mcp-server` (stdio JSON-RPC, -// tools codex/codex-reply) and consumes servers via `[mcp_servers.*]` in -// ~/.codex/config.toml (TOML). Claude Code uses settings.json (JSON) + -// `claude mcp add`. +// - codex consumes servers via `[mcp_servers.*]` in ~/.codex/config.toml +// (TOML). OpenAI deprecated its former `codex mcp-server` mode; managed +// cross-host execution uses `ak run` (ADR-0033). // - codex statusline is a fixed built-in enum (`tui.status_line`); a // command-backed footer like Claude Code's is an unimplemented upstream // request (openai/codex #16921/#17827/#20140/#20244). @@ -142,10 +141,9 @@ export function hostAsymmetryNote(hostIdOrEntry, { registry = effectiveHostRegis if (!host?.capabilities?.canDriveSession) return ''; const notes = []; - // F-25: this host is registered as callable FROM another host via MCP — a - // real bridge capability, read off the trust manifest rather than an id - // check ('expose