From c25514d26de10382b5270e7d1446521e0e20789f Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Sat, 15 Aug 2026 18:14:50 -0700 Subject: [PATCH 1/2] feat(opencode): add compact connected ruv gateway --- README.md | 32 +- claude/ruflo-opencode-reference.md | 45 +- docs/HOST-SUPPORT.md | 6 +- docs/TROUBLESHOOTING.md | 5 +- docs/adr/0017-opencode-host.md | 113 ++- src/commands/setup.mjs | 12 +- src/commands/status.mjs | 59 +- src/commands/sync.mjs | 7 +- src/commands/x/host.mjs | 12 +- src/lib/adapters/registries.mjs | 7 +- src/lib/opencode.mjs | 614 +++++++++++--- src/templates/opencode-ruflo-gateway.js | 783 ++++++++++++++++++ src/templates/opencode-ruflo-hooks.js | 13 + tests/kit/opencode-ruflo-gateway.test.mjs | 715 ++++++++++++++++ .../kit/opencode-stock-ruflo-gateway.test.mjs | 552 ++++++++++++ tests/kit/opencode.test.mjs | 55 +- tests/kit/provider-cli.test.mjs | 20 +- tests/kit/setup-command.test.mjs | 11 +- tests/kit/status-command.test.mjs | 4 +- tests/kit/sync-command.test.mjs | 10 +- tests/kit/trust-manifest.test.mjs | 5 +- 21 files changed, 2856 insertions(+), 224 deletions(-) create mode 100644 src/templates/opencode-ruflo-gateway.js create mode 100644 tests/kit/opencode-ruflo-gateway.test.mjs create mode 100644 tests/kit/opencode-stock-ruflo-gateway.test.mjs diff --git a/README.md b/README.md index 875365f..8413017 100644 --- a/README.md +++ b/README.md @@ -224,27 +224,29 @@ native surfaces rather than env flags. `ak setup --opencode` (or `integrations.hosts.opencode: true` in `kit.json`) converges, on every `ak sync`: -- **`~/.config/opencode/opencode.json`** — the `claude-flow` MCP server (ruflo's 300+ tools, - via `claude-flow-mcp` with `ruflo mcp start` fallback) and `ruvnet-brain` MCP (the - stable-spine shim, hot-swapped on brain updates), plus ruflo's `skills.paths` and - pre-approved `permission` patterns — merged backup-first into whatever you already have - (a JSONC file ak can't parse is refused, never clobbered). Opting in authorizes the - `claude-flow_*` and `ruvnet-brain_*` MCP tool families without per-call prompts; use - OpenCode's permission configuration if you need narrower approval policy. +- **`~/.config/opencode/opencode.json`** — connected `claude-flow`, `agentic-qe`, and + `ruvnet-brain` MCP entries, plus Ruflo skill paths and explicit permission rules. Values are + merged backup-first into the operator's existing JSON; collisions and user-authored tool or + permission policies are preserved rather than overwritten. +- **Compact rUv gateway** — `claude-flow` and `agentic-qe` remain visibly connected in OpenCode, + while their hundreds of direct tool schemas are blacklisted from provider requests and exposed + lazily as `ak_ruflo_search` / `ak_ruflo_call` and `ak_aqe_search` / `ak_aqe_call`. RuvNet Brain + remains a small direct MCP. The gateway asks through OpenCode's permission system before calls + and opts a family back into direct exposure when user tool policy conflicts with projection. - **Lifecycle hooks** — `~/.config/opencode/plugins/ruflo-hooks.js`: session restore/end, best-effort bash safety screening (defense-in-depth, fail-open if the local handler is unavailable), edit/task outcome recording for ruflo's learning substrate (opencode has no settings-hooks surface; its plugin events are the hook spine). -- **Subagents + skills** — ruflo's agent set converted to opencode subagents - (`~/.config/opencode/agents/`, re-converted whenever the catalog source changes) and the - platform skill (`~/.config/opencode/skills/ruflo/`). The catalog source resolves - automatically: claude marketplace clone (full set, auto-updated) → published - `@claude-flow/cli` package (substrate set); override via - `integrations.ownership.opencode.catalogDir` or `$RUFLO_REPO`. +- **Lazy specialists + skills** — one receipt-owned `ak-specialist` subagent replaces the eager + 107-profile task catalogue. The complete converted catalogue is embedded in the gateway and + reached through `ak_agent_search`, stock OpenCode `task`, and `ak_agent_load`. Installed skills + remain loadable through stock `skill`; only their eager system-prompt catalogue is compacted + behind `ak_skill_search`. Optional external MCP dependencies named by a specialist are reported + explicitly and are never presented as installed by Agentic Kit. - **Guidance** — `~/.config/opencode/AGENTS.md` gets ak's managed blocks with - opencode-correct tool names (`claude-flow_*`, `ruvnet-brain_search_ruvnet`). + the compact OpenCode workflow and direct RuvNet Brain grounding rule. -Everything is ownership-recorded (`integrations.ownership.opencode.mcp`) and stripped surgically by +Everything is value- and SHA-receipted under `integrations.ownership.opencode` and stripped surgically by `ak host off` / `ak uninstall` — your own opencode.json entries are never touched. **Execution routing.** `ak run` is the host-neutral runner for explicit OpenCode routes. For diff --git a/claude/ruflo-opencode-reference.md b/claude/ruflo-opencode-reference.md index 842b9ad..c93508e 100644 --- a/claude/ruflo-opencode-reference.md +++ b/claude/ruflo-opencode-reference.md @@ -6,22 +6,25 @@ ## Ruflo for opencode Ruflo is an AI orchestration toolkit (memory, hooks, swarms, neural learning, -security). On this machine it is wired into opencode three ways (all managed by +security). On this machine it is wired into opencode through native host surfaces (all managed by `ak`, converged on every `ak sync`): -1. **MCP server `claude-flow`** — the full ruflo tool surface (300+ tools): - memory, swarms, agents, hooks, routing, workflows. Tools appear with the - `claude-flow_` prefix (e.g. `claude-flow_memory_store`, - `claude-flow_memory_search`, `claude-flow_swarm_init`, - `claude-flow_agent_spawn`, `claude-flow_hooks_route`). Pre-approved in - `~/.config/opencode/opencode.json` (`permission`). +1. **Connected MCP servers, compact provider projection** — `claude-flow` and + `agentic-qe` stay connected in OpenCode, but their eager direct tool catalogues are + blacklisted from model requests. Discover and invoke the full live surfaces through + `ak_ruflo_search` / `ak_ruflo_call` and `ak_aqe_search` / `ak_aqe_call`. + RuvNet Brain stays direct: search it before making a factual rUv capability claim and + cite the returned source. Never substitute memory or model priors for Brain evidence. 2. **Lifecycle hooks** — `~/.config/opencode/plugins/ruflo-hooks.js` maps opencode events to `ruflo hooks` verbs: session restore/end, bash safety screening, edit/task outcome recording for the learning substrate. -3. **Skills + agents** — ruflo's skill catalog is on the skills path - (`skills.paths` in opencode.json), and ruflo's agent set is converted to - opencode subagents under `~/.config/opencode/agents/` (re-converted on - every `ak sync` after a ruflo upgrade). +3. **Lazy skills + specialists** — use `ak_skill_search`, then stock `skill` with the + exact selected name. Use `ak_agent_search`, then stock `task` with + `subagent_type="ak-specialist"` and a prompt beginning `PROFILE: `. + The specialist loads its receipt-owned profile with `ak_agent_load`. This preserves the + complete catalogue without paying its descriptions on every initial provider request. + If a profile names an external MCP dependency that is unavailable, report it; do not + invent the tool call or its result. **Restart after wiring.** opencode loads config, plugins, MCP servers, and agents once at startup. After `ak setup --opencode` (or any `ak sync` that @@ -48,14 +51,14 @@ over prior decisions. ### Quick decision tree -``` +```text Need to ... ? -├─ Search past work / decisions → ruflo memory search -q "..." --smart (or claude-flow_memory_search) +├─ Search past work / decisions → ak_ruflo_search, then ak_ruflo_call(memory_search) ├─ Store a decision/pattern → ruflo memory store -k K --value V -n patterns -├─ Pick the right agent for a task → ruflo route "task description" -├─ Run a security audit → ruflo security scan && the security-auditor subagent +├─ Pick the right specialist → ak_agent_search, then task(ak-specialist) +├─ Run a security audit → ak_agent_search for the security specialist ├─ Check ruv stack health → ruflo doctor && ruflo status && ak status -├─ Coordinate 3+ subagents → native task tool first; claude-flow_swarm_init if topology/consensus needed +├─ Coordinate 3+ agents → ak_ruflo_search, then invoke the selected swarm operation ├─ Scan untrusted text → ruflo security defend -i "..." ├─ Re-apply after a ruflo upgrade → ak sync (one command heals everything) └─ Anything rUv CLI → ruflo --help @@ -63,12 +66,10 @@ Need to ... ? ### Subagent coordination -opencode's native `task` tool spawns subagents (ruflo's converted agent set is -under `~/.config/opencode/agents/`, e.g. `coder`, `reviewer`, `tester`, -`planner`, `researcher`, `security-auditor`, swarm coordinators). Spawn -parallel subagents in ONE message whenever the work is independent. There is -no SendMessage equivalent — subagents return a single final report; design -prompts accordingly (self-contained context, explicit deliverable). +opencode's native `task` tool spawns the receipt-owned `ak-specialist` dispatcher. +Select the exact profile with `ak_agent_search`; do not guess profile names. Spawn parallel +specialists in one message only when work is genuinely independent. There is no SendMessage +equivalent — subagents return a final report, so give each a self-contained task and deliverable. ### Daemon (host-independent) diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index 2c9cdb1..67c5b07 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -93,13 +93,13 @@ 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 | -| Ruflo MCP tools | Native registration | Managed Ruflo MCP registration | Managed OpenCode MCP entry | +| 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 | Ruflo agents converted to OpenCode frontmatter/tool names | +| 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` reconverts catalog assets and repairs the plugin/config | +| Upgrade convergence | `ak sync` heals managed assets | `ak sync` heals bridge/guidance | `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/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7ab37a9..ab7b963 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -33,9 +33,10 @@ ak sync # apply it | Want the rich Ruflo/SONA/AQE display inside Codex | Codex currently accepts built-in status-line fields only, not a command-backed renderer | Keep the rich footer in Claude Code; see [Managed Codex status line](CODEX-STATUSLINE.md) for the current boundary | | Too many `⚙` daemons / stale daemons | One daemon per active project is normal (local-only workers, $0). Stale = workspace deleted or past the 12h TTL | `ak x daemon-gc --kill`; `sync` also reaps (and verifies the pid really is a ruflo daemon before killing) | | Want to change which MCP tool families are callable | Exclusions are `permissions.deny` rules, persisted in kit.json | `ak x mcp pick` (re-runnable); `x mcp status` shows the inventory; `x mcp off` unregisters | -| opencode: `claude-flow_*` tools / hooks / agents missing after `ak setup --opencode` or a sync | opencode loads config, plugins, MCP servers, and agents **once at startup** — a running session never sees the new wiring | quit and restart opencode; `ak status` (opencode rows) shows exactly which piece is missing | +| opencode: Ruflo/AQE are not connected, compact `ak_*` tools are missing, or `ak-specialist` is unavailable after `ak setup --opencode` / `ak sync` | opencode loads config, plugins, MCP servers, and agents **once at startup** — a running session never sees new wiring | quit and restart opencode; `ak status` shows MCP connectivity, compact gateway, lifecycle plugin, skill, and specialist state separately | | opencode: `status` says `opencode.json is not plain JSON` | opencode legally allows JSONC comments; ak refuses to rewrite a file it can't parse rather than normalize (and silently drop) your comments | hand-merge the ak entries (`mcp`, `skills.paths`, `permission`) per `docs/adr/0017-opencode-host.md`, or remove the comments and run `ak sync` | -| opencode: an agent/skill/plugin file you created yourself keeps ak's version away | deploys are no-clobber: a file without ak's generated marker at the destination is treated as user-owned and preserved (`status` reports it as `foreign`) | rename yours (or delete it and `ak sync` to get ak's managed copy) | +| opencode: `status` reports a later `opencode.jsonc` override | stock OpenCode loads that file after `opencode.json`, so it can shadow the exact MCP/permission values ak receipts; ak cannot verify JSONC without rewriting user comments | merge the Agentic Kit entries into the later file and remove the duplicate override, or keep the override and use direct user-managed wiring; ak preserves both files and does not deploy its gateway against ambiguous effective config | +| opencode: an agent/skill/plugin file you created yourself keeps ak's version away | deploys are no-clobber: only exact receipt-matching bytes are repairable; an unreceipted or edited destination is user-owned and preserved (`status` reports it as `foreign`) | rename yours (or remove it and run `ak sync` to get ak's managed copy) | | opencode: `status` says `no ruflo catalog source` | the agent/skill catalog resolves override → `$RUFLO_REPO` → claude marketplace clone → `@claude-flow/cli` (direct, then nested under ruflo) — all missing | install ruflo (`ak setup` does), or point `integrations.ownership.opencode.catalogDir` / `$RUFLO_REPO` at a ruflo checkout | | `ruflo memory store` says OK but reads return nothing | Absolute-DB-path pin missing, or an older check looked only at `.swarm/memory.db` while the native bridge selected `.swarm/agentdb-memory.db` | Run `ak x verify memory` for an isolated store/retrieve/on-disk/purge proof; `ak setup` re-pins and verifies the runtime-selected store | | `status` shows a `codex-plugins` warning such as unknown field `_note` | An enabled plugin's newest cached hook file does not match Codex's `description` + `hooks` top-level schema | Open Codex `/plugins`, refresh or disable the named plugin, then start a new session. `ak sync` deliberately does not rewrite Codex-owned cache | diff --git a/docs/adr/0017-opencode-host.md b/docs/adr/0017-opencode-host.md index 647d6a8..79a724f 100644 --- a/docs/adr/0017-opencode-host.md +++ b/docs/adr/0017-opencode-host.md @@ -3,13 +3,15 @@ - **Status:** Accepted; compatibility references amended by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-04 +- **Updated:** 2026-08-15 - **Update note:** Clarified that the AQE boundary applies to inference-provider routing, not AQE's upstream OpenCode platform assets, and recorded the implemented OpenCode transcript, token, observed-cost, and provider-id analytics path. ADR-0023 adds classified SQLite source health, preserves last-good OpenCode usage when a present store is temporarily unreadable, and requires pre-mutation disclosure of OpenCode's wildcard approvals, MCP registrations, - lifecycle plugin, and managed host assets. + lifecycle plugin, and managed host assets. The 2026-08-15 amendment keeps Ruflo and Agentic QE + connected in stock OpenCode while blacklisting their eager tool catalogues from model requests + and projecting a compact, lazy Agentic Kit gateway instead. - **Deciders:** agentic-kit maintainers > **GA amendment:** OpenCode remains opt-in, non-primary, and outside AQE inference-provider @@ -48,6 +50,82 @@ the plugin, the config entries, and the guidance file are static artifacts with ## Decision +### 2026-08-15 amendment: connected MCPs with a compact model projection + +Agentic Kit keeps its managed `claude-flow` and `agentic-qe` MCP entries enabled. Operators must +see both integrations as connected in OpenCode; hiding hundreds of eager schemas from the model +must not be implemented by disabling either MCP integration. + +The OpenCode-only gateway separates process connectivity from model-facing catalogue exposure: + +- the managed MCP processes remain enabled and available to the host; +- direct `claude-flow_*` and `agentic-qe_*` tool families are blacklisted from the provider + request at runtime; +- compact `ak_ruflo_search`, `ak_ruflo_call`, `ak_aqe_search`, and `ak_aqe_call` tools discover + and execute the live catalogues lazily; and +- RuvNet Brain remains a small direct MCP integration rather than being folded into the lazy + Ruflo/AQE projection. + +This projection is provider- and model-neutral. It belongs only to Agentic Kit's OpenCode host +adapter and does not change the Claude or Codex integrations. Receipt ownership, user permission +policy, explicit tool-policy collisions, and teardown protections still apply: the gateway may +hide or proxy only the exact family entries Agentic Kit can prove it owns. +Because stock OpenCode loads `opencode.jsonc` after `opencode.json`, a sibling later override makes +the effective MCP and permission values unprovable. Agentic Kit preserves that user file and fails +closed before writing config or deploying executable projections; status names the collision. + +The first load-bearing acceptance slice is intentionally narrower than full rUv-stack parity. +Isolated exact-stock OpenCode 1.18.18 runs (binary SHA-256 +`4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422`) proved: + +- `/mcp` reported both `claude-flow` and `agentic-qe` as `connected`; +- the Brain route additionally reported `ruvnet-brain` as `connected`; +- the deterministic fixture run advertised 18 provider tools and the installed-service run + advertised 21; both included seven compact `ak_*` gateway tools and zero direct Ruflo/AQE + tools; +- `ak_ruflo_search` selected `memory_search`, `ak_ruflo_call` executed it through a real MCP + protocol child, and the resulting tool output continued through the stock OpenCode session in + both modes; +- `ak_aqe_search` independently selected `fleet_init`, `ak_aqe_call` executed it without a + gateway or MCP error, and its result continued through the same stock session; +- direct `ruvnet-brain_search_ruvnet` executed a grounded AgentDB capability search and continued + through the stock session; +- `ak_skill_search` selected the receipt-known `memory` skill and the stock `skill` tool loaded + its exact body; +- `ak_agent_search` selected `memory-specialist`, the stock `task` tool created an + `ak-specialist` child session, and that child loaded the exact profile through + `ak_agent_load`; and +- the installed `marketplace@3.35.0` catalogue independently loaded the real `memory-search` + skill and dispatched the real `coder` profile from its 107 converted profiles; and +- the installed-service modes used the machine's installed `claude-flow-mcp`, `aqe-mcp`, and + RuvNet Brain shim rather than fixture servers. + +The fixture mode provides deterministic argument and call-ID inspection; the installed mode +proves one live Ruflo operation, one live Agentic QE initialization operation, and one live Brain +grounding operation. Together they prove host, gateway, protocol, schema-projection, and +continuation behavior—not full rUv-stack outcome parity. The deterministic fixture checks and +installed-catalogue checks prove loading and dispatch mechanics, not that every profile's optional +dependencies are installed or every specialist outcome is correct. `ak_agent_load` therefore names +remaining external MCP families in the selected profile, states that this adapter does not provision +them, and requires the specialist to report an unavailable dependency instead of inventing a call or +result. Full acceptance still requires +broader representative operations, command/status and collision/rollback coverage, +packed-artifact replay, and measured initial-provider-request reduction. Those gates must pass +before the adapter is described as full-stack parity or release-ready. + +The matched installed-service projection capture quantifies the initial-load objective. With the +gateway removed, stock OpenCode advertised 429 tools, including 415 direct Ruflo/AQE tools; tool +schemas occupied 335,442 bytes and the serialized provider request occupied 413,542 bytes. With +the compact gateway enabled, the same stock binary and installed MCPs advertised 21 tools, zero +direct Ruflo/AQE tools, 27,458 schema bytes, and a 37,501-byte provider request. That is a 95.10% +tool-count reduction, 91.81% schema-byte reduction, and 90.93% total-request reduction. The direct +Brain route advertised 24 tools, 29,492 schema bytes, and a 39,650-byte request, remaining inside +the same compact ceilings. The stock +acceptance gate therefore enforces both sides: at least 400/300,000 direct tools/schema bytes in +the installed eager control, and no more than 25 tools, 30,000 schema bytes, or 45,000 request +bytes in the compact candidate. These are serialized request sizes, not tokenizer-specific token +estimates or end-to-end inference timings. + ### 1. A managed host adapter — opt-in, `hosts.opencode: false` by default The ADR-0016 host registry declares `canDriveSession:true`, `canBePrimary:false`, and now @@ -101,20 +179,16 @@ Every ak-managed byte on opencode's surfaces lives behind one module, following refreshed or removed; marker-bearing user edits are preserved and reported. Failure policy: hooks never break the host. Bash screening is explicitly defense-in-depth and fails open when the local handler errors or times out. -- **Agents converted, not copied:** `convertAgents` rewrites Claude-format frontmatter to - `{description, mode: subagent}` (dropping the `tools:` string list — opencode uses - permissions, and subagents inherit the invoker's tools, matching the broad lists these - agents declare), emits descriptions as JSON double-quoted scalars (valid YAML 1.2 — - unquoted colon-space content would corrupt frontmatter), rewrites body refs across - all three - catalog spellings (`mcp__claude-flow__`/`mcp__claude_flow__`/`mcp__ruflo__` → - `claude-flow_`), prefixes basename collisions with the category dir, and skips - `type: documentation` files. Generated files carry an ak marker; `syncAgents` - rewrites/removes marked files only and leaves user files untouched (the earlier - standalone script's marker alone is not treated as proof of ownership). The stamp - (`.ak-agents-stamp.json`) records the source id, actually-deployed file list, and - content hashes; kit.json retains the authoritative last-written receipts. Status - distinguishes user-modified files from repairable structural/version drift. +- **Agents converted into a lazy receipt-owned catalogue:** `convertAgents` normalizes the + complete upstream profile set, deterministically resolves name collisions, and embeds the + resulting metadata and bodies in the exact-receipted gateway. OpenCode scans only one managed + `ak-specialist` subagent. The parent chooses a profile with `ak_agent_search`, stock `task` + creates the child, and the child loads the exact body with `ak_agent_load`. Managed Ruflo/AQE + references are translated at load time from the capabilities the gateway actually captured; + remaining external MCP families are named as optional dependencies and never claimed as + installed. Migration removes only receipt-matching legacy generated agents after the gateway + and dispatcher are both current; user-modified files and a user-owned dispatcher name collision + preserve the eager fallback and are reported rather than overwritten. - **Catalog source resolution** (`catalogSource`): kit.json `opencodeCatalogDir` override → `$RUFLO_REPO` → the claude marketplace clone `~/.claude/plugins/marketplaces/ruflo` (full repo mirror — all agents, all plugin skills, platform `SKILL.md` — auto-updated @@ -132,8 +206,8 @@ Every ak-managed byte on opencode's surfaces lives behind one module, following `guidanceTargets` gains `agents-opencode` → `~/.config/opencode/AGENTS.md` under the same dir-exists gate as `~/.codex` (never `mkdir`'d; existence = install signal). Two new -registry rows carry opencode-correct content — `ruflo-opencode-reference` (opencode tool -naming, plugin bridge, converted agents) and `ruvnet-brain-opencode-reference` (the +registry rows carry opencode-correct content — `ruflo-opencode-reference` (connected MCPs, +compact gateway, lazy skills/specialists) and `ruvnet-brain-opencode-reference` (the `ruvnet-brain_search_ruvnet` tool name) — both **enablement-gated** (`flag: opencodeEnabled`: the template asserts active wiring, so an installed-but-disabled host must not receive it; `ak host off` / pick-disable strip them on the next @@ -274,3 +348,6 @@ discrepancy until reconciled. (enable/disable/dry-run/teardown orchestration, sandboxed), `tests/kit/opencode-version-drift.test.mjs` (npm-managed vs external update ownership), `tests/kit/{hosts,guidance-targets}.test.mjs` (extended). +- Compact-gateway tests: `tests/kit/opencode-ruflo-gateway.test.mjs` (gateway protocol, + lifecycle, policy, and ownership contracts) and + `tests/kit/opencode-stock-ruflo-gateway.test.mjs` (isolated exact-stock OpenCode acceptance). diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index a285216..16874bd 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -213,8 +213,9 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } } - // 6b. host lifecycle wiring — config-file MCP + skills, lifecycle plugin, - // converted agents, platform skill (each adapter owns its own surfaces + // 6b. host lifecycle wiring — connected MCPs, compact lazy gateway, + // lifecycle plugin, converted agents, specialist dispatcher, and + // platform skill (each adapter owns its own surfaces // — opencode.mjs for opencode). Registry-driven: loops // builtinHostsWithLifecycle() rather than naming opencode, so a second // BUILT-IN lifecycle host needs no new branch here. Only when the CLI @@ -240,11 +241,12 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const stack = lifecycle.result; (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); if (stack.oc.fatal) { - warn(`opencode plugin/agents/skill/guidance skipped — ${stack.oc.detail}`); + warn(`opencode plugins/agent projection/skill/guidance skipped — ${stack.oc.detail}`); return false; } ok(`opencode plugin: ${stack.plugin.detail}`); - ok(`opencode agents: ${stack.agents.detail}`); + ok(`opencode gateway: ${stack.gateway.detail}`); + ok(`opencode agent projection: ${stack.agents.detail}`); if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`); // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) // — not on the next status-driven reconcile. Same shared reconcile pick @@ -253,7 +255,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); // opencode loads config/plugins/MCP/agents once at startup — say so now, // or the user files "hooks don't work" issues (observed live). - info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); + info('restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections (loaded once at startup)'); } // 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`) diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 3594881..27e2cd1 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -10,8 +10,8 @@ import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, import { scanNpxStale } from '../lib/npx.mjs'; import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus, ruvectorRegistered } from '../lib/mcp.mjs'; import { - opencodeMcpStatus, opencodeConverged, catalogSource, agentsStatus, - pluginStatus, skillStatus, opencodeArtifactReceiptState, + opencodeMcpStatus, catalogSource, createOpencodeLifecycleAdapter, + opencodeArtifactReceiptState, } from '../lib/opencode.mjs'; import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; import { scanRvf } from '../lib/rvf.mjs'; @@ -77,7 +77,8 @@ async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'open } else { const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir }); const st = opencodeMcpStatus(cfg); - const conv = st.parseError ? null : await opencodeConverged(cfg); + const lifecycle = await createOpencodeLifecycleAdapter({ pkgRoot }).detect({ cfg }); + const conv = st.parseError ? null : lifecycle.convergence; if (st.parseError) { rows.push(row('opencode', 'warn', 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', @@ -92,19 +93,15 @@ async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'open 'sync re-applies the opencode wiring')); } else { rows.push(row('opencode', 'ok', - `opencode.json converged (claude-flow${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`)); + `opencode.json converged (claude-flow${st.aqe ? ' + agentic-qe' : ''}${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`)); } const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed); - const artifactReceipts = receiptState.receipts; if (receiptState.adoptionBlocked) { rows.push(row('opencode', 'warn', 'artifact receipt ledger is malformed — ownership adoption blocked; artifacts left untouched', 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup')); } - const plug = pluginStatus({ - pkgRoot, receipt: artifactReceipts.plugin, - adoptionBlocked: receiptState.adoptionBlocked, - }); + const plug = lifecycle.plugin; if (!receiptState.adoptionBlocked && plug.adoptable) { rows.push(row('opencode', 'warn', 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt', @@ -116,33 +113,47 @@ async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'open } else if (!receiptState.adoptionBlocked && !plug.current) { rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it')); } - const ag = agentsStatus({ - source, receipts: artifactReceipts.agents, - stampReceipt: artifactReceipts.agentStamp, - adoptionBlocked: receiptState.agentsAdoptionBlocked, - }); + const gateway = lifecycle.gateway; + if (!receiptState.adoptionBlocked && gateway.adoptable) { + rows.push(row('opencode', 'warn', + 'lazy rUv gateway is exact and marker-bearing but lacks an ownership receipt', + 'sync adopts it into the receipt ledger without rewriting it')); + } else if (!receiptState.adoptionBlocked && gateway.foreign) { + rows.push(row('opencode', 'info', + 'lazy rUv gateway slot is user-owned — direct MCP exposure is preserved')); + } else if (!receiptState.adoptionBlocked && gateway.required && !gateway.present) { + rows.push(row('opencode', 'warn', 'lazy rUv gateway not deployed', 'sync deploys it')); + } else if (!receiptState.adoptionBlocked && gateway.required && !gateway.current) { + rows.push(row('opencode', 'warn', 'lazy rUv gateway out of date', 'sync rewrites it')); + } else if (!receiptState.adoptionBlocked && !gateway.required && gateway.present) { + rows.push(row('opencode', 'warn', 'lazy rUv gateway is no longer required', 'sync retires it')); + } else if (!receiptState.adoptionBlocked && gateway.required && gateway.current) { + rows.push(row('opencode', 'ok', + 'Ruflo and Agentic QE connected; compact ak_* gateway projection active')); + } + const ag = lifecycle.agents; + const lazyAgents = gateway.required && gateway.current && ag.count === 1; if (!receiptState.adoptionBlocked && ag.adoptable) { rows.push(row('opencode', 'warn', - `${ag.count} exact marker-bearing agents or stamp lack ownership receipts`, + `${ag.count} exact marker-bearing agent projection or stamp lacks ownership receipts`, 'sync adopts them into the receipt ledger without rewriting them')); } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) { rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog')); } else if (!receiptState.adoptionBlocked && ag.count === 0) { - rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set')); + rows.push(row('opencode', 'warn', 'no Agentic Kit specialist projection', 'sync deploys the specialist dispatcher')); } else if (!receiptState.adoptionBlocked && ag.modified) { rows.push(row('opencode', 'info', - `${ag.count} converted agents include user edits — ak leaves those files alone`)); + `${ag.count} agent projection files include user edits — ak leaves those files alone`)); } else if (!receiptState.adoptionBlocked && ag.stale) { rows.push(row('opencode', 'warn', - `${ag.count} agents from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, - 'sync re-converts the agent set')); + `${ag.count} agent projection files from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, + 'sync refreshes the agent projection')); } else if (!receiptState.adoptionBlocked) { - rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`)); + rows.push(row('opencode', 'ok', lazyAgents + ? `lazy specialist dispatcher current (${ag.currentId})` + : `${ag.count} converted agents (${ag.currentId})`)); } - const sk = skillStatus({ - source, receipt: artifactReceipts.skill, - adoptionBlocked: receiptState.adoptionBlocked, - }); + const sk = lifecycle.skill; if (!receiptState.adoptionBlocked && sk.adoptable) { rows.push(row('opencode', 'warn', 'platform skill is exact and marker-bearing but lacks an ownership receipt', diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 9f44e46..a754252 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -180,8 +180,8 @@ export async function run({ flags, pkgRoot, fetchLatest }) { await step(`install ${h.id}`, () => installHost(h.id)); } } - // opencode host wiring: config-file MCP + skills + permissions, the plugins/ - // lifecycle bridge, the converted agent set, the platform skill. Runs AFTER + // opencode host wiring: connected MCPs, compact lazy gateway, lifecycle + // bridge, specialist dispatcher, and platform skill. Runs AFTER // the hosts install branch so an enable+install converges in one sync, and // only when the CLI is actually present — otherwise the writers would create // the host's config home for a host that isn't there (codex-review #4). @@ -212,7 +212,8 @@ export async function run({ flags, pkgRoot, fetchLatest }) { if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc); report('opencode plugin', stack.plugin); - report('opencode agents', stack.agents); + report('opencode gateway', stack.gateway); + report('opencode agent projection', stack.agents); if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill); } // The 'opencode' guard: the opencode branch above can CREATE the config home diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 5e48455..0d4ad37 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -622,8 +622,8 @@ async function pick({ flags, cwd, pkgRoot }) { } // opencode (integration host): apply the same owner-module stack setup/sync - // use — config wiring, lifecycle plugin, converted agents, platform skill — - // then converge the guidance blocks the same way setup/sync do ("wired + + // use — connected MCPs, compact lazy gateway, lifecycle plugin, specialist + // dispatcher, and platform skill — then converge guidance the same way ("wired + // guided" is one contract, not two). // CLI-gated: an enabled-but-absent CLI never fabricates the config home. let incompleteTeardown = false; @@ -640,14 +640,16 @@ async function pick({ flags, cwd, pkgRoot }) { if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); if (stack.oc.changed || !stack.oc.ok) (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); if (stack.plugin.changed || !stack.plugin.ok) (stack.plugin.ok ? ok : warn)(`opencode plugin: ${stack.plugin.detail}`); - if (stack.agents.changed || !stack.agents.ok) (stack.agents.ok ? ok : warn)(`opencode agents: ${stack.agents.detail}`); + if (stack.gateway.changed || !stack.gateway.ok) (stack.gateway.ok ? ok : warn)(`opencode gateway: ${stack.gateway.detail}`); + if (stack.agents.changed || !stack.agents.ok) (stack.agents.ok ? ok : warn)(`opencode agent projection: ${stack.agents.detail}`); if (stack.skill.changed || !stack.skill.ok) (stack.skill.ok ? ok : warn)(`opencode skill: ${stack.skill.detail}`); const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: true }); if (guidance.changed) ok(`opencode ${guidance.detail}`); // opencode loads config/plugins/MCP/agents once at startup — say so now, // or the user files "hooks don't work" issues (observed live). - if (stack.oc.changed || stack.plugin.changed || stack.agents.changed || stack.skill.changed) { - info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); + if (stack.oc.changed || stack.plugin.changed || stack.gateway.changed + || stack.agents.changed || stack.skill.changed) { + info('restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections (loaded once at startup)'); } } } else if (prevOpencode) { diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index 29fb535..7c2ebeb 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -213,12 +213,17 @@ const hostEntries = [ changes: [ { id: 'ruflo-mcp-dash', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'claude-flow_*', effect: 'allow Ruflo MCP tools using dash-separated names', operations: ['setup', 'host-pick', 'sync'] }, { id: 'ruflo-mcp-underscore', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'claude_flow_*', effect: 'allow Ruflo MCP tools using underscore-separated names', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'aqe-mcp-dash', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'agentic-qe_*', effect: 'allow Agentic QE MCP tools using dash-separated names', operations: ['setup', 'host-pick', 'sync'], features: ['aqe'] }, + { id: 'aqe-mcp-underscore', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'agentic_qe_*', effect: 'allow Agentic QE MCP tools using underscore-separated names', operations: ['setup', 'host-pick', 'sync'], features: ['aqe'] }, { id: 'brain-mcp-dash', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'ruvnet-brain_*', effect: 'allow RuvNet Brain MCP tools using dash-separated names', operations: ['setup', 'host-pick', 'sync'] }, { id: 'brain-mcp-underscore', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'ruvnet_brain_*', effect: 'allow RuvNet Brain MCP tools using underscore-separated names', operations: ['setup', 'host-pick', 'sync'] }, { id: 'ruflo-mcp-registration', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'mcp.claude-flow', effect: 'register the Ruflo MCP server in OpenCode configuration', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'aqe-mcp-registration', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'mcp.agentic-qe', effect: 'register the Agentic QE MCP server in OpenCode configuration', operations: ['setup', 'host-pick', 'sync'], features: ['aqe'] }, { id: 'brain-mcp-registration', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'mcp.ruvnet-brain', effect: 'register the RuvNet Brain MCP server in OpenCode configuration', operations: ['setup', 'host-pick', 'sync'], features: ['brain'] }, { id: 'ruflo-lifecycle-plugin', kind: 'lifecycle-extension', scope: 'user', owner: 'agentic-kit', value: 'plugins/ruflo-hooks.js', effect: 'connect OpenCode lifecycle and tool events to Ruflo hooks', operations: ['setup', 'host-pick', 'sync'] }, - { id: 'ruflo-host-assets', kind: 'host-integration', scope: 'user', owner: 'agentic-kit', value: 'agents, skills, and AGENTS.md', effect: 'install the managed Ruflo agent, skill, and guidance projections for OpenCode', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruv-lazy-gateway', kind: 'lifecycle-extension', scope: 'user', owner: 'agentic-kit', value: 'plugins/ruflo-gateway.js', effect: 'expose receipt-owned lazy Ruflo, Agentic QE, skill, and specialist discovery tools while suppressing eager catalogue duplication', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruv-lazy-tools', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'ak_ruflo_*, ak_aqe_*, ak_skill_search, ak_agent_*', effect: 'run compact Agentic Kit discovery and receipt-bound call tools under OpenCode permission checks', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruflo-host-assets', kind: 'host-integration', scope: 'user', owner: 'agentic-kit', value: 'ak-specialist agent, embedded profile catalog, skills, and AGENTS.md', effect: 'install the managed lazy specialist, skill, and guidance projections for OpenCode', operations: ['setup', 'host-pick', 'sync'] }, ], }, configProjection: 'opencode', observability: ['opencode-logs'], diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs index 5a70fa2..dfacb2a 100644 --- a/src/lib/opencode.mjs +++ b/src/lib/opencode.mjs @@ -6,22 +6,26 @@ // claude (settings.mjs / mcp.mjs) and codex (providers.mjs reverse bridge) // contracts: // -// ~/.config/opencode/opencode.json mcp.claude-flow + mcp.ruvnet-brain, +// ~/.config/opencode/opencode.json mcp.claude-flow + mcp.agentic-qe + +// mcp.ruvnet-brain, // skills.paths, permission patterns // ~/.config/opencode/AGENTS.md guidance blocks (blocks.mjs target // 'agents-opencode' — NOT here) // ~/.config/opencode/plugins/ruflo-hooks.js lifecycle bridge (opencode has // no settings-hooks surface; its plugin // events are the hook spine) -// ~/.config/opencode/agents/*.md ruflo's agent set, converted (Claude -// Code agent format → opencode subagent) +// ~/.config/opencode/plugins/ruflo-gateway.js lazy bridges to the complete +// live Ruflo and Agentic QE catalogues +// ~/.config/opencode/agents/ak-specialist.md +// one stock subagent; receipt-owned rUv +// profiles stay embedded and load lazily // ~/.config/opencode/skills/ruflo/ the platform SKILL.md // // Grounded: // - opencode.json schema (https://opencode.ai/config.json): mcp local // servers {type,command[],environment,enabled,timeout}, skills.paths[], // permission as wildcard tool-name patterns (MCP tools surface as -// `_`, hence the claude-flow_*/ruvnet-brain_* patterns). +// `_`, hence the claude-flow_*/agentic-qe_*/ruvnet-brain_* patterns). // - ruflo's own init/mcp-generator.ts env block (CLAUDE_FLOW_* below). // - `claude-flow-mcp` (the dedicated stdio bin of @claude-flow/cli) answers // initialize directly; `ruflo mcp start` is the fallback (what ak already @@ -37,7 +41,6 @@ import { have } from './exec.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from './blocks.mjs'; import { CURRENT_INTEGRATIONS_VERSION } from './adapters/config.mjs'; -import { autoApproveValues } from './trust-manifest.mjs'; import * as paths from './paths.mjs'; const opencodeOwnership = (cfg) => cfg?.integrations?.ownership?.opencode ?? {}; @@ -64,9 +67,25 @@ export const RUFLO_MCP_ENV = { CLAUDE_FLOW_MEMORY_BACKEND: 'hybrid', }; -/** Permission patterns ak pre-approves (opencode surfaces MCP tools as - * `_`; cover both separator spellings defensively). */ -export const PERMISSION_KEYS = autoApproveValues('opencode'); +/** agentic-qe init's project MCP environment, mirrored for OpenCode. */ +export const AQE_MCP_ENV = { + AQE_LEARNING_ENABLED: 'true', + AQE_WORKERS_ENABLED: 'true', + NODE_ENV: 'production', +}; + +/** Permission patterns follow the rUv capabilities AK actually projects. */ +function permissionFamiliesFor(entries) { + return [ + ...('claude-flow' in entries ? [['claude-flow_*', 'claude_flow_*']] : []), + ...('agentic-qe' in entries ? [['agentic-qe_*', 'agentic_qe_*']] : []), + ...('ruvnet-brain' in entries ? [['ruvnet-brain_*', 'ruvnet_brain_*']] : []), + ]; +} + +function permissionKeysFor(entries) { + return permissionFamiliesFor(entries).flat(); +} /** The brain's stable-spine shim (same registration codex carries). */ export const brainShimPath = () => path.join(paths.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs'); @@ -90,10 +109,13 @@ export function mcpCommandFor({ binPresent, nestedPath }) { /** @typedef {{ kind: string, root: string, id: string, hasPlugins: boolean, hasPlatformSkill: boolean }} CatalogSource */ /** The MCP server entries ak writes. `claude-flow` resolves via mcpCommandFor - * (bin on PATH → nested mcp-server.js → `ruflo mcp start`). ruvnet-brain is - * included only when its shim is on disk. - * @param {{ brainShim?: string, nestedPath?: string }} [opts] */ -export async function mcpEntriesFor({ brainShim = brainShimPath(), nestedPath = nestedMcpServerPath() } = {}) { + * (bin on PATH → nested mcp-server.js → `ruflo mcp start`). Agentic QE is + * included by default because machine setup installs it; `--no-aqe` disables + * that projection. ruvnet-brain is included only when its shim is on disk. + * @param {{ brainShim?: string, nestedPath?: string, includeAqe?: boolean }} [opts] */ +export async function mcpEntriesFor({ + brainShim = brainShimPath(), nestedPath = nestedMcpServerPath(), includeAqe = true, +} = {}) { const entries = { 'claude-flow': { type: 'local', @@ -103,6 +125,15 @@ export async function mcpEntriesFor({ brainShim = brainShimPath(), nestedPath = environment: { ...RUFLO_MCP_ENV }, }, }; + if (includeAqe) { + entries['agentic-qe'] = { + type: 'local', + command: ['aqe-mcp'], + enabled: true, + timeout: 30000, + environment: { ...AQE_MCP_ENV }, + }; + } if (fs.existsSync(brainShim)) { entries['ruvnet-brain'] = { type: 'local', command: ['node', brainShim], enabled: true, timeout: 30000 }; } @@ -122,20 +153,35 @@ function readJsonStrict(file) { } } +/** OpenCode loads opencode.jsonc after opencode.json. A sibling JSONC file can + * shadow managed MCP, permission, or plugin values, and AK deliberately does + * not normalize or rewrite user comments. */ +function laterJsoncOverride(configFile) { + if (path.basename(configFile) !== 'opencode.json') return null; + const candidate = path.join(path.dirname(configFile), 'opencode.jsonc'); + return fs.existsSync(candidate) ? candidate : null; +} + /** Registration state, spawn-free (mirrors mcp.mjs registrationStatus's * file-read approach). `parseError` distinguishes "absent" from "present but * not plain JSON" (JSONC) — the writer refuses the latter. * @param {any} cfg @param {{ configFile?: string }} [opts] */ export function opencodeMcpStatus(cfg, { configFile = paths.opencodeConfigPath() } = {}) { const exists = fs.existsSync(configFile); + const laterOverride = laterJsoncOverride(configFile); const { ok, doc } = exists ? readJsonStrict(configFile) : { ok: true, doc: {} }; if (!ok) { - return { exists, parseError: true, claudeFlow: false, brain: false, owned: opencodeOwnership(cfg).mcp === 'ak' }; + return { + exists, parseError: true, laterOverride, claudeFlow: false, aqe: false, brain: false, + owned: opencodeOwnership(cfg).mcp === 'ak', + }; } return { exists, parseError: false, + laterOverride, claudeFlow: !!doc?.mcp?.['claude-flow'], + aqe: !!doc?.mcp?.['agentic-qe'], brain: !!doc?.mcp?.['ruvnet-brain'], paths: doc?.skills?.paths ?? [], owned: opencodeOwnership(cfg).mcp === 'ak', @@ -153,21 +199,39 @@ export function opencodeMcpStatus(cfg, { configFile = paths.opencodeConfigPath() export async function opencodeConverged(cfg, { configFile = paths.opencodeConfigPath(), brainShim } = {}) { const st = opencodeMcpStatus(cfg, { configFile }); if (!st.exists || st.parseError) return { converged: false, reasons: st.parseError ? ['unparseable config'] : ['no config file'] }; + if (st.laterOverride) { + return { + converged: false, + reasons: [`later OpenCode config override is unverified: ${st.laterOverride}`], + }; + } const doc = readJsonStrict(configFile).doc; const reasons = []; - const entries = await mcpEntriesFor({ brainShim }); + const entries = await mcpEntriesFor({ brainShim, includeAqe: cfg.aqe !== false }); for (const [name, want] of Object.entries(entries)) { if (!(name in (doc.mcp ?? {}))) reasons.push(`${name} missing`); else if (!deepEqual(doc.mcp[name], want)) reasons.push(`${name} drifted`); } - if (doc.mcp?.['ruvnet-brain'] && !entries['ruvnet-brain']) reasons.push('ruvnet-brain stale (brain shim gone)'); + const managed = normalizeManaged(opencodeOwnership(cfg).managed); + const ownedEntries = Object.fromEntries(Object.entries(entries).filter( + ([name]) => managed.mcp[name]?.written != null, + )); + const permissionKeys = permissionKeysFor(ownedEntries); + for (const [name, rec] of Object.entries(managed.mcp)) { + if (name in entries || rec.written == null) continue; + if (deepEqual(doc.mcp?.[name], rec.written)) reasons.push(`${name} stale (no longer desired)`); + } const source = catalogSource({ override: opencodeOwnership(cfg).catalogDir }); for (const p of skillPathsFor(source)) { if (!(doc.skills?.paths ?? []).includes(p)) reasons.push(`skills path missing: ${p}`); } - for (const k of PERMISSION_KEYS) { + for (const k of permissionKeys) { if (doc.permission?.[k] !== 'allow') reasons.push(`permission ${k} not allowed`); } + for (const [key, rec] of Object.entries(managed.permissions)) { + if (permissionKeys.includes(key) || rec.written == null) continue; + if (deepEqual(doc.permission?.[key], rec.written)) reasons.push(`permission ${key} stale (no longer desired)`); + } return { converged: reasons.length === 0, reasons }; } @@ -194,7 +258,7 @@ const receiptMatches = (text, receipt) => function normalizeManaged(m) { const out = { mcp: {}, paths: [], permissions: {}, permissionScalar: null, - artifacts: { plugin: null, agents: {}, agentStamp: null, skill: null }, + artifacts: { plugin: null, gateway: null, agents: {}, agentStamp: null, skill: null }, artifactState: { containerMalformed: false, agentsMalformed: false, rawContainer: null, @@ -223,6 +287,7 @@ function normalizeManaged(m) { } const artifacts = m.artifacts ?? {}; out.artifacts.plugin = hasReceiptValue(artifacts.plugin) ? artifacts.plugin : null; + out.artifacts.gateway = hasReceiptValue(artifacts.gateway) ? artifacts.gateway : null; out.artifacts.agentStamp = hasReceiptValue(artifacts.agentStamp) ? artifacts.agentStamp : null; out.artifacts.skill = hasReceiptValue(artifacts.skill) ? artifacts.skill : null; if (hasReceiptValue(artifacts.agents) @@ -250,6 +315,29 @@ export function opencodeArtifactReceiptState(managed) { }; } +/** Exact MCP values the lazy gateway may capture. A same-name entry is not + * enough: the command and both direct permission spellings must still match + * values positively recorded as AK-written. Explicit direct-tool enablement + * is an operator opt-out from lazy capture. */ +export function managedGatewayMcp(cfg, { configFile = paths.opencodeConfigPath() } = {}) { + const managed = normalizeManaged(opencodeOwnership(cfg).managed); + const parsed = fs.existsSync(configFile) ? readJsonStrict(configFile) : { ok: true, doc: {} }; + const tools = parsed.ok ? (parsed.doc?.tools ?? {}) : {}; + const permissions = parsed.ok && typeof parsed.doc?.permission === 'object' + ? parsed.doc.permission + : {}; + const families = { + 'claude-flow': ['claude-flow_*', 'claude_flow_*'], + 'agentic-qe': ['agentic-qe_*', 'agentic_qe_*'], + }; + return Object.fromEntries(Object.entries(families) + .filter(([name, keys]) => managed.mcp[name]?.written != null + && keys.every((key) => managed.permissions[key]?.written === 'allow' + && permissions[key] === 'allow') + && !keys.some((key) => tools[key] === true)) + .map(([name]) => [name, structuredClone(managed.mcp[name].written)])); +} + /** Reconcile opencode.json: ak's MCP servers, skills.paths, and permission * patterns merged into whatever is already there. The ownership contract is * VALUE-PRECISE, not name-precise: @@ -267,6 +355,13 @@ export function opencodeArtifactReceiptState(managed) { * @param {any} cfg @param {{ dryRun?: boolean, configFile?: string, brainShim?: string }} [opts] */ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.opencodeConfigPath(), brainShim } = {}) { if (!cfg.integrations?.hosts?.opencode) return { ok: true, changed: false, detail: 'opencode not enabled — unmanaged' }; + const laterOverride = laterJsoncOverride(configFile); + if (laterOverride) { + return { + ok: false, fatal: true, changed: false, + detail: `${laterOverride} loads after opencode.json — refusing to write or claim an unverified effective config; merge the Agentic Kit entries there manually or remove the override`, + }; + } const exists = fs.existsSync(configFile); const { ok: parsedOk, doc } = exists ? readJsonStrict(configFile) : { ok: true, doc: {} }; if (!parsedOk) { @@ -275,7 +370,7 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op detail: `${configFile} is not plain JSON (JSONC comments?) — refusing to touch it; merge manually`, }; } - const entries = await mcpEntriesFor({ brainShim }); + const entries = await mcpEntriesFor({ brainShim, includeAqe: cfg.aqe !== false }); const source = catalogSource({ override: opencodeOwnership(cfg).catalogDir }); const skillPaths = skillPathsFor(source); const prevManaged = normalizeManaged(opencodeOwnership(cfg).managed); @@ -352,8 +447,44 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op : (prevManaged.permissionScalar ?? null); if (typeof next.permission === 'string') next.permission = { '*': next.permission }; next.permission = { ...(next.permission ?? {}) }; + + // Permissions are family-atomic with MCP ownership. A foreign/colliding + // same-name MCP must never inherit broad AK-written `allow` patterns, and a + // collision on either spelling prevents AK from adding the other spelling. + const ownedEntries = Object.fromEntries(Object.entries(entries).filter( + ([name]) => managed.mcp[name]?.written != null, + )); + const permissionKeys = []; + for (const keys of permissionFamiliesFor(ownedEntries)) { + const blocked = keys.some((key) => { + const cur = next.permission[key]; + const priorRec = prevManaged.permissions[key]; + const conflicts = cur !== undefined && cur !== 'allow' + && !(priorRec?.written && deepEqual(cur, priorRec.written)); + const previouslyUnowned = cur !== undefined && priorRec && priorRec.written == null; + return conflicts || previouslyUnowned; + }); + if (!blocked) { + permissionKeys.push(...keys); + continue; + } + for (const key of keys) { + const cur = next.permission[key]; + const priorRec = prevManaged.permissions[key]; + if (cur !== undefined && cur !== 'allow' + && !(priorRec?.written && deepEqual(cur, priorRec.written))) { + collisions.push(`permission.${key}`); + } + if (cur !== undefined) { + managed.permissions[key] = { + prior: priorRec ? priorRec.prior : cur, + written: null, + }; + } + } + } for (const [k, rec] of Object.entries(prevManaged.permissions)) { - if (PERMISSION_KEYS.includes(k)) continue; + if (permissionKeys.includes(k)) continue; if (!(k in next.permission)) continue; if (rec.written && deepEqual(next.permission[k], rec.written)) { if (rec.prior != null) { @@ -365,14 +496,9 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op } } } - for (const k of PERMISSION_KEYS) { + for (const k of permissionKeys) { const cur = next.permission[k]; const priorRec = prevManaged.permissions[k]; - if (cur !== undefined && cur !== 'allow' && !(priorRec?.written && deepEqual(cur, priorRec.written))) { - collisions.push(`permission.${k}`); - managed.permissions[k] = { prior: cur, written: null }; - continue; - } managed.permissions[k] = { prior: priorRec ? priorRec.prior : (cur ?? null), written: 'allow' }; next.permission[k] = 'allow'; } @@ -384,14 +510,21 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op ownership.managed = managed; } if (changed && !dryRun) writeJsonWithBackup(configFile, next); - const brain = entries['ruvnet-brain'] ? ' + ruvnet-brain' : ' (brain shim absent — ruflo only)'; + const aqe = entries['agentic-qe'] ? ' + agentic-qe' : ''; + const brain = entries['ruvnet-brain'] ? ' + ruvnet-brain' : ' (brain shim absent)'; const notes = [ - changed ? `opencode.json wired: claude-flow (${entries['claude-flow'].command.join(' ')})${brain}, ${skillPaths.length} skills path(s), ${PERMISSION_KEYS.length} permission pattern(s)` + changed ? `opencode.json wired: claude-flow (${entries['claude-flow'].command.join(' ')})${aqe}${brain}, ${skillPaths.length} skills path(s), ${permissionKeys.length} permission pattern(s)` : `opencode.json in sync${source ? '' : ' — ⚠ no ruflo catalog source found for skills.paths'}`, ]; if (pruned.length) notes.push(`pruned: ${pruned.join(', ')}`); if (collisions.length) notes.push(`⚠ collisions preserved (user-owned, untouched): ${collisions.join(', ')}`); - return { ok: collisions.length === 0, fatal: false, changed, detail: notes.join(' — ') }; + return { + ok: collisions.length === 0, + fatal: false, + changed, + collisions, + detail: notes.join(' — '), + }; } /** Surgical teardown of ak's opencode.json wiring — ONLY the recorded managed @@ -484,8 +617,8 @@ export function undoOpencode(cfg, { configFile = paths.opencodeConfigPath() } = // CALLER (applyOpencode/undoOpencode mutate the ownership markers; the command // decides when saveKitConfig runs). -/** Enable path: wire opencode.json, deploy the lifecycle plugin, convert the - * agent set, deploy the platform skill. Callers gate on the CLI being present +/** Enable path: wire opencode.json, deploy the lifecycle and lazy-catalogue + * plugins, convert the agent set, deploy the platform skill. Callers gate on the CLI being present * first (have('opencode')) — this never fabricates the config home for an * absent host. Returns each step's result for the caller's own formatting, * plus `markersChanged`: applyOpencode re-records the ownership markers on @@ -505,7 +638,10 @@ export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, plugi const oc = await applyOpencode(cfg, { ...(configFile ? { configFile } : {}), ...(brainShim ? { brainShim } : {}) }); if (oc.fatal) { const skipped = { ok: false, changed: false, detail: 'skipped because opencode.json did not converge' }; - return { oc, plugin: skipped, agents: skipped, skill: skipped, source: null, markersChanged: false }; + return { + oc, plugin: skipped, gateway: skipped, agents: skipped, skill: skipped, + source: null, markersChanged: false, + }; } const receiptState = opencodeArtifactReceiptState(opencodeOwnership(cfg).managed); const { receipts, adoptionBlocked } = receiptState; @@ -513,6 +649,7 @@ export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, plugi if (adoptionBlocked) { const detail = 'skipped because the artifact receipt ledger is malformed'; const plugin = { ok: false, changed: false, receipt: receipts.plugin, adoptionBlocked: true, detail }; + const gateway = { ok: false, changed: false, receipt: receipts.gateway, adoptionBlocked: true, detail }; const agents = { ok: false, changed: false, receipts: receipts.agents, stampReceipt: receipts.agentStamp, adopted: 0, adoptionBlocked: true, detail, @@ -522,17 +659,52 @@ export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, plugi opencodeOwnership(cfg).mcp ?? null, opencodeOwnership(cfg).managed ?? null, ]) !== before; - return { oc, plugin, agents, skill, source, markersChanged }; + return { oc, plugin, gateway, agents, skill, source, markersChanged }; } + const managedMcp = managedGatewayMcp(cfg, { ...(configFile ? { configFile } : {}) }); + const dispatcher = specialistDispatcherState({ + destDir: agentsDir ?? paths.opencodeAgentsDir(), + receipts: receipts.agents, + adoptionBlocked: receiptState.agentsAdoptionBlocked, + }); + const agentCatalog = dispatcher.available ? gatewayAgentCatalog(source) : []; + const gatewayRequired = Object.keys(managedMcp).length > 0 || agentCatalog.length > 0; const plugin = deployPlugin({ pkgRoot, receipt: receipts.plugin, adoptionBlocked, ...(pluginsDir ? { pluginsDir } : {}), }); - const agents = syncAgents({ - source, receipts: receipts.agents, stampReceipt: receipts.agentStamp, - adoptionBlocked: receiptState.agentsAdoptionBlocked, - ...(agentsDir ? { destDir: agentsDir } : {}), - }); + const gateway = gatewayRequired + ? deployGatewayPlugin({ + pkgRoot, managedMcp, agentCatalog, receipt: receipts.gateway, adoptionBlocked, + ...(pluginsDir ? { pluginsDir } : {}), + }) + : retireGatewayPlugin({ + receipt: receipts.gateway, ...(pluginsDir ? { pluginsDir } : {}), + }); + const gatewayFacts = gatewayRequired + ? gatewayPluginStatus({ + pkgRoot, managedMcp, agentCatalog, + receipt: gateway.receipt ?? receipts.gateway ?? null, + ...(pluginsDir ? { pluginsDir } : {}), + }) + : { current: false }; + const gatewayCapabilities = { + ruflo: gatewayFacts.current && managedMcp['claude-flow'] != null, + aqe: gatewayFacts.current && managedMcp['agentic-qe'] != null, + }; + const agents = dispatcher.blocked + ? { + ok: false, changed: false, receipts: receipts.agents, + stampReceipt: receipts.agentStamp, adopted: 0, adoptionBlocked: true, + detail: 'specialist dispatcher receipt mismatch; agent projection preserved', + } + : syncAgents({ + source, receipts: receipts.agents, stampReceipt: receipts.agentStamp, + adoptionBlocked: receiptState.agentsAdoptionBlocked, + gatewayCapabilities, + lazyCatalog: gatewayFacts.current && agentCatalog.length > 0, + ...(agentsDir ? { destDir: agentsDir } : {}), + }); const skill = deploySkill({ source, receipt: receipts.skill, adoptionBlocked, ...(skillsDir ? { skillsDir } : {}), @@ -540,6 +712,9 @@ export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, plugi if (!adoptionBlocked) { mutableOpencodeOwnership(cfg).managed.artifacts = { plugin: plugin.receipt ?? receipts.plugin ?? null, + gateway: gatewayRequired + ? (gateway.receipt ?? receipts.gateway ?? null) + : (gateway.ok ? null : (gateway.receipt ?? receipts.gateway ?? null)), agents: agents.receipts ?? receipts.agents ?? {}, agentStamp: agents.stampReceipt ?? receipts.agentStamp ?? null, skill: skill.receipt ?? receipts.skill ?? null, @@ -549,7 +724,7 @@ export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, plugi opencodeOwnership(cfg).mcp ?? null, opencodeOwnership(cfg).managed ?? null, ]) !== before; - return { oc, plugin, agents, skill, source, markersChanged }; + return { oc, plugin, gateway, agents, skill, source, markersChanged }; } /** Retire path: strip the ak-managed opencode.json wiring (user priors @@ -597,22 +772,48 @@ export function createOpencodeLifecycleAdapter(defaults = {}) { }); const receiptState = opencodeArtifactReceiptState(opencodeOwnership(cfg).managed); const { receipts, adoptionBlocked } = receiptState; + const managedMcp = managedGatewayMcp(cfg, { + ...(opts.configFile ? { configFile: opts.configFile } : {}), + }); + const dispatcher = specialistDispatcherState({ + destDir: opts.agentsDir ?? paths.opencodeAgentsDir(), + receipts: receipts.agents, + adoptionBlocked: receiptState.agentsAdoptionBlocked, + }); + const agentCatalog = dispatcher.available ? gatewayAgentCatalog(source) : []; + const gatewayRequired = Object.keys(managedMcp).length > 0 || agentCatalog.length > 0; const plugin = opts.pkgRoot ? pluginStatus({ pkgRoot: opts.pkgRoot, receipt: receipts.plugin, adoptionBlocked, ...(opts.pluginsDir ? { pluginsDir: opts.pluginsDir } : {}), }) : { present: false, current: false, foreign: false, adoptable: false }; + const gateway = opts.pkgRoot + ? gatewayPluginStatus({ + pkgRoot: opts.pkgRoot, managedMcp, agentCatalog, + receipt: receipts.gateway, adoptionBlocked, + ...(opts.pluginsDir ? { pluginsDir: opts.pluginsDir } : {}), + }) + : { present: false, current: false, foreign: false, adoptable: false }; + gateway.required = gatewayRequired; const agents = agentsStatus({ source, receipts: receipts.agents, stampReceipt: receipts.agentStamp, - adoptionBlocked: receiptState.agentsAdoptionBlocked, + adoptionBlocked: receiptState.agentsAdoptionBlocked || dispatcher.blocked, + gatewayCapabilities: { + ruflo: gateway.current && managedMcp['claude-flow'] != null, + aqe: gateway.current && managedMcp['agentic-qe'] != null, + }, + lazyCatalog: gateway.current && agentCatalog.length > 0, ...(opts.agentsDir ? { destDir: opts.agentsDir } : {}), }); const skill = skillStatus({ source, receipt: receipts.skill, adoptionBlocked, ...(opts.skillsDir ? { skillsDir: opts.skillsDir } : {}), }); - return { enabled: !!cfg.integrations?.hosts?.opencode, convergence, plugin, agents, skill }; + return { + enabled: !!cfg.integrations?.hosts?.opencode, + convergence, plugin, gateway, agents, skill, + }; }; return { id: 'opencode', @@ -621,9 +822,15 @@ export function createOpencodeLifecycleAdapter(defaults = {}) { const facts = request.facts ?? await detect(request); const changed = facts.enabled && (!facts.convergence.converged || facts.plugin.adoptable || (!facts.plugin.current && !facts.plugin.foreign) + || (facts.gateway.required + && (facts.gateway.adoptable || (!facts.gateway.current && !facts.gateway.foreign))) + || (!facts.gateway.required && facts.gateway.present && !facts.gateway.foreign) || (!facts.agents.adoptionBlocked && (facts.agents.adoptable || facts.agents.stale)) || facts.skill.adoptable || (!facts.skill.current && !facts.skill.foreign)); - return { changed, facts, operations: changed ? ['config', 'plugin', 'agents', 'skill'] : [] }; + return { + changed, facts, + operations: changed ? ['config', 'plugin', 'gateway', 'agents', 'skill'] : [], + }; }, async apply(request = {}) { const cfg = request.cfg; @@ -631,7 +838,7 @@ export function createOpencodeLifecycleAdapter(defaults = {}) { if (!cfg || !opts.pkgRoot) throw new TypeError('opencode lifecycle apply requires cfg and pkgRoot'); const result = await opencodeStack(cfg, opts); return { - changed: result.oc.changed || result.plugin.changed || result.agents.changed + changed: result.oc.changed || result.plugin.changed || result.gateway.changed || result.agents.changed || result.skill.changed || result.markersChanged, result, }; @@ -732,7 +939,8 @@ const AGENT_MARKERS = ['generated-by: agentic-kit', 'generated-by: sync-ruflo-ag const STAMP_FILE = '.ak-agents-stamp.json'; function* walkMd(dir) { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + for (const e of fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { const p = path.join(dir, e.name); if (e.isDirectory()) yield* walkMd(p); else if (e.isFile() && e.name.endsWith('.md')) yield p; @@ -770,18 +978,50 @@ function parseFrontmatter(text) { const collapse = (s) => String(s ?? '').replace(/\s+/g, ' ').trim(); +const lazyGatewayCall = (family, name) => + `\`${family}_call\` with \`name=${JSON.stringify(name)}\` and \`arguments_json\` set to one JSON object string`; + +const directOpenCodeReferences = (body) => String(body) + .replace(/mcp__(?:claude-flow|claude_flow|ruflo)__([A-Za-z0-9_./:*-]+)/g, 'claude-flow_$1') + .replace(/mcp__(?:agentic-qe|agentic_qe)__([A-Za-z0-9_./:*-]+)/g, 'agentic-qe_$1'); + +/** Rewrite tool-name references inside an OpenCode-only generated agent so + * the instructions use the lazy gateway that is actually advertised. The + * Claude/Ruflo source file is never changed. Families without a managed + * gateway retain their direct OpenCode tool spelling. + * @param {string} body @param {{ruflo?:boolean,aqe?:boolean}} capabilities */ +export function rewriteAgentGatewayReferences(body, capabilities = {}) { + let result = directOpenCodeReferences(body); + if (capabilities.ruflo) { + result = result + .replace(/\b(?:claude-flow|claude_flow)_\*/g, + () => 'the Ruflo operation selected with `ak_ruflo_search`, then invoked through `ak_ruflo_call`') + .replace(/\b(?:claude-flow|claude_flow)_([A-Za-z0-9_./:-]+)/g, + (_match, name) => lazyGatewayCall('ruflo', name)); + } + if (capabilities.aqe) { + result = result + .replace(/\b(?:agentic-qe|agentic_qe)_\*/g, + () => 'the Agentic QE operation selected with `ak_aqe_search`, then invoked through `ak_aqe_call`') + .replace(/\b(?:agentic-qe|agentic_qe)_([A-Za-z0-9_./:-]+)/g, + (_match, name) => lazyGatewayCall('aqe', name)); + } + return result; +} + /** Convert every agent under /.claude/agents into opencode form: * frontmatter → {description, mode: subagent} (Claude's `tools:` string list - * is dropped — opencode uses permissions; subagents inherit the invoker's - * tool access, matching the broad lists these agents declare); body MCP refs - * rewritten across all three spellings the catalog uses - * (mcp__claude-flow__ / mcp__claude_flow__ / mcp__ruflo__ → claude-flow_); + * is dropped — OpenCode applies the subagent's permissions plus inherited + * parent/session deny rules); body MCP refs + * rewritten across all catalogue spellings. Lazy-gateway conversion emits + * ak_ruflo_call/ak_aqe_call guidance; direct fallback conversion emits OpenCode's + * direct tool spelling. * basename collisions across category dirs get the parent dir prefixed. The * description is emitted as a JSON double-quoted scalar (valid YAML 1.2 — * unquoted values containing ': ' or '#' would corrupt the frontmatter). * Pure (returns content, writes nothing). * @param {string} srcRoot */ -export function convertAgents(srcRoot) { +export function convertAgents(srcRoot, { gatewayCapabilities = {} } = {}) { const srcDir = path.join(srcRoot, '.claude', 'agents'); const agents = []; let scanned = 0; @@ -799,10 +1039,7 @@ export function convertAgents(srcRoot) { base: path.basename(file, '.md'), dir, description, - body: parsed.body - .replace(/mcp__claude-flow__/g, 'claude-flow_') - .replace(/mcp__claude_flow__/g, 'claude-flow_') - .replace(/mcp__ruflo__/g, 'claude-flow_'), + body: rewriteAgentGatewayReferences(parsed.body, gatewayCapabilities), }); } const seen = new Set(); @@ -819,6 +1056,55 @@ export function convertAgents(srcRoot) { return { agents, scanned, skipped: scanned - agents.length, renamed }; } +const SPECIALIST_AGENT = { + name: 'ak-specialist', + description: 'Runs one Agentic Kit specialist profile selected lazily with ak_agent_search', + content: `--- +description: "Runs one Agentic Kit specialist profile selected lazily with ak_agent_search" +mode: subagent +--- + + +You are the Agentic Kit specialist dispatcher for stock OpenCode. + +The parent task must begin with \`PROFILE: \`. Call \`ak_agent_load\` with that exact +name before doing any work. Treat the returned receipt-owned profile as your specialist +instructions for the rest of this task. If the profile names an optional dependency that is not +available, report the missing dependency instead of inventing a result. +`, +}; + +function specialistDispatcherState({ + destDir = paths.opencodeAgentsDir(), receipts = {}, adoptionBlocked = false, +} = {}) { + if (adoptionBlocked) return { available: false, blocked: true }; + const file = 'ak-specialist.md'; + const target = path.join(destDir, file); + if (!fs.existsSync(target)) return { available: true, blocked: false }; + let current; + try { current = fs.readFileSync(target, 'utf8'); } catch { + return { available: false, blocked: true }; + } + if (receiptMatches(current, receipts?.[file])) return { available: true, blocked: false }; + if (hasReceiptValue(receipts?.[file])) return { available: false, blocked: true }; + const adoptable = current === SPECIALIST_AGENT.content && isGeneratedContent(current); + return { available: adoptable, blocked: false }; +} + +function desiredAgentSet(source, gatewayCapabilities, lazyCatalog) { + const converted = convertAgents(source.root, { gatewayCapabilities }); + return { ...converted, agents: lazyCatalog ? [SPECIALIST_AGENT] : converted.agents }; +} + +function gatewayAgentCatalog(source) { + if (!source) return []; + return convertAgents(source.root, { gatewayCapabilities: {} }).agents.map((agent) => ({ + name: agent.name, + description: agent.description, + body: agent.body, + })); +} + const isGeneratedContent = (text) => AGENT_MARKERS.some((m) => text.includes(m)); /** Reconcile the converted agent set into the dest dir: rewrite generated @@ -827,32 +1113,21 @@ const isGeneratedContent = (text) => AGENT_MARKERS.some((m) => text.includes(m)) * is preserved and reported). The stamp records the source id + the exact * generated file list and is only rewritten when the set actually changed * (no per-run timestamp churn — idempotent-write semantics). - * @param {{ source: CatalogSource|null, destDir?: string, dryRun?: boolean, receipts?:Record, stampReceipt?:string|null, adoptionBlocked?:boolean }} opts */ + * @param {{ source: CatalogSource|null, destDir?: string, dryRun?: boolean, receipts?:Record, stampReceipt?:string|null, adoptionBlocked?:boolean, gatewayCapabilities?:{ruflo?:boolean,aqe?:boolean}, lazyCatalog?:boolean }} opts */ export function syncAgents({ source, destDir = paths.opencodeAgentsDir(), dryRun = false, receipts = {}, stampReceipt = null, - adoptionBlocked = false, + adoptionBlocked = false, gatewayCapabilities = {}, lazyCatalog = false, }) { if (!source) return { ok: false, changed: false, detail: 'no ruflo catalog source (marketplace clone or @claude-flow/cli) found' }; const receiptMap = receipts && typeof receipts === 'object' && !Array.isArray(receipts) ? receipts : {}; - const { agents, scanned, skipped, renamed } = convertAgents(source.root); + const { agents, scanned, skipped, renamed } = desiredAgentSet( + source, gatewayCapabilities, lazyCatalog, + ); if (!dryRun) fs.mkdirSync(destDir, { recursive: true }); let removed = 0, userOwned = 0, adopted = 0; const removedFiles = new Set(); - if (fs.existsSync(destDir)) { - for (const f of fs.readdirSync(destDir).filter((f) => f.endsWith('.md'))) { - const p = path.join(destDir, f); - let owned = false; - try { owned = receiptMatches(fs.readFileSync(p, 'utf8'), receiptMap[f]); } catch { /* leave alone */ } - const wanted = agents.some((a) => `${a.name}.md` === f); - if (owned && !wanted) { - if (!dryRun) fs.rmSync(p); - removedFiles.add(f); - removed++; - } - } - } let written = 0; const deployed = []; for (const a of agents) { @@ -871,6 +1146,22 @@ export function syncAgents({ if (!dryRun) fs.writeFileSync(p, a.content); } } + // Deploy/adopt the dispatcher or complete direct set before retiring any + // receipt-owned predecessor. A write failure therefore preserves the last + // known-good eager catalogue instead of leaving no executable agent path. + if ((!lazyCatalog || deployed.includes('ak-specialist.md')) && fs.existsSync(destDir)) { + for (const f of fs.readdirSync(destDir).filter((f) => f.endsWith('.md'))) { + const p = path.join(destDir, f); + let owned = false; + try { owned = receiptMatches(fs.readFileSync(p, 'utf8'), receiptMap[f]); } catch { /* leave alone */ } + const wanted = agents.some((a) => `${a.name}.md` === f); + if (owned && !wanted) { + if (!dryRun) fs.rmSync(p); + removedFiles.add(f); + removed++; + } + } + } const changed = written > 0 || removed > 0; // The stamp records what was ACTUALLY deployed (a user-owned file occupying // a slot is never in it) — otherwise status would diverge forever. @@ -885,7 +1176,11 @@ export function syncAgents({ return [f, contentHash(agent.content)]; })); Object.assign(nextReceipts, deployedHashes); - const stamp = { source: source.id, count: deployed.length, files: deployed.sort(), hashes: deployedHashes }; + const gateway = { ruflo: !!gatewayCapabilities.ruflo, aqe: !!gatewayCapabilities.aqe }; + const stamp = { + source: source.id, gateway, lazyCatalog: !!lazyCatalog, + count: deployed.length, files: deployed.sort(), hashes: deployedHashes, + }; const stampText = JSON.stringify(stamp, null, 2) + '\n'; const stampPath = path.join(destDir, STAMP_FILE); const priorStampText = fs.existsSync(stampPath) ? fs.readFileSync(stampPath, 'utf8') : null; @@ -905,12 +1200,12 @@ export function syncAgents({ fs.writeFileSync(stampPath, stampText); } return { - ok: true, + ok: !lazyCatalog || deployed.includes('ak-specialist.md'), changed, receipts: nextReceipts, stampReceipt: mayWriteStamp ? contentHash(stampText) : stampReceipt, adopted, - detail: `${agents.length} agents from ${source.id} (${written} written, ${removed} removed, ${skipped} skipped, ${renamed} collision-renamed${adopted ? `, ${adopted} adopted` : ''}${userOwned ? `, ${userOwned} user-owned preserved` : ''}; scanned ${scanned})`, + detail: `${agents.length} ${lazyCatalog ? 'lazy dispatcher agent' : 'agents'} from ${source.id} (${written} written, ${removed} removed, ${skipped} skipped, ${renamed} collision-renamed${adopted ? `, ${adopted} adopted` : ''}${userOwned ? `, ${userOwned} user-owned preserved` : ''}; scanned ${scanned})`, }; } @@ -919,10 +1214,10 @@ export function syncAgents({ * file set differs from the stamp. Count reports marker-bearing agents for * visibility, while receipt/hash divergence is reported as `modified` so * callers classify user edits as preserved rather than repairable drift. - * @param {{ source?: CatalogSource|null, destDir?: string, receipts?:Record|null, stampReceipt?:string|null, adoptionBlocked?:boolean }} [opts] */ + * @param {{ source?: CatalogSource|null, destDir?: string, receipts?:Record|null, stampReceipt?:string|null, adoptionBlocked?:boolean, gatewayCapabilities?:{ruflo?:boolean,aqe?:boolean}, lazyCatalog?:boolean }} [opts] */ export function agentsStatus({ source, destDir = paths.opencodeAgentsDir(), receipts = null, stampReceipt = null, - adoptionBlocked = false, + adoptionBlocked = false, gatewayCapabilities = {}, lazyCatalog = false, } = {}) { const stampPath = path.join(destDir, STAMP_FILE); const stamp = readJson(stampPath, null); @@ -932,7 +1227,8 @@ export function agentsStatus({ ? receipts : {}; const desired = source - ? new Map(convertAgents(source.root).agents.map((a) => [`${a.name}.md`, a.content])) + ? new Map(desiredAgentSet(source, gatewayCapabilities, lazyCatalog) + .agents.map((a) => [`${a.name}.md`, a.content])) : new Map(); const adoptableFiles = []; let generatedCount = 0; @@ -964,7 +1260,9 @@ export function agentsStatus({ }); const expectedStamp = source && onDisk.length > 0 && onDisk.every((f) => desired.has(f)) ? `${JSON.stringify({ - source: source.id, + source: source.id, + gateway: { ruflo: !!gatewayCapabilities.ruflo, aqe: !!gatewayCapabilities.aqe }, + lazyCatalog: !!lazyCatalog, count: onDisk.length, files: onDisk, // syncAgents hashes in converter declaration order, then sorts only @@ -990,68 +1288,174 @@ export function agentsStatus({ && !receiptMatches(fs.readFileSync(path.join(destDir, f), 'utf8'), receiptMap[f]); } catch { return true; } })), - stale: !stamp || stamp.source !== (source?.id ?? null) || filesDiverged, + stale: !stamp || stamp.source !== (source?.id ?? null) || filesDiverged + || !!stamp.lazyCatalog !== !!lazyCatalog + || !deepEqual(stamp.gateway ?? { ruflo: false, aqe: false }, { + ruflo: !!gatewayCapabilities.ruflo, aqe: !!gatewayCapabilities.aqe, + }), }; } // ── plugin (lifecycle bridge) ──────────────────────────────────────────────── export const PLUGIN_NAME = 'ruflo-hooks.js'; +export const GATEWAY_PLUGIN_NAME = 'ruflo-gateway.js'; const pluginTemplate = (pkgRoot) => path.join(pkgRoot, 'src', 'templates', 'opencode-ruflo-hooks.js'); +const gatewayPluginTemplate = (pkgRoot) => path.join(pkgRoot, 'src', 'templates', 'opencode-ruflo-gateway.js'); /** The marker any ak-deployed plugin copy carries (from the template header). */ const PLUGIN_MARKER = 'src/templates/opencode-ruflo-hooks.js'; +const GATEWAY_PLUGIN_MARKER = 'src/templates/opencode-ruflo-gateway.js'; -/** Deploy the lifecycle bridge plugin from the kit's template, content-diffed - * (rewrites only when the template changed — hash-stamped by content itself). - * A destination file that exists WITHOUT the ak marker is user-owned: - * preserved and reported, never overwritten. - * @param {{ pkgRoot: string, pluginsDir?: string, dryRun?: boolean, receipt?:string|null, adoptionBlocked?:boolean }} opts */ -export function deployPlugin({ - pkgRoot, pluginsDir = paths.opencodePluginsDir(), dryRun = false, receipt = null, - adoptionBlocked = false, +function deployManagedPlugin({ + template, marker, name, label, pluginsDir, dryRun, receipt, adoptionBlocked, + desiredText = null, }) { - const tpl = pluginTemplate(pkgRoot); - if (!fs.existsSync(tpl)) return { ok: false, changed: false, detail: `template missing: ${tpl}` }; - const want = fs.readFileSync(tpl, 'utf8'); - const dest = path.join(pluginsDir, PLUGIN_NAME); + if (!fs.existsSync(template)) return { ok: false, changed: false, detail: `template missing: ${template}` }; + const want = desiredText ?? fs.readFileSync(template, 'utf8'); + const dest = path.join(pluginsDir, name); const cur = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null; const hasReceipt = adoptionBlocked || hasReceiptValue(receipt); - const adoptable = cur === want && !hasReceipt && cur.includes(PLUGIN_MARKER); + const adoptable = cur === want && !hasReceipt && cur.includes(marker); if (cur === want && (receiptMatches(cur, receipt) || adoptable)) { return { ok: true, changed: false, receipt: contentHash(cur), adopted: adoptable, - detail: adoptable ? 'lifecycle plugin adopted into receipt ledger' : 'lifecycle plugin current', + detail: adoptable ? `${label} adopted into receipt ledger` : `${label} current`, }; } if (cur !== null && (!hasReceipt || !receiptMatches(cur, receipt))) { return { ok: true, changed: false, receipt, detail: `⚠ ${dest} differs from ak's last-written receipt (user-owned/edited) — left untouched` }; } + if (!want.includes(marker)) return { ok: false, changed: false, receipt, detail: `template marker missing: ${marker}` }; if (!dryRun) { fs.mkdirSync(pluginsDir, { recursive: true }); fs.writeFileSync(dest, want); } - return { ok: true, changed: true, receipt: contentHash(want), detail: cur == null ? 'lifecycle plugin deployed (ruflo-hooks.js)' : 'lifecycle plugin updated (ruflo-hooks.js)' }; + return { + ok: true, + changed: true, + receipt: contentHash(want), + detail: cur == null ? `${label} deployed (${name})` : `${label} updated (${name})`, + }; } -/** Plugin presence/currency against the kit template. `foreign` flags a - * user-owned file occupying the destination (status must not nag to - * overwrite it — deploy will leave it alone). */ -export function pluginStatus({ - pkgRoot, pluginsDir = paths.opencodePluginsDir(), receipt = null, adoptionBlocked = false, +function managedPluginStatus({ + template, marker, name, pluginsDir, receipt, adoptionBlocked, desiredText = null, }) { - const dest = path.join(pluginsDir, PLUGIN_NAME); + const dest = path.join(pluginsDir, name); const present = fs.existsSync(dest); const currentText = present ? fs.readFileSync(dest, 'utf8') : null; - const tpl = pluginTemplate(pkgRoot); - const desired = fs.existsSync(tpl) ? fs.readFileSync(tpl, 'utf8') : null; + const desired = fs.existsSync(template) ? (desiredText ?? fs.readFileSync(template, 'utf8')) : null; const hasReceipt = adoptionBlocked || hasReceiptValue(receipt); const receiptOwned = present && receiptMatches(currentText, receipt); const adoptable = present && !hasReceipt && desired !== null - && currentText === desired && currentText.includes(PLUGIN_MARKER); + && currentText === desired && currentText.includes(marker); const foreign = present && !receiptOwned && !adoptable; - const current = present && !foreign && desired !== null && currentText === desired; - return { present, current, foreign, adoptable, adoptionBlocked }; + return { + present, + current: present && !foreign && desired !== null && currentText === desired, + foreign, + adoptable, + adoptionBlocked, + }; +} + +/** Deploy the lifecycle bridge plugin from the kit's template, content-diffed + * (rewrites only when the template changed — hash-stamped by content itself). + * A destination file that exists WITHOUT the ak marker is user-owned: + * preserved and reported, never overwritten. + * @param {{ pkgRoot: string, pluginsDir?: string, dryRun?: boolean, receipt?:string|null, adoptionBlocked?:boolean }} opts */ +export function deployPlugin({ + pkgRoot, pluginsDir = paths.opencodePluginsDir(), dryRun = false, receipt = null, + adoptionBlocked = false, +}) { + return deployManagedPlugin({ + template: pluginTemplate(pkgRoot), marker: PLUGIN_MARKER, name: PLUGIN_NAME, + label: 'lifecycle plugin', pluginsDir, dryRun, receipt, adoptionBlocked, + }); +} + +const GATEWAY_MCP_PLACEHOLDER = '/* AK_MANAGED_MCP_ENTRIES */ {}'; +const GATEWAY_AGENT_PLACEHOLDER = '/* AK_MANAGED_AGENT_CATALOG */ []'; +const GATEWAY_SPECIALIST_PLACEHOLDER = '/* AK_SPECIALIST_AGENT_PROMPT */ ""'; + +function gatewayDesiredText(pkgRoot, managedMcp, agentCatalog = []) { + const template = gatewayPluginTemplate(pkgRoot); + if (!fs.existsSync(template)) return null; + const source = fs.readFileSync(template, 'utf8'); + for (const [placeholder, label] of [ + [GATEWAY_MCP_PLACEHOLDER, 'managed-MCP'], + [GATEWAY_AGENT_PLACEHOLDER, 'managed-agent'], + [GATEWAY_SPECIALIST_PLACEHOLDER, 'specialist-agent'], + ]) { + const first = source.indexOf(placeholder); + if (first < 0 || source.indexOf(placeholder, first + 1) >= 0) { + throw new Error(`lazy gateway template must contain exactly one ${label} placeholder`); + } + } + const stable = Object.fromEntries(Object.entries(managedMcp ?? {}) + .sort(([a], [b]) => a.localeCompare(b))); + const specialistPrompt = parseFrontmatter(SPECIALIST_AGENT.content)?.body.trim() ?? ''; + return source + .replace(GATEWAY_MCP_PLACEHOLDER, JSON.stringify(stable)) + .replace(GATEWAY_AGENT_PLACEHOLDER, JSON.stringify(agentCatalog)) + .replace(GATEWAY_SPECIALIST_PLACEHOLDER, JSON.stringify(specialistPrompt)); +} + +/** Deploy the lazy Ruflo/Agentic-QE catalogue gateway for stock OpenCode. */ +export function deployGatewayPlugin({ + pkgRoot, managedMcp = {}, agentCatalog = [], pluginsDir = paths.opencodePluginsDir(), dryRun = false, + receipt = null, adoptionBlocked = false, +}) { + return deployManagedPlugin({ + template: gatewayPluginTemplate(pkgRoot), marker: GATEWAY_PLUGIN_MARKER, + name: GATEWAY_PLUGIN_NAME, label: 'lazy rUv gateway', pluginsDir, dryRun, + receipt, adoptionBlocked, desiredText: gatewayDesiredText(pkgRoot, managedMcp, agentCatalog), + }); +} + +/** Remove only exact receipt-owned gateway bytes when no rUv family remains + * safe to capture. User-edited/unreceipted files are preserved. */ +export function retireGatewayPlugin({ + pluginsDir = paths.opencodePluginsDir(), receipt = null, dryRun = false, +} = {}) { + const dest = path.join(pluginsDir, GATEWAY_PLUGIN_NAME); + if (!fs.existsSync(dest)) { + return { ok: true, changed: false, receipt: null, detail: 'lazy gateway not deployed' }; + } + const current = fs.readFileSync(dest, 'utf8'); + if (!receipt || !receiptMatches(current, receipt)) { + return { + ok: false, changed: false, receipt, + detail: `⚠ ${dest} is not provably ak-owned; left untouched`, + }; + } + if (!dryRun) fs.rmSync(dest, { force: true }); + return { ok: true, changed: true, receipt: null, detail: 'lazy rUv gateway retired' }; +} + +/** Plugin presence/currency against the kit template. `foreign` flags a + * user-owned file occupying the destination (status must not nag to + * overwrite it — deploy will leave it alone). */ +export function pluginStatus({ + pkgRoot, pluginsDir = paths.opencodePluginsDir(), receipt = null, adoptionBlocked = false, +}) { + return managedPluginStatus({ + template: pluginTemplate(pkgRoot), marker: PLUGIN_MARKER, name: PLUGIN_NAME, + pluginsDir, receipt, adoptionBlocked, + }); +} + +/** Lazy gateway presence/currency against its embedded, receipt-bound MCP commands. */ +export function gatewayPluginStatus({ + pkgRoot, managedMcp = {}, agentCatalog = [], pluginsDir = paths.opencodePluginsDir(), receipt = null, + adoptionBlocked = false, +}) { + return managedPluginStatus({ + template: gatewayPluginTemplate(pkgRoot), marker: GATEWAY_PLUGIN_MARKER, + name: GATEWAY_PLUGIN_NAME, pluginsDir, receipt, adoptionBlocked, + desiredText: gatewayDesiredText(pkgRoot, managedMcp, agentCatalog), + }); } // ── platform skill ─────────────────────────────────────────────────────────── @@ -1135,6 +1539,12 @@ export function removeArtifacts({ fs.rmSync(plugin, { force: true }); removed.push('plugin ruflo-hooks.js'); } + const gateway = path.join(pluginsDir, GATEWAY_PLUGIN_NAME); + if (fs.existsSync(gateway) && receipts.gateway + && contentHash(fs.readFileSync(gateway, 'utf8')) === receipts.gateway) { + fs.rmSync(gateway, { force: true }); + removed.push('plugin ruflo-gateway.js'); + } if (fs.existsSync(agentsDir)) { let n = 0; for (const f of fs.readdirSync(agentsDir)) { diff --git a/src/templates/opencode-ruflo-gateway.js b/src/templates/opencode-ruflo-gateway.js new file mode 100644 index 0000000..e206a93 --- /dev/null +++ b/src/templates/opencode-ruflo-gateway.js @@ -0,0 +1,783 @@ +// ruflo-gateway.js — lazy RuvNet catalogue access for stock OpenCode. +// Deployed to ~/.config/opencode/plugins/ by `ak setup --opencode` / `ak sync` +// (agentic-kit, src/templates/opencode-ruflo-gateway.js — managed; do not edit +// the deployed copy, sync rewrites receipt-matching content). +// +// Ruflo and Agentic QE publish hundreds of MCP operations. Advertising every schema to a +// local model on every turn makes even a simple prompt expensive. Keep the +// exact ak-managed MCP registrations as the source of truth, disable their eager +// OpenCode exposure at runtime, and expose compact discovery/call tools: +// +// ak_ruflo_search → find an operation in the live catalogue +// ak_ruflo_call → invoke one exact operation returned by search +// ak_aqe_search → find an Agentic QE operation in its live catalogue +// ak_aqe_call → invoke one exact Agentic QE operation returned by search +// ak_skill_search → find an installed skill before stock `skill` loads it +// ak_agent_search → find an installed specialist before stock `task` runs it +// +// The full RuvNet/AK surface remains available; only catalogue delivery changes. + +import { spawn } from "node:child_process" +import { createInterface } from "node:readline" +import { tool } from "@opencode-ai/plugin" + +const configuredTimeout = Number(process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS) +const REQUEST_TIMEOUT_MS = Number.isFinite(configuredTimeout) && configuredTimeout > 0 + ? Math.trunc(configuredTimeout) + : 30_000 +const CHILD_EXIT_GRACE_MS = 250 +const RUFLO_SERVER_NAME = "claude-flow" +const AQE_SERVER_NAME = "agentic-qe" +const SPECIALIST_AGENT_NAME = "ak-specialist" +const AK_MANAGED_MCP = Object.freeze(/* AK_MANAGED_MCP_ENTRIES */ {}) +const AK_MANAGED_AGENTS = Object.freeze(/* AK_MANAGED_AGENT_CATALOG */ []) +const AK_SPECIALIST_PROMPT = /* AK_SPECIALIST_AGENT_PROMPT */ "" +const AK_CLAUDE_BLOCKS = [ + "ruflo-preamble", + "ruflo-reference", + "ruflo-opencode-reference", + "ruflo-aqe-reference", + "ruflo-providers-reference", + "ruvnet-brain-reference", + "ruvnet-brain-opencode-reference", + "ruflo-dual-mode-reference", +] + +function normalize(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() +} + +function equalValue(left, right) { + if (Object.is(left, right)) return true + if (!left || !right || typeof left !== "object" || typeof right !== "object") return false + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length + && left.every((value, index) => equalValue(value, right[index])) + } + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + return leftKeys.length === rightKeys.length + && leftKeys.every((key, index) => key === rightKeys[index] && equalValue(left[key], right[key])) +} + +function managedEntry(cfg, name, toolPatterns, permissionPatterns) { + const expected = AK_MANAGED_MCP[name] + const current = cfg.mcp?.[name] + const familyPrefixes = toolPatterns.map((pattern) => pattern.replace(/\*+$/, "")) + const userToolPolicy = Object.keys(cfg.tools || {}).some((key) => + familyPrefixes.some((prefix) => key.startsWith(prefix))) + if (userToolPolicy) return undefined + if (!permissionPatterns.every((pattern) => cfg.permission?.[pattern] === "allow")) return undefined + return expected && equalValue(current, expected) ? current : undefined +} + +function validLocalMcp(entry) { + return entry?.type === "local" + && entry.enabled !== false + && Array.isArray(entry.command) + && entry.command.length > 0 + && entry.command.every((part) => typeof part === "string" && part.length > 0) +} + +function rankTools(tools, query, limit = 3) { + const phrase = normalize(query) + const terms = [...new Set(phrase.split(/\s+/).filter(Boolean))] + return tools + .map((entry) => { + const name = normalize(entry.name) + const description = normalize(entry.description) + let score = name === phrase ? 10_000 : 0 + if (phrase && name.includes(phrase)) score += 1_000 + for (const term of terms) { + if (name === term) score += 500 + else if (name.startsWith(term)) score += 160 + else if (name.includes(term)) score += 90 + if (description.includes(term)) score += 12 + } + return { entry, score } + }) + .filter((candidate) => candidate.score > 0) + .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name)) + .slice(0, Math.min(Math.max(Math.trunc(limit), 1), 12)) + .map(({ entry }) => entry) +} + +function compactEntry(entry) { + const description = String(entry.description || "").replace(/\s+/g, " ").trim() + return { name: entry.name, description: description.slice(0, 400) } +} + +function parseSkillCatalog(text) { + const match = text.match(/\s*([\s\S]*?)\s*<\/available_skills>/) + if (!match) return [] + const skills = [] + const seen = new Set() + for (const item of match[1].matchAll(/\s*([\s\S]*?)\s*<\/skill>/g)) { + const name = item[1].match(/\s*([\s\S]*?)\s*<\/name>/)?.[1]?.trim() + const description = item[1].match(/\s*([\s\S]*?)\s*<\/description>/)?.[1]?.trim() + if (!name || seen.has(name)) continue + seen.add(name) + skills.push({ name, description: description || "" }) + } + return skills +} + +function compactSystem(text) { + let result = text + for (const id of AK_CLAUDE_BLOCKS) { + result = result.replace( + new RegExp(`[\\s\\S]*?`, "g"), + "", + ) + } + return result.replace( + /\s*[\s\S]*?\s*<\/available_skills>/g, + "Installed skills remain available. Use ak_skill_search to find one, " + + "then call the stock skill tool with its exact name.", + ) +} + +function rewriteLazyToolReferences(text, capabilities) { + const call = (family, name) => + `\`ak_${family}_call\` with \`name=${JSON.stringify(name)}\` and \`arguments_json\` set to one JSON object string` + let result = String(text) + if (capabilities.ruflo) { + result = result + .replace(/mcp__(?:claude-flow|claude_flow|ruflo|plugin_ruflo-core_ruflo)__\*/g, + () => "the Ruflo operation selected with `ak_ruflo_search`, then invoked through `ak_ruflo_call`") + .replace(/\b(?:claude-flow|claude_flow)_\*/g, + () => "the Ruflo operation selected with `ak_ruflo_search`, then invoked through `ak_ruflo_call`") + .replace(/mcp__(?:claude-flow|claude_flow|ruflo|plugin_ruflo-core_ruflo)__([A-Za-z0-9_./:-]+)/g, + (_match, name) => call("ruflo", name)) + .replace(/\b(?:claude-flow|claude_flow)_([A-Za-z0-9_./:-]+)/g, + (_match, name) => call("ruflo", name)) + } + if (capabilities.aqe) { + result = result + .replace(/mcp__(?:agentic-qe|agentic_qe)__\*/g, + () => "the Agentic QE operation selected with `ak_aqe_search`, then invoked through `ak_aqe_call`") + .replace(/\b(?:agentic-qe|agentic_qe)_\*/g, + () => "the Agentic QE operation selected with `ak_aqe_search`, then invoked through `ak_aqe_call`") + .replace(/mcp__(?:agentic-qe|agentic_qe)__([A-Za-z0-9_./:-]+)/g, + (_match, name) => call("aqe", name)) + .replace(/\b(?:agentic-qe|agentic_qe)_([A-Za-z0-9_./:-]+)/g, + (_match, name) => call("aqe", name)) + } + return result +} + +function optionalMcpFamilies(text) { + const managed = new Set([ + "claude-flow", + "claude_flow", + "ruflo", + "plugin_ruflo-core_ruflo", + "agentic-qe", + "agentic_qe", + ]) + return [...new Set( + [...String(text).matchAll(/\bmcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_./:-]+/g)] + .map((match) => match[1]) + .filter((family) => !managed.has(family)), + )].sort() +} + +function validateCallArguments(entry, args) { + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new Error(`Invalid arguments for ${entry.name}: expected one JSON object`) + } + const required = Array.isArray(entry.inputSchema?.required) + ? entry.inputSchema.required.filter((key) => typeof key === "string") + : [] + const missing = required.filter((key) => !Object.prototype.hasOwnProperty.call(args, key)) + if (missing.length) { + throw new Error( + `Invalid arguments for ${entry.name}: missing required argument${missing.length === 1 ? "" : "s"} ` + + `${missing.join(", ")}. Use the inputSchema returned by the matching search tool.`, + ) + } +} + +function parseCallArgumentsJson(name, encoded) { + if (typeof encoded !== "string") { + throw new Error( + `Invalid arguments_json for ${name}: expected one JSON-encoded object string`, + ) + } + let decoded + try { + decoded = JSON.parse(encoded) + } catch (error) { + throw new Error(`Invalid arguments_json for ${name}: ${error.message}`, { cause: error }) + } + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + throw new Error(`Invalid arguments_json for ${name}: decoded value must be one JSON object`) + } + return decoded +} + +async function askForTool(context, permission, pattern, metadata = {}) { + if (!context?.ask) throw new Error(`${permission} cannot enforce the OpenCode permission policy`) + await context.ask({ + permission, + patterns: [String(pattern)], + always: [String(pattern)], + metadata, + }) +} + +async function askForFamilyCall(context, gatewayPermission, familyNames, operation) { + void familyNames + await askForTool(context, gatewayPermission, operation, { operation }) +} + +function directPermissionAction(value) { + if (typeof value === "string") return value + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return typeof value["*"] === "string" ? value["*"] : undefined +} + +function projectGatewayCallPolicy(cfg, gatewayPermission, familyPrefixes) { + const existing = cfg.permission?.[gatewayPermission] + const projected = existing && typeof existing === "object" && !Array.isArray(existing) + ? { ...existing } + : { "*": typeof existing === "string" ? existing : "allow" } + for (const [permission, value] of Object.entries(cfg.permission || {})) { + const prefix = familyPrefixes.find((candidate) => permission.startsWith(candidate)) + if (!prefix) continue + const action = directPermissionAction(value) + const operationPattern = permission.slice(prefix.length) + if (operationPattern === "*" && existing !== undefined) continue + if (action) projected[operationPattern] = action + } + return projected +} + +function hideDirectFamily(permission, patterns) { + for (const pattern of patterns) delete permission[pattern] + for (const pattern of patterns) permission[pattern] = "deny" +} + +class RufloGatewayClient { + constructor(label) { + this.label = label + this.command = null + this.args = [] + this.environment = {} + this.child = undefined + this.starting = undefined + this.initialized = false + this.nextID = 1 + this.pending = new Map() + this.tools = undefined + this.cataloging = undefined + this.stderr = [] + } + + configure(entry) { + const command = entry?.command + if (entry?.type !== "local" || !Array.isArray(command) || !command.length + || command.some((part) => typeof part !== "string" || !part)) { + this.command = null + this.args = [] + this.environment = {} + return false + } + if (this.child || this.starting) throw new Error(`${this.label} gateway cannot be reconfigured after use`) + this.command = command[0] + this.args = command.slice(1) + this.environment = entry.environment && typeof entry.environment === "object" + ? { ...entry.environment } + : {} + return true + } + + async start() { + if (this.starting) return this.starting + if (this.child && !this.child.killed && this.initialized) return + if (!this.command) { + throw new Error(`ak-managed ${this.label} MCP is missing or is not a local command; run \`ak sync\``) + } + this.starting = this.startInner().finally(() => { this.starting = undefined }) + return this.starting + } + + async startInner() { + const child = spawn(this.command, this.args, { + env: { ...process.env, ...this.environment }, + stdio: ["pipe", "pipe", "pipe"], + detached: false, + }) + this.child = child + createInterface({ input: child.stdout }).on("line", (line) => this.handleLine(line)) + createInterface({ input: child.stderr }).on("line", (line) => { + this.stderr.push(line) + if (this.stderr.length > 20) this.stderr.shift() + }) + child.once("error", (error) => this.handleExit(child, error)) + child.once("exit", (code, signal) => { + this.handleExit(child, new Error(`${this.label} MCP exited (code=${code ?? "null"}, signal=${signal ?? "null"})`)) + }) + + try { + await this.requestRaw("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-ak-lazy-gateway", version: "1" }, + }) + this.notify("notifications/initialized", {}) + this.initialized = true + } catch (error) { + await this.terminateChild(child, error) + throw error + } + } + + handleLine(line) { + let message + try { message = JSON.parse(line) } catch { return } + if (message.id === undefined || message.id === null) return + const pending = this.pending.get(message.id) + if (!pending) return + this.pending.delete(message.id) + clearTimeout(pending.timer) + pending.signal?.removeEventListener("abort", pending.abort) + if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error))) + else pending.resolve(message.result) + } + + handleExit(child, error) { + if (this.child !== child) return + this.child = undefined + this.initialized = false + this.tools = undefined + this.cataloging = undefined + if (child?.stdin && !child.stdin.destroyed) child.stdin.destroy() + const detail = this.stderr.length ? `\n${this.stderr.join("\n")}` : "" + for (const pending of this.pending.values()) { + clearTimeout(pending.timer) + pending.signal?.removeEventListener("abort", pending.abort) + pending.reject(new Error(`${error.message}${detail}`)) + } + this.pending.clear() + } + + async terminateChild(child, error, { graceful = false } = {}) { + if (!child) return + let exited = child.exitCode !== null || child.signalCode !== null + let resolveExit + const exit = new Promise((resolve) => { resolveExit = resolve }) + const onExit = () => { exited = true; resolveExit() } + child.once("exit", onExit) + child.once("close", onExit) + if (this.child === child) this.handleExit(child, error) + try { + if (graceful && child.stdin?.writable) child.stdin.end() + else if (child.stdin && !child.stdin.destroyed) child.stdin.destroy() + } catch { /* continue to process termination */ } + + const wait = async (milliseconds) => { + if (exited || child.exitCode !== null || child.signalCode !== null) return true + return Promise.race([ + exit.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), milliseconds)), + ]) + } + if (graceful && await wait(CHILD_EXIT_GRACE_MS)) return + if (!exited) { + try { child.kill("SIGTERM") } catch { /* escalate below */ } + } + if (await wait(CHILD_EXIT_GRACE_MS)) return + try { child.kill("SIGKILL") } catch { /* final wait reports failure */ } + if (!await wait(1_000)) { + throw new Error(`${this.label} MCP process could not be reaped after SIGKILL`) + } + } + + notify(method, params) { + if (!this.child?.stdin?.writable) throw new Error(`${this.label} MCP is not writable`) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`) + } + + requestRaw(method, params, signal) { + if (!this.child?.stdin?.writable) return Promise.reject(new Error(`${this.label} MCP is not running`)) + if (signal?.aborted) return Promise.reject(new Error(`${this.label} request cancelled`)) + const id = this.nextID++ + return new Promise((resolve, reject) => { + const cancel = (reason) => { + if (!this.pending.delete(id)) return + try { this.notify("notifications/cancelled", { requestId: id, reason }) } catch { /* best effort */ } + } + const timer = setTimeout(() => { + const reason = `${this.label} MCP request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method}` + cancel(reason) + signal?.removeEventListener("abort", abort) + reject(new Error(reason)) + }, REQUEST_TIMEOUT_MS) + const abort = () => { + clearTimeout(timer) + const reason = `${this.label} request cancelled` + cancel(reason) + reject(new Error(reason)) + } + signal?.addEventListener("abort", abort, { once: true }) + this.pending.set(id, { resolve, reject, timer, signal, abort }) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`) + }) + } + + async request(method, params, signal) { + await this.start() + return this.requestRaw(method, params, signal) + } + + async catalog(signal) { + if (this.tools) return this.tools + if (this.cataloging) return this.cataloging + this.cataloging = this.loadCatalog(signal).finally(() => { this.cataloging = undefined }) + return this.cataloging + } + + async loadCatalog(signal) { + const tools = [] + const cursors = new Set() + let cursor + do { + const result = await this.request("tools/list", cursor ? { cursor } : {}, signal) + if (!Array.isArray(result?.tools)) throw new Error(`${this.label} MCP returned an invalid tool catalogue`) + tools.push(...result.tools) + const next = result.nextCursor + if (next !== undefined && (typeof next !== "string" || !next || cursors.has(next))) { + throw new Error(`${this.label} MCP returned an invalid or repeated catalogue cursor`) + } + cursor = next + if (cursor) cursors.add(cursor) + } while (cursor) + if (!tools.length) throw new Error(`${this.label} MCP returned an empty tool catalogue`) + this.tools = tools + return this.tools + } + + async search(query, limit, signal) { + return rankTools(await this.catalog(signal), query, limit) + } + + async call(name, args, signal) { + const catalog = await this.catalog(signal) + const entry = catalog.find((candidate) => candidate.name === name) + if (!entry) { + throw new Error(`Unknown ${this.label} operation: ${name}`) + } + validateCallArguments(entry, args) + return this.request("tools/call", { name, arguments: args || {} }, signal) + } + + async close() { + const child = this.child + if (!child) return + await this.terminateChild(child, new Error(`${this.label} MCP gateway closed`), { graceful: true }) + } +} + +function renderToolResult(result, errorPrefix) { + const blocks = Array.isArray(result?.content) ? result.content : [] + const text = blocks + .filter((block) => block?.type === "text") + .map((block) => block.text) + .join("\n") + if (text && blocks.every((block) => block?.type === "text") + && result?.structuredContent === undefined) { + return result.isError ? `${errorPrefix}_ERROR: ${text}` : text + } + return JSON.stringify(result ?? null, null, 2) +} + +/** @type {import("@opencode-ai/plugin").Plugin} */ +export default async function rufloGateway() { + const rufloClient = new RufloGatewayClient("Ruflo") + const aqeClient = new RufloGatewayClient("Agentic QE") + const skillCatalogs = new Map() + let available = { ruflo: false, aqe: false, brain: false, agents: false } + const plugin = { + config(cfg) { + const rufloEntry = managedEntry( + cfg, RUFLO_SERVER_NAME, + ["claude-flow_*", "claude_flow_*"], + ["claude-flow_*", "claude_flow_*"], + ) + const aqeEntry = managedEntry( + cfg, AQE_SERVER_NAME, + ["agentic-qe_*", "agentic_qe_*"], + ["agentic-qe_*", "agentic_qe_*"], + ) + available = { + ruflo: rufloClient.configure(rufloEntry), + aqe: aqeClient.configure(aqeEntry), + brain: validLocalMcp(cfg.mcp?.["ruvnet-brain"]), + agents: AK_MANAGED_AGENTS.length > 0 + && typeof cfg.agent?.[SPECIALIST_AGENT_NAME]?.prompt === "string" + && cfg.agent[SPECIALIST_AGENT_NAME].prompt.trim() === AK_SPECIALIST_PROMPT.trim(), + } + if (!available.ruflo) { + delete plugin.tool.ak_ruflo_search + delete plugin.tool.ak_ruflo_call + } + if (!available.aqe) { + delete plugin.tool.ak_aqe_search + delete plugin.tool.ak_aqe_call + } + if (!available.agents) { + delete plugin.tool.ak_agent_search + delete plugin.tool.ak_agent_load + } + + // Blacklist direct catalogue exposure. The gateway tools below remain + // explicit and small; custom user policy for them is preserved. + cfg.tools = { + ...(cfg.tools || {}), + ...(available.ruflo ? { "claude-flow_*": false, "claude_flow_*": false } : {}), + ...(available.aqe ? { "agentic-qe_*": false, "agentic_qe_*": false } : {}), + } + const permission = { + ...(cfg.permission || {}), + ...(available.ruflo ? { + ak_ruflo_search: cfg.permission?.ak_ruflo_search ?? "allow", + ak_ruflo_call: projectGatewayCallPolicy( + cfg, "ak_ruflo_call", ["claude-flow_", "claude_flow_"], + ), + } : {}), + ...(available.aqe ? { + ak_aqe_search: cfg.permission?.ak_aqe_search ?? "allow", + ak_aqe_call: projectGatewayCallPolicy( + cfg, "ak_aqe_call", ["agentic-qe_", "agentic_qe_"], + ), + } : {}), + ak_skill_search: cfg.permission?.ak_skill_search ?? "allow", + ...(available.agents ? { + ak_agent_search: cfg.permission?.ak_agent_search ?? "allow", + ak_agent_load: cfg.permission?.ak_agent_load ?? "allow", + } : {}), + } + if (available.ruflo) hideDirectFamily(permission, ["claude-flow_*", "claude_flow_*"]) + if (available.aqe) hideDirectFamily(permission, ["agentic-qe_*", "agentic_qe_*"]) + cfg.permission = permission + }, + event: async ({ event }) => { + if (event?.type === "session.deleted") { + skillCatalogs.delete(event.properties?.sessionID ?? event.properties?.info?.id) + } + }, + "experimental.chat.system.transform"(input, output) { + const discovered = output.system.flatMap((text) => parseSkillCatalog(text)) + if (input?.sessionID) skillCatalogs.set(input.sessionID, discovered) + const transformed = output.system.map((text) => compactSystem(text)) + const routes = [] + if (available.ruflo) routes.push("ak_ruflo_search then ak_ruflo_call for the complete live Ruflo catalogue") + if (available.aqe) { + routes.push( + "ak_aqe_search then ak_aqe_call for Agentic QE; initialize the fleet first and never claim success without its tool result", + ) + } + if (available.brain) { + routes.push( + "direct RuvNet Brain search before any rUv capability claim, citing the returned source instead of prior knowledge", + ) + } + const guidance = "Agentic Kit for OpenCode: " + + `${routes.length ? `use ${routes.join(", ")}. ` : ""}` + + "Use ak_skill_search then skill for installed skills. " + + `${available.agents ? "Use ak_agent_search then stock task with ak-specialist for specialist profiles. " : ""}` + + "Discovery is lazy; configured capability is preserved." + if (transformed.length) transformed[0] = `${transformed[0]}\n\n${guidance}` + else transformed.push(guidance) + output.system.splice(0, output.system.length, ...transformed) + }, + dispose: async () => { + await Promise.all([rufloClient.close(), aqeClient.close()]) + }, + "tool.execute.after"(input, output) { + if (input.tool !== "skill" || typeof output?.output !== "string") return + const rewritten = rewriteLazyToolReferences(output.output, available) + if (rewritten === output.output) return + output.output = + "[Agentic Kit OpenCode adaptation: direct Ruflo/AQE operation names below use the lazy gateway.]\n" + + rewritten + }, + tool: { + ak_skill_search: tool({ + description: + "Search installed OpenCode skills. Then call stock skill with the exact returned name; " + + "instructions load on demand.", + args: { + query: tool.schema.string().describe("Skill capability needed"), + limit: tool.schema.number().optional().describe("Results to return (default 3, max 12)"), + }, + async execute(args, context) { + await askForTool(context, "ak_skill_search", args.query, { query: args.query }) + const skillCatalog = skillCatalogs.get(context?.sessionID) ?? [] + if (!skillCatalog.length) return "AK_SKILL_SEARCH_FAILED: OpenCode skill catalogue was not found" + return JSON.stringify( + rankTools(skillCatalog, args.query, args.limit ?? 3).map(compactEntry), + null, + 2, + ) + }, + }), + ak_agent_search: tool({ + description: + "Search Agentic Kit specialist profiles. Then call stock task with " + + "subagent_type=\"ak-specialist\" and the exact returned profile name.", + args: { + query: tool.schema.string().describe("Agent capability needed"), + limit: tool.schema.number().optional().describe("Results to return (default 3, max 12)"), + }, + async execute(args, context) { + await askForTool(context, "ak_agent_search", args.query, { query: args.query }) + if (!AK_MANAGED_AGENTS.length) return "AK_AGENT_SEARCH_FAILED: Agentic Kit profile catalogue was not found" + return JSON.stringify( + { + instruction: + "Choose one profile, then call the stock task tool with subagent_type=\"ak-specialist\". " + + "Begin its prompt with `PROFILE: ` followed by the user's task.", + matches: rankTools(AK_MANAGED_AGENTS, args.query, args.limit ?? 3).map(compactEntry), + }, + null, + 2, + ) + }, + }), + ak_agent_load: tool({ + description: + "Load one receipt-owned Agentic Kit profile selected by ak_agent_search. " + + "Use only inside the stock ak-specialist subagent.", + args: { + name: tool.schema.string().describe("Exact profile name returned by ak_agent_search"), + }, + async execute(args, context) { + await askForTool(context, "ak_agent_load", args.name, { profile: args.name }) + const entry = AK_MANAGED_AGENTS.find((candidate) => candidate.name === args.name) + if (!entry) return `AK_AGENT_LOAD_FAILED: Unknown Agentic Kit profile: ${args.name}` + const optionalFamilies = optionalMcpFamilies(entry.body) + const dependencyNote = optionalFamilies.length + ? "Optional external MCP families named by this profile: " + + `${optionalFamilies.map((family) => `\`${family}\``).join(", ")}. ` + + "This Agentic Kit OpenCode adapter does not provision them. If one is unavailable, " + + "report that dependency instead of inventing a tool call or result." + : "If this profile names an optional tool that is unavailable, report that dependency " + + "instead of inventing a tool call or result." + return [ + `Agentic Kit specialist profile: ${entry.name}`, + "Treat the following receipt-owned profile as your specialist instructions for this task. " + + dependencyNote, + rewriteLazyToolReferences(entry.body, available), + ].join("\n\n") + }, + }), + ak_ruflo_search: tool({ + description: + "Search the live Ruflo catalogue for AgentDB memory, swarms, routing, hooks, workflows, " + + "or rUv coordination. Then use ak_ruflo_call.", + args: { + query: tool.schema.string().describe("Capability needed, such as 'semantic project memory search'"), + limit: tool.schema.number().optional().describe("Results to return (default 3, max 12)"), + }, + async execute(args, context) { + try { + await askForTool(context, "ak_ruflo_search", args.query, { query: args.query }) + const matches = await rufloClient.search(args.query, args.limit ?? 3, context.abort) + if (!matches.length) return `No Ruflo operations matched: ${args.query}` + return JSON.stringify({ + instruction: + "Choose one match, then call ak_ruflo_call. Copy its name exactly. Set arguments_json to " + + "one JSON object encoded as a string, containing every required field from inputSchema.", + matches: matches.map((entry) => ({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + })), + }, null, 2) + } catch (error) { + return `RUFLO_SEARCH_FAILED: ${error.message}` + } + }, + }), + ak_ruflo_call: tool({ + description: + "Invoke an operation returned by ak_ruflo_search. Pass arguments_json as one JSON object " + + "string matching its inputSchema.", + args: { + name: tool.schema.string().describe("Exact operation name returned by ak_ruflo_search"), + arguments_json: tool.schema.string().describe( + "One JSON-encoded object matching the selected inputSchema; use \"{}\" only for no-argument operations", + ), + }, + async execute(args, context) { + try { + await askForFamilyCall( + context, "ak_ruflo_call", ["claude-flow", "claude_flow"], args.name, + ) + const decoded = parseCallArgumentsJson(args.name, args.arguments_json) + return renderToolResult(await rufloClient.call(args.name, decoded, context.abort), "RUFLO") + } catch (error) { + return `RUFLO_CALL_FAILED: ${error.message}` + } + }, + }), + ak_aqe_search: tool({ + description: + "Search live Agentic QE operations for fleets, tests, coverage, quality, security, or learning. " + + "Then use ak_aqe_call; call fleet_init first when required.", + args: { + query: tool.schema.string().describe("Quality-engineering capability needed"), + limit: tool.schema.number().optional().describe("Results to return (default 3, max 12)"), + }, + async execute(args, context) { + try { + await askForTool(context, "ak_aqe_search", args.query, { query: args.query }) + const matches = await aqeClient.search(args.query, args.limit ?? 3, context.abort) + if (!matches.length) return `No Agentic QE operations matched: ${args.query}` + return JSON.stringify({ + instruction: + "Choose one match, then call ak_aqe_call. Copy its name exactly. Set arguments_json to " + + "one JSON object encoded as a string, containing every required field from inputSchema. " + + "Call fleet_init before operations whose schema or description requires an initialized fleet.", + matches: matches.map((entry) => ({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + })), + }, null, 2) + } catch (error) { + return `AQE_SEARCH_FAILED: ${error.message}` + } + }, + }), + ak_aqe_call: tool({ + description: + "Invoke an operation returned by ak_aqe_search. Pass arguments_json as one JSON object " + + "string matching its inputSchema.", + args: { + name: tool.schema.string().describe("Exact Agentic QE operation name returned by ak_aqe_search"), + arguments_json: tool.schema.string().describe( + "One JSON-encoded object matching the selected inputSchema; use \"{}\" only for no-argument operations", + ), + }, + async execute(args, context) { + try { + await askForFamilyCall( + context, "ak_aqe_call", ["agentic-qe", "agentic_qe"], args.name, + ) + const decoded = parseCallArgumentsJson(args.name, args.arguments_json) + return renderToolResult(await aqeClient.call(args.name, decoded, context.abort), "AQE") + } catch (error) { + return `AQE_CALL_FAILED: ${error.message}` + } + }, + }), + }, + } + return plugin +} diff --git a/src/templates/opencode-ruflo-hooks.js b/src/templates/opencode-ruflo-hooks.js index be6b7a9..fef09ac 100644 --- a/src/templates/opencode-ruflo-hooks.js +++ b/src/templates/opencode-ruflo-hooks.js @@ -108,6 +108,12 @@ function promptText(parts) { .trim() } +function directOpenCodeReferences(text) { + return String(text) + .replace(/mcp__(?:claude-flow|claude_flow|ruflo)__([A-Za-z0-9_*-]+)/g, "claude-flow_$1") + .replace(/mcp__(?:agentic-qe|agentic_qe)__([A-Za-z0-9_*-]+)/g, "agentic-qe_$1") +} + const plugin = async ({ client }) => { await client.app.log({ body: { @@ -182,6 +188,13 @@ const plugin = async ({ client }) => { "tool.execute.after": async (input, output) => { try { + // Skills originate in the shared Ruflo catalogue and can carry Claude + // MCP spellings. Normalize them on the OpenCode-only surface even when + // the optional lazy gateway is unavailable; the gateway may then + // rewrite an owned family from this direct spelling to search/call. + if (input.tool === "skill" && typeof output?.output === "string") { + output.output = directOpenCodeReferences(output.output) + } if (input.tool === "edit" || input.tool === "write" || input.tool === "apply_patch") { const filePath = input?.args?.filePath ?? input?.args?.file_path ?? "" fire("post-edit", { diff --git a/tests/kit/opencode-ruflo-gateway.test.mjs b/tests/kit/opencode-ruflo-gateway.test.mjs new file mode 100644 index 0000000..491e2f8 --- /dev/null +++ b/tests/kit/opencode-ruflo-gateway.test.mjs @@ -0,0 +1,715 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +const rm = (dir) => fs.rmSync(dir, { recursive: true, force: true }); +const template = new URL('../../src/templates/opencode-ruflo-gateway.js', import.meta.url); +const toolContext = (overrides = {}) => ({ + abort: new AbortController().signal, + ask: async () => {}, + ...overrides, +}); + +function writePluginStub(root) { + const dir = path.join(root, 'node_modules', '@opencode-ai', 'plugin'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: '@opencode-ai/plugin', version: '0.0.0-test', type: 'module', exports: './index.js', + })); + fs.writeFileSync(path.join(dir, 'index.js'), ` +const schema = (kind, detail = {}) => ({ + kind, ...detail, + describe() { return this }, + optional() { this.isOptional = true; return this }, +}) +export const tool = (spec) => spec +tool.schema = { + string: () => schema('string'), + number: () => schema('number'), + unknown: () => schema('unknown'), + record: (key, value) => schema('record', { key, value }), +} +`); +} + +function writeFakeMcp(file) { + fs.writeFileSync(file, ` +import fs from 'node:fs' +import { createInterface } from 'node:readline' +const log = process.env.AK_FAKE_MCP_LOG +const kind = process.env.AK_FAKE_MCP_KIND || 'ruflo' +const send = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\\n') +const later = (fn, delay = 0) => delay > 0 ? setTimeout(fn, delay) : fn() +const initDelay = Number(process.env.AK_FAKE_MCP_INIT_DELAY_MS || 0) + const listDelay = Number(process.env.AK_FAKE_MCP_LIST_DELAY_MS || 0) + const callDelay = Number(process.env.AK_FAKE_MCP_CALL_DELAY_MS || 0) + const cancelLog = process.env.AK_FAKE_MCP_CANCEL_LOG +const hangMarker = process.env.AK_FAKE_MCP_FIRST_INIT_HANG_MARKER +const termDelay = Number(process.env.AK_FAKE_MCP_TERM_DELAY_MS || 0) +const pidFile = process.env.AK_FAKE_MCP_PID_FILE +if (pidFile) fs.writeFileSync(pidFile, String(process.pid)) +if (process.env.AK_FAKE_MCP_IGNORE_TERM === '1') { + process.on('SIGTERM', () => {}) + setInterval(() => {}, 1_000) +} +if (termDelay > 0) process.on('SIGTERM', () => setTimeout(() => process.exit(0), termDelay)) +createInterface({ input: process.stdin }).on('line', (line) => { + const msg = JSON.parse(line) + if (log) fs.appendFileSync(log, msg.method + '\\n') + if (msg.method === 'notifications/cancelled' && cancelLog) { + fs.appendFileSync(cancelLog, JSON.stringify(msg.params) + '\\n') + } + if (msg.id == null) return + if (msg.method === 'initialize') { + if (hangMarker && !fs.existsSync(hangMarker)) { + fs.writeFileSync(hangMarker, 'hung once') + return + } + return later(() => send(msg.id, { protocolVersion: '2024-11-05', capabilities: {} }), initDelay) + } + if (msg.method === 'tools/list' && kind === 'aqe') return later(() => send(msg.id, { + tools: [ + { name: 'fleet_init', description: 'Initialize the QE fleet', inputSchema: { type: 'object', properties: {} } }, + { name: 'quality_assess', description: 'Evaluate the quality gate', inputSchema: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } }, + ], + }), listDelay) + if (msg.method === 'tools/list' && !msg.params.cursor) return later(() => send(msg.id, { + tools: [ + { name: 'memory_search', description: 'Semantic project memory search', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } }, + { name: 'structured_result', description: 'Return structured MCP content', inputSchema: { type: 'object', properties: {} } }, + ], + nextCursor: 'page-2', + }), listDelay) + if (msg.method === 'tools/list' && msg.params.cursor === 'page-2') return later(() => send(msg.id, { + tools: [{ name: 'swarm_init', description: 'Initialize an agent swarm', inputSchema: { type: 'object', properties: {} } }], + }), listDelay) + if (msg.method === 'tools/call' && msg.params.name === 'structured_result') return send(msg.id, { + content: [{ type: 'text', text: 'fallback text' }], + structuredContent: { exact: true, nested: { count: 2 } }, + }) + if (msg.method === 'tools/call') return later(() => send(msg.id, { content: [ + { type: 'text', text: 'called:' + msg.params.name + ':' + JSON.stringify(msg.params.arguments) }, + ] }), callDelay) + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'unknown method' } }) + '\\n') +}) +`); +} + +async function loadGateway(root, managedMcp = {}, agentCatalog = []) { + writePluginStub(root); + const pluginDir = path.join(root, 'plugins'); + fs.mkdirSync(pluginDir, { recursive: true }); + const pluginFile = path.join(pluginDir, 'ruflo-gateway.mjs'); + const source = fs.readFileSync(template, 'utf8'); + const mcpPlaceholder = '/* AK_MANAGED_MCP_ENTRIES */ {}'; + const agentPlaceholder = '/* AK_MANAGED_AGENT_CATALOG */ []'; + const specialistPlaceholder = '/* AK_SPECIALIST_AGENT_PROMPT */ ""'; + assert.equal(source.split(mcpPlaceholder).length, 2, 'gateway template has one MCP placeholder'); + assert.equal(source.split(agentPlaceholder).length, 2, 'gateway template has one agent placeholder'); + assert.equal(source.split(specialistPlaceholder).length, 2, 'gateway template has one specialist placeholder'); + const specialistPrompt = 'You are the Agentic Kit specialist dispatcher for stock OpenCode.'; + fs.writeFileSync(pluginFile, source + .replace(mcpPlaceholder, JSON.stringify(managedMcp)) + .replace(agentPlaceholder, JSON.stringify(agentCatalog)) + .replace(specialistPlaceholder, JSON.stringify(specialistPrompt))); + return import(`${pathToFileURL(pluginFile).href}?v=${Date.now()}`); +} + +test('gateway uses the ak-managed MCP command lazily and preserves the full live catalogue', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-'); + try { + const server = path.join(root, 'fake-mcp.mjs'); + const log = path.join(root, 'mcp.log'); + const aqeLog = path.join(root, 'aqe-mcp.log'); + writeFakeMcp(server); + const mcp = { + 'claude-flow': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { AK_FAKE_MCP_LOG: log }, + }, + 'agentic-qe': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { AK_FAKE_MCP_LOG: aqeLog, AK_FAKE_MCP_KIND: 'aqe' }, + }, + }; + const mod = await loadGateway(root, mcp); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { + mcp, + tools: { bash: true }, + permission: { + edit: 'ask', ak_ruflo_call: 'ask', + 'claude-flow_*': 'allow', 'claude_flow_*': 'allow', + 'agentic-qe_*': 'allow', 'agentic_qe_*': 'allow', + }, + }; + + hooks.config(cfg); + assert.equal(cfg.mcp['claude-flow'].enabled, true, 'Ruflo remains visibly connected in stock OpenCode'); + assert.equal(cfg.mcp['agentic-qe'].enabled, true, 'Agentic QE remains visibly connected in stock OpenCode'); + assert.equal(cfg.tools['claude-flow_*'], false); + assert.equal(cfg.tools['claude_flow_*'], false); + assert.equal(cfg.tools['agentic-qe_*'], false); + assert.equal(cfg.tools['agentic_qe_*'], false); + assert.equal(cfg.tools.bash, true, 'unrelated tool configuration preserved'); + assert.equal(cfg.permission.ak_ruflo_search, 'allow'); + assert.deepEqual(cfg.permission.ak_ruflo_call, { '*': 'ask' }, 'user gateway policy preserved'); + assert.equal(cfg.permission.ak_aqe_search, 'allow'); + assert.deepEqual(cfg.permission.ak_aqe_call, { '*': 'allow' }); + assert.equal(cfg.permission['claude-flow_*'], 'deny', 'eager Ruflo schemas are hidden from requests'); + assert.equal(cfg.permission['agentic-qe_*'], 'deny', 'eager AQE schemas are hidden from requests'); + assert.equal(fs.existsSync(log), false, 'Ruflo process is not started until a gateway tool is used'); + assert.equal(fs.existsSync(aqeLog), false, 'AQE process is not started until an AQE gateway tool is used'); + + const context = toolContext(); + const search = await hooks.tool.ak_ruflo_search.execute({ query: 'semantic memory', limit: 4 }, context); + const searchResult = JSON.parse(search); + assert.match(searchResult.instruction, /arguments_json/); + assert.equal(searchResult.matches[0].name, 'memory_search'); + assert.deepEqual(searchResult.matches[0].inputSchema.properties.query, { type: 'string' }); + const secondPage = JSON.parse(await hooks.tool.ak_ruflo_search.execute({ query: 'agent swarm' }, context)); + assert.equal(secondPage.matches[0].name, 'swarm_init', 'paginated catalogue remains fully searchable'); + + const unknown = await hooks.tool.ak_ruflo_call.execute({ name: 'not_real', arguments_json: '{}' }, context); + assert.match(unknown, /^RUFLO_CALL_FAILED: Unknown Ruflo operation/); + const invalid = await hooks.tool.ak_ruflo_call.execute({ + name: 'memory_search', arguments_json: '{": ":"namespace","limit":2}', + }, context); + assert.match(invalid, /^RUFLO_CALL_FAILED: Invalid arguments for memory_search: missing required argument query/); + const malformed = await hooks.tool.ak_ruflo_call.execute({ + name: 'memory_search', arguments_json: '{"query":', + }, context); + assert.match(malformed, /^RUFLO_CALL_FAILED: Invalid arguments_json for memory_search:/); + const called = await hooks.tool.ak_ruflo_call.execute({ + name: 'memory_search', arguments_json: '{"query":"cache","limit":3}', + }, context); + assert.equal(called, 'called:memory_search:{"query":"cache","limit":3}'); + const structured = JSON.parse(await hooks.tool.ak_ruflo_call.execute({ + name: 'structured_result', arguments_json: '{}', + }, context)); + assert.deepEqual(structured.structuredContent, { exact: true, nested: { count: 2 } }); + const decodedByProvider = await hooks.tool.ak_ruflo_call.execute({ + name: 'memory_search', arguments_json: { query: 'cache', limit: 2 }, + }, context); + assert.match(decodedByProvider, /^RUFLO_CALL_FAILED: Invalid arguments_json/); + + const aqeSearch = JSON.parse(await hooks.tool.ak_aqe_search.execute({ query: 'quality gate' }, context)); + assert.equal(aqeSearch.matches[0].name, 'quality_assess'); + assert.match(aqeSearch.instruction, /fleet_init/); + const aqeInvalid = await hooks.tool.ak_aqe_call.execute({ + name: 'quality_assess', arguments_json: '{}', + }, context); + assert.match(aqeInvalid, /^AQE_CALL_FAILED: Invalid arguments for quality_assess: missing required argument target/); + const aqeCalled = await hooks.tool.ak_aqe_call.execute({ + name: 'quality_assess', arguments_json: '{"target":"working tree"}', + }, context); + assert.equal(aqeCalled, 'called:quality_assess:{"target":"working tree"}'); + + const methods = fs.readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(methods.filter((method) => method === 'initialize').length, 1); + assert.equal(methods.filter((method) => method === 'tools/list').length, 2, 'all pages loaded once, then catalogue cached'); + assert.equal( + methods.filter((method) => method === 'tools/call').length, + 2, + 'unknown operations and malformed arguments never reach MCP', + ); + const aqeMethods = fs.readFileSync(aqeLog, 'utf8').trim().split('\n'); + assert.equal(aqeMethods.filter((method) => method === 'initialize').length, 1); + assert.equal(aqeMethods.filter((method) => method === 'tools/list').length, 1); + assert.equal(aqeMethods.filter((method) => method === 'tools/call').length, 1); + await hooks.dispose(); + } finally { + rm(root); + } +}); + +test('gateway coalesces concurrent catalogue startup and recovers from a timed-out stale child', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-recovery-'); + const priorTimeout = process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS; + try { + process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS = '80'; + const server = path.join(root, 'fake-mcp.mjs'); + const log = path.join(root, 'mcp.log'); + const marker = path.join(root, 'hung-once'); + writeFakeMcp(server); + const mcp = { + 'claude-flow': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { + AK_FAKE_MCP_LOG: log, + AK_FAKE_MCP_FIRST_INIT_HANG_MARKER: marker, + AK_FAKE_MCP_TERM_DELAY_MS: '30', + AK_FAKE_MCP_INIT_DELAY_MS: '5', + AK_FAKE_MCP_LIST_DELAY_MS: '20', + }, + }, + }; + const mod = await loadGateway(root, mcp); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + hooks.config({ + mcp, + tools: {}, permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }); + const context = toolContext(); + assert.match( + await hooks.tool.ak_ruflo_search.execute({ query: 'memory' }, context), + /^RUFLO_SEARCH_FAILED: Ruflo MCP request timed out/, + ); + const [memory, swarm] = await Promise.all([ + hooks.tool.ak_ruflo_search.execute({ query: 'memory' }, context), + hooks.tool.ak_ruflo_search.execute({ query: 'swarm' }, context), + ]); + assert.equal(JSON.parse(memory).matches[0].name, 'memory_search'); + assert.equal(JSON.parse(swarm).matches[0].name, 'swarm_init'); + const methods = fs.readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(methods.filter((method) => method === 'initialize').length, 2); + assert.equal(methods.filter((method) => method === 'tools/list').length, 2, + 'concurrent searches share one post-recovery catalogue load'); + await hooks.dispose(); + } finally { + if (priorTimeout === undefined) delete process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS; + else process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS = priorTimeout; + rm(root); + } +}); + +test('gateway schema preserves arbitrary Ruflo arguments through one JSON string', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-schema-'); + try { + const mod = await loadGateway(root); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { mcp: {}, tools: {}, permission: {} }; + hooks.config(cfg); + assert.equal(hooks.tool.ak_ruflo_call, undefined, 'absent Ruflo is not advertised'); + assert.equal(hooks.tool.ak_ruflo_search, undefined, 'absent Ruflo search is not advertised'); + assert.equal(hooks.tool.ak_aqe_call, undefined, 'absent AQE is not advertised'); + assert.equal(cfg.tools['claude-flow_*'], undefined, 'unowned direct tools are not blacklisted'); + await hooks.dispose(); + } finally { + rm(root); + } +}); + +test('gateway lazily discovers every installed OpenCode skill and agent without removing capability', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-discovery-'); + try { + const server = path.join(root, 'fake-mcp.mjs'); + writeFakeMcp(server); + const agentCatalog = [ + { + name: 'code-review-swarm', + description: 'Comprehensive multi-agent code review', + body: 'Use claude-flow_swarm_init to coordinate the review. ' + + 'Use mcp__flow-nexus__sandbox_create only when that optional service is installed.', + }, + { + name: 'performance-analyzer', + description: 'Performance profiling and optimization', + body: 'Profile the target before changing it.', + }, + ]; + const managed = { + 'claude-flow': { type: 'local', command: [process.execPath, server], enabled: true }, + 'agentic-qe': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { AK_FAKE_MCP_KIND: 'aqe' }, + }, + }; + const mod = await loadGateway(root, managed, agentCatalog); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + hooks.config({ + mcp: managed, + agent: { + 'ak-specialist': { + prompt: 'You are the Agentic Kit specialist dispatcher for stock OpenCode.', + }, + }, + tools: {}, permission: { + 'claude-flow_*': 'allow', 'claude_flow_*': 'allow', + 'agentic-qe_*': 'allow', 'agentic_qe_*': 'allow', + }, + }); + const system = { + system: [ + 'user-owned global guidance\n' + + 'large Claude-only AK reference\n' + + '\n' + + 'memory-managementSemantic AgentDB memory search and storage\n' + + 'security-auditSecurity scanning and vulnerability detection\n' + + '', + ], + }; + const originalSystemArray = system.system; + hooks['experimental.chat.system.transform']({ sessionID: 'session-a' }, system); + assert.equal(system.system, originalSystemArray, 'stock OpenCode observes the in-place system mutation'); + assert.equal(system.system.length, 1, 'AK guidance stays inside the leading system message'); + assert.match(system.system[0], /user-owned global guidance/, 'non-AK user guidance survives'); + assert.doesNotMatch(system.system[0], /large Claude-only AK reference/); + assert.doesNotMatch(system.system[0], /Semantic AgentDB memory search/, 'eager skill descriptions removed'); + assert.match(system.system[0], /ak_skill_search/); + assert.match(system.system[0], /configured capability is preserved/); + + const loadedSkill = { + output: 'Use mcp__claude-flow__memory_search and mcp__agentic-qe__quality_assess. ' + + 'For dynamic work, use mcp__claude-flow__*, claude-flow_*, and agentic-qe_*.', + }; + hooks['tool.execute.after']({ tool: 'skill' }, loadedSkill); + assert.match(loadedSkill.output, /ak_ruflo_call/); + assert.match(loadedSkill.output, /ak_aqe_call/); + assert.doesNotMatch(loadedSkill.output, /mcp__(?:claude-flow|agentic-qe)__/); + assert.doesNotMatch(loadedSkill.output, /(?:claude-flow|agentic-qe)_\*/); + + const skillMatches = JSON.parse(await hooks.tool.ak_skill_search.execute( + { query: 'semantic memory' }, toolContext({ sessionID: 'session-a' }), + )); + assert.equal(skillMatches[0].name, 'memory-management'); + + const agentMatches = JSON.parse(await hooks.tool.ak_agent_search.execute( + { query: 'code review' }, toolContext(), + )); + assert.equal(agentMatches.matches[0].name, 'code-review-swarm'); + assert.match(agentMatches.instruction, /subagent_type="ak-specialist"/); + const loadedAgent = await hooks.tool.ak_agent_load.execute( + { name: 'code-review-swarm' }, toolContext(), + ); + assert.match(loadedAgent, /Agentic Kit specialist profile: code-review-swarm/); + assert.match(loadedAgent, /ak_ruflo_call.*name="swarm_init"/); + assert.match(loadedAgent, /Optional external MCP families named by this profile: `flow-nexus`/); + assert.match(loadedAgent, /does not provision them/); + assert.match(loadedAgent, /mcp__flow-nexus__sandbox_create/); + assert.doesNotMatch(loadedAgent, /mcp__(?:claude-flow|claude_flow|ruflo)__/); + + assert.match( + await hooks.tool.ak_skill_search.execute( + { query: 'does-not-exist' }, toolContext({ sessionID: 'session-a' }), + ), + /\[\]/, + 'a miss is an empty result, not capability loss', + ); + await hooks.dispose(); + } finally { + rm(root); + } +}); + +test('skill discovery is isolated per OpenCode session and empty catalogues replace prior state', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-session-skills-'); + try { + const mod = await loadGateway(root); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + hooks.config({ mcp: {}, tools: {}, permission: {} }); + + const systemA = { system: [ + 'project-a-only' + + 'Private project A capability', + ] }; + hooks['experimental.chat.system.transform']({ sessionID: 'session-a' }, systemA); + const foundA = JSON.parse(await hooks.tool.ak_skill_search.execute( + { query: 'private capability' }, toolContext({ sessionID: 'session-a' }), + )); + assert.equal(foundA[0].name, 'project-a-only'); + + const systemB = { system: ['No project skills are installed.'] }; + hooks['experimental.chat.system.transform']({ sessionID: 'session-b' }, systemB); + assert.match( + await hooks.tool.ak_skill_search.execute( + { query: 'private capability' }, toolContext({ sessionID: 'session-b' }), + ), + /^AK_SKILL_SEARCH_FAILED:/, + ); + assert.equal( + JSON.parse(await hooks.tool.ak_skill_search.execute( + { query: 'private capability' }, toolContext({ sessionID: 'session-a' }), + ))[0].name, + 'project-a-only', + ); + await hooks.event({ + event: { type: 'session.deleted', properties: { info: { id: 'session-a' } } }, + }); + assert.match( + await hooks.tool.ak_skill_search.execute( + { query: 'private capability' }, toolContext({ sessionID: 'session-a' }), + ), + /^AK_SKILL_SEARCH_FAILED:/, + ); + } finally { + rm(root); + } +}); + +test('aborted side-effecting MCP calls request protocol cancellation exactly once', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-cancel-'); + try { + const server = path.join(root, 'fake-mcp.mjs'); + const log = path.join(root, 'mcp.log'); + const cancelLog = path.join(root, 'cancel.log'); + writeFakeMcp(server); + const mcp = { + 'claude-flow': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { + AK_FAKE_MCP_LOG: log, + AK_FAKE_MCP_CANCEL_LOG: cancelLog, + AK_FAKE_MCP_CALL_DELAY_MS: '100', + }, + }, + }; + const mod = await loadGateway(root, mcp); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + hooks.config({ + mcp, tools: {}, + permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }); + const warm = toolContext({ sessionID: 'cancel-session' }); + await hooks.tool.ak_ruflo_search.execute({ query: 'memory' }, warm); + + const controller = new AbortController(); + const pending = hooks.tool.ak_ruflo_call.execute( + { name: 'memory_search', arguments_json: '{"query":"side effect"}' }, + toolContext({ sessionID: 'cancel-session', abort: controller.signal }), + ); + setTimeout(() => controller.abort(), 15); + assert.match(await pending, /^RUFLO_CALL_FAILED: Ruflo request cancelled$/); + await new Promise((resolve) => setTimeout(resolve, 30)); + + const methods = fs.readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(methods.filter((method) => method === 'tools/call').length, 1); + assert.equal(methods.filter((method) => method === 'notifications/cancelled').length, 1); + const cancellation = JSON.parse(fs.readFileSync(cancelLog, 'utf8').trim()); + assert.equal(typeof cancellation.requestId, 'number'); + assert.match(cancellation.reason, /Ruflo request cancelled/); + } finally { + rm(root); + } +}); + +test('skill rewriting follows the actually managed Ruflo and AQE capabilities', async (t) => { + for (const [label, mcp, rewritten, retained] of [ + [ + 'ruflo-only', + { 'claude-flow': { type: 'local', command: [process.execPath, '/bin/true'], enabled: true } }, + /ak_ruflo_call/, + /mcp__agentic-qe__quality_assess/, + ], + [ + 'aqe-only', + { 'agentic-qe': { type: 'local', command: [process.execPath, '/bin/true'], enabled: true } }, + /ak_aqe_call/, + /mcp__claude-flow__memory_search/, + ], + ]) { + const root = tmp(`ak-oc-ruflo-gateway-${label}-`); + try { + const mod = await loadGateway(root, mcp); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const permission = label === 'ruflo-only' + ? { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' } + : { 'agentic-qe_*': 'allow', 'agentic_qe_*': 'allow' }; + hooks.config({ mcp, tools: {}, permission }); + const loadedSkill = { + output: 'Use mcp__claude-flow__memory_search, ' + + 'mcp__plugin_ruflo-core_ruflo__memory_list, and mcp__agentic-qe__quality_assess.', + }; + hooks['tool.execute.after']({ tool: 'skill' }, loadedSkill); + assert.match(loadedSkill.output, rewritten, `${label} rewrites its managed family`); + assert.match(loadedSkill.output, retained, `${label} preserves the unavailable family`); + if (label === 'ruflo-only') { + assert.match(loadedSkill.output, /ak_ruflo_call.*memory_list/, + 'Claude plugin-qualified Ruflo refs use the same lazy operation call'); + assert.doesNotMatch(loadedSkill.output, /mcp__plugin_ruflo-core_ruflo__/); + } + await hooks.dispose(); + } finally { + rm(root); + } + } +}); + +test('gateway refuses post-sync MCP drift without disabling or blacklisting user tools', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-drift-'); + try { + const expected = { + 'claude-flow': { type: 'local', command: ['node', 'ak-server.mjs'], enabled: true }, + }; + const mod = await loadGateway(root, expected); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const userEntry = { type: 'local', command: ['node', 'user-server.mjs'], enabled: true }; + const cfg = { + mcp: { 'claude-flow': userEntry }, tools: { bash: true }, + permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }; + hooks.config(cfg); + assert.deepEqual(cfg.mcp['claude-flow'], userEntry, 'drifted user entry is untouched'); + assert.equal(cfg.tools['claude-flow_*'], undefined, 'drifted family is not blacklisted'); + assert.equal(hooks.tool.ak_ruflo_search, undefined, 'drifted family is not captured'); + assert.equal(hooks.tool.ak_ruflo_call, undefined, 'drifted family cannot be spawned'); + } finally { + rm(root); + } +}); + +test('gateway honors an explicit user request to keep a direct tool family enabled', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-tools-policy-'); + try { + const expected = { + 'claude-flow': { type: 'local', command: ['node', 'ak-server.mjs'], enabled: true }, + }; + const mod = await loadGateway(root, expected); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { + mcp: structuredClone(expected), + tools: { 'claude-flow_*': true, bash: true }, + permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }; + hooks.config(cfg); + assert.equal(cfg.mcp['claude-flow'].enabled, true); + assert.equal(cfg.tools['claude-flow_*'], true); + assert.equal(hooks.tool.ak_ruflo_search, undefined); + assert.equal(hooks.tool.ak_ruflo_call, undefined); + } finally { + rm(root); + } +}); + +test('gateway preserves every user tools policy that targets a managed family', async (t) => { + for (const [label, policy] of [ + ['family false', { 'claude-flow_*': false }], + ['subgroup false', { 'claude-flow_memory_*': false }], + ['exact true', { 'claude-flow_memory_store': true }], + ['underscore exact false', { claude_flow_memory_search: false }], + ]) { + const root = tmp(`ak-oc-ruflo-gateway-tools-policy-${label.replace(/\s+/g, '-')}-`); + try { + const expected = { + 'claude-flow': { type: 'local', command: ['node', 'ak-server.mjs'], enabled: true }, + }; + const mod = await loadGateway(root, expected); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { + mcp: structuredClone(expected), + tools: { ...policy, bash: true }, + permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }; + hooks.config(cfg); + assert.deepEqual( + Object.fromEntries(Object.keys(policy).map((key) => [key, cfg.tools[key]])), + policy, + `${label}: user policy preserved exactly`, + ); + assert.equal(hooks.tool.ak_ruflo_search, undefined, `${label}: family remains direct`); + assert.equal(hooks.tool.ak_ruflo_call, undefined, `${label}: gateway cannot bypass policy`); + } finally { + rm(root); + } + } +}); + +test('gateway honors post-sync permission drift without granting a bypass', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-permission-drift-'); + try { + const expected = { + 'claude-flow': { type: 'local', command: ['node', 'ak-server.mjs'], enabled: true }, + }; + const mod = await loadGateway(root, expected); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { + mcp: structuredClone(expected), tools: {}, + permission: { 'claude-flow_*': 'ask', 'claude_flow_*': 'allow' }, + }; + hooks.config(cfg); + assert.equal(cfg.mcp['claude-flow'].enabled, true); + assert.equal(cfg.permission['claude-flow_*'], 'ask'); + assert.equal(cfg.permission.ak_ruflo_search, undefined); + assert.equal(hooks.tool.ak_ruflo_search, undefined); + assert.equal(hooks.tool.ak_ruflo_call, undefined); + } finally { + rm(root); + } +}); + +test('gateway projects a granular direct-operation deny and never reaches MCP', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-granular-deny-'); + try { + const server = path.join(root, 'fake-mcp.mjs'); + const log = path.join(root, 'mcp.log'); + writeFakeMcp(server); + const expected = { + 'claude-flow': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { AK_FAKE_MCP_LOG: log }, + }, + }; + const mod = await loadGateway(root, expected); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + const cfg = { + mcp: structuredClone(expected), tools: {}, + permission: { + 'claude-flow_*': 'allow', + 'claude_flow_*': 'allow', + claude_flow_memory_store: 'deny', + }, + }; + hooks.config(cfg); + assert.equal(cfg.permission.ak_ruflo_call.memory_store, 'deny'); + const denied = await hooks.tool.ak_ruflo_call.execute( + { name: 'memory_store', arguments_json: '{"key":"must-not-run","value":"x"}' }, + toolContext({ + ask: async (request) => { + assert.equal(request.permission, 'ak_ruflo_call'); + assert.deepEqual(request.patterns, ['memory_store']); + throw new Error('permission denied'); + }, + }), + ); + assert.match(denied, /^RUFLO_CALL_FAILED: permission denied$/); + assert.equal(fs.existsSync(log), false, 'denied operation never starts or calls the MCP server'); + } finally { + rm(root); + } +}); + +test('dispose reaps a TERM-ignoring MCP child before returning', async (t) => { + const root = tmp('ak-oc-ruflo-gateway-reap-'); + try { + const server = path.join(root, 'fake-mcp.mjs'); + const pidFile = path.join(root, 'mcp.pid'); + writeFakeMcp(server); + const mcp = { + 'claude-flow': { + type: 'local', command: [process.execPath, server], enabled: true, + environment: { AK_FAKE_MCP_PID_FILE: pidFile, AK_FAKE_MCP_IGNORE_TERM: '1' }, + }, + }; + const mod = await loadGateway(root, mcp); + const hooks = await mod.default(); + t.after(() => hooks.dispose()); + hooks.config({ + mcp, tools: {}, + permission: { 'claude-flow_*': 'allow', 'claude_flow_*': 'allow' }, + }); + const context = toolContext(); + await hooks.tool.ak_ruflo_search.execute({ query: 'memory' }, context); + const pid = Number(fs.readFileSync(pidFile, 'utf8')); + assert.doesNotThrow(() => process.kill(pid, 0), 'fixture child is live before dispose'); + await hooks.dispose(); + assert.throws(() => process.kill(pid, 0), 'dispose waits through SIGKILL and reaps child'); + } finally { + rm(root); + } +}); diff --git a/tests/kit/opencode-stock-ruflo-gateway.test.mjs b/tests/kit/opencode-stock-ruflo-gateway.test.mjs new file mode 100644 index 0000000..c3c626d --- /dev/null +++ b/tests/kit/opencode-stock-ruflo-gateway.test.mjs @@ -0,0 +1,552 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const harnessRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const pkgRoot = process.env.AK_STOCK_PACKAGE_ROOT + ? path.resolve(process.env.AK_STOCK_PACKAGE_ROOT) + : harnessRoot; +const { + catalogSource, + GATEWAY_PLUGIN_NAME, + opencodeStack, + PLUGIN_NAME, +} = await import(pathToFileURL(path.join(pkgRoot, 'src', 'lib', 'opencode.mjs')).href); +const COMPACT_TOOL_LIMIT = 25; +const COMPACT_SCHEMA_BYTE_LIMIT = 30_000; +const COMPACT_REQUEST_BYTE_LIMIT = 45_000; +const EAGER_DIRECT_TOOL_FLOOR = 400; +const EAGER_SCHEMA_BYTE_FLOOR = 300_000; +const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function stockOpenCode() { + if (process.env.AK_STOCK_OPENCODE_BIN) return process.env.AK_STOCK_OPENCODE_BIN; + try { + return execFileSync('sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function installedCommand(name) { + try { + return execFileSync('sh', ['-c', `command -v ${name}`], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close((error) => error ? reject(error) : resolve(port)); + }); + }); +} + +function writeExecutable(file, body) { + fs.writeFileSync(file, body, { mode: 0o755 }); +} + +function writeFakeMcp(file) { + writeExecutable(file, `#!/usr/bin/env node +const fs = require('node:fs') +const readline = require('node:readline') +const basename = require('node:path').basename(process.argv[1]) +const kind = basename.includes('aqe') ? 'aqe' : basename.includes('brain') ? 'brain' : 'ruflo' +const log = process.env.AK_STOCK_GATEWAY_MCP_LOG +const record = (msg) => { + if (log) fs.appendFileSync(log, JSON.stringify({ pid: process.pid, kind, ...msg }) + '\\n') +} +record({ method: 'process/start' }) +const send = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\\n') +readline.createInterface({ input: process.stdin }).on('line', (line) => { + const msg = JSON.parse(line) + record({ method: msg.method, params: msg.params }) + if (msg.id == null) return + if (msg.method === 'initialize') return send(msg.id, { + protocolVersion: '2024-11-05', capabilities: { tools: {} }, + serverInfo: { name: 'ak-stock-' + kind, version: '1.0.0' }, + }) + if (msg.method === 'tools/list') return send(msg.id, { tools: kind === 'brain' ? [ + { name: 'search_ruvnet', description: 'Search grounded RuvNet sources', inputSchema: { type: 'object', properties: { query: { type: 'string' }, k: { type: 'number' } }, required: ['query'] } }, + ] : kind === 'aqe' ? [ + { name: 'fleet_init', description: 'Initialize the QE fleet', inputSchema: { type: 'object', properties: {} } }, + { name: 'quality_assess', description: 'Assess quality', inputSchema: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } }, + ] : [ + { name: 'memory_search', description: 'Semantic project memory search', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } }, + { name: 'swarm_init', description: 'Initialize an agent swarm', inputSchema: { type: 'object', properties: {} } }, + ] }) + if (msg.method === 'tools/call') return send(msg.id, { content: [{ type: 'text', text: 'called:' + msg.params.name + ':' + JSON.stringify(msg.params.arguments) }] }) + send(msg.id, {}) +}) +`); +} + +function writeCatalog(root) { + fs.mkdirSync(path.join(root, '.claude', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'skills', 'memory'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'stock-gateway-fixture', version: '1.0.0' })); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'memory-specialist.md'), [ + '---', 'description: Memory specialist', '---', '', 'Use mcp__claude-flow__memory_search.', '', + ].join('\n')); + fs.writeFileSync(path.join(root, '.claude', 'skills', 'memory', 'SKILL.md'), [ + '---', 'name: memory', 'description: Search project memory', '---', '', '# Memory', '', + ].join('\n')); + return root; +} + +function createProvider(requests, route, { agentProfile, skillName }) { + let server; + const ready = new Promise((resolve) => { + server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + requests.push(body); + const tools = Array.isArray(body.tools) ? body.tools : []; + const names = tools.map((entry) => entry?.function?.name || entry?.name).filter(Boolean); + const toolResults = (body.messages || []).filter((message) => message.role === 'tool'); + const created = Math.floor(Date.now() / 1000); + response.writeHead(200, { 'Content-Type': 'text/event-stream', Connection: 'close' }); + const respondText = (content) => response.end(`data: ${JSON.stringify({ id: 'gateway', object: 'chat.completion.chunk', created, model: body.model, choices: [{ index: 0, delta: { role: 'assistant', content }, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`); + const respondTool = (functionCall) => { + response.write(`data: ${JSON.stringify({ id: 'gateway', object: 'chat.completion.chunk', created, model: body.model, choices: [{ index: 0, delta: { role: 'assistant', tool_calls: [{ index: 0, id: `call-${route}-${toolResults.length}`, type: 'function', function: functionCall }] }, finish_reason: null }] })}\n\n`); + response.end(`data: ${JSON.stringify({ id: 'gateway', object: 'chat.completion.chunk', created, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] })}\n\ndata: [DONE]\n\n`); + }; + const callTool = route === 'brain' + ? 'ruvnet-brain_search_ruvnet' + : route === 'skill' ? 'ak_skill_search' + : route === 'agent' ? 'ak_agent_search' + : route === 'aqe' ? 'ak_aqe_call' : 'ak_ruflo_call'; + if (!names.includes(callTool)) { + respondText('title'); + return; + } + if (route === 'agent') { + const specialist = JSON.stringify(body.messages || []).includes(`PROFILE: ${agentProfile}`); + if (specialist) { + if (toolResults.length === 0) { + respondTool({ name: 'ak_agent_load', arguments: JSON.stringify({ name: agentProfile }) }); + } else { + respondText(`specialist child complete: ${agentProfile} profile loaded`); + } + return; + } + if (toolResults.length === 0) { + respondTool({ name: 'ak_agent_search', arguments: JSON.stringify({ query: 'project memory specialist', limit: 2 }) }); + } else if (toolResults.length === 1) { + respondTool({ + name: 'task', + arguments: JSON.stringify({ + description: 'Run the memory specialist', + prompt: `PROFILE: ${agentProfile}\nSearch project memory for kata.`, + subagent_type: 'ak-specialist', + }), + }); + } else { + respondText('specialist parent complete'); + } + return; + } + const functionCall = toolResults.length === 0 + ? route === 'brain' + ? { name: 'ruvnet-brain_search_ruvnet', arguments: JSON.stringify({ query: 'AgentDB memory capabilities', k: 2 }) } + : route === 'skill' + ? { name: 'ak_skill_search', arguments: JSON.stringify({ query: 'project memory', limit: 2 }) } + : route === 'aqe' + ? { name: 'ak_aqe_search', arguments: JSON.stringify({ query: 'fleet initialization', limit: 2 }) } + : { name: 'ak_ruflo_search', arguments: JSON.stringify({ query: 'semantic project memory', limit: 2 }) } + : route === 'aqe' + ? { name: 'ak_aqe_call', arguments: JSON.stringify({ name: 'fleet_init', arguments_json: '{}' }) } + : route === 'skill' + ? { name: 'skill', arguments: JSON.stringify({ name: skillName }) } + : { name: 'ak_ruflo_call', arguments: JSON.stringify({ name: 'memory_search', arguments_json: JSON.stringify({ query: 'kata' }) }) }; + if (toolResults.length < (route === 'brain' ? 1 : 2)) { + respondTool(functionCall); + return; + } + respondText('gateway complete'); + }); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); + return { + ready, + close: () => new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(resolve); + }), + }; +} + +async function waitFor(predicate, message, attempts = 160) { + let last; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + last = await predicate(); + if (last) return last; + } catch (error) { + last = error; + } + await delay(50); + } + throw new Error(`${message}: ${last?.message ?? JSON.stringify(last)}`); +} + +async function stop(child) { + if (!child || child.exitCode != null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => child.once('close', resolve)), + delay(2_000).then(() => { if (child.exitCode == null) child.kill('SIGKILL'); }), + ]); +} + +const opencode = stockOpenCode(); +const installedRuv = process.env.AK_STOCK_RUV_MODE === 'installed'; +const installedCatalog = process.env.AK_STOCK_CATALOG_MODE === 'installed'; +const compactProjection = process.env.AK_STOCK_PROJECTION_MODE !== 'eager'; +const route = ['agent', 'aqe', 'brain', 'skill'].includes(process.env.AK_STOCK_RUV_ROUTE) + ? process.env.AK_STOCK_RUV_ROUTE + : 'ruflo'; +const installedBrainShim = process.env.AK_STOCK_BRAIN_SHIM + || path.join(os.homedir(), '.claude', 'ruvnet-brain', 'mcp', 'server.mjs'); +const installedCatalogSource = installedCatalog ? catalogSource() : undefined; +const agentProfile = installedCatalog ? 'coder' : 'memory-specialist'; +const skillName = installedCatalog ? 'memory-search' : 'memory'; +const installedRuvCommands = installedRuv + ? { + ruflo: installedCommand('claude-flow-mcp'), + aqe: installedCommand('aqe-mcp'), + brain: fs.existsSync(installedBrainShim) ? installedBrainShim : '', + } + : {}; +const acceptanceSkip = !opencode + ? 'stock opencode is not installed' + : installedRuv && (!installedRuvCommands.ruflo || !installedRuvCommands.aqe + || (route === 'brain' && !installedRuvCommands.brain)) + ? 'installed claude-flow-mcp and aqe-mcp are required for AK_STOCK_RUV_MODE=installed' + : installedCatalog && !installedCatalogSource + ? 'an installed Ruflo catalog is required for AK_STOCK_CATALOG_MODE=installed' + : !compactProjection && !installedRuv + ? 'AK_STOCK_PROJECTION_MODE=eager requires AK_STOCK_RUV_MODE=installed' + : false; + +test(`stock OpenCode keeps Ruflo and Agentic QE connected with ${compactProjection ? `one compact lazy ${route} call path` : 'the eager direct catalogue'} (${installedRuv ? 'installed MCPs' : 'fixture MCPs'}, ${installedCatalog ? 'installed catalog' : 'fixture catalog'})`, { + skip: acceptanceSkip, + timeout: 60_000, +}, async (t) => { + const root = tmp('ak-stock-opencode-ruflo-'); + const requests = []; + const provider = createProvider(requests, route, { agentProfile, skillName }); + const processes = { opencode: undefined }; + const output = []; + let phase = 'fixture setup'; + let completed = false; + t.after(async () => { + if (!completed) { + t.diagnostic(JSON.stringify({ + failedPhase: phase, + providerRequests: requests.length, + opencodeTail: output.join('').slice(-12_000), + })); + } + await stop(processes.opencode); + await provider.close(); + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + }); + + const version = execFileSync(opencode, ['--version'], { encoding: 'utf8' }).trim(); + const binarySha256 = createHash('sha256').update(fs.readFileSync(opencode)).digest('hex'); + assert.match(version, /^1\.18\.18(?:\b|$)/, `acceptance is pinned to stock OpenCode 1.18.18 (${binarySha256})`); + + const configHome = path.join(root, 'config'); + const configDir = path.join(configHome, 'opencode'); + const configFile = path.join(configDir, 'opencode.json'); + const pluginsDir = path.join(configDir, 'plugins'); + const agentsDir = path.join(configDir, 'agents'); + const skillsDir = path.join(configDir, 'skills'); + const workspace = path.join(root, 'workspace'); + const fakeBin = path.join(root, 'bin'); + const fixtureBrainShim = path.join(fakeBin, 'brain-server.cjs'); + const mcpLog = path.join(root, 'mcp.jsonl'); + const providerPort = await provider.ready; + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(workspace, { recursive: true }); + if (!installedRuv) { + writeFakeMcp(path.join(fakeBin, 'claude-flow-mcp')); + writeFakeMcp(path.join(fakeBin, 'aqe-mcp')); + if (route === 'brain') writeFakeMcp(fixtureBrainShim); + } + fs.writeFileSync(configFile, JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + model: 'local/acceptance-model', + snapshot: false, + lsp: false, + provider: { + local: { + npm: '@ai-sdk/openai-compatible', name: 'AK stock acceptance', + options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: 'local' }, + models: { + 'acceptance-model': { + name: 'Acceptance model', tool_call: true, temperature: true, + cost: { input: 0, output: 0 }, limit: { context: 131072, output: 4096 }, + }, + }, + }, + }, + }, null, 2)); + + const previousPath = process.env.PATH; + process.env.PATH = `${fakeBin}${path.delimiter}${previousPath}`; + try { + const cfg = { + aqe: true, + integrations: { + version: 2, hosts: { claude: false, codex: false, opencode: true }, bindings: [], + ownership: { + opencode: { + mcp: null, + managed: null, + catalogDir: installedCatalog + ? installedCatalogSource.root + : writeCatalog(path.join(root, 'catalog')), + }, + }, + }, + routing: { version: 1, primaryHost: 'opencode', routes: {} }, providers: {}, + }; + phase = 'Agentic Kit convergence'; + const stack = await opencodeStack(cfg, { + pkgRoot, configFile, pluginsDir, agentsDir, skillsDir, + brainShim: route === 'brain' + ? installedRuv ? installedBrainShim : fixtureBrainShim + : path.join(root, 'missing-brain-shim.mjs'), + }); + assert.equal(stack.oc.ok, true); + assert.equal(stack.gateway.ok, true); + assert.equal(stack.agents.ok, true); + // This focused slice exercises the MCP/gateway contract only. The separate + // lifecycle bridge intentionally launches Ruflo hooks and daemons, which + // would make an otherwise hermetic stock-host acceptance depend on the + // developer's globally installed Ruflo runtime. + fs.rmSync(path.join(pluginsDir, PLUGIN_NAME)); + if (!compactProjection) fs.rmSync(path.join(pluginsDir, GATEWAY_PLUGIN_NAME)); + } finally { + process.env.PATH = previousPath; + } + + phase = 'stock OpenCode startup'; + const port = await freePort(); + processes.opencode = spawn(opencode, ['serve', '--hostname', '127.0.0.1', '--port', String(port), '--print-logs', '--log-level', 'DEBUG'], { + cwd: workspace, + env: { + ...process.env, + HOME: path.join(root, 'home'), + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: path.join(root, 'cache'), + XDG_DATA_HOME: path.join(root, 'data'), + XDG_STATE_HOME: path.join(root, 'state'), + TMPDIR: path.join(root, 'tmp'), + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + AK_STOCK_GATEWAY_MCP_LOG: mcpLog, + OPENCODE_DISABLE_LSP_DOWNLOAD: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + processes.opencode.stdout.on('data', (chunk) => output.push(String(chunk))); + processes.opencode.stderr.on('data', (chunk) => output.push(String(chunk))); + const endpoint = `http://127.0.0.1:${port}`; + await waitFor(async () => (await fetch(`${endpoint}/global/health`, { + signal: AbortSignal.timeout(2_000), + })).ok, 'stock OpenCode did not become healthy'); + + const directory = `directory=${encodeURIComponent(workspace)}`; + phase = 'MCP connection'; + const mcp = await waitFor(async () => { + const response = await fetch(`${endpoint}/mcp?${directory}`, { + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) return false; + const status = await response.json(); + return status['claude-flow']?.status === 'connected' && status['agentic-qe']?.status === 'connected' + && (route !== 'brain' || status['ruvnet-brain']?.status === 'connected') + ? status : false; + }, 'Ruflo and Agentic QE did not both connect'); + assert.equal(mcp['claude-flow'].status, 'connected'); + assert.equal(mcp['agentic-qe'].status, 'connected'); + if (route === 'brain') assert.equal(mcp['ruvnet-brain'].status, 'connected'); + + phase = 'session creation'; + const sessionResponse = await fetch(`${endpoint}/session?${directory}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'AK stock gateway acceptance' }), + signal: AbortSignal.timeout(10_000), + }); + if (!sessionResponse.ok) throw new Error(`session create failed: ${await sessionResponse.text()}`); + const session = await sessionResponse.json(); + phase = 'prompt submission'; + const promptResponse = await fetch(`${endpoint}/session/${session.id}/prompt_async?${directory}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agent: 'build', model: { providerID: 'local', modelID: 'acceptance-model' }, + parts: [{ type: 'text', text: 'Use Agentic Kit to search project memory for kata.' }], + }), + signal: AbortSignal.timeout(20_000), + }); + if (promptResponse.status !== 204) { + throw new Error(`prompt failed with HTTP ${promptResponse.status}: ${await promptResponse.text()}`); + } + + if (!compactProjection) { + phase = 'eager request capture'; + const eagerRequest = await waitFor(() => requests.find((body) => (body.tools || []).some( + (entry) => /^(?:claude[-_]flow|agentic[-_]qe)_/.test(entry?.function?.name || entry?.name), + )), `stock OpenCode did not advertise the eager installed catalogues\n${output.join('')}`); + const eagerNames = eagerRequest.tools + .map((entry) => entry?.function?.name || entry?.name) + .filter(Boolean); + const direct = eagerNames.filter((name) => /^(?:claude[-_]flow|agentic[-_]qe)_/.test(name)); + const toolSchemaBytes = Buffer.byteLength(JSON.stringify(eagerRequest.tools)); + assert.ok(direct.length >= EAGER_DIRECT_TOOL_FLOOR, + `expected at least ${EAGER_DIRECT_TOOL_FLOOR} direct Ruflo/AQE tools, got ${direct.length}`); + assert.ok(toolSchemaBytes >= EAGER_SCHEMA_BYTE_FLOOR, + `expected at least ${EAGER_SCHEMA_BYTE_FLOOR} eager schema bytes, got ${toolSchemaBytes}`); + assert.equal(eagerNames.some((name) => name.startsWith('ak_ruflo_') || name.startsWith('ak_aqe_')), false); + t.diagnostic(JSON.stringify({ + projection: 'eager', + opencode: version, + binarySha256, + mcp: { claudeFlow: mcp['claude-flow'].status, agenticQe: mcp['agentic-qe'].status }, + advertisedTools: eagerNames.length, + directRufloOrAqeTools: direct.length, + toolSchemaBytes, + providerRequestBytes: Buffer.byteLength(JSON.stringify(eagerRequest)), + })); + completed = true; + return; + } + + phase = 'compact tool continuation'; + const mainRequests = await waitFor(() => { + const matching = requests.filter((body) => (body.tools || []).some( + (entry) => (entry?.function?.name || entry?.name) === (route === 'brain' + ? 'ruvnet-brain_search_ruvnet' + : route === 'skill' ? 'ak_skill_search' + : route === 'agent' ? 'ak_agent_search' + : route === 'aqe' ? 'ak_aqe_call' : 'ak_ruflo_call'), + )); + return matching.some((body) => (body.messages || []).filter( + (message) => message.role === 'tool', + ).length >= (route === 'brain' ? 1 : 2)) + ? matching : false; + }, `stock OpenCode did not complete the lazy search/call loop\n${output.join('')}`); + + const names = mainRequests[0].tools.map((entry) => entry?.function?.name || entry?.name).filter(Boolean); + assert.ok(names.includes('ak_ruflo_search')); + assert.ok(names.includes('ak_ruflo_call')); + assert.ok(names.includes('ak_aqe_search')); + assert.ok(names.includes('ak_aqe_call')); + const toolSchemaBytes = Buffer.byteLength(JSON.stringify(mainRequests[0].tools)); + const providerRequestBytes = Buffer.byteLength(JSON.stringify(mainRequests[0])); + assert.ok(names.length <= COMPACT_TOOL_LIMIT, + `compact request exceeded ${COMPACT_TOOL_LIMIT} tools: ${names.length}`); + assert.ok(toolSchemaBytes <= COMPACT_SCHEMA_BYTE_LIMIT, + `compact schemas exceeded ${COMPACT_SCHEMA_BYTE_LIMIT} bytes: ${toolSchemaBytes}`); + assert.ok(providerRequestBytes <= COMPACT_REQUEST_BYTE_LIMIT, + `compact provider request exceeded ${COMPACT_REQUEST_BYTE_LIMIT} bytes: ${providerRequestBytes}`); + assert.equal(names.some((name) => name.startsWith('claude-flow_') || name.startsWith('claude_flow_')), false); + assert.equal(names.some((name) => name.startsWith('agentic-qe_') || name.startsWith('agentic_qe_')), false); + + const continuation = mainRequests.find((body) => { + const toolMessages = (body.messages || []).filter((message) => message.role === 'tool'); + return toolMessages.length >= (route === 'brain' ? 1 : 2); + }); + const continuationText = JSON.stringify(continuation); + assert.match(continuationText, route === 'brain' + ? /search_ruvnet/ + : route === 'skill' ? new RegExp(skillName) + : route === 'agent' ? new RegExp(`specialist child complete: ${agentProfile}`) + : route === 'aqe' ? /fleet_init/ : /memory_search/); + if (route === 'ruflo') assert.match(continuationText, /kata/); + assert.doesNotMatch(continuationText, /(?:RUFLO|AQE)_(?:(?:SEARCH|CALL)_FAILED|ERROR)/); + assert.doesNotMatch(continuationText, /"type":"tool-error"/); + if (route === 'skill' && installedCatalog) { + assert.match(continuationText, /ak_ruflo_call/, + 'installed skill rewrites Claude plugin-qualified Ruflo operations lazily'); + assert.doesNotMatch(continuationText, /mcp__plugin_ruflo-core_ruflo__/); + } + if (route === 'agent') { + const loadedProfile = requests.find((body) => JSON.stringify( + (body.messages || []).filter((message) => message.role === 'tool'), + ).includes(`Agentic Kit specialist profile: ${agentProfile}`)); + assert.ok(loadedProfile, 'stock task child did not receive the receipt-owned specialist profile'); + const loadedProfileText = JSON.stringify( + (loadedProfile.messages || []).filter((message) => message.role === 'tool'), + ); + if (installedCatalog) { + assert.match(loadedProfileText, /ak_ruflo_call/, + 'installed specialist rewrites managed Ruflo operations lazily'); + assert.doesNotMatch(loadedProfileText, /mcp__(?:claude-flow|claude_flow|ruflo)__/); + assert.doesNotMatch(loadedProfileText, /(?:claude-flow|claude_flow)_/); + } + const childNames = (loadedProfile.tools || []) + .map((entry) => entry?.function?.name || entry?.name) + .filter(Boolean); + assert.equal(childNames.some((name) => /^(?:claude[-_]flow|agentic[-_]qe)_/.test(name)), false); + } + if (!installedRuv && !['agent', 'skill'].includes(route)) { + assert.match(continuationText, route === 'brain' + ? /called:search_ruvnet/ + : route === 'aqe' ? /called:fleet_init/ : /called:memory_search/); + const mcpRows = fs.readFileSync(mcpLog, 'utf8').trim().split('\n').map(JSON.parse); + assert.ok(mcpRows.some((row) => row.kind === route && row.method === 'tools/call' + && row.params?.name === (route === 'brain' + ? 'search_ruvnet' + : route === 'aqe' ? 'fleet_init' : 'memory_search') + && (route === 'aqe' || row.params?.arguments?.query === (route === 'brain' + ? 'AgentDB memory capabilities' + : 'kata')))); + } + t.diagnostic(JSON.stringify({ + mcpMode: installedRuv ? 'installed' : 'fixture', + catalogMode: installedCatalog ? installedCatalogSource.id : 'fixture@1.0.0', + opencode: version, + binarySha256, + mcp: { + claudeFlow: mcp['claude-flow'].status, + agenticQe: mcp['agentic-qe'].status, + ...(route === 'brain' ? { ruvnetBrain: mcp['ruvnet-brain'].status } : {}), + }, + advertisedTools: names.length, + toolSchemaBytes, + providerRequestBytes, + compactAkTools: names.filter((name) => name.startsWith('ak_')).sort(), + directRufloOrAqeTools: names.filter((name) => /^(?:claude[-_]flow|agentic[-_]qe)_/.test(name)), + operation: route === 'brain' + ? 'ruvnet-brain_search_ruvnet(AgentDB memory capabilities)' + : route === 'skill' + ? `ak_skill_search -> stock skill(${skillName})` + : route === 'agent' + ? `ak_agent_search -> stock task(ak-specialist) -> ak_agent_load(${agentProfile})` + : route === 'aqe' + ? 'ak_aqe_search -> ak_aqe_call -> fleet_init' + : 'ak_ruflo_search -> ak_ruflo_call -> memory_search(kata)', + })); + completed = true; +}); diff --git a/tests/kit/opencode.test.mjs b/tests/kit/opencode.test.mjs index b8676e4..bf3f366 100644 --- a/tests/kit/opencode.test.mjs +++ b/tests/kit/opencode.test.mjs @@ -3,11 +3,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { applyOpencode, undoOpencode, opencodeMcpStatus, opencodeConverged, mcpEntriesFor, catalogSource, skillPathsFor, convertAgents, syncAgents, agentsStatus, deployPlugin, pluginStatus, deploySkill, skillStatus, removeArtifacts, - PERMISSION_KEYS, PLUGIN_NAME, createOpencodeLifecycleAdapter, + PLUGIN_NAME, createOpencodeLifecycleAdapter, opencodeStack, } from '../../src/lib/opencode.mjs'; import { runLifecycle } from '../../src/lib/adapters/lifecycle.mjs'; import { detectHosts } from '../../src/lib/providers.mjs'; @@ -105,6 +106,38 @@ test('opencodeMcpStatus flags JSONC files as parseError instead of clobbering', rm(d); }); +test('a later opencode.jsonc override blocks convergence and executable projection', async () => { + const d = tmp('ak-oc-later-jsonc-'); + const configFile = path.join(d, 'opencode.json'); + const pluginsDir = path.join(d, 'plugins'); + const agentsDir = path.join(d, 'agents'); + const skillsDir = path.join(d, 'skills'); + fs.writeFileSync(configFile, JSON.stringify({ user: { keep: true } })); + fs.writeFileSync(path.join(d, 'opencode.jsonc'), '{\n// loaded last\n"mcp": {}\n}\n'); + const before = fs.readFileSync(configFile, 'utf8'); + + const status = opencodeMcpStatus(cfgOn(), { configFile }); + assert.equal(status.laterOverride, path.join(d, 'opencode.jsonc')); + const convergence = await opencodeConverged(cfgOn(), { configFile }); + assert.equal(convergence.converged, false); + assert.match(convergence.reasons[0], /later OpenCode config override is unverified/); + + const result = await opencodeStack(cfgOn(), { + pkgRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'), + configFile, + pluginsDir, + agentsDir, + skillsDir, + }); + assert.equal(result.oc.fatal, true); + assert.match(result.oc.detail, /loads after opencode\.json/); + assert.equal(fs.readFileSync(configFile, 'utf8'), before, 'owned JSON is untouched'); + assert.equal(fs.existsSync(pluginsDir), false, 'no executable plugin is deployed'); + assert.equal(fs.existsSync(agentsDir), false, 'no specialist is deployed'); + assert.equal(fs.existsSync(skillsDir), false, 'no skill is deployed'); + rm(d); +}); + // ── applyOpencode / undoOpencode round-trip ────────────────────────────────── test('applyOpencode merges wiring, preserves user keys, records value-precise ownership; undo restores priors', async () => { @@ -135,7 +168,12 @@ test('applyOpencode merges wiring, preserves user keys, records value-precise ow assert.ok(doc.skills.paths.includes('/user/path'), 'user skills path preserved'); assert.ok(doc.skills.paths.includes(path.join(srcRoot, 'plugins')), 'catalog plugins path added'); assert.equal(doc.permission.edit, 'ask', 'user permission preserved'); - for (const k of PERMISSION_KEYS) assert.equal(doc.permission[k], 'allow'); + const expectedPermissionKeys = [ + 'claude-flow_*', 'claude_flow_*', + 'agentic-qe_*', 'agentic_qe_*', + 'ruvnet-brain_*', 'ruvnet_brain_*', + ]; + for (const k of expectedPermissionKeys) assert.equal(doc.permission[k], 'allow'); assert.equal(cfg.integrations.ownership.opencode.mcp, 'ak'); // value-precise ownership: claude-flow had no prior → prior null, written recorded const rec = cfg.integrations.ownership.opencode.managed.mcp['claude-flow']; @@ -155,7 +193,7 @@ test('applyOpencode merges wiring, preserves user keys, records value-precise ow assert.ok(after.mcp['my-server'], 'user server survives teardown'); assert.deepEqual(after.skills.paths, ['/user/path']); assert.equal(after.permission.edit, 'ask'); - for (const k of PERMISSION_KEYS) assert.equal(after.permission[k], undefined); + for (const k of expectedPermissionKeys) assert.equal(after.permission[k], undefined); assert.equal(cfg.integrations.ownership.opencode.mcp, null); rm(d); }); @@ -716,16 +754,15 @@ test('desired-set shrink RESTORES a prior (user entry that equaled the old desir test('permission desired-set shrink restores a recorded user prior', async () => { const d = tmp('ak-oc-permission-restore-'); const file = path.join(d, 'opencode.json'); - const key = PERMISSION_KEYS.at(-1); + const key = 'agentic-qe_*'; fs.writeFileSync(file, JSON.stringify({ permission: { [key]: 'allow' } })); const cfg = cfgOn(); try { await applyOpencode(cfg, { configFile: file, brainShim: path.join(d, 'absent-shim') }); - PERMISSION_KEYS.pop(); + cfg.aqe = false; await applyOpencode(cfg, { configFile: file, brainShim: path.join(d, 'absent-shim') }); assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).permission[key], 'allow'); } finally { - if (!PERMISSION_KEYS.includes(key)) PERMISSION_KEYS.push(key); rm(d); } }); @@ -838,7 +875,7 @@ test('opencodeStack reports markersChanged when a converged file has stale/missi assert.equal(cfg.integrations.ownership.opencode.mcp, 'ak'); assert.ok(cfg.integrations.ownership.opencode.managed?.mcp?.['claude-flow']?.written); assert.ok(cfg.integrations.ownership.opencode.managed?.artifacts?.plugin); - assert.ok(cfg.integrations.ownership.opencode.managed?.artifacts?.agents?.['coder.md']); + assert.ok(cfg.integrations.ownership.opencode.managed?.artifacts?.agents?.['ak-specialist.md']); assert.ok(cfg.integrations.ownership.opencode.managed?.artifacts?.agentStamp); assert.ok(cfg.integrations.ownership.opencode.managed?.artifacts?.skill); // A third run with truthful markers is then fully quiet. @@ -868,7 +905,7 @@ test('opencodeStack normalizes poisoned null artifact maps before adopting exact assert.equal(adopted.plugin.adopted, true); assert.ok(adopted.agents.adopted > 0); assert.equal(adopted.skill.adopted, true); - assert.ok(cfg.integrations.ownership.opencode.managed.artifacts.agents['coder.md']); + assert.ok(cfg.integrations.ownership.opencode.managed.artifacts.agents['ak-specialist.md']); rm(d); }); @@ -1017,7 +1054,7 @@ test('opencodeStack preserves a valid config collision while converging independ assert.equal(result.oc.ok, false, 'collision remains an explicit warning'); assert.deepEqual(JSON.parse(fs.readFileSync(configFile, 'utf8')).mcp['claude-flow'], userMcp); assert.ok(fs.existsSync(path.join(d, 'plugins', PLUGIN_NAME))); - assert.ok(fs.existsSync(path.join(d, 'agents', 'coder.md'))); + assert.ok(fs.existsSync(path.join(d, 'agents', 'ak-specialist.md'))); assert.ok(fs.existsSync(path.join(d, 'skills', 'ruflo', 'SKILL.md'))); rm(d); }); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 24ce7d3..4ca1161 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -221,7 +221,8 @@ test('pick --host claude,opencode enables + wires opencode (config, plugin, agen const r = akPick(['x', 'host', 'pick', '--host', 'claude,opencode', '--yes'], sb); assert.equal(r.status, 0, `pick failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); - assert.match(r.stdout, /restart opencode to load the hooks/, 'restart guidance printed after wiring'); + assert.match(r.stdout, /restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections/, + 'restart guidance printed after wiring'); const doc = ocJson(sb.home); assert.ok(doc.mcp['claude-flow'], 'claude-flow MCP wired into opencode.json'); @@ -232,9 +233,13 @@ test('pick --host claude,opencode enables + wires opencode (config, plugin, agen const plugin = path.join(sb.home, '.config', 'opencode', 'plugins', 'ruflo-hooks.js'); assert.ok(fs.existsSync(plugin), 'lifecycle plugin deployed'); - const agent = path.join(sb.home, '.config', 'opencode', 'agents', 'coder.md'); - assert.ok(fs.existsSync(agent), 'ruflo agent converted into opencode subagents'); - assert.match(fs.readFileSync(agent, 'utf8'), /claude-flow_swarm_init/, 'MCP tool refs rewritten for opencode'); + const agent = path.join(sb.home, '.config', 'opencode', 'agents', 'ak-specialist.md'); + assert.ok(fs.existsSync(agent), 'compact Agentic Kit specialist dispatcher deployed'); + assert.match( + fs.readFileSync(agent, 'utf8'), + /Call `ak_agent_load`/, + 'dispatcher loads the selected receipt-owned profile lazily', + ); const agentsMd = path.join(sb.home, '.config', 'opencode', 'AGENTS.md'); assert.ok(fs.existsSync(agentsMd) && fs.readFileSync(agentsMd, 'utf8').includes('BEGIN ruflo-opencode-reference'), 'enablement-gated guidance converges on enable — "wired + guided" is one contract'); @@ -260,7 +265,10 @@ test('pick --host claude on an opencode-enabled machine disables it: ak wiring s const on = akPick(['x', 'host', 'pick', '--host', 'claude,opencode', '--yes'], sb); assert.equal(on.status, 0, on.stderr); - assert.ok(fs.existsSync(path.join(agentsDir, 'coder.md')), 'ak agents deployed in the enable run'); + assert.ok( + fs.existsSync(path.join(agentsDir, 'ak-specialist.md')), + 'compact Agentic Kit specialist dispatcher deployed in the enable run', + ); const off = akPick(['x', 'host', 'pick', '--host', 'claude', '--yes'], sb); assert.equal(off.status, 0, `disable failed\nstdout: ${off.stdout}\nstderr: ${off.stderr}`); @@ -270,7 +278,7 @@ test('pick --host claude on an opencode-enabled machine disables it: ak wiring s assert.ok(!doc.mcp?.['claude-flow'], 'ak-managed MCP entry stripped'); assert.equal(doc.model, 'opencode/kimi-k3', 'user model key survives the strip'); assert.ok(!doc.permission?.['claude-flow_*'], 'ak permission patterns stripped'); - assert.ok(!fs.existsSync(path.join(agentsDir, 'coder.md')), 'ak-generated agents removed'); + assert.ok(!fs.existsSync(path.join(agentsDir, 'ak-specialist.md')), 'ak dispatcher removed'); assert.ok(!fs.existsSync(path.join(agentsDir, '.ak-agents-stamp.json')), 'agent stamp removed'); assert.ok(fs.existsSync(path.join(agentsDir, 'my-agent.md')), 'user-owned agent survives'); assert.ok(!fs.existsSync(path.join(sb.home, '.config', 'opencode', 'plugins', 'ruflo-hooks.js')), diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index 95c732a..4bea139 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -298,7 +298,9 @@ test('ak setup --opencode --yes persists the host and wires it via the shared st const { result, out } = await captureLog(() => setup.run({ flags: FLAGS({ opencode: true, yes: true, minimal: true }), pkgRoot: PKG_ROOT })); assert.equal(result, 0, out); - assert.match(out, /restart opencode to load the hooks/, 'restart guidance after successful wiring'); + assert.match(out, /restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections/, + 'restart guidance after successful wiring'); + assert.match(out, /opencode gateway:/, 'setup reports the compact gateway explicitly'); }); const cfg = loadKitConfig(); assert.equal(cfg.integrations.hosts.opencode, true, 'enabled host persisted to kit.json'); @@ -306,7 +308,10 @@ test('ak setup --opencode --yes persists the host and wires it via the shared st const doc = JSON.parse(fs.readFileSync(path.join(ocHome(), 'opencode.json'), 'utf8')); assert.ok(doc.mcp['claude-flow'], 'claude-flow MCP wired'); assert.ok(fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-hooks.js')), 'lifecycle plugin deployed'); - assert.ok(fs.existsSync(path.join(ocHome(), 'agents', 'coder.md')), 'agents converted'); + assert.ok(fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-gateway.js')), 'lazy gateway deployed'); + assert.ok(fs.existsSync(path.join(ocHome(), 'agents', 'ak-specialist.md')), 'specialist dispatcher deployed'); + assert.equal(fs.existsSync(path.join(ocHome(), 'agents', 'coder.md')), false, + 'eager agent catalogue is not projected into the initial task description'); assert.ok(fs.existsSync(path.join(ocHome(), 'skills', 'ruflo', 'SKILL.md')), 'platform skill deployed'); // guidance blocks land NOW, not on the next reconcile (codex-review #18) const agentsMd = path.join(ocHome(), 'AGENTS.md'); @@ -354,7 +359,7 @@ test('ak setup --opencode fails honestly and deploys nothing when JSONC is refus const { result, out } = await withOpencodeCli(() => captureLog(() => setup.run({ flags: FLAGS({ opencode: true, yes: true, minimal: true }), pkgRoot: PKG_ROOT }))); assert.equal(result, 1); - assert.match(out, /plugin\/agents\/skill\/guidance skipped/); + assert.match(out, /plugins\/agent projection\/skill\/guidance skipped/); assert.doesNotMatch(out, /restart opencode|setup complete/); for (const surface of ['plugins', 'agents', 'skills', 'AGENTS.md']) { assert.equal(fs.existsSync(path.join(ocHome(), surface)), false, `${surface} must not be deployed`); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 67a2868..aed6484 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -410,8 +410,8 @@ test('exact legacy artifacts without receipts surface as read-only adoption work const rows = await withOpencodeCli(() => collect()); const adoption = rowsFor(rows, 'opencode') .filter((r) => /lacks? (?:an |ownership )?ownership receipt|lack ownership receipts/.test(r.message)); - assert.equal(adoption.length, 3, - `plugin, agents/stamp, and skill must each expose adoption: ${rowsFor(rows, 'opencode').map((r) => r.message)}`); + assert.equal(adoption.length, 4, + `lifecycle plugin, gateway, agent projection, and skill must each expose adoption: ${rowsFor(rows, 'opencode').map((r) => r.message)}`); for (const row of adoption) { assert.equal(row.level, 'warn'); assert.match(row.fix, /sync adopts .*receipt ledger without rewriting/i); diff --git a/tests/kit/sync-command.test.mjs b/tests/kit/sync-command.test.mjs index ada514e..cc8568b 100644 --- a/tests/kit/sync-command.test.mjs +++ b/tests/kit/sync-command.test.mjs @@ -218,9 +218,13 @@ test('enabled + drifted: a real sync converges opencode after hosts, before fina const doc = JSON.parse(fs.readFileSync(path.join(ocHome(), 'opencode.json'), 'utf8')); assert.ok(doc.mcp['claude-flow'], 'claude-flow MCP converged by sync'); assert.ok(fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-hooks.js')), 'plugin deployed by sync'); - assert.ok(fs.existsSync(path.join(ocHome(), 'agents', 'coder.md')), 'agents converted by sync'); + assert.ok(fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-gateway.js')), 'lazy gateway deployed by sync'); + assert.ok(fs.existsSync(path.join(ocHome(), 'agents', 'ak-specialist.md')), + 'specialist dispatcher deployed by sync'); + assert.equal(fs.existsSync(path.join(ocHome(), 'agents', 'coder.md')), false, + 'sync retires the eager agent projection after the dispatcher is current'); // ordering: opencode steps ran before the convergence proof - const stepIdx = out.search(/opencode (plugin|agents):/); + const stepIdx = out.search(/opencode (plugin|gateway|agent projection):/); const verdictIdx = out.search(/converged — no failing subsystems/); assert.ok(stepIdx > -1 && verdictIdx > -1 && stepIdx < verdictIdx, `opencode convergence must land before the final verification:\n${out}`); @@ -235,7 +239,7 @@ test('a second sync is a no-op for every opencode surface', async () => { const { result, out } = await withOpencodeCli(() => realSync()); assert.equal(result, 0, out); assertUnchanged(convergedHome, ocHome(), 'a converged sync must not rewrite any opencode file'); - assert.ok(!/opencode (plugin|agents|skill):/.test(out), + assert.ok(!/opencode (plugin|gateway|agent projection|skill):/.test(out), 'the opencode branch is not even entered once every row reports converged'); }); diff --git a/tests/kit/trust-manifest.test.mjs b/tests/kit/trust-manifest.test.mjs index 410c346..6ab90cc 100644 --- a/tests/kit/trust-manifest.test.mjs +++ b/tests/kit/trust-manifest.test.mjs @@ -40,7 +40,10 @@ test('built-in hosts distinguish managed approval from unchanged host policy', ( assert.equal(byId.opencode.trust.approvalPolicy, 'managed'); assert.equal(byId.codex.trust.approvalPolicy, 'unchanged'); assert.deepEqual(autoApproveValues('opencode'), [ - 'claude-flow_*', 'claude_flow_*', 'ruvnet-brain_*', 'ruvnet_brain_*', + 'claude-flow_*', 'claude_flow_*', + 'agentic-qe_*', 'agentic_qe_*', + 'ruvnet-brain_*', 'ruvnet_brain_*', + 'ak_ruflo_*, ak_aqe_*, ak_skill_search, ak_agent_*', ]); }); From cad636e82c9f8eb1617a375b6d5c616aa9ab7fa9 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Sun, 16 Aug 2026 13:06:57 -0700 Subject: [PATCH 2/2] test(opencode): windows-safe timeout budget for gateway recovery test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-request timer starts before the spawned MCP child finishes booting, so the 80ms budget also had to absorb a node process cold start — routinely exceeded on Windows CI runners (observed on windows-latest/node 22: the recovery search timed out and JSON.parse hit 'RUFLO_SEARCH_FAILED...'). A 2000ms budget keeps every phase deterministic: the hang phase still times out (the fake server never answers the first initialize) and the recovery phase now has spawn headroom. --- tests/kit/opencode-ruflo-gateway.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/kit/opencode-ruflo-gateway.test.mjs b/tests/kit/opencode-ruflo-gateway.test.mjs index 491e2f8..591d8c8 100644 --- a/tests/kit/opencode-ruflo-gateway.test.mjs +++ b/tests/kit/opencode-ruflo-gateway.test.mjs @@ -232,7 +232,11 @@ test('gateway coalesces concurrent catalogue startup and recovers from a timed-o const root = tmp('ak-oc-ruflo-gateway-recovery-'); const priorTimeout = process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS; try { - process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS = '80'; + // The gateway's per-request timer starts before the child finishes booting, + // so this budget must absorb a node process cold start — >80ms on slow + // Windows CI runners. The hang phase still times out deterministically at + // any budget: the fake server never answers the first initialize. + process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS = '2000'; const server = path.join(root, 'fake-mcp.mjs'); const log = path.join(root, 'mcp.log'); const marker = path.join(root, 'hung-once');