From c4d20c5ada29651369d590609dbdcd2a25870ae1 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 11:31:05 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20opencode=20as=20a=20third=20host?= =?UTF-8?q?=20adapter=20=E2=80=94=20ak-managed=20ruflo/ruvnet-brain=20wiri?= =?UTF-8?q?ng=20(ADR-0015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode (opencode.ai) joins claude and codex behind the host-adapter abstraction, wired through its own native surfaces (never env flags, which don't exist for it): - ~/.config/opencode/opencode.json: claude-flow + ruvnet-brain MCP servers, skills.paths, permission patterns — merged backup-first with VALUE-precise ownership (collisions preserved, priors restored on teardown, stale ak entries pruned only while == written; scalar permission lifted to {"*": v}; JSONC refused, never clobbered) - plugins/ruflo-hooks.js: opencode's plugin events mapped to ruflo's local hook verbs (session lifecycle, pre-bash safety, edit/task learning, route context) — the only hook surface opencode has - agents/: ruflo's agent set converted (frontmatter shape, YAML-safe descriptions, all three mcp__ spellings rewritten, collision-prefixed, documentation skipped), drift-stamped against a lazily-resolved catalog source (override → RUFLO_REPO → marketplace clone → npm direct/nested) - AGENTS.md guidance: new agents-opencode target + opencode-flavored block templates, same never-mkdir gate as ~/.codex - lifecycle: status subsystem (deep convergence probe), sync apply (after hosts install, CLI-gated), setup --opencode, x provider off / uninstall teardown, dashboard Hosts-tab categorization, nudge consistency via the shared guidanceTargets composition, pick preserves the opencode flag, detectHosts reads config-file wired state 25 new kit tests + dashboard/opencode cases; suites green: 605 + 189 tests, eslint, tsc, markdownlint. Cross-vendor reviewed (21 findings addressed). --- README.md | 32 +- claude/ruflo-opencode-reference.md | 92 +++ claude/ruvnet-brain-opencode-reference.md | 34 ++ docs/MANAGED-TOOLS.md | 2 +- docs/adr/0015-opencode-host.md | 211 +++++++ docs/adr/README.md | 7 + src/commands/setup.mjs | 56 +- src/commands/status.mjs | 71 +++ src/commands/sync.mjs | 21 + src/commands/uninstall.mjs | 32 + src/commands/x/provider.mjs | 38 +- src/lib/blocks.mjs | 43 +- src/lib/config.mjs | 5 +- src/lib/dashboard/client.mjs | 4 +- src/lib/nudge.mjs | 14 +- src/lib/opencode.mjs | 687 ++++++++++++++++++++++ src/lib/paths.mjs | 20 + src/lib/providers.mjs | 35 +- src/lib/versions.mjs | 2 +- src/templates/opencode-ruflo-hooks.js | 202 +++++++ tests/dashboard.test.cjs | 19 +- tests/kit/guidance-targets.test.mjs | 7 +- tests/kit/hosts.test.mjs | 21 +- tests/kit/opencode.test.mjs | 488 +++++++++++++++ 24 files changed, 2097 insertions(+), 46 deletions(-) create mode 100644 claude/ruflo-opencode-reference.md create mode 100644 claude/ruvnet-brain-opencode-reference.md create mode 100644 docs/adr/0015-opencode-host.md create mode 100644 src/lib/opencode.mjs create mode 100644 src/templates/opencode-ruflo-hooks.js create mode 100644 tests/kit/opencode.test.mjs diff --git a/README.md b/README.md index b020a78..028364d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ [![node](https://img.shields.io/node/v/@pacphi/agentic-kit)](https://nodejs.org) [![license: MIT](https://img.shields.io/npm/l/@pacphi/agentic-kit)](LICENSE) -**One npm package that installs, heals, and *proves* [ruflo](https://github.com/ruvnet/ruflo) (claude-flow) + [agentic-qe](https://github.com/proffesor-for-testing/agentic-qe) — and wires Claude Code + Codex into one ambidextrous, self-routing setup. macOS · Linux · Windows.** +**One npm package that installs, heals, and *proves* [ruflo](https://github.com/ruvnet/ruflo) (claude-flow) + [agentic-qe](https://github.com/proffesor-for-testing/agentic-qe) — and wires Claude Code + Codex (+ opencode) into one ambidextrous, self-routing setup. macOS · Linux · Windows.** ```bash npm install -g @pacphi/agentic-kit@next # alpha channel until 4.0.0 GA ak setup # once per machine; run inside a git repo to set that project up too ak setup --codex # …or bring up Claude + Codex together in one shot +ak setup --opencode # …and wire ruflo + ruvnet-brain into opencode (third host) ``` > [!IMPORTANT] @@ -61,7 +62,7 @@ in [docs/archive/](docs/archive/). ```text ak status + one suggested next action ak setup first-time setup — machine and/or the project you're standing in - [--codex] [--primary-host claude|codex] [--project] [--minimal] [--yes] [--no-aqe] [--no-security] [--reconfigure] + [--codex] [--opencode] [--primary-host claude|codex] [--project] [--minimal] [--yes] [--no-aqe] [--no-security] [--reconfigure] ak status read-only dashboard: what's true, what's drifted [--json] [--deep] ak sync converge to good: upgrade + heal + verify [--dry-run] [--no-upgrade] ak dashboard open the local web dashboard (auto-opens your browser) @@ -169,6 +170,33 @@ applies (reversibly); `ak host off` restores the claude-only default. Full guide [docs/PROVIDERS.md](docs/PROVIDERS.md). Already on an older `ak` and adopting a later capability (like dual-host)? [docs/UPGRADING.md](docs/UPGRADING.md) covers the `sync` vs `host pick` motion. +### opencode host (opt-in) + +[opencode](https://opencode.ai) is a third host alongside claude/codex — wired through its own +native surfaces rather than env flags. `ak setup --opencode` (or `providers.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). +- **Lifecycle hooks** — `~/.config/opencode/plugins/ruflo-hooks.js`: session restore/end, + bash safety screening, 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 + `providers.opencodeCatalogDir` or `$RUFLO_REPO`. +- **Guidance** — `~/.config/opencode/AGENTS.md` gets ak's managed blocks with + opencode-correct tool names (`claude-flow_*`, `ruvnet-brain_search_ruvnet`). + +Everything is ownership-marked (`providers.opencodeMcp`) and stripped surgically by +`ak x provider off` / `ak uninstall` — your own opencode.json entries are never touched. +Design record: [docs/adr/0015-opencode-host.md](docs/adr/0015-opencode-host.md). + ## Troubleshooting [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — symptom → `agentic-kit` command. diff --git a/claude/ruflo-opencode-reference.md b/claude/ruflo-opencode-reference.md new file mode 100644 index 0000000..7663104 --- /dev/null +++ b/claude/ruflo-opencode-reference.md @@ -0,0 +1,92 @@ + + + +## 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 +`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`). +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). + +### Most-used commands + +```bash +ruflo memory search -q "..." --smart -n patterns # semantic recall across sessions +ruflo memory store -k KEY --value V -n patterns # persist a decision/pattern +ruflo route "task description" # pick the right agent (Q-learning) +ruflo analyze boundaries src/ # find natural refactor seams +ruflo security scan && ruflo security defend -i "…" # code scan + prompt-injection check +ruflo doctor # health check after install/upgrade +``` + +### When NOT to use ruflo + +Single-file edits, trivial fixes, read-only questions, spawning ONE subagent +(use the native task tool). Reach for ruflo on: multi-file refactors, +cross-session memory, 3+ agent swarms, security/perf audits, semantic search +over prior decisions. + +### Quick decision tree + +``` +Need to ... ? +├─ Search past work / decisions → ruflo memory search -q "..." --smart (or claude-flow_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 +├─ 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 +├─ Scan untrusted text → ruflo security defend -i "..." +├─ Re-apply after a ruflo upgrade → ak sync (one command heals everything) +└─ Anything rUv CLI → ruflo --help +``` + +### 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). + +### Daemon (host-independent) + +The ruflo daemon runs per-project background workers (default: local-only, +$0; 12h TTL). It serves every host equally — nothing opencode-specific to +set up. Inspect/control: `ruflo daemon budget show|pause|resume`, +`ruflo daemon status`, stop all with `ruflo daemon stop --all`. +AI workers are opt-in (`RUFLO_DAEMON_AI_WORKERS=1`) and spawn the `claude` +CLI — they are the only Claude-specific piece. + +### Per-project ruflo init for opencode + +In a project that should have its own ruflo runtime (`.swarm/memory.db`, +hooks, swarm state): + +```bash +ruflo init --codex # AGENTS.md + .agents/ layout — the closest fit for opencode +ruflo init --dual # both Claude Code (.claude/, CLAUDE.md) and AGENTS.md +``` + +opencode reads project `AGENTS.md` natively (and `CLAUDE.md` as a fallback +when no `AGENTS.md` exists), so either mode works. The machine-wide MCP +registration above already covers every project; project init adds the +per-project memory DB and runtime state. + + diff --git a/claude/ruvnet-brain-opencode-reference.md b/claude/ruvnet-brain-opencode-reference.md new file mode 100644 index 0000000..375358b --- /dev/null +++ b/claude/ruvnet-brain-opencode-reference.md @@ -0,0 +1,34 @@ + + + +## RuvNet Brain — ground before you assert + +> Applies when the **RuvNet Brain** is installed — a local, offline knowledge base over the +> rUv stack (RuVector/RVF, Ruflo, AgentDB, SPARC, agentic-flow, QuDAG, SAFLA, RuLake, …), +> surfaced through the `ruvnet-brain` MCP server (`ruvnet-brain_search_ruvnet` tool). +> Managed by `ak` (install/update/status); the KB lives at `~/.cache/ruvnet-brain/kb` +> (override with `RUVNET_BRAIN_KB`). + +- Your training priors on the rUv stack are **stale** — rUv ships well ahead of the training + horizon. When a task touches any rUv tool, call `ruvnet-brain_search_ruvnet` and ground the + answer in the **cited source paths** it returns **before** stating what that tool can or + cannot do. Asserting a rUv capability from memory, without a cited path, is an error. +- Prefer rUv-native primitives over classical defaults: RVF over Pinecone/pgvector, Ruflo + over LangChain, AgentDB over ad-hoc memory — but confirm the specific capability via + `ruvnet-brain_search_ruvnet` first, don't assert the substitution from memory either. +- Signal when the brain is working so the user has confidence it's in charge (e.g. + "Grounding in rUv's source…" → cite the path). + +### Managing it (via `ak`) + +- **Install / update:** `ak setup` installs it when absent; `ak sync` re-runs the installer to + pull the latest release. The opencode MCP registration points at the stable-spine shim + (`~/.claude/ruvnet-brain/mcp/server.mjs`), which hot-swaps brain versions — no opencode + config change needed on update. +- **Health check:** `npx ruvnet-brain --doctor` (verifies the KB, reader, and + runs a smoke query). + + diff --git a/docs/MANAGED-TOOLS.md b/docs/MANAGED-TOOLS.md index 8c4fcca..ebaddb4 100644 --- a/docs/MANAGED-TOOLS.md +++ b/docs/MANAGED-TOOLS.md @@ -52,7 +52,7 @@ them. | --- | --- | --- | --- | --- | --- | | **ruflo** | npm `ruflo@latest` | `ak sync` | disk: global `package.json` | npm `view latest` (TTL-cached) | row ✓ / upstream's own `RuFlo V` header ✓ / card + banner ✓ | | **agentic-qe** | npm `agentic-qe@latest` | `ak sync` | disk: global `package.json` (project-local fallback) | npm `view latest` (TTL-cached) | row ✓ / `Agentic QE V` chip ✓ / card + banner ✓ | -| **hosts** (currently Claude/Codex; OpenCode is non-routable) | npm `@latest` — only when npm-managed | `ak sync` if npm-installed; **explicitly disowned** if brew/mise/native | disk: global `package.json`, else `--version` probe | npm latest for npm-managed only; external → `outdated:false` | row ✓ (version + method) / n/a / card + banner (npm-managed only) ✓ | +| **hosts** (Claude, Codex, OpenCode; routing remains Claude/Codex-only) | npm `@latest` — only when npm-managed | `ak sync` if npm-installed; **explicitly disowned** if brew/mise/native | disk: global `package.json`, else `--version` probe | npm latest for npm-managed only; external → `outdated:false` | row ✓ (version + method) / n/a / card + banner (npm-managed only) ✓ | | **agentdb** | npm, **pinned to ruflo's bundled version** — deliberately not latest | `ak sync` (repins on core skew) | disk: global `package.json` | ruflo's **bundled** copy (coherence), not npm latest — by design | row ✓ / n/a / card ✓; banner excluded (its authority isn't "latest") | | **ruvnet-brain** | npm `ruvnet-brain@latest` + `--version v` pin (never `github:` HEAD) | `ak sync`; the installer's own nightly self-updater is suppressed at install (`--no-nightly-prompt`) and disabled by sync if found (`ruvnet-brain-nightly` subsystem) | disk: KB `SOURCE.json → releaseTag`, falling back to ak's kit.json stamp for pre-stamping bundles | GitHub `releases/latest` tag (TTL-cached) | row ✓ / `V` chip ✓ / card + banner ✓ | | **kit (self)** | npm, **pinned to the exact version drift saw** (`@pacphi/agentic-kit@`) | `ak sync` (runs last — npm replaces the running code) | disk: running copy's `package.json` | npm `latest` (+ `next` for prereleases, TTL-cached) | row ✓ / n/a / header version + card + banner ✓ | diff --git a/docs/adr/0015-opencode-host.md b/docs/adr/0015-opencode-host.md new file mode 100644 index 0000000..38a7940 --- /dev/null +++ b/docs/adr/0015-opencode-host.md @@ -0,0 +1,211 @@ +# ADR-0015 — opencode as a third host: ruflo/ruvnet-brain wiring through opencode's native surfaces + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Deciders:** agentic-kit maintainers + +## Context + +`ak` models two frontier hosts (`claude`, `codex`) behind the host-adapter abstraction in +`src/lib/hosts.mjs`, each wired to the same rUv stack (ruflo MCP, ruvnet-brain, skills, +guidance) through that host's *native* surfaces — ak's standing rule is "write the host's +own config, never a parallel config layer." **opencode** (opencode.ai) is a third agent +CLI in the same class, and its native surfaces are different again: + +- **Config:** `~/.config/opencode/opencode.json` (JSONC-tolerant schema), holding `mcp` + local-server entries (`{type, command[], environment, enabled, timeout}`), `skills.paths[]`, + and `permission` as wildcard tool-name patterns. MCP tools surface as `_` + (so `claude-flow_memory_search`, not `mcp__claude-flow__memory_search`). +- **Hooks:** none. There is no settings-hooks surface; lifecycle extension happens through + plugin files (`~/.config/opencode/plugins/*.js`) exporting event handlers + (`event`, `chat.message`, `tool.execute.before/after`, …). +- **Guidance:** `~/.config/opencode/AGENTS.md` — and when it exists, opencode prefers it + **over** falling back to `~/.claude/CLAUDE.md` (Claude Code compatibility). So opencode + needs its *own* managed copy of the machine guidance; inheriting claude's file silently + stops working the moment ak creates opencode's. +- **Agents/skills:** opencode reads subagents from `~/.config/opencode/agents/*.md` + (frontmatter `description` + `mode`, body = prompt) and skills from + `~/.claude/skills/` + `skills.paths`. ruflo ships its agent set in Claude Code's + different frontmatter format (`name`, `tools: …` string list) with bodies referencing + `mcp__claude-flow__*` tool names. + +Without an adapter, a ruflo upgrade leaves opencode's wiring stale: the agent copies, +the plugin, the config entries, and the guidance file are static artifacts with no owner. + +## Decision + +### 1. A third host adapter — opt-in, `hosts.opencode: false` by default + +`HOST_ADAPTERS.opencode` in `hosts.mjs` (`configFormat:'json'`, `statuslineSupported:false`, +`aqeProvider:null`, no session env markers), a `HOSTS` row (`bin:'opencode'`, +`pkg:'opencode-ai'` — brew/mise installs report `external` and are never touched), and +`providers.hosts.opencode` in kit.json's defaults. `--opencode` on `ak setup` opts in. +No `ENABLE_*` env exists for opencode (ruflo's ADR-034 backend flags don't cover it), so +wiring is entirely config-file based — the `MANAGED_ENV_KEYS` surface is unchanged. + +### 2. One owner module: `src/lib/opencode.mjs` + +Every ak-managed byte on opencode's surfaces lives behind one module, following the +`settings.mjs`/`mcp.mjs` contracts (backup-first, merge-not-clobber, idempotent): + +- **`opencode.json` wiring** (`applyOpencode`): `mcp.claude-flow` (command + `claude-flow-mcp` when the dedicated stdio bin is present — it answers `initialize` + directly — else `ruflo mcp start`, ak's claude/codex registration), `mcp.ruvnet-brain` + (the stable-spine shim `~/.claude/ruvnet-brain/mcp/server.mjs`, which hot-swaps brain + versions so the registration never needs rewriting), `skills.paths`, and + `permission` allow-patterns for both separator spellings. A file that is not plain + JSON (legal JSONC comments) is **refused, never clobbered** — detected via a strict + reader, since `settings.readJson`'s fallback parameter cannot distinguish + "absent" from "unparseable". +- **Ownership is VALUE-precise, not name-precise** (hardened after cross-vendor + review): every managed key is recorded as `{prior, written}`. A pre-existing entry + whose value DIFFERS from ak's desired value (and was not previously ak-written) is a + **collision** — preserved and reported, never adopted or torn down. Teardown restores + the user's prior value rather than deleting the key, and only while the current value + still equals what ak wrote (a user edit survives both pruning and teardown). + Previously-managed keys that fall out of the desired set (brain shim removed, catalog + source changed) are pruned under the same ==-written guard. Scalar `permission` + shorthand is lifted to its documented object equivalent (`{"*": v}`) before merging + and restored on undo. +- **Ownership:** on first write ak records `providers.opencodeMcp='ak'` plus the exact + managed key set (`opencodeManaged: {mcp[], paths[], permissions[]}`) in kit.json. + `undoOpencode` strips exactly that set — user MCP servers, user skills paths, and user + permissions survive teardown (mirrors the `codexMcp`/`rufloCodexMcp` ownership guards, + made precise for a shared JSON document). +- **Hooks as a plugin file:** `src/templates/opencode-ruflo-hooks.js` maps opencode's + plugin events to ruflo's local hook-handler verbs (`session-restore`/`session-end` on + session lifecycle, `pre-bash` blocking only explicit `[BLOCKED]` verdicts, + `post-edit`/`pre-task`/`post-task` feeding the learning substrate, `route` injecting + routing context on `chat.message` as a fully-formed `synthetic` text part — + opencode validates id/messageID/sessionID on persisted parts). Deployed + content-diffed (`deployPlugin`), refreshed whenever the template changes, and + **no-clobber**: a foreign (marker-less) file at any deploy slot is preserved and + reported, never overwritten. Failure policy: hooks never break the host. +- **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 is adopted, not orphaned). The stamp + (`.ak-agents-stamp.json`) records the source id **and the actually-deployed file + list** (user-occupied slots are never in it), written only when the set changes — + so status detects structural divergence (deleted/extra generated files), not just + version drift. +- **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 + by claude) → the published `@claude-flow/cli` package (the ADR-128 substrate agents + + core skills) → the nested copy under `ruflo/node_modules` (the layout a plain + `npm i -g ruflo` produces). Candidates are lazy thunks so the npm-root lookups + (which spawn `npm root -g`) only run when earlier candidates miss — status probes + stay spawn-free on marketplace machines. The source id (`kind@version`, read from + its `package.json`) drives drift detection: a ruflo upgrade or marketplace + auto-update diverges the stamp, `ak status` flags it, `ak sync` re-converts. +- **Platform skill:** the repo-root `SKILL.md` deploys to + `~/.config/opencode/skills/ruflo/` with a deployed-marker for gated teardown. + +### 3. Guidance target `agents-opencode` + opencode-flavored block templates + +`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) gated on `command: opencode`, and +`ruvnet-brain-opencode-reference` (the `ruvnet-brain_search_ruvnet` tool name) gated on +the KB dir — while `ruflo-preamble` (host-agnostic operating rules) is shared: +`guidanceFiles: ['claude', 'agents-opencode']`. The claude-only twins +(`ruflo-reference`, `ruvnet-brain-reference`) deliberately do **not** target +`agents-opencode`, and `retiredForTarget` force-strips them if they ever land there. + +### 4. Sync/status/setup/teardown wiring + +`status.collect` gains an `opencode` subsystem (config convergence via +`opencodeConverged` — deep value comparison, not key presence — plus plugin currency, +agent-set drift, platform skill; gated on the CLI being present); `sync` applies via +the same rows, AFTER the hosts install branch and likewise CLI-gated (enabled-but-absent +never creates the config home); `setup --opencode` runs the identical machine-step and +deploys the guidance blocks immediately. `ak x provider off` and `ak uninstall` strip +the config wiring (ownership-gated, priors restored), the deployed artifacts +(marker-gated, precise to ak's files), and the guidance blocks — with `uninstall`'s +cfg read hoisted above any kit.json purge so ownership is known at teardown time. +`x provider pick` (the claude↔codex routing chooser) deliberately excludes opencode and +preserves its flag + ownership markers verbatim. `detectHosts` reads the config-file +host's wired state from its own config (no `env[null]`). `versions.mjs` tracks +`opencode-ai` when npm-managed. + +The **dashboard** categorizes the subsystem into the Hosts tab alongside +`mcp`/`codex-mcp` (its designed fallback is Runtime, so nothing was ever dropped — this +is categorization, not plumbing; the rows flow through the same `collect()` payload +`ak status` prints, so the two surfaces cannot disagree). The **drift nudge** (`nudge.mjs`) +switches from its hardcoded two-target block list to the shared `guidanceTargets` + +`retiredForTarget` composition — its stated contract is "never disagrees with +`ak status`", which a frozen subset silently breaks whenever a guidance target is added +(this also closes a pre-existing gap: codex's `agents-user` drift never surfaced there +either). **`x provider pick`** is the claude↔codex *routing* tool; it now preserves +`hosts.opencode` and the opencode ownership markers verbatim instead of rebuilding the +`providers` object without them (previously a pick would have silently unwired the host). +Out of scope (matching codex's own asymmetry): routing-table integration +(`routing.mjs` untouched), aqe provider wiring (no opencode provider type exists), +statusline (no upstream surface), `drivingHost` session detection (opencode sets no +session env marker), and usage/cost attribution (`usage-index.mjs` reads claude/codex +transcripts only — the pricing surface has no opencode input and shows nothing for it, +which is the honest shape). + +## Consequences + +- `ak sync` now converges opencode alongside claude/codex: a ruflo upgrade re-keys the + agent set + skills paths + plugin; a brain update hot-swaps through the shim with zero + opencode-side writes; drift shows in `ak status` with a fix attached. +- opencode machines with no claude marketplace clone still work, degraded to the npm + package's substrate agent/skill set; the override (`opencodeCatalogDir` / `$RUFLO_REPO`) + covers git-checkout power users. +- Machines without opencode see zero new writes: the guidance target is gated on the + config home existing, and every apply path is gated on `hosts.opencode`. +- Teardown is surgical: `x provider off` / `uninstall` remove only ak-managed keys, + marked files, and ak's blocks — user customizations to `opencode.json` persist. +- opencode's config is rewritten as plain JSON: legal JSONC comments in a pre-existing + `opencode.json` would be lost on merge — so ak **refuses** unparseable files with a + manual-merge message instead of silently normalizing them. + +## Alternatives considered + +- **Register MCP via `opencode mcp add`.** Rejected: the CLI's add surface cannot + reliably express the env block + request timeout + global scope in one + non-interactive call, and ak still needs a merge writer for `skills.paths` and + `permission` regardless — one writer with backup/idempotence beats two mechanisms. +- **Point skills/agents at a git checkout (`/opt/ruflo`) by default.** Rejected as a + default: machine-specific and undiscoverable. The marketplace clone (auto-updated, + full catalog) and the published package (universal fallback) cover the spectrum; + the checkout remains available as an explicit override. +- **Copy agents unconverted.** Rejected: Claude-format `tools:` strings are not valid + opencode frontmatter, and `mcp__claude-flow__*` body refs name tools that don't exist + under opencode's naming. Conversion is required for correctness, not just style. +- **No plugin (skip hooks).** Rejected: the learning substrate (`post-edit` outcome + recording, session consolidation) and `pre-bash` safety screening are half the value + of the ruflo integration; opencode's plugin events are the only hook surface and they + map cleanly. +- **Share `ruflo-reference`/`ruvnet-brain-reference` with opencode.** Rejected: both + templates name claude-only tool spellings and claude-specific management paths. + Twin templates keep each file honest; the preamble stays shared because it is + genuinely host-agnostic. + +## References + +- `src/lib/opencode.mjs` (the owner module), `src/lib/hosts.mjs` (adapter), + `src/lib/providers.mjs` (`HOSTS` row, `--opencode` flag handling, `hostAuthState` + home seam), `src/lib/blocks.mjs` (`agents-opencode` target, new registry rows), + `src/commands/{sync,status,setup,uninstall}.mjs`, `src/commands/x/provider.mjs` + (`off` teardown + `pick` preservation), `src/lib/nudge.mjs` (shared targets), + `src/lib/dashboard-server.mjs` (Hosts-tab categorization), + `src/lib/versions.mjs` (drift), `src/templates/opencode-ruflo-hooks.js`, + `claude/ruflo-opencode-reference.md`, `claude/ruvnet-brain-opencode-reference.md`. +- opencode config schema: `https://opencode.ai/config.json`; opencode plugin/docs: + `https://opencode.ai/docs/{plugins,agents,skills,rules,mcp-servers}`. +- ruflo env parity: `v3/@claude-flow/cli/src/init/mcp-generator.ts`; hook verbs: + `.claude/helpers/hook-handler.cjs` (in the ruflo repo / marketplace clone). +- Tests: `tests/kit/opencode.test.mjs`, `tests/dashboard.test.cjs` (opencode rows + + categorization), `tests/kit/{hosts,guidance-targets}.test.mjs` (extended). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4eff1d5..7534a96 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0014](0014-dashboard-auth-and-remediation.md) | Dashboard auth token, plus a security/quality remediation pass | Accepted | | [0015](0015-managed-codex-native-statusline.md) | Manage Codex's native user-wide status line without claiming rich-renderer parity | Accepted | | [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted | +| [0017](0017-opencode-host.md) | OpenCode as a managed, observable, non-routable host through native surfaces | Accepted | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -79,3 +80,9 @@ provider behind two hosts, OpenRouter behind an existing host, and OpenCode mana routable by accident. Its Ollama bindings are a structural multi-host proof; ADR-0011 remains the independent Proposed decision for observed local execution, catalogue-backed identity, usage pricing, and transcript fidelity. + +**0017** applies ADR-0016 to OpenCode as a managed, observable, non-routable host. It wires the rUv +stack through OpenCode's native JSON configuration, plugin, converted-agent, skill, and +machine-guidance surfaces; preserves user values through ownership receipts and guarded teardown; +and keeps primary/activity routing limited to capability-qualified Claude/Codex hosts. Generalized +multi-host routing, including an OpenCode execution-worker contract, remains follow-on issue #76. diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 45f4de9..fcd91c3 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -10,10 +10,11 @@ import readline from 'node:readline/promises'; import { run as runCmd, have } from '../lib/exec.mjs'; import * as heal from '../lib/heal.mjs'; import { fixStatusline } from '../lib/statusline.mjs'; -import { registry, syncBlocks } from '../lib/blocks.mjs'; +import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; +import { applyOpencode, deployPlugin, syncAgents, deploySkill, catalogSource } from '../lib/opencode.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { commandHosts, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags } from '../lib/providers.mjs'; +import { commandHosts, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; @@ -31,6 +32,7 @@ export const options = { 'no-ruvnet-brain': { type: 'boolean', default: false }, 'no-security': { type: 'boolean', default: false }, codex: { type: 'boolean', default: false }, + opencode: { type: 'boolean', default: false }, 'primary-host': { type: 'string' }, reconfigure: { type: 'boolean', default: false }, }; @@ -53,6 +55,10 @@ Options: default is claude-only, codex opt-in). Installs @openai/codex if absent (prompted; external installs untouched) and wires the Claude↔Codex bridges + per-activity routing. + --opencode enable the opencode host during setup: wires opencode.json + (claude-flow + ruvnet-brain MCP, skills paths, permissions), + deploys the lifecycle plugin + platform skill, and converts + the ruflo agent set into opencode subagents. --primary-host which host leads: claude|codex (default claude). Passing codex implies --codex and mirrors the routing defaults so codex drives with claude as the alternate. @@ -163,10 +169,46 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } } + // 6b. opencode host wiring — config-file MCP + skills, lifecycle plugin, + // converted agents, platform skill (opencode.mjs owns all of it). Only + // when the CLI is actually present: a declined/failed install must not + // leave a freshly-created config home behind (codex-review #4). + if (cfg.providers?.hosts?.opencode) { + if (!(await have('opencode'))) { + warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); + } else { + const oc = await applyOpencode(cfg); + (oc.ok ? ok : warn)(`opencode: ${oc.detail}`); + ok(`opencode plugin: ${deployPlugin({ pkgRoot }).detail}`); + const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + ok(`opencode agents: ${syncAgents({ source }).detail}`); + const sk = deploySkill({ source }); + if (sk.changed) ok(`opencode skill: ${sk.detail}`); + // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) + // — not on the next status-driven reconcile. + const rows = registry(cfg.customBlocks); + const resolve = (r) => (r.custom + ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) + : path.join(pkgRoot, 'claude', r.template)); + const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; + for (const t of guidanceTargets({ cwd: process.cwd(), cfg })) { + if (t.name !== 'agents-opencode') continue; + const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)]; + const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); + const changed = res.filter((r) => r.action !== 'unchanged').map((r) => `${r.slug} ${r.action}`).join(', '); + ok(`opencode guidance: ${changed || 'in sync'}`); + } + } + } + // 7. frontier host hint — codex detected but not enabled (opt-in via `x provider pick`) if (!cfg.providers?.hosts?.codex && await have('codex')) { info('codex CLI detected — run `ak host pick` to let ruflo use both claude and codex'); } + // opencode hint — detected but not enabled (opt-in via `setup --opencode`) + if (!cfg.providers?.hosts?.opencode && await have('opencode')) { + info('opencode CLI detected — wire ruflo + ruvnet-brain into it with: ak setup --opencode'); + } return true; } @@ -333,13 +375,17 @@ export async function run({ flags, pkgRoot }) { // cfg here would persist during a dry-run — "change nothing" must hold. if (flags['dry-run']) { if (flags.codex || flags['primary-host']) info('dry-run: --codex/--primary-host would enable + install the codex host and wire dual-mode (no changes made)'); + if (flags.opencode) info('dry-run: --opencode would enable the opencode host and wire it (no changes made)'); } else { const hostFlags = applySetupHostFlags(cfg, flags); for (const w of hostFlags.warnings) warn(w); if (hostFlags.changed) { - const primary = cfg.providers.primaryHost && cfg.providers.primaryHost !== 'claude' - ? ` (primary: ${cfg.providers.primaryHost})` : ''; - info(`codex host enabled${primary} — will install + wire dual-mode`); + if (flags.codex || flags['primary-host'] === 'codex') { + const primary = cfg.providers.primaryHost && cfg.providers.primaryHost !== 'claude' + ? ` (primary: ${cfg.providers.primaryHost})` : ''; + info(`codex host enabled${primary} — will install + wire dual-mode`); + } + if (flags.opencode) info('opencode host enabled — will wire opencode.json and deploy plugin/agents/skills'); } } diff --git a/src/commands/status.mjs b/src/commands/status.mjs index ada7dd5..c497329 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -9,6 +9,7 @@ import * as paths from '../lib/paths.mjs'; import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; import { scanNpxStale } from '../lib/npx.mjs'; import { registrationStatus, codexMcpStatus, rufloCodexMcpStatus, ruvectorRegistered } from '../lib/mcp.mjs'; +import { opencodeMcpStatus, opencodeConverged, catalogSource, agentsStatus, pluginStatus, skillStatus } from '../lib/opencode.mjs'; import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; import { scanRvf } from '../lib/rvf.mjs'; import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; @@ -324,6 +325,69 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } } + // opencode host wiring — the third host's counterpart of the codex-mcp rows: + // opencode.json (mcp + skills.paths + permissions), the plugins/ lifecycle + // bridge, the converted agent set, and the platform skill. Only surfaces when + // the opencode host is enabled AND installed (enabled-but-absent is the + // hosts row's story); probes are file reads + one bin check (codex-review #4). + if (cfg.providers?.hosts?.opencode) { + try { + if (!(await have('opencode'))) { + rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)')); + } else { + const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + const st = opencodeMcpStatus(cfg); + const conv = st.parseError ? null : await opencodeConverged(cfg); + if (st.parseError) { + rows.push(row('opencode', 'warn', + 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', + 'merge the ak wiring manually')); + } else if (!st.exists || !st.claudeFlow) { + rows.push(row('opencode', 'warn', + `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`, + 'sync writes the opencode wiring')); + } else if (!conv?.converged) { + rows.push(row('opencode', 'warn', + `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? '…' : ''})`, + '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)'}`)); + } + const plug = pluginStatus({ pkgRoot }); + if (plug.foreign) { + rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js — ak leaves it alone')); + } else if (!plug.present) { + rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it')); + } else if (!plug.current) { + rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it')); + } + const ag = agentsStatus({ source }); + if (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 (ag.count === 0) { + rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set')); + } else if (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')); + } else { + rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`)); + } + const sk = skillStatus({ source }); + if (sk.foreign) { + rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned — ak leaves it alone')); + } else if (source?.hasPlatformSkill && !sk.present) { + rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it')); + } else if (source?.hasPlatformSkill && !sk.current) { + rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it')); + } + } + } catch (e) { + rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`)); + } + } + // hosts (install-if-missing) — cheap: file read + `which`, no network. // An enabled host that is entirely absent is installable by sync; an external // install (mise/native/brew) is reported but never touched. @@ -369,6 +433,9 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } else { rows.push(row('providers', 'info', 'claude-only (default host)')); } + if (!cfg.providers?.hosts?.opencode && await have('opencode')) { + rows.push(row('providers', 'info', 'opencode CLI installed but not enabled (`ak setup --opencode` wires it)')); + } } else { const desired = managedEnv(cfg); const envDrift = MANAGED_ENV_KEYS.some((k) => (k in desired ? env[k] !== desired[k] : k in env)); @@ -552,6 +619,10 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { `managed Codex ${codexLine.preset} native status line is current (rich ruflo/SONA/AQE segments remain Claude-only)`)); } } + if (cfg.providers?.hosts?.opencode) { + rows.push(row('statusline', 'info', + 'opencode has no statusline surface; its ruflo lifecycle ships via the plugins/ bridge + AGENTS.md')); + } // qe-court (ADR-124): TEMPORARY, remove once fixed upstream. agentic-qe's own // shipped default config.json violates its own writerIsNeverJuror invariant diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 92c92d8..936b6af 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -4,9 +4,11 @@ import path from 'node:path'; import { collect } from './status.mjs'; import * as heal from '../lib/heal.mjs'; +import { have } from '../lib/exec.mjs'; import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; +import { applyOpencode, deployPlugin, syncAgents, deploySkill, catalogSource } from '../lib/opencode.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; @@ -186,6 +188,25 @@ export async function run({ flags, pkgRoot }) { 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 + // 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). + if (subsystems.has('opencode') && cfg.providers?.hosts?.opencode) { + if (!(await have('opencode'))) { + info('opencode: enabled but CLI not installed — wiring skipped (hosts step installs it)'); + } else { + const oc = await applyOpencode(cfg); + if (oc.changed) saveKitConfig(cfg); // persist opencodeMcp/opencodeManaged markers + if (oc.changed || !oc.ok) report('opencode', oc); + report('opencode plugin', deployPlugin({ pkgRoot })); + const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + report('opencode agents', syncAgents({ source })); + const sk = deploySkill({ source }); + if (sk.changed || !sk.ok) report('opencode skill', sk); + } + } if (subsystems.has('providers') || subsystems.has('routing') || subsystems.has('codex-mcp')) { report('providers', applyHosts(cfg, cwd)); // heal per-activity routing: seed from defaults if dual-host only just became diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 2f75747..690aa9e 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -9,6 +9,7 @@ import readline from 'node:readline/promises'; import { run as runCmd } from '../lib/exec.mjs'; import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs'; import { unregister } from '../lib/mcp.mjs'; +import { undoOpencode, removeArtifacts } from '../lib/opencode.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { present as rbPresent } from '../lib/ruvnet-brain.mjs'; import * as paths from '../lib/paths.mjs'; @@ -58,6 +59,10 @@ const confirm = async (q, yes) => { export async function run({ flags }) { const dry = flags['dry-run']; const act = (msg, fn) => { if (dry) info(`[dry-run] ${msg}`); else { fn(); ok(msg); } }; + // Ownership markers are read ONCE up front: the purge path removes kit.json + // below, and teardown decisions (opencode undo) must still see what ak owned + // (codex-review — purge ordering must not strand managed opencode.json keys). + const cfg = loadKitConfig(); const kitCfg = loadKitConfig(); @@ -104,6 +109,33 @@ export async function run({ flags }) { act('removed kit.json', () => fs.rmSync(paths.kitConfigPath())); } + // 2b. opencode host footprint (when ak managed it): strip the guidance blocks + // from opencode's AGENTS.md, the opencode.json wiring, and deployed artifacts. + const ocMd = paths.opencodeAgentsMdPath(); + if (fs.existsSync(ocMd)) { + let content = fs.readFileSync(ocMd, 'utf8'); + const slugs = new Set([...content.matchAll(//g)].map((m) => m[1])); + if (slugs.size) { + act(`stripped ${slugs.size} managed block(s) from opencode AGENTS.md (backup written)`, () => { + fs.copyFileSync(ocMd, `${ocMd}.bak.${Date.now()}`); + for (const s of slugs) content = stripBlock(content, s); + fs.writeFileSync(ocMd, content); + }); + } + } + { + // cfg comes from the top of run() (read before any purge of kit.json) + if (cfg.providers?.opencodeMcp === 'ak') { + act('stripped ak-managed opencode.json wiring (mcp/skills/permissions)', () => { + undoOpencode(cfg); + saveKitConfig(cfg); + }); + } + if (fs.existsSync(paths.opencodeDir())) { + act('removed ak-deployed opencode artifacts (plugin/agents/skill)', () => removeArtifacts({})); + } + } + // 3. MCP registration + deny rules if (dry) info('[dry-run] unregister claude-flow/ruflo MCP + clean deny rules'); else { const removed = await unregister(); ok(`MCP unregistered (deny rules cleaned: ${removed})`); } diff --git a/src/commands/x/provider.mjs b/src/commands/x/provider.mjs index c0e52e5..f5b7077 100644 --- a/src/commands/x/provider.mjs +++ b/src/commands/x/provider.mjs @@ -16,7 +16,9 @@ import { } from '../../lib/providers.mjs'; import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; +import { undoOpencode, removeArtifacts } from '../../lib/opencode.mjs'; import { ok, warn, fail, info, dim, bold, yellow } from '../../lib/output.mjs'; +import { installedVersion, cmpVersions } from '../../lib/versions.mjs'; import { repoRoot } from '../../lib/paths.mjs'; import { writeJsonWithBackup } from '../../lib/settings.mjs'; import { panelFromRouting, validatePanel, readQeCourtConfig, qeCourtConfigPath, vendorOf, qeCourtShipped } from '../../lib/qeCourt.mjs'; @@ -281,13 +283,17 @@ async function off({ cwd }) { const cfg = loadKitConfig(); const codexMcpManaged = cfg.providers?.codexMcp === 'ak'; const rufloCodexManaged = cfg.providers?.rufloCodexMcp === 'ak'; - cfg.providers = { hosts: { claude: true, codex: false }, primaryHost: 'claude', aqeProvider: null, aqeFallback: [], models: [], maxBudgetUsd: null, dualRouting: {}, codexMcp: null, rufloCodexMcp: null }; + // opencode teardown reads the ownership markers from cfg — strip BEFORE the + // reset below clears them (mirrors the codex managed-flag captures above). + const oc = undoOpencode(cfg); + const art = removeArtifacts({}); + cfg.providers = { hosts: { claude: true, codex: false, opencode: false }, primaryHost: 'claude', aqeProvider: null, aqeFallback: [], models: [], maxBudgetUsd: null, dualRouting: {}, codexMcp: null, rufloCodexMcp: null, opencodeMcp: null, opencodeManaged: null }; saveKitConfig(cfg); const env = undoProviders(cwd); const router = undoAqeRouter(cwd); const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); - ok(`reset to claude-only default — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}`); + ok(`reset to claude-only default — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}; opencode: ${oc.detail}; ${art.detail}`); return 0; } @@ -342,6 +348,11 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv async function pick({ flags, cwd }) { const cfg = loadKitConfig(); const hosts = await detectHosts(cwd); + // pick is the claude↔codex ROUTING chooser: opencode (config-file wiring, + // ADR-0015) is deliberately NOT a choice here — selecting it would have no + // effect (routing seeds/projection cover only claude+codex). Its enablement + // flag is preserved verbatim in the cfg write below instead. + const PICKABLE = new Set(HOSTS.filter((h) => h.id !== 'opencode').map((h) => h.id)); let enabled; let aqeProvider = cfg.providers.aqeProvider ?? null; // A legacy chain written before provenance existed reads as 'user': we cannot @@ -368,7 +379,7 @@ async function pick({ flags, cwd }) { } if (flags.provider !== undefined) models = parseModels(flags.provider); } else { - const installed = HOSTS.filter((h) => hosts[h.id].present).map((h) => h.id); + const installed = HOSTS.filter((h) => hosts[h.id].present && PICKABLE.has(h.id)).map((h) => h.id); if (installed.length === 0) { fail('no frontier CLI (claude/codex) found on PATH'); return 1; } console.log(`Installed hosts: ${installed.join(', ')}`); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -389,9 +400,13 @@ async function pick({ flags, cwd }) { rl.close(); } - // validate hosts + // validate hosts (opencode is not pickable — see PICKABLE above) const known = new Set(HOSTS.map((h) => h.id)); - enabled = enabled.filter((h) => known.has(h)); + enabled = enabled.filter((h) => { + if (!known.has(h)) return false; + if (!PICKABLE.has(h)) { warn(`'${h}' is not a routing host — its kit.json flag is preserved, not changed by pick`); return false; } + return true; + }); if (!enabled.includes('claude') && !enabled.includes('codex')) enabled = ['claude']; // primary host — which host leads (default claude); must be an enabled host. let primaryHost = prevPrimary; @@ -433,13 +448,24 @@ async function pick({ flags, cwd }) { } cfg.providers = { - hosts: { claude: enabled.includes('claude'), codex: enabled.includes('codex') }, + hosts: { + claude: enabled.includes('claude'), + codex: enabled.includes('codex'), + // pick is the claude↔codex ROUTING tool; the opencode host (config-file + // wiring, ADR-0015) is not a routing choice — preserve it verbatim so a + // pick never silently unwires it. + opencode: cfg.providers?.hosts?.opencode ?? false, + }, aqeProvider, aqeFallback, models, primaryHost, maxBudgetUsd: cfg.providers.maxBudgetUsd ?? null, dualRouting: reseedForPrimary ? {} : { ...oldPolicy }, + // opencode ownership markers survive too (teardown contract). + opencodeMcp: cfg.providers?.opencodeMcp ?? null, + opencodeManaged: cfg.providers?.opencodeManaged ?? null, + opencodeCatalogDir: cfg.providers?.opencodeCatalogDir ?? null, }; // dual-host: seed per-activity routing from defaults (only when the policy is // empty), then layer any explicit --route overrides on top (marked user, never diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index b8399d3..f09a6ab 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -16,7 +16,7 @@ // `blocksForTarget(rows, name)`. Logical names only; paths stay a caller concern. import fs from 'node:fs'; import path from 'node:path'; -import { claudeDir, claudeMdPath, codexDir, home } from './paths.mjs'; +import { claudeDir, claudeMdPath, codexDir, opencodeDir, home } from './paths.mjs'; import { have } from './exec.mjs'; export const BEGIN = (slug) => ``; @@ -27,10 +27,14 @@ export const END = (slug) => ``; * where the block lands when it is NOT already present in the file. */ export const BUILTIN_BLOCKS = [ { + // Host-agnostic operating rules — shared by the claude + opencode machine + // guidance files (the opencode file exists BECAUSE opencode prefers it over + // falling back to ~/.claude/CLAUDE.md, so it needs its own copy). slug: 'ruflo-preamble', template: 'ruflo-preamble.md', position: 'prepend', detector: { type: 'always' }, + guidanceFiles: ['claude', 'agents-opencode'], }, { slug: 'ruflo-reference', @@ -38,6 +42,28 @@ export const BUILTIN_BLOCKS = [ position: 'append', detector: { type: 'always' }, }, + { + // opencode's ruflo surface: MCP tools are `claude-flow_*` (not + // `mcp__claude-flow__*`), hooks arrive via the plugins/ bridge, and agents + // are converted subagents — a different enough story to warrant its own + // template rather than reusing ruflo-reference. Gated on the opencode CLI + // being on PATH (mirrors the codex/aqe command detectors). + slug: 'ruflo-opencode-reference', + template: 'ruflo-opencode-reference.md', + position: 'append', + detector: { type: 'command', target: 'opencode' }, + guidanceFiles: ['agents-opencode'], + }, + { + // opencode twin of ruvnet-brain-reference (that slug stays claude-only): + // same ground-before-assert rule, but the tool name is the opencode-style + // `ruvnet-brain_search_ruvnet` and updates ride the stable-spine shim. + slug: 'ruvnet-brain-opencode-reference', + template: 'ruvnet-brain-opencode-reference.md', + position: 'append', + detector: { type: 'dir', target: '~/.cache/ruvnet-brain/kb' }, + guidanceFiles: ['agents-opencode'], + }, { slug: 'ruflo-aqe-reference', template: 'aqe-reference.md', @@ -240,10 +266,14 @@ export function retiredForTarget(rows, targetName) { * this discovery (dir-exists gate, no mkdir). That single gate covers both cases: * a codex machine that is momentarily single-host still gets the target (so a * stale block can be stripped), and a codex-less machine never grows a ~/.codex. - * `cfg` is accepted for call-site symmetry/forward-compat; the target set is - * cfg-independent today. `codexRoot` is a test seam (defaults to the real dir). - * @param {{ cwd?: string, cfg?: object, codexRoot?: string }} opts */ -export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir() } = {}) { + * `~/.config/opencode/AGENTS.md` (agents-opencode) follows the identical rule + * (opencode's config home is its presence signal; opencode prefers this file + * over ~/.claude/CLAUDE.md, so it needs its own managed copy rather than + * inheriting claude's). `cfg` is accepted for call-site symmetry/forward-compat; + * the target set is cfg-independent today. `codexRoot`/`opencodeRoot` are test + * seams (default to the real dirs). + * @param {{ cwd?: string, cfg?: object, codexRoot?: string, opencodeRoot?: string }} opts */ +export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir(), opencodeRoot = opencodeDir() } = {}) { const targets = [ { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, @@ -251,6 +281,9 @@ export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir() } if (fs.existsSync(codexRoot)) { targets.push({ name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') }); } + if (fs.existsSync(opencodeRoot)) { + targets.push({ name: 'agents-opencode', label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') }); + } return targets; } diff --git a/src/lib/config.mjs b/src/lib/config.mjs index b5e2561..8e47592 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -19,13 +19,16 @@ const DEFAULTS = { // Default = claude-only, codex opt-in — preserves today's behavior exactly: // when this stays at defaults, the provider heal is a deliberate no-op. providers: { - hosts: { claude: true, codex: false }, // which agent CLIs ruflo may run (ADR-034 ENABLE_*) + hosts: { claude: true, codex: false, opencode: false }, // which agent CLIs ak wires (ADR-034 ENABLE_*; opencode has no enable-env — wiring is config-file based) aqeProvider: null, // AQE_LLM_PROVIDER (claude-code|openai|gemini|…); null = aqe default aqeFallback: [], // [{ provider, models:[...] }] — ordered aqe fallback chain (.agentic-qe/llm-config.json) models: [], // [{ id:'openai', model:'gpt-5.6' }] — ruflo API-key providers maxBudgetUsd: null, // → AQE_MAX_BUDGET_USD when set dualRouting: {}, // activity → {host,model,escalate?,source} per-activity routing policy (ADR-0001; seeded on dual-host) codexMcp: null, // 'ak' when ak registered the codex MCP server (ownership guard for teardown) + opencodeMcp: null, // 'ak' when ak wrote opencode.json's mcp/skills/permission wiring (ownership guard for teardown) + opencodeManaged: null, // {mcp[], paths[], permissions[]} — exact opencode.json keys ak wrote (surgical teardown) + opencodeCatalogDir: null, // optional override: ruflo repo checkout used as the agents/skills catalog source (default: marketplace clone → npm package) }, statusline: { codex: null }, // {preset,lastProjection}: explicit ownership of Codex [tui] keys customBlocks: [], // [{slug, templatePath, detector:{type:'command'|'dir'|'file', target}}] diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index 3dda81a..45c0484 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -66,7 +66,7 @@ export const JS = ` var TABS=["overview","hosts","providers","runtime","intel","usage","live"]; var VIEWS=["score","limits","findings","sessions","transcript"]; var CAT={ - hosts:"hosts", mcp:"hosts", "codex-mcp":"hosts", routing:"hosts", + hosts:"hosts", mcp:"hosts", "codex-mcp":"hosts", opencode:"hosts", routing:"hosts", providers:"providers", learning:"intel", "ruvnet-brain":"intel", "ruvnet-brain-nightly":"intel", aqe:"intel", agentdb:"intel", ruvector:"intel" @@ -144,7 +144,7 @@ export const JS = ` // severity rank for rollups + triage sort; preferred order breaks ties. var RANK={fail:3,warn:2,ok:1,info:0,unknown:0}; - var PREF=["versions","self","natives","security","learning","providers","hosts","routing","mcp","codex-mcp","ruvnet-brain","ruvnet-brain-nightly","ruvector","aqe","daemons","blocks","statusline","npx"]; + var PREF=["versions","self","natives","security","learning","providers","hosts","routing","mcp","codex-mcp","opencode","ruvnet-brain","ruvnet-brain-nightly","ruvector","aqe","daemons","blocks","statusline","npx"]; // Collapse rows into one group per subsystem (kills repeated labels); the // group's level is the worst of its rows. Sort worst-first, then by PREF. diff --git a/src/lib/nudge.mjs b/src/lib/nudge.mjs index 7d83031..a852053 100644 --- a/src/lib/nudge.mjs +++ b/src/lib/nudge.mjs @@ -15,7 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; import * as paths from './paths.mjs'; -import { registry, syncBlocks, blocksForTarget } from './blocks.mjs'; +import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from './blocks.mjs'; import { loadKitConfig } from './config.mjs'; import { bothHostsEnabled } from './providers.mjs'; import { codexMcpStatus, rufloCodexMcpStatus } from './mcp.mjs'; @@ -41,12 +41,14 @@ export async function localDrift({ pkgRoot, cwd = process.cwd(), cfg, targets } ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) : path.join(pkgRoot, 'claude', r.template)); const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; - const tgs = targets ?? [ - { name: 'claude', label: 'CLAUDE.md', file: paths.claudeMdPath() }, - { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, - ]; + // The SHARED target list + retired-strip composition (blocks.mjs) — the + // nudge's contract is "never disagrees with ak status", which a hardcoded + // subset silently breaks every time a guidance target is added (codex's + // agents-user, opencode's agents-opencode). + const tgs = targets ?? guidanceTargets({ cwd, cfg }); for (const t of tgs) { - const res = await syncBlocks(t.file, blocksForTarget(rowsReg, t.name), resolve, { dryRun: true, context: ctx }); + const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; + const res = await syncBlocks(t.file, treg, resolve, { dryRun: true, context: ctx }); const n = res.filter((r) => r.action === 'upserted' || r.action === 'stripped').length; if (n) lines.push(`${n} ${t.label} block(s)`); } diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs new file mode 100644 index 0000000..5980b9a --- /dev/null +++ b/src/lib/opencode.mjs @@ -0,0 +1,687 @@ +// opencode host integration — the third host adapter's I/O half. +// +// why: opencode (opencode.ai) consumes the same rUv stack as claude/codex but +// through different surfaces. This module owns every ak-managed byte on those +// surfaces, backup-first + merge-not-clobber + ownership-marked, mirroring the +// claude (settings.mjs / mcp.mjs) and codex (providers.mjs reverse bridge) +// contracts: +// +// ~/.config/opencode/opencode.json mcp.claude-flow + 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/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). +// - 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 +// registers for claude/codex) when that bin is absent. +// - ruvnet-brain's stable-spine shim (~/.claude/ruvnet-brain/mcp/server.mjs) +// hot-swaps brain versions — the registration never needs rewriting. +// - opencode.json may legally contain JSONC comments ($schema allowComments): +// a file we cannot parse is REFUSED, never clobbered. +import fs from 'node:fs'; +import path from 'node:path'; +import { have } from './exec.mjs'; +import { readJson, writeJsonWithBackup } from './settings.mjs'; +import * as paths from './paths.mjs'; + +// ── config-file wiring (opencode.json) ────────────────────────────────────── + +/** ruflo init/mcp-generator.ts's env block, mirrored for parity. */ +export const RUFLO_MCP_ENV = { + npm_config_update_notifier: 'false', + CLAUDE_FLOW_MODE: 'v3', + CLAUDE_FLOW_HOOKS_ENABLED: 'true', + CLAUDE_FLOW_TOPOLOGY: 'hierarchical-mesh', + CLAUDE_FLOW_MAX_AGENTS: '15', + CLAUDE_FLOW_MEMORY_BACKEND: 'hybrid', +}; + +/** Permission patterns ak pre-approves (opencode surfaces MCP tools as + * `_`; cover both separator spellings defensively). */ +export const PERMISSION_KEYS = ['claude-flow_*', 'claude_flow_*', 'ruvnet-brain_*', 'ruvnet_brain_*']; + +/** The brain's stable-spine shim (same registration codex carries). */ +export const brainShimPath = () => path.join(paths.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs'); + +/** @typedef {{ kind: string, root: string, id: string, hasPlugins: boolean, hasPlatformSkill: boolean }} CatalogSource */ + +/** The MCP server entries ak writes. `claude-flow` prefers the dedicated + * claude-flow-mcp bin (purpose-built stdio server) and falls back to + * `ruflo mcp start` (ak's claude/codex registration) when it is absent. + * ruvnet-brain is included only when its shim is on disk. + * @param {{ brainShim?: string }} [opts] */ +export async function mcpEntriesFor({ brainShim = brainShimPath() } = {}) { + const entries = { + 'claude-flow': { + type: 'local', + command: (await have('claude-flow-mcp')) ? ['claude-flow-mcp'] : ['ruflo', 'mcp', 'start'], + enabled: true, + timeout: 30000, + environment: { ...RUFLO_MCP_ENV }, + }, + }; + if (fs.existsSync(brainShim)) { + entries['ruvnet-brain'] = { type: 'local', command: ['node', brainShim], enabled: true, timeout: 30000 }; + } + return entries; +} + +/** Strict read: distinguishes "absent/empty" from "present but not plain JSON" + * (opencode.json may legally be JSONC). NB: settings.readJson's fallback + * parameter can't express this — passing undefined re-triggers its default. */ +function readJsonStrict(file) { + try { + const raw = fs.readFileSync(file, 'utf8'); + if (!raw.trim()) return { ok: true, doc: {} }; + return { ok: true, doc: JSON.parse(raw) }; + } catch { + return { ok: false, doc: 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 { ok, doc } = exists ? readJsonStrict(configFile) : { ok: true, doc: {} }; + if (!ok) { + return { exists, parseError: true, claudeFlow: false, brain: false, owned: cfg?.providers?.opencodeMcp === 'ak' }; + } + return { + exists, + parseError: false, + claudeFlow: !!doc?.mcp?.['claude-flow'], + brain: !!doc?.mcp?.['ruvnet-brain'], + paths: doc?.skills?.paths ?? [], + owned: cfg?.providers?.opencodeMcp === 'ak', + }; +} + +/** Convergence check — deeper than key existence (codex-review #16): the MCP + * entries must EQUAL today's desired values (command/env/timeout drift when + * the user edits them or a kit upgrade changes the template), desired skills + * paths must all be present, desired permission patterns must be 'allow', and + * a ruvnet-brain entry whose shim has vanished is stale. Async because the + * desired entries probe the claude-flow-mcp bin (one `which`, matching the + * spawn profile of status's hosts rows). + * @param {any} cfg @param {{ configFile?: string, brainShim?: string }} [opts] */ +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'] }; + const doc = readJsonStrict(configFile).doc; + const reasons = []; + const entries = await mcpEntriesFor({ brainShim }); + 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 source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + for (const p of skillPathsFor(source)) { + if (!(doc.skills?.paths ?? []).includes(p)) reasons.push(`skills path missing: ${p}`); + } + for (const k of PERMISSION_KEYS) { + if (doc.permission?.[k] !== 'allow') reasons.push(`permission ${k} not allowed`); + } + return { converged: reasons.length === 0, reasons }; +} + +/** Order-insensitive deep compare (JSON with sorted keys). */ +function deepEqual(a, b) { + const stable = (v) => JSON.stringify(v, (k, x) => ( + x && typeof x === 'object' && !Array.isArray(x) + ? Object.fromEntries(Object.entries(x).sort(([p], [q]) => p.localeCompare(q))) + : x + )); + return stable(a) === stable(b); +} + +/** Normalize an opencodeManaged record — the current precise shape + * { mcp: {name:{prior,written}}, paths: [], permissions: {key:{prior,written}} }, + * tolerating the legacy names-only shape from the first shipped version + * (legacy entries have unknown prior/written → treated conservatively: prior + * null, written null → never auto-deleted, only re-recorded on next apply). */ +function normalizeManaged(m) { + const out = { mcp: {}, paths: [], permissions: {} }; + if (!m || typeof m !== 'object') return out; + const legacyNames = Array.isArray(m.mcp) ? m.mcp : Object.keys(m.mcp ?? {}); + for (const n of legacyNames) { + const rec = Array.isArray(m.mcp) ? null : m.mcp[n]; + out.mcp[n] = rec && typeof rec === 'object' && 'written' in rec ? rec : { prior: null, written: null }; + } + out.paths = Array.isArray(m.paths) ? [...m.paths] : []; + const permKeys = Array.isArray(m.permissions) ? m.permissions : Object.keys(m.permissions ?? {}); + for (const k of permKeys) { + const rec = Array.isArray(m.permissions) ? null : m.permissions[k]; + out.permissions[k] = rec && typeof rec === 'object' && 'written' in rec ? rec : { prior: null, written: null }; + } + return out; +} + +/** 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: + * - a pre-existing entry that DIFFERS from ak's desired value (and was not + * previously ak-written) is a COLLISION: preserved, reported, never taken + * over — merge-not-clobber applies to values, not just files; + * - every key ak writes is recorded as {prior, written} so teardown can + * restore the user's original value instead of deleting it; + * - previously-managed keys that fall OUT of the desired set (brain shim + * removed, catalog source changed) are removed only while they still equal + * what ak wrote — a user-edited value is left and reported. + * Scalar `permission` ("permission":"allow") is first lifted to its + * documented object equivalent {"*":"allow"} (wildcard key semantics), never + * spread character-by-character. Backup-first, idempotent, JSONC-refusing. + * @param {any} cfg @param {{ dryRun?: boolean, configFile?: string, brainShim?: string }} [opts] */ +export async function applyOpencode(cfg, { dryRun = false, configFile = paths.opencodeConfigPath(), brainShim } = {}) { + if (!cfg.providers?.hosts?.opencode) return { ok: true, changed: false, detail: 'opencode not enabled — unmanaged' }; + const exists = fs.existsSync(configFile); + const { ok: parsedOk, doc } = exists ? readJsonStrict(configFile) : { ok: true, doc: {} }; + if (!parsedOk) { + return { ok: false, changed: false, detail: `${configFile} is not plain JSON (JSONC comments?) — refusing to touch it; merge manually` }; + } + const entries = await mcpEntriesFor({ brainShim }); + const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + const skillPaths = skillPathsFor(source); + const prevManaged = normalizeManaged(cfg.providers?.opencodeManaged); + const collisions = []; + const pruned = []; + + const next = JSON.parse(JSON.stringify(doc)); + next.$schema ??= 'https://opencode.ai/config.json'; + + // ── mcp: prune stale ak entries, then merge desired with collision refusal ── + next.mcp = { ...(next.mcp ?? {}) }; + for (const [name, rec] of Object.entries(prevManaged.mcp)) { + if (name in entries) continue; + if (!(name in next.mcp)) continue; + if (rec.written && deepEqual(next.mcp[name], rec.written)) { + delete next.mcp[name]; + pruned.push(name); + } // else: user edited (or legacy record) → leave it, keep no ownership + } + const managed = { mcp: {}, paths: [], permissions: {} }; + for (const [name, want] of Object.entries(entries)) { + const cur = next.mcp[name]; + const priorRec = prevManaged.mcp[name]; + if (cur !== undefined && !deepEqual(cur, want) && !(priorRec?.written && deepEqual(cur, priorRec.written))) { + collisions.push(`mcp.${name}`); + managed.mcp[name] = { prior: cur, written: null }; // tracked but NOT ak-owned + continue; + } + // prior is the ORIGINAL pre-ak value (kept across reapplies), never the + // ak-written value currently in place. + managed.mcp[name] = { prior: priorRec ? priorRec.prior : (cur ?? null), written: want }; + next.mcp[name] = want; + } + + // ── skills.paths: remove stale ak-added paths, add desired ── + if (next.skills?.paths && prevManaged.paths.length) { + const stale = new Set(prevManaged.paths.filter((p) => !skillPaths.includes(p))); + next.skills.paths = next.skills.paths.filter((p) => !stale.has(p)); + if (stale.size) pruned.push(`${stale.size} stale skills path(s)`); + } + if (skillPaths.length) { + next.skills = { ...(next.skills ?? {}) }; + const cur = new Set(next.skills.paths ?? []); + const newlyAdded = skillPaths.filter((p) => !cur.has(p)); + // ownership = previously-recorded ak paths that are still desired + newly + // added ones (a re-apply must not erase the record of what ak added). + managed.paths = [...new Set([...prevManaged.paths.filter((p) => skillPaths.includes(p)), ...newlyAdded])]; + next.skills.paths = [...cur, ...newlyAdded]; + } + + // ── permission: lift scalar shorthand, prune stale, merge desired ── + if (typeof next.permission === 'string') next.permission = { '*': next.permission }; + next.permission = { ...(next.permission ?? {}) }; + for (const [k, rec] of Object.entries(prevManaged.permissions)) { + if (PERMISSION_KEYS.includes(k)) continue; + if (!(k in next.permission)) continue; + if (rec.written && deepEqual(next.permission[k], rec.written)) { + delete next.permission[k]; + pruned.push(`permission.${k}`); + } + } + for (const k of PERMISSION_KEYS) { + 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'; + } + + const changed = JSON.stringify(next) !== JSON.stringify(doc); + if (cfg.providers) { + cfg.providers.opencodeMcp = 'ak'; + cfg.providers.opencodeManaged = managed; + } + if (changed && !dryRun) writeJsonWithBackup(configFile, next); + const brain = entries['ruvnet-brain'] ? ' + ruvnet-brain' : ' (brain shim absent — ruflo only)'; + 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)` + : `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, changed, detail: notes.join(' — ') }; +} + +/** Surgical teardown of ak's opencode.json wiring — ONLY the recorded managed + * keys, and ONLY when ak wrote them (providers.opencodeMcp === 'ak'). For each + * managed key: when the current value still equals what ak wrote, the user's + * PRIOR value is restored (or the key removed if there was none); a value the + * user edited since is left and reported, never silently deleted. Scalar + * permission shorthand is restored to scalar when teardown empties the object + * but a prior '*' wildcard exists. Deployed artifacts are removed separately + * (removeArtifacts). + * @param {any} cfg @param {{ configFile?: string }} [opts] */ +export function undoOpencode(cfg, { configFile = paths.opencodeConfigPath() } = {}) { + if (cfg?.providers?.opencodeMcp !== 'ak') { + return { ok: true, changed: false, detail: 'opencode.json left as-is (not ak-managed)' }; + } + const managed = normalizeManaged(cfg.providers?.opencodeManaged); + if (!fs.existsSync(configFile)) return { ok: true, changed: false, detail: 'opencode.json absent — nothing to strip' }; + const { ok: parsedOk, doc } = readJsonStrict(configFile); + if (!parsedOk) return { ok: true, changed: false, detail: 'opencode.json unparseable — nothing to strip (refusing to touch JSONC)' }; + const kept = []; + let changed = false; + + const restore = (obj, key, rec, label) => { + if (!obj || !(key in obj)) return; + if (rec.written == null) { kept.push(`${label} (not ak-written)`); return; } + if (!deepEqual(obj[key], rec.written)) { kept.push(`${label} (edited since ak wrote it)`); return; } + if (rec.prior == null) delete obj[key]; + else obj[key] = rec.prior; + changed = true; + }; + + for (const [name, rec] of Object.entries(managed.mcp)) restore(doc.mcp, name, rec, `mcp.${name}`); + if (doc.mcp && Object.keys(doc.mcp).length === 0) delete doc.mcp; + + if (doc.skills?.paths && managed.paths.length) { + const drop = new Set(managed.paths); + const keptPaths = doc.skills.paths.filter((p) => !drop.has(p)); + if (keptPaths.length !== doc.skills.paths.length) { + changed = true; + if (keptPaths.length) doc.skills.paths = keptPaths; + else { delete doc.skills.paths; if (Object.keys(doc.skills).length === 0) delete doc.skills; } + } + } + + const hadScalarStar = typeof doc.permission === 'object' && doc.permission?.['*'] != null; + for (const [k, rec] of Object.entries(managed.permissions)) restore(doc.permission, k, rec, `permission.${k}`); + if (doc.permission && Object.keys(doc.permission).length === 0) delete doc.permission; + else if (doc.permission && hadScalarStar && Object.keys(doc.permission).length === 1 && doc.permission['*'] != null) { + doc.permission = doc.permission['*']; // restore the scalar shorthand we lifted + changed = true; + } + + if (changed) writeJsonWithBackup(configFile, doc); + if (cfg.providers) { cfg.providers.opencodeMcp = null; cfg.providers.opencodeManaged = null; } + const detail = [ + changed ? 'ak-managed opencode.json wiring stripped (user priors restored)' : 'nothing managed found in opencode.json', + kept.length ? `left untouched: ${kept.join(', ')}` : null, + ].filter(Boolean).join(' — '); + return { ok: true, changed, detail }; +} + +// ── ruflo catalog source (agents + skills) ────────────────────────────────── + +/** Resolve where ruflo's agent/skill catalog comes from. Order: explicit + * override (kit.json providers.opencodeCatalogDir) → $RUFLO_REPO → the claude + * marketplace clone (full repo mirror, auto-updated by claude) → the + * published @claude-flow/cli package (subset: ADR-128 agents + core skills) + * → the nested copy under global ruflo/node_modules (the layout a plain + * `npm i -g ruflo` actually produces). Candidates are LAZY thunks: the + * npm-root lookups spawn `npm root -g` (cached per process), so evaluating + * them only when earlier candidates miss keeps status probes spawn-free on + * marketplace machines. Returns {kind, root, id, hasPlugins, hasPlatformSkill} + * or null. + * @param {{ override?: string }} [opts] + * @returns {CatalogSource|null} */ +export function catalogSource({ override } = {}) { + const candidates = []; + if (override) candidates.push(() => ({ kind: 'override', root: override })); + if (process.env.RUFLO_REPO) candidates.push(() => ({ kind: 'env', root: process.env.RUFLO_REPO })); + candidates.push(() => ({ kind: 'marketplace', root: paths.rufloMarketplaceRoot() })); + candidates.push(() => ({ kind: 'npm', root: paths.rufloCliPkgRoot() })); + candidates.push(() => ({ kind: 'npm-nested', root: path.join(paths.rufloNodeModules(), '@claude-flow', 'cli') })); + for (const thunk of candidates) { + const c = thunk(); + if (!c.root || !fs.existsSync(path.join(c.root, '.claude', 'agents'))) continue; + let version = null; + try { version = JSON.parse(fs.readFileSync(path.join(c.root, 'package.json'), 'utf8')).version; } catch { /* unversioned source */ } + return { + ...c, + id: `${c.kind}@${version ?? 'unversioned'}`, + hasPlugins: fs.existsSync(path.join(c.root, 'plugins')), + hasPlatformSkill: fs.existsSync(path.join(c.root, 'SKILL.md')), + }; + } + return null; +} + +/** skills.paths entries for a catalog source (existing dirs only). + * @param {CatalogSource|null} source */ +export function skillPathsFor(source) { + if (!source) return []; + const out = [path.join(source.root, '.claude', 'skills')]; + if (source.hasPlugins) out.push(path.join(source.root, 'plugins')); + return out.filter((p) => fs.existsSync(p)); +} + +// ── agent conversion (Claude Code agent .md → opencode subagent .md) ───────── + +/** Ownership markers on generated agent files — the current ak marker plus the + * legacy standalone-script marker, so the one-time script's output is adopted + * (removed/replaced) rather than orphaned. */ +const AGENT_MARKERS = ['generated-by: agentic-kit', 'generated-by: sync-ruflo-agents.mjs']; +const STAMP_FILE = '.ak-agents-stamp.json'; + +function* walkMd(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) yield* walkMd(p); + else if (e.isFile() && e.name.endsWith('.md')) yield p; + } +} + +/** Minimal YAML frontmatter reader: scalar fields + block scalars + * (description: | / >). A block scalar's content is every following line that + * is INDENTED, with blank lines allowed inside — terminating at the first + * blank (they'd otherwise truncate multi-paragraph descriptions and leak the + * remainder into mis-parsed fields). */ +function parseFrontmatter(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) return null; + const [, fm, body] = m; + const fields = {}; + const lines = fm.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const km = lines[i].match(/^([A-Za-z_][\w-]*):\s*(.*)$/); + if (!km) continue; + const [, key, raw] = km; + if (/^[>|]-?$/.test(raw) && i + 1 < lines.length) { + const buf = []; + while (i + 1 < lines.length && (lines[i + 1].trim() === '' || /^\s+\S/.test(lines[i + 1]))) { + const t = lines[++i].trim(); + if (t) buf.push(t); + } + fields[key] = buf.join(' ').trim(); + } else { + fields[key] = raw.replace(/^["']|["']$/g, '').trim(); + } + } + return { fields, body }; +} + +const collapse = (s) => String(s ?? '').replace(/\s+/g, ' ').trim(); + +/** 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_); + * 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) { + const srcDir = path.join(srcRoot, '.claude', 'agents'); + const agents = []; + let scanned = 0; + for (const file of walkMd(srcDir)) { + scanned++; + const parsed = parseFrontmatter(fs.readFileSync(file, 'utf8')); + if (!parsed) continue; + // documentation masquerading as an agent (e.g. MIGRATION_SUMMARY.md) + if (collapse(parsed.fields.type).toLowerCase() === 'documentation') continue; + const description = collapse(parsed.fields.description); + if (!description) continue; + const rel = path.relative(srcDir, file); + const dir = path.dirname(rel) === '.' ? null : path.dirname(rel).split(path.sep)[0]; + agents.push({ + 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_'), + }); + } + const seen = new Set(); + let renamed = 0; + for (const a of agents) { + let name = a.base; + if (seen.has(name)) { name = a.dir ? `${a.dir}-${a.base}` : `${a.base}-x`; renamed++; } + let n = 2; + while (seen.has(name)) name = `${a.dir ?? 'agent'}-${a.base}-${n++}`; + seen.add(name); + a.name = name; + a.content = `---\ndescription: ${JSON.stringify(a.description)}\nmode: subagent\n---\n\n\n${a.body}`; + } + return { agents, scanned, skipped: scanned - agents.length, renamed }; +} + +const isGeneratedContent = (text) => AGENT_MARKERS.some((m) => text.includes(m)); + +/** Reconcile the converted agent set into the dest dir: rewrite generated + * files, remove stale generated ones (either marker), NEVER overwrite a file + * that carries no generated marker (a user-owned agent with a colliding name + * 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 }} opts */ +export function syncAgents({ source, destDir = paths.opencodeAgentsDir(), dryRun = false }) { + if (!source) return { ok: false, changed: false, detail: 'no ruflo catalog source (marketplace clone or @claude-flow/cli) found' }; + const { agents, scanned, skipped, renamed } = convertAgents(source.root); + if (!dryRun) fs.mkdirSync(destDir, { recursive: true }); + let removed = 0, userOwned = 0; + if (fs.existsSync(destDir)) { + for (const f of fs.readdirSync(destDir).filter((f) => f.endsWith('.md'))) { + const p = path.join(destDir, f); + let isGenerated = false; + try { isGenerated = isGeneratedContent(fs.readFileSync(p, 'utf8')); } catch { /* leave alone */ } + const wanted = agents.some((a) => `${a.name}.md` === f); + if (isGenerated && !wanted) { if (!dryRun) fs.rmSync(p); removed++; } + } + } + let written = 0; + const deployed = []; + for (const a of agents) { + const p = path.join(destDir, `${a.name}.md`); + const cur = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; + if (cur !== null && cur !== a.content && !isGeneratedContent(cur)) { userOwned++; continue; } + deployed.push(`${a.name}.md`); + if (cur !== a.content) { + written++; + if (!dryRun) fs.writeFileSync(p, a.content); + } + } + 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. + const stamp = { source: source.id, count: deployed.length, files: deployed.sort() }; + const priorStamp = readJson(path.join(destDir, STAMP_FILE), null); + if (!dryRun && (changed || !priorStamp || JSON.stringify(priorStamp) !== JSON.stringify(stamp))) { + fs.writeFileSync(path.join(destDir, STAMP_FILE), JSON.stringify(stamp, null, 2) + '\n'); + } + return { + ok: true, + changed, + detail: `${agents.length} agents from ${source.id} (${written} written, ${removed} removed, ${skipped} skipped, ${renamed} collision-renamed${userOwned ? `, ${userOwned} user-owned preserved` : ''}; scanned ${scanned})`, + }; +} + +/** Agent-set drift, honestly: stale when the stamp is missing, the catalog + * source id diverged (upgrade/marketplace pull), or the on-disk generated + * file set differs from the stamp (deleted/extra/edited-then-removed files). + * Count reports GENERATED (marker-bearing) agents — user files are not ak's + * to count. Content edits to an existing generated file are detected as + * 'modified' only by name-set comparison's complement, so those surface via + * sync's content-diff rewrite rather than here (documented asymmetry: status + * catches structural drift; sync fixes content drift when it runs). + * @param {{ source?: CatalogSource|null, destDir?: string }} [opts] */ +export function agentsStatus({ source, destDir = paths.opencodeAgentsDir() } = {}) { + const stamp = readJson(path.join(destDir, STAMP_FILE), null); + let generatedCount = 0; + if (fs.existsSync(destDir)) { + for (const f of fs.readdirSync(destDir).filter((f) => f.endsWith('.md'))) { + try { if (isGeneratedContent(fs.readFileSync(path.join(destDir, f), 'utf8'))) generatedCount++; } catch { /* skip */ } + } + } + const onDisk = fs.existsSync(destDir) + ? fs.readdirSync(destDir).filter((f) => { + if (!f.endsWith('.md')) return false; + try { return isGeneratedContent(fs.readFileSync(path.join(destDir, f), 'utf8')); } catch { return false; } + }).sort() + : []; + const stampFiles = Array.isArray(stamp?.files) ? [...stamp.files].sort() : null; + const filesDiverged = stampFiles != null && JSON.stringify(stampFiles) !== JSON.stringify(onDisk); + return { + count: generatedCount, + stampedId: stamp?.source ?? null, + currentId: source?.id ?? null, + stale: !stamp || stamp.source !== (source?.id ?? null) || filesDiverged, + }; +} + +// ── plugin (lifecycle bridge) ──────────────────────────────────────────────── + +export const PLUGIN_NAME = 'ruflo-hooks.js'; +const pluginTemplate = (pkgRoot) => path.join(pkgRoot, 'src', 'templates', 'opencode-ruflo-hooks.js'); + +/** The marker any ak-deployed plugin copy carries (from the template header). */ +const PLUGIN_MARKER = 'src/templates/opencode-ruflo-hooks.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 }} opts */ +export function deployPlugin({ pkgRoot, pluginsDir = paths.opencodePluginsDir(), dryRun = false }) { + 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); + const cur = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null; + if (cur === want) return { ok: true, changed: false, detail: 'lifecycle plugin current' }; + if (cur !== null && !cur.includes(PLUGIN_MARKER)) { + return { ok: true, changed: false, detail: `⚠ ${dest} exists without the ak marker (user-owned) — left untouched` }; + } + if (!dryRun) { + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync(dest, want); + } + return { ok: true, changed: true, detail: cur == null ? 'lifecycle plugin deployed (ruflo-hooks.js)' : 'lifecycle plugin updated (ruflo-hooks.js)' }; +} + +/** 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() }) { + const dest = path.join(pluginsDir, PLUGIN_NAME); + const present = fs.existsSync(dest); + const foreign = present && !fs.readFileSync(dest, 'utf8').includes(PLUGIN_MARKER); + const tpl = pluginTemplate(pkgRoot); + const current = present && !foreign && fs.existsSync(tpl) && fs.readFileSync(dest, 'utf8') === fs.readFileSync(tpl, 'utf8'); + return { present, current, foreign }; +} + +// ── platform skill ─────────────────────────────────────────────────────────── + +const SKILL_DEPLOYED_MARKER = ''; + +/** Deploy ruflo's platform SKILL.md (from the catalog source) into opencode's + * global skills dir, stamped with the source id for drift detection. A + * destination SKILL.md without the ak marker is user-owned: preserved. + * @param {{ source: CatalogSource|null, skillsDir?: string, dryRun?: boolean }} opts */ +export function deploySkill({ source, skillsDir = paths.opencodeSkillsDir(), dryRun = false }) { + if (!source?.hasPlatformSkill) return { ok: true, changed: false, detail: `no platform SKILL.md in catalog source${source ? ` (${source.id})` : ''}` }; + const src = path.join(source.root, 'SKILL.md'); + const dest = path.join(skillsDir, 'ruflo', 'SKILL.md'); + const want = `${fs.readFileSync(src, 'utf8').replace(/\s*$/, '')}\n\n${SKILL_DEPLOYED_MARKER} from ${source.id} — re-synced by \`ak sync\`\n`; + const cur = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null; + if (cur === want) return { ok: true, changed: false, detail: 'platform skill current' }; + if (cur !== null && !cur.includes(SKILL_DEPLOYED_MARKER)) { + return { ok: true, changed: false, detail: `⚠ ${dest} exists without the ak marker (user-owned) — left untouched` }; + } + if (!dryRun) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, want); + } + return { ok: true, changed: true, detail: `platform skill deployed (skills/ruflo/SKILL.md, ${source.id})` }; +} + +/** Platform skill presence/currency against the catalog source id. `foreign` + * flags a user-owned SKILL.md at the destination. + * @param {{ source?: CatalogSource|null, skillsDir?: string }} [opts] */ +export function skillStatus({ source, skillsDir = paths.opencodeSkillsDir() } = {}) { + const dest = path.join(skillsDir, 'ruflo', 'SKILL.md'); + const present = fs.existsSync(dest); + const foreign = present && !fs.readFileSync(dest, 'utf8').includes(SKILL_DEPLOYED_MARKER); + return { + present, + foreign, + current: present && !foreign && !!source?.hasPlatformSkill + && fs.readFileSync(dest, 'utf8').includes(`from ${source.id}`), + }; +} + +/** Remove ak-deployed artifacts (marker-gated — user files are never touched): + * the lifecycle plugin, generated agents (+ stamp), the platform skill's + * SKILL.md. Directories are pruned only when EMPTY after the managed files + * are gone — user resources placed beside them survive. + * @param {{ pluginsDir?: string, agentsDir?: string, skillsDir?: string }} [opts] */ +export function removeArtifacts({ pluginsDir = paths.opencodePluginsDir(), agentsDir = paths.opencodeAgentsDir(), skillsDir = paths.opencodeSkillsDir() } = {}) { + const removed = []; + const rmdirIfEmpty = (dir) => { + try { if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch { /* absent or not empty */ } + }; + const plugin = path.join(pluginsDir, PLUGIN_NAME); + if (fs.existsSync(plugin) && fs.readFileSync(plugin, 'utf8').includes(PLUGIN_MARKER)) { + fs.rmSync(plugin, { force: true }); + removed.push('plugin ruflo-hooks.js'); + } + if (fs.existsSync(agentsDir)) { + let n = 0; + for (const f of fs.readdirSync(agentsDir)) { + const p = path.join(agentsDir, f); + if (f === STAMP_FILE) { fs.rmSync(p, { force: true }); continue; } + if (f.endsWith('.md') && AGENT_MARKERS.some((m) => fs.readFileSync(p, 'utf8').includes(m))) { + fs.rmSync(p, { force: true }); + n++; + } + } + if (n) removed.push(`${n} generated agents`); + } + const skillDir = path.join(skillsDir, 'ruflo'); + const skill = path.join(skillDir, 'SKILL.md'); + if (fs.existsSync(skill) && fs.readFileSync(skill, 'utf8').includes(SKILL_DEPLOYED_MARKER)) { + fs.rmSync(skill, { force: true }); + rmdirIfEmpty(skillDir); + removed.push('platform skill'); + } + return { ok: true, changed: removed.length > 0, detail: removed.length ? `removed: ${removed.join(', ')}` : 'no ak-deployed artifacts found' }; +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 8f48748..8e914e6 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -36,6 +36,16 @@ export const codexDir = () => path.join(home, '.codex'); export const codexAgentsMdPath = () => path.join(codexDir(), 'AGENTS.md'); export const codexConfigPath = () => path.join(codexDir(), 'config.toml'); +/** opencode user-level locations (XDG config home, same base as the kit's own + * configDir). `~/.config/opencode` is opencode's global config home; like + * ~/.codex, ak NEVER creates it — existence signals opencode is installed. */ +export const opencodeDir = () => path.join(configBase(), 'opencode'); +export const opencodeConfigPath = () => path.join(opencodeDir(), 'opencode.json'); +export const opencodeAgentsMdPath = () => path.join(opencodeDir(), 'AGENTS.md'); +export const opencodePluginsDir = () => path.join(opencodeDir(), 'plugins'); +export const opencodeAgentsDir = () => path.join(opencodeDir(), 'agents'); +export const opencodeSkillsDir = () => path.join(opencodeDir(), 'skills'); + /** Per-project locations, relative to a project root. */ export const projectSettings = (root) => path.join(root, '.claude', 'settings.json'); export const projectSettingsLocal = (root) => path.join(root, '.claude', 'settings.local.json'); @@ -108,4 +118,14 @@ export const rufloCliDist = () => path.join(rufloNodeModules(), '@claude-flow', 'cli', 'dist', 'src'); export const aqeRoot = () => path.join(globalRoot(), 'agentic-qe'); +/** ruflo content sources for host integrations (opencode agents/skills). + * The published @claude-flow/cli package bundles a SUBSET (.claude/agents — + * the ADR-128 substrate set, .claude/skills); the FULL catalog (all agents + + * every plugin's skills + the platform SKILL.md) ships only in the git repo, + * which is mirrored by Claude Code's plugin marketplace clone + * (~/.claude/plugins/marketplaces/ruflo, auto-updated by claude). */ +export const rufloCliPkgRoot = () => path.join(globalRoot(), '@claude-flow', 'cli'); +export const rufloMarketplaceRoot = () => + path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo'); + export { isWindows, home }; diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 595ee9c..98e8179 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -36,6 +36,7 @@ import { HOST_ADAPTERS } from './hosts.mjs'; import { HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, } from './adapters/index.mjs'; +import { opencodeMcpStatus } from './opencode.mjs'; /** Frontier agent-CLI hosts. `pkg` is the npm global package; `enableEnv` is * ruflo's ADR-034 backend flag; `aqe` is the AQE_LLM_PROVIDER value (null when @@ -47,11 +48,11 @@ import { export const CODEX_ADAPTER_PKG = '@claude-flow/codex'; export const HOSTS = HOST_REGISTRY - .filter((host) => host.capabilities.canRouteActivities) + .filter((host) => host.capabilities.canDriveSession) .map((host) => ({ id: host.id, bin: host.install.bin, pkg: host.install.npmPackage, - enableEnv: host.legacy.enableEnv, - aqe: host.id === 'claude' ? host.legacy.aqeProvider : null, + enableEnv: host.legacy?.enableEnv ?? null, + aqe: host.id === 'claude' ? host.legacy?.aqeProvider : null, ...(host.id === 'codex' ? { adapterPkg: CODEX_ADAPTER_PKG } : {}), })); @@ -201,12 +202,13 @@ export async function hostInstallState(host) { * so when the CLI is present with no api key we INFER subscription and say so. * - else → 'none'. * Pure-ish: reads env + one fs.existsSync per host. `present` lets the caller pass - * the already-known install state so an absent host reads 'none' without a probe. */ -export function hostAuthState(id, { env = process.env, present = true } = {}) { + * the already-known install state so an absent host reads 'none' without a probe. + * `home` is a test seam (same convention as rufloCodexMcpStatus's opts.home). */ +export function hostAuthState(id, { env = process.env, present = true, home = os.homedir() } = {}) { const a = HOST_ADAPTERS[id]?.auth; if (!a) return { mode: 'unknown', billing: 'unknown', source: null, note: null }; const keyEnv = a.apiKeyEnv.find((k) => !!env[k]); - const loginPath = a.loginFile ? path.join(os.homedir(), ...a.loginFile) : null; + const loginPath = a.loginFile ? path.join(home, ...a.loginFile) : null; const loginPresent = !!loginPath && fs.existsSync(loginPath); if (keyEnv) { return { @@ -235,8 +237,10 @@ export async function installHost(id) { // updateHost/hostDrift pair here. Two earlier ones were dead code (zero // production callers) and were removed; don't reintroduce a second drift path. -/** Detect installed hosts + whether they are currently wired on in `cwd`. */ -export async function detectHosts(cwd = process.cwd()) { +/** Detect installed hosts + whether they are currently wired on in `cwd`. + * `opts.opencodeConfigFile` is a test seam for the config-file wired probe. + * @param {string} [cwd] @param {{ opencodeConfigFile?: string }} [opts] */ +export async function detectHosts(cwd = process.cwd(), { opencodeConfigFile } = {}) { const env = currentEnv(cwd); const out = {}; for (const h of HOSTS) { @@ -244,7 +248,13 @@ export async function detectHosts(cwd = process.cwd()) { out[h.id] = { present, version: present ? await hostVersion(h.bin) : null, - wired: env[h.enableEnv] === 'true', + // config-file hosts (enableEnv: null) have no env to be "wired" in — + // their wired state is the presence of the ak-managed server entry in + // the host's own config (opencode.json mcp.claude-flow), read + // spawn-free. env[null] would read 'not wired' forever (codex-review). + wired: h.enableEnv + ? env[h.enableEnv] === 'true' + : (h.id === 'opencode' ? opencodeMcpStatus(null, { configFile: opencodeConfigFile ?? paths.opencodeConfigPath() }).claudeFlow : false), }; } return out; @@ -506,7 +516,7 @@ export function seedDualRoutingIfDualHost(cfg) { * {changed, warnings} — pure except for the intended cfg mutation. */ export function applySetupHostFlags(cfg, flags = {}) { const p = cfg.providers ?? (cfg.providers = {}); - p.hosts ?? (p.hosts = { claude: true, codex: false }); + p.hosts ?? (p.hosts = { claude: true, codex: false, opencode: false }); const warnings = []; let changed = false; const wantPrimary = typeof flags['primary-host'] === 'string' ? flags['primary-host'].trim().toLowerCase() : null; @@ -515,6 +525,11 @@ export function applySetupHostFlags(cfg, flags = {}) { if (!p.hosts.codex || !p.hosts.claude) changed = true; p.hosts = { ...p.hosts, claude: true, codex: true }; } + // --opencode opts the opencode host in (config-file wiring, no env flags) + if (flags.opencode) { + if (!p.hosts.opencode) changed = true; + p.hosts = { ...p.hosts, opencode: true }; + } if (wantPrimary) { if (PRIMARY_HOSTS.includes(wantPrimary)) { if (p.primaryHost !== wantPrimary) { p.primaryHost = wantPrimary; changed = true; } diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index 50103ee..a7d1637 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -64,7 +64,7 @@ export async function driftReport({ force = false } = {}) { // package.json → installedVersion is null → filtered out here, so ak never // claims to manage an update it doesn't own. Pkg names mirror HOSTS in // providers.mjs (kept local to avoid an import cycle). - const HOST_PKGS = ['@anthropic-ai/claude-code', '@openai/codex']; + const HOST_PKGS = ['@anthropic-ai/claude-code', '@openai/codex', 'opencode-ai']; const pkgs = ['ruflo', 'agentic-qe', ...HOST_PKGS.filter((p) => installedVersion(p))]; const report = []; let latest = cfg.versionCheck?.seen ?? {}; diff --git a/src/templates/opencode-ruflo-hooks.js b/src/templates/opencode-ruflo-hooks.js new file mode 100644 index 0000000..3e188e1 --- /dev/null +++ b/src/templates/opencode-ruflo-hooks.js @@ -0,0 +1,202 @@ +// ruflo-hooks.js — bridge opencode lifecycle events to ruflo's hook verbs. +// Deployed to ~/.config/opencode/plugins/ by `ak setup` / `ak sync` +// (agentic-kit, src/templates/opencode-ruflo-hooks.js — managed; do not edit +// the deployed copy, sync rewrites it). +// +// Mirrors what `ruflo init` wires into Claude Code's settings.json, but for +// opencode. All verbs run through ruflo's LOCAL hook-handler (fast paths, no +// MCP round-trip, 5s internal safety timeout, always exits 0): +// +// session.created → session-restore (load intelligence/session state) +// session.compacted → session-restore (reload state after compaction) +// session.deleted → session-end (consolidate learning, persist) +// bash before → pre-bash (block catastrophic commands) +// edit/write after → post-edit (record outcome for learning) +// task before/after → pre-task/post-task(route subagent work, feed learning) +// chat.message → route (inject routing recommendation) +// +// Failure policy: hooks NEVER break opencode. Only an explicit [BLOCKED] +// verdict from pre-bash blocks a tool call; every other error is swallowed. + +import { spawn } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import os from "node:os" + +const HOOK_TIMEOUT_MS = 4500 +const ROUTE_MIN_PROMPT = 12 +const ROUTE_MAX_INJECT = 1200 +const TRIVIAL_PROMPT = /^(yes|y|ok|k|sure|continue|go ahead|proceed|lgtm|next)[.!]?$/i + +// The hook-handler ships with ruflo — resolve it without machine-specific +// hardcodes: explicit override → claude marketplace clone (auto-updated) → +// npm global package (direct + nested-under-ruflo layouts) → legacy /opt checkout. +function resolveHookHandler() { + const home = os.homedir() + const rel = path.join(".claude", "helpers", "hook-handler.cjs") + const candidates = [] + if (process.env.RUFLO_REPO) candidates.push(path.join(process.env.RUFLO_REPO, rel)) + candidates.push(path.join(home, ".claude", "plugins", "marketplaces", "ruflo", rel)) + for (const root of [ + process.env.npm_prefix && path.join(process.env.npm_prefix, "lib", "node_modules"), + path.join(process.execPath, "..", "..", "lib", "node_modules"), + ]) { + if (root) { + candidates.push(path.join(root, "@claude-flow", "cli", rel)) + candidates.push(path.join(root, "ruflo", "node_modules", "@claude-flow", "cli", rel)) + } + } + candidates.push(path.join("/opt/ruflo", rel)) + for (const c of candidates) { + try { if (fs.existsSync(c)) return c } catch { /* next */ } + } + return null +} + +const HANDLER = resolveHookHandler() + +function runHook(verb, payload) { + return new Promise((resolve) => { + if (!HANDLER) return resolve({ ok: false, stdout: "", stderr: "no handler" }) + let stdout = "" + let stderr = "" + let settled = false + const done = (result) => { + if (!settled) { + settled = true + clearTimeout(timer) + resolve(result) + } + } + let child + try { + child = spawn("node", [HANDLER, verb], { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }) + } catch { + return done({ ok: false, stdout: "", stderr: "spawn failed" }) + } + const timer = setTimeout(() => { + try { child.kill("SIGKILL") } catch { /* already dead */ } + done({ ok: false, stdout, stderr: stderr + " [timeout]" }) + }, HOOK_TIMEOUT_MS) + child.stdout.on("data", (d) => { stdout += d }) + child.stderr.on("data", (d) => { stderr += d }) + child.on("error", () => done({ ok: false, stdout, stderr })) + child.on("close", (code) => done({ ok: code === 0, stdout, stderr, code })) + try { + child.stdin.write(JSON.stringify(payload ?? {})) + child.stdin.end() + } catch { + done({ ok: false, stdout, stderr: "stdin failed" }) + } + }) +} + +// Fire-and-forget wrapper: never throws, never blocks the event loop turn. +function fire(verb, payload) { + runHook(verb, payload).catch(() => {}) +} + +function promptText(parts) { + if (!Array.isArray(parts)) return "" + return parts + .filter((p) => p && p.type === "text" && typeof p.text === "string") + .map((p) => p.text) + .join("\n") + .trim() +} + +const plugin = async ({ client }) => { + await client.app.log({ + body: { + service: "ruflo-hooks", + level: HANDLER ? "info" : "warn", + message: HANDLER ? `ruflo lifecycle bridge loaded (${HANDLER})` : "ruflo hook-handler not found — bridge inert", + }, + }) + + return { + event: async ({ event }) => { + try { + switch (event?.type) { + case "session.created": + fire("session-restore") + break + case "session.compacted": + fire("session-restore") + break + case "session.deleted": + fire("session-end") + break + } + } catch { /* never break opencode */ } + }, + + "chat.message": async (input, output) => { + try { + const prompt = promptText(output?.parts) + if (prompt.length < ROUTE_MIN_PROMPT || TRIVIAL_PROMPT.test(prompt)) return + const res = await runHook("route", { prompt }) + const text = (res.stdout || "").trim() + if (!res.ok || !text || text.includes("Router not available")) return + // A part opencode can actually persist (codex review): the validator + // requires id/messageID/sessionID on every returned part — a bare + // {type,text} part can fail message persistence. synthetic:true marks + // it as injected context, not user content. + const messageID = output?.message?.id ?? input?.messageID + output.parts.push({ + id: `part_ruflo_route_${Date.now().toString(36)}`, + messageID, + sessionID: input?.sessionID ?? output?.message?.sessionID, + type: "text", + synthetic: true, + text: `[ruflo routing context — ephemeral, not part of the user request]\n${text.slice(0, ROUTE_MAX_INJECT)}`, + }) + } catch { /* routing context is best-effort */ } + }, + + "tool.execute.before": async (input, output) => { + try { + if (input.tool === "bash") { + const command = output?.args?.command + if (typeof command !== "string" || !command) return + const res = await runHook("pre-bash", { + tool_name: "Bash", + tool_input: { command }, + }) + if (!res.ok && /\[BLOCKED]/i.test(res.stderr + res.stdout)) { + throw new Error(`[ruflo] Blocked dangerous command: ${command.slice(0, 120)}`) + } + } else if (input.tool === "task") { + const description = output?.args?.description ?? output?.args?.prompt ?? "" + fire("pre-task", { prompt: String(description).slice(0, 500) }) + } + } catch (e) { + if (e && e.message && e.message.startsWith("[ruflo]")) throw e + /* anything else: allow */ + } + }, + + "tool.execute.after": async (input, output) => { + try { + if (input.tool === "edit" || input.tool === "write" || input.tool === "apply_patch") { + const filePath = input?.args?.filePath ?? input?.args?.file_path ?? "" + fire("post-edit", { + tool_name: input.tool, + tool_input: { file_path: filePath }, + tool_response: typeof output?.output === "string" ? output.output.slice(0, 2000) : "", + }) + } else if (input.tool === "task") { + fire("post-task", { + tool_response: typeof output?.output === "string" ? output.output.slice(0, 2000) : "", + }) + } + } catch { /* learning hooks are best-effort */ } + }, + } +} + +export default plugin +export { plugin as RufloHooks } diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 975cb3d..305e7ca 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -113,6 +113,7 @@ async function main() { { subsystem: 'versions', level: 'ok', message: 'ruflo 4.0.0 (latest)', fix: null }, { subsystem: 'natives', level: 'fail', message: 'WASM fallback', fix: 'sync installs native better-sqlite3' }, { subsystem: 'learning', level: 'warn', message: 'no patterns yet', fix: null }, + { subsystem: 'opencode', level: 'warn', message: 'lifecycle plugin out of date', fix: 'sync rewrites it' }, ], drift: [{ pkg: 'ruflo', installed: '4.0.0', latest: '4.0.0', outdated: false }], }; @@ -196,7 +197,23 @@ async function main() { const j = JSON.parse(r.body); assert(Array.isArray(j.rows), 'rows must be an array'); assert(j.overall === 'warn', 'overall must pass through, got ' + j.overall); - assert(j.rows.length === 3, 'expected 3 rows, got ' + j.rows.length); + assert(j.rows.length === 4, 'expected 4 rows, got ' + j.rows.length); + }); + + await test('GET /api/status passes opencode subsystem rows through untouched', async () => { + const r = await get(url + 'api/status', token); + const j = JSON.parse(r.body); + const oc = (j.rows || []).filter((x) => x.subsystem === 'opencode'); + assert(oc.length === 1, 'expected the opencode row to pass through, got ' + oc.length); + assert(oc[0].level === 'warn' && oc[0].fix === 'sync rewrites it', + 'opencode row level/fix must survive the payload verbatim'); + }); + + await test('GET / categorizes the opencode subsystem into the Hosts tab (not the runtime fallback)', async () => { + const r = await get(url); + contains(r.body, 'opencode:"hosts"'); + // and it must sort with the host MCP subsystems, not at the unknown end + contains(r.body, '"codex-mcp","opencode"'); }); await test('GET /api/status embeds improvement.json read off the fixture', async () => { diff --git a/tests/kit/guidance-targets.test.mjs b/tests/kit/guidance-targets.test.mjs index 8ba1314..8e94466 100644 --- a/tests/kit/guidance-targets.test.mjs +++ b/tests/kit/guidance-targets.test.mjs @@ -21,8 +21,9 @@ test('codexDir / codexAgentsMdPath resolve under ~/.codex', () => { test('guidanceTargets always yields claude + project agents, gated codex', () => { const cwd = '/tmp/proj-x'; - // codexRoot points at a NON-existent dir → agents-user omitted (never mkdir'd). - const absent = guidanceTargets({ cwd, codexRoot: path.join(os.tmpdir(), 'no-such-codex-xyz') }); + // codexRoot + opencodeRoot point at NON-existent dirs → agents-user AND + // agents-opencode omitted (never mkdir'd). + const absent = guidanceTargets({ cwd, codexRoot: path.join(os.tmpdir(), 'no-such-codex-xyz'), opencodeRoot: path.join(os.tmpdir(), 'no-such-opencode-xyz') }); assert.deepEqual(absent.map((t) => t.name), ['claude', 'agents']); assert.equal(absent[0].file, claudeMdPath()); assert.equal(absent[1].file, path.join(cwd, 'AGENTS.md')); @@ -31,7 +32,7 @@ test('guidanceTargets always yields claude + project agents, gated codex', () => test('guidanceTargets includes agents-user only when the codex dir exists', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-codex-')); const cwd = '/tmp/proj-y'; - const targets = guidanceTargets({ cwd, codexRoot: tmp }); + const targets = guidanceTargets({ cwd, codexRoot: tmp, opencodeRoot: path.join(os.tmpdir(), 'no-such-opencode-xyz') }); assert.deepEqual(targets.map((t) => t.name), ['claude', 'agents', 'agents-user']); const au = targets.find((t) => t.name === 'agents-user'); assert.equal(au.file, path.join(tmp, 'AGENTS.md')); diff --git a/tests/kit/hosts.test.mjs b/tests/kit/hosts.test.mjs index b00197b..5410c13 100644 --- a/tests/kit/hosts.test.mjs +++ b/tests/kit/hosts.test.mjs @@ -1,11 +1,14 @@ 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 { HOST_IDS, adapterFor, statuslineSupported, drivingHost } from '../../src/lib/hosts.mjs'; import { hostAuthState } from '../../src/lib/providers.mjs'; // ── HOST_ADAPTERS descriptors ──────────────────────────────────────────────── -test('HOST_ADAPTERS defines both frontier hosts', () => { - assert.deepEqual(HOST_IDS, ['claude', 'codex']); +test('HOST_ADAPTERS defines the three hosts', () => { + assert.deepEqual(HOST_IDS, ['claude', 'codex', 'opencode']); }); test('claude adapter targets CLAUDE.md/json and supports a statusline', () => { @@ -25,6 +28,15 @@ test('codex adapter targets AGENTS.md/toml and has NO command-backed statusline' assert.equal(a.aqeProvider, 'codex'); }); +test('opencode adapter targets its own AGENTS.md/json, no statusline, no aqe provider, no session env markers', () => { + const a = adapterFor('opencode'); + assert.equal(a.guidanceFile, 'agents-opencode'); + assert.equal(a.configFormat, 'json'); + assert.equal(a.statuslineSupported, false); + assert.equal(a.aqeProvider, null); + assert.deepEqual(a.envMarkers, []); +}); + test('adapterFor returns null for an unknown host', () => { assert.equal(adapterFor('gemini'), null); }); @@ -80,7 +92,10 @@ test('hostAuthState infers oauth/subscription for a present claude with no key', }); test('hostAuthState reports none for an absent claude with no key', () => { - const a = hostAuthState('claude', { env: {}, present: false }); + // home must point at an EMPTY dir: on a real machine with file-based claude + // credentials (~/.claude/.credentials.json present, e.g. Linux installs), the + // login-file probe legitimately wins and this test flaked 'oauth'. + const a = hostAuthState('claude', { env: {}, present: false, home: fs.mkdtempSync(path.join(os.tmpdir(), 'ak-nohome-')) }); assert.equal(a.mode, 'none'); }); diff --git a/tests/kit/opencode.test.mjs b/tests/kit/opencode.test.mjs new file mode 100644 index 0000000..aee6775 --- /dev/null +++ b/tests/kit/opencode.test.mjs @@ -0,0 +1,488 @@ +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 { + applyOpencode, undoOpencode, opencodeMcpStatus, opencodeConverged, mcpEntriesFor, + catalogSource, skillPathsFor, convertAgents, syncAgents, agentsStatus, + deployPlugin, pluginStatus, deploySkill, skillStatus, removeArtifacts, + PERMISSION_KEYS, PLUGIN_NAME, +} from '../../src/lib/opencode.mjs'; +import { detectHosts } from '../../src/lib/providers.mjs'; +import { guidanceTargets, BUILTIN_BLOCKS } from '../../src/lib/blocks.mjs'; + +const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); +const cfgOn = () => ({ providers: { hosts: { opencode: true }, opencodeMcp: null, opencodeManaged: null } }); + +/** Fixture ruflo catalog source: .claude/agents (+ frontmatter variants), + * .claude/skills, plugins, SKILL.md, package.json. */ +function makeCatalog(root, { version = '9.9.9', plugins = true, platformSkill = true } = {}) { + fs.mkdirSync(path.join(root, '.claude', 'agents', 'core'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'agents', 'extra'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'skills', 'some-skill'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version })); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'core', 'coder.md'), + '---\nname: coder\ndescription: Implementation specialist\ntools: Bash, Read\n---\n\nUse mcp__claude-flow__swarm_init here.\n'); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'extra', 'coder.md'), + '---\nname: coder\ndescription: |\n Block-scalar duplicate name\n across two lines\n---\n\nDuplicate.\n'); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'notes.md'), + '---\nname: Notes\ntype: documentation\ndescription: not an agent\n---\n\nDocs.\n'); + if (plugins) fs.mkdirSync(path.join(root, 'plugins', 'ruflo-x', 'skills', 'y'), { recursive: true }); + if (platformSkill) fs.writeFileSync(path.join(root, 'SKILL.md'), '---\nname: ruflo\ndescription: platform\n---\n\n# Ruflo\n'); + return root; +} + +// ── catalogSource / skillPathsFor ──────────────────────────────────────────── + +test('catalogSource prefers the explicit override and reports capabilities', () => { + const root = makeCatalog(tmp('ak-oc-src-')); + const s = catalogSource({ override: root }); + assert.equal(s.kind, 'override'); + assert.equal(s.id, 'override@9.9.9'); + assert.equal(s.hasPlugins, true); + assert.equal(s.hasPlatformSkill, true); + rm(root); +}); + +test('catalogSource honors $RUFLO_REPO after override', () => { + const root = makeCatalog(tmp('ak-oc-env-')); + process.env.RUFLO_REPO = root; + try { + const s = catalogSource(); + assert.equal(s.kind, 'env'); + assert.equal(s.root, root); + } finally { + delete process.env.RUFLO_REPO; + } + rm(root); +}); + +test('skillPathsFor emits existing dirs only', () => { + const root = makeCatalog(tmp('ak-oc-sp-'), { plugins: false }); + const s = catalogSource({ override: root }); + const paths = skillPathsFor(s); + assert.deepEqual(paths, [path.join(root, '.claude', 'skills')]); + rm(root); +}); + +// ── mcpEntriesFor / opencodeMcpStatus ──────────────────────────────────────── + +test('mcpEntriesFor includes ruvnet-brain only when the shim exists', async () => { + const d = tmp('ak-oc-shim-'); + const shim = path.join(d, 'server.mjs'); + const without = await mcpEntriesFor({ brainShim: shim }); + assert.equal(Object.keys(without).includes('ruvnet-brain'), false); + fs.writeFileSync(shim, '// shim\n'); + const withBrain = await mcpEntriesFor({ brainShim: shim }); + assert.equal(withBrain['ruvnet-brain'].type, 'local'); + assert.deepEqual(withBrain['ruvnet-brain'].command, ['node', shim]); + assert.equal(withBrain['ruvnet-brain'].timeout, 30000); + assert.equal(withBrain['claude-flow'].enabled, true); + assert.equal(withBrain['claude-flow'].environment.CLAUDE_FLOW_MODE, 'v3'); + assert.ok(Array.isArray(withBrain['claude-flow'].command)); + rm(d); +}); + +test('opencodeMcpStatus flags JSONC files as parseError instead of clobbering', () => { + const d = tmp('ak-oc-jsonc-'); + const file = path.join(d, 'opencode.json'); + fs.writeFileSync(file, '{\n // a comment\n "mcp": {}\n}\n'); + const st = opencodeMcpStatus(cfgOn(), { configFile: file }); + assert.equal(st.exists, true); + assert.equal(st.parseError, true); + rm(d); +}); + +// ── applyOpencode / undoOpencode round-trip ────────────────────────────────── + +test('applyOpencode merges wiring, preserves user keys, records value-precise ownership; undo restores priors', async () => { + const d = tmp('ak-oc-apply-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const srcRoot = makeCatalog(path.join(d, 'catalog')); + fs.writeFileSync(file, JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + model: 'opencode/kimi-k3', + mcp: { 'my-server': { type: 'local', command: ['x'] } }, + skills: { paths: ['/user/path'] }, + permission: { edit: 'ask' }, + }, null, 2)); + + const cfg = cfgOn(); + cfg.providers.opencodeCatalogDir = srcRoot; + const r = await applyOpencode(cfg, { configFile: file, brainShim: shim }); + assert.equal(r.ok, true); + assert.equal(r.changed, true); + + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.ok(doc.mcp['claude-flow'], 'claude-flow server written'); + assert.ok(doc.mcp['ruvnet-brain'], 'brain server written (shim present)'); + assert.ok(doc.mcp['my-server'], 'user MCP server preserved'); + assert.equal(doc.model, 'opencode/kimi-k3', 'unrelated keys preserved'); + 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'); + assert.equal(cfg.providers.opencodeMcp, 'ak'); + // value-precise ownership: claude-flow had no prior → prior null, written recorded + const rec = cfg.providers.opencodeManaged.mcp['claude-flow']; + assert.equal(rec.prior, null); + assert.deepEqual(rec.written, doc.mcp['claude-flow']); + + // idempotent + const again = await applyOpencode(cfg, { configFile: file, brainShim: shim }); + assert.equal(again.changed, false); + assert.ok(fs.existsSync(`${file}.bak`)); + + const u = undoOpencode(cfg, { configFile: file }); + assert.equal(u.changed, true); + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(after.mcp['claude-flow'], undefined); + assert.equal(after.mcp['ruvnet-brain'], undefined); + 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); + assert.equal(cfg.providers.opencodeMcp, null); + rm(d); +}); + +test('applyOpencode refuses an unparseable config file (never clobbers JSONC)', async () => { + const d = tmp('ak-oc-refuse-'); + const file = path.join(d, 'opencode.json'); + const body = '{\n // comment\n "mcp": {}\n}\n'; + fs.writeFileSync(file, body); + const r = await applyOpencode(cfgOn(), { configFile: file }); + assert.equal(r.ok, false); + assert.equal(r.changed, false); + assert.equal(fs.readFileSync(file, 'utf8'), body, 'file untouched'); + rm(d); +}); + +test('applyOpencode is a no-op when the host is not enabled', async () => { + const d = tmp('ak-oc-off-'); + const file = path.join(d, 'opencode.json'); + const r = await applyOpencode({ providers: { hosts: { opencode: false } } }, { configFile: file }); + assert.equal(r.changed, false); + assert.equal(fs.existsSync(file), false); + rm(d); +}); + +test('undoOpencode never strips a config ak did not write', () => { + const d = tmp('ak-oc-unowned-'); + const file = path.join(d, 'opencode.json'); + fs.writeFileSync(file, JSON.stringify({ mcp: { 'claude-flow': { type: 'local', command: ['x'] } } })); + const cfg = cfgOn(); // opencodeMcp: null → not ak-owned + const u = undoOpencode(cfg, { configFile: file }); + assert.equal(u.changed, false); + assert.ok(JSON.parse(fs.readFileSync(file, 'utf8')).mcp['claude-flow'], 'user-registered claude-flow preserved'); + rm(d); +}); + +// ── ownership collisions + value-precise teardown (codex review) ───────────── + +test('a user-owned same-name MCP entry that DIFFERS is a collision: preserved, not adopted, not torn down', async () => { + const d = tmp('ak-oc-collide-'); + const file = path.join(d, 'opencode.json'); + const userEntry = { type: 'local', command: ['my', 'own', 'server'], timeout: 5 }; + fs.writeFileSync(file, JSON.stringify({ mcp: { 'claude-flow': userEntry } })); + const cfg = cfgOn(); + const r = await applyOpencode(cfg, { configFile: file, brainShim: path.join(d, 'absent-shim') }); + assert.equal(r.ok, false, 'collision reported'); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.deepEqual(doc.mcp['claude-flow'], userEntry, 'user entry untouched'); + const u = undoOpencode(cfg, { configFile: file }); + // teardown still strips what ak legitimately wrote (permission keys) — but + // the collided user entry must survive untouched. + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.deepEqual(after.mcp['claude-flow'], userEntry, 'teardown never deletes a non-ak-written value'); + assert.ok(u.detail.includes('mcp.claude-flow'), 'the kept collision is reported'); + rm(d); +}); + +test('a user permission pattern set to ask is a collision: preserved', async () => { + const d = tmp('ak-oc-perm-'); + const file = path.join(d, 'opencode.json'); + fs.writeFileSync(file, JSON.stringify({ permission: { 'claude-flow_*': 'ask' } })); + const r = await applyOpencode(cfgOn(), { configFile: file, brainShim: path.join(d, 'absent-shim') }); + assert.equal(r.ok, false); + assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).permission['claude-flow_*'], 'ask'); + rm(d); +}); + +test('scalar permission shorthand is lifted to {"*": v} before merging and restored on undo', async () => { + const d = tmp('ak-oc-scalar-'); + const file = path.join(d, 'opencode.json'); + fs.writeFileSync(file, JSON.stringify({ model: 'x', permission: 'allow' })); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: path.join(d, 'absent-shim') }); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(typeof doc.permission, 'object', 'no character-spread corruption'); + assert.equal(doc.permission['*'], 'allow'); + assert.equal(doc.permission['claude-flow_*'], 'allow'); + assert.equal(doc.permission['0'], undefined, 'no numeric character keys'); + undoOpencode(cfg, { configFile: file }); + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(after.permission, 'allow', 'scalar shorthand restored'); + rm(d); +}); + +test('desired-set shrink: removing the brain shim prunes the ak-written brain entry (still == written)', async () => { + const d = tmp('ak-oc-shrink-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + assert.ok(JSON.parse(fs.readFileSync(file, 'utf8')).mcp['ruvnet-brain']); + fs.rmSync(shim); // brain removed + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(doc.mcp['ruvnet-brain'], undefined, 'stale ak entry pruned'); + assert.ok(doc.mcp['claude-flow'], 'ruflo entry kept'); + rm(d); +}); + +test('desired-set shrink never prunes a user-EDITED stale entry', async () => { + const d = tmp('ak-oc-shrink-edit-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + // user edits the brain entry afterwards + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + doc.mcp['ruvnet-brain'].timeout = 99999; + fs.writeFileSync(file, JSON.stringify(doc)); + fs.rmSync(shim); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(after.mcp['ruvnet-brain'].timeout, 99999, 'user edit preserved over prune'); + rm(d); +}); + +test('opencodeConverged detects value drift, not just key presence', async () => { + const d = tmp('ak-oc-conv-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + const good = await opencodeConverged(cfg, { configFile: file, brainShim: shim }); + assert.equal(good.converged, true, JSON.stringify(good.reasons)); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + doc.mcp['claude-flow'].timeout = 1; // user/template drift + fs.writeFileSync(file, JSON.stringify(doc)); + const bad = await opencodeConverged(cfg, { configFile: file, brainShim: shim }); + assert.equal(bad.converged, false); + assert.ok(bad.reasons.some((r) => r.includes('claude-flow'))); + rm(d); +}); + +test('detectHosts reads the opencode wired state from the config file (seam)', async () => { + const d = tmp('ak-oc-detect-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + const hosts = await detectHosts(process.cwd(), { opencodeConfigFile: file }); + assert.equal(hosts.opencode.wired, true, 'config-file host reports wired from its own config'); + rm(d); +}); + +// ── agent conversion ───────────────────────────────────────────────────────── + +test('convertAgents converts, rewrites ALL MCP ref spellings, skips documentation, prefixes collisions', () => { + const root = makeCatalog(tmp('ak-oc-conv2-')); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'core', 'alias.md'), + '---\nname: alias\ndescription: alias refs\n---\n\nmcp__claude_flow__memory_search and mcp__ruflo__memory_list here.\n'); + const { agents, scanned, skipped, renamed } = convertAgents(root); + assert.equal(scanned, 4); + assert.equal(skipped, 1, 'documentation-type file skipped'); + assert.equal(renamed, 1); + const names = agents.map((a) => a.name).sort(); + assert.deepEqual(names, ['alias', 'coder', 'extra-coder']); + const blockScalar = agents.find((a) => a.name === 'extra-coder'); + assert.equal(blockScalar.description, 'Block-scalar duplicate name across two lines'); + const alias = agents.find((a) => a.name === 'alias'); + assert.ok(alias.body.includes('claude-flow_memory_search'), 'claude_flow alias rewritten'); + assert.ok(alias.body.includes('claude-flow_memory_list'), 'ruflo alias rewritten'); + assert.ok(!/mcp__(claude[-_]flow|ruflo)__/.test(alias.body), 'no claude-style refs remain'); + const core = agents.find((a) => a.name === 'coder'); + assert.ok(core.content.includes('mode: subagent')); + assert.ok(core.content.includes('claude-flow_swarm_init')); + assert.ok(!core.content.includes('tools: Bash'), 'claude tools list dropped'); + rm(root); +}); + +test('convertAgents emits YAML-safe descriptions (": " content is JSON-quoted)', () => { + const root = makeCatalog(tmp('ak-oc-yaml-')); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'core', 'colon.md'), + '---\nname: colon\ndescription: Examples: Context: user wants x\n---\n\nBody.\n'); + const { agents } = convertAgents(root); + const a = agents.find((x) => x.name === 'colon'); + const fm = a.content.split('---')[1]; + const descLine = fm.split('\n').find((l) => l.startsWith('description:')); + assert.ok(descLine.startsWith('description: "'), 'description is double-quoted'); + const parsed = JSON.parse(descLine.slice('description: '.length)); + assert.equal(parsed, 'Examples: Context: user wants x'); + rm(root); +}); + +test('parseFrontmatter block scalars keep blank-line-separated paragraphs', () => { + const root = makeCatalog(tmp('ak-oc-block-')); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'core', 'multi.md'), + '---\nname: multi\ndescription: |\n First paragraph here.\n\n Second paragraph after a blank line.\ntools: Bash\n---\n\nBody.\n'); + const { agents } = convertAgents(root); + const a = agents.find((x) => x.name === 'multi'); + assert.equal(a.description, 'First paragraph here. Second paragraph after a blank line.'); + rm(root); +}); + +test('syncAgents writes + stamps, never overwrites user-owned files, is idempotent (stamp included); agentsStatus flags structural drift', () => { + const d = tmp('ak-oc-sync-'); + const root = makeCatalog(path.join(d, 'catalog')); + const dest = path.join(d, 'agents'); + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, 'coder.md'), '\nold\n'); + fs.writeFileSync(path.join(dest, 'stale-thing.md'), '\nold\n'); + fs.writeFileSync(path.join(dest, 'mine.md'), 'user file — never touched\n'); + fs.writeFileSync(path.join(dest, 'extra-coder.md'), 'MY OWN agent, not ak\'s\n'); // user-owned same-name + + const source = catalogSource({ override: root }); + const r1 = syncAgents({ source, destDir: dest }); + assert.equal(r1.ok, true); + assert.ok(r1.detail.includes('user-owned preserved'), 'user-owned same-name reported'); + assert.equal(fs.existsSync(path.join(dest, 'stale-thing.md')), false, 'stale legacy file removed'); + assert.ok(fs.readFileSync(path.join(dest, 'coder.md'), 'utf8').includes('generated-by: agentic-kit'), 'legacy file adopted'); + assert.equal(fs.readFileSync(path.join(dest, 'mine.md'), 'utf8'), 'user file — never touched\n'); + assert.equal(fs.readFileSync(path.join(dest, 'extra-coder.md'), 'utf8'), 'MY OWN agent, not ak\'s\n', 'user-owned same-name NOT clobbered'); + + const st1 = agentsStatus({ source, destDir: dest }); + assert.equal(st1.stale, false); + assert.equal(st1.count, 1, 'only generated agents counted (coder; extra-coder.md is user-owned, notes.md is documentation)'); + + const stampBefore = fs.readFileSync(path.join(dest, '.ak-agents-stamp.json'), 'utf8'); + const r2 = syncAgents({ source, destDir: dest }); + assert.equal(r2.changed, false, 'idempotent second run'); + assert.equal(fs.readFileSync(path.join(dest, '.ak-agents-stamp.json'), 'utf8'), stampBefore, 'stamp not churned on a no-op run'); + + // source upgrade → stamp diverges → stale + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '9.9.10' })); + assert.equal(agentsStatus({ source: catalogSource({ override: root }), destDir: dest }).stale, true); + // structural drift: delete a generated file → stale even with matching source + fs.rmSync(path.join(dest, 'coder.md')); + assert.equal(agentsStatus({ source, destDir: dest }).stale, true, 'missing generated file detected'); + rm(d); +}); + +// ── plugin / skill deploy + removeArtifacts ────────────────────────────────── + +test('deployPlugin + pluginStatus: deploy, current, rewrite on template change, never overwrite user-owned', () => { + const d = tmp('ak-oc-plug-'); + const pkgRoot = path.join(d, 'pkg'); + fs.mkdirSync(path.join(pkgRoot, 'src', 'templates'), { recursive: true }); + const tpl = path.join(pkgRoot, 'src', 'templates', 'opencode-ruflo-hooks.js'); + fs.writeFileSync(tpl, '// ak plugin — see src/templates/opencode-ruflo-hooks.js\n// v1\n'); + const pluginsDir = path.join(d, 'plugins'); + + const r1 = deployPlugin({ pkgRoot, pluginsDir }); + assert.equal(r1.changed, true); + assert.equal(pluginStatus({ pkgRoot, pluginsDir }).current, true); + assert.equal(deployPlugin({ pkgRoot, pluginsDir }).changed, false, 'idempotent'); + + fs.writeFileSync(tpl, '// ak plugin — see src/templates/opencode-ruflo-hooks.js\n// v2\n'); + assert.equal(pluginStatus({ pkgRoot, pluginsDir }).current, false); + assert.equal(deployPlugin({ pkgRoot, pluginsDir }).changed, true); + + // user-owned file at the slot (no marker) → foreign, preserved + fs.writeFileSync(path.join(pluginsDir, PLUGIN_NAME), '// my own plugin\n'); + assert.equal(pluginStatus({ pkgRoot, pluginsDir }).foreign, true); + const r3 = deployPlugin({ pkgRoot, pluginsDir }); + assert.equal(r3.changed, false); + assert.equal(fs.readFileSync(path.join(pluginsDir, PLUGIN_NAME), 'utf8'), '// my own plugin\n'); + rm(d); +}); + +test('deploySkill + skillStatus + removeArtifacts (marker-gated, precise teardown)', () => { + const d = tmp('ak-oc-skill-'); + const root = makeCatalog(path.join(d, 'catalog')); + const source = catalogSource({ override: root }); + const skillsDir = path.join(d, 'skills'); + const agentsDir = path.join(d, 'agents'); + const pluginsDir = path.join(d, 'plugins'); + + const r = deploySkill({ source, skillsDir }); + assert.equal(r.changed, true); + assert.equal(skillStatus({ source, skillsDir }).current, true); + assert.equal(deploySkill({ source, skillsDir }).changed, false, 'idempotent'); + + // user resource beside the managed file must survive teardown + fs.writeFileSync(path.join(skillsDir, 'ruflo', 'my-notes.md'), 'user notes\n'); + + const pkgRoot = path.join(d, 'pkg'); + fs.mkdirSync(path.join(pkgRoot, 'src', 'templates'), { recursive: true }); + fs.writeFileSync(path.join(pkgRoot, 'src', 'templates', 'opencode-ruflo-hooks.js'), + '// deployed by ak — see src/templates/opencode-ruflo-hooks.js\n'); + deployPlugin({ pkgRoot, pluginsDir }); + syncAgents({ source, destDir: agentsDir }); + fs.writeFileSync(path.join(agentsDir, 'keep.md'), 'user agent\n'); + + const gone = removeArtifacts({ pluginsDir, agentsDir, skillsDir }); + assert.equal(gone.changed, true); + assert.equal(fs.existsSync(path.join(pluginsDir, PLUGIN_NAME)), false); + assert.equal(fs.existsSync(path.join(skillsDir, 'ruflo', 'SKILL.md')), false); + assert.equal(fs.existsSync(path.join(agentsDir, 'coder.md')), false); + assert.equal(fs.readFileSync(path.join(agentsDir, 'keep.md'), 'utf8'), 'user agent\n', 'user agent preserved'); + assert.equal(fs.readFileSync(path.join(skillsDir, 'ruflo', 'my-notes.md'), 'utf8'), 'user notes\n', 'user resource beside managed skill survives'); + assert.equal(fs.existsSync(path.join(skillsDir, 'ruflo')), true, 'non-empty skill dir not pruned'); + rm(d); +}); + +test('deploySkill never overwrites a user-owned SKILL.md (foreign flagged)', () => { + const d = tmp('ak-oc-skill-foreign-'); + const root = makeCatalog(path.join(d, 'catalog')); + const source = catalogSource({ override: root }); + const skillsDir = path.join(d, 'skills'); + fs.mkdirSync(path.join(skillsDir, 'ruflo'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'ruflo', 'SKILL.md'), '---\nname: ruflo\ndescription: my own\n---\n'); + assert.equal(skillStatus({ source, skillsDir }).foreign, true); + const r = deploySkill({ source, skillsDir }); + assert.equal(r.changed, false); + assert.ok(fs.readFileSync(path.join(skillsDir, 'ruflo', 'SKILL.md'), 'utf8').includes('my own')); + rm(d); +}); + +// ── guidance target + registry scoping ─────────────────────────────────────── + +test('guidanceTargets includes agents-opencode only when the config home exists (never mkdir)', () => { + const missing = path.join(os.tmpdir(), `ak-oc-never-${Date.now()}`); + const without = guidanceTargets({ cwd: '/tmp/z', opencodeRoot: missing }); + assert.equal(without.some((t) => t.name === 'agents-opencode'), false); + assert.equal(fs.existsSync(missing), false, 'discovery must never mkdir'); + + const present = tmp('ak-oc-home-'); + const withOc = guidanceTargets({ cwd: '/tmp/z', opencodeRoot: present }); + const t = withOc.find((x) => x.name === 'agents-opencode'); + assert.ok(t, 'target present when dir exists'); + assert.equal(t.file, path.join(present, 'AGENTS.md')); + rm(present); +}); + +test('opencode blocks are scoped to agents-opencode; preamble is shared claude + agents-opencode', () => { + const oc = BUILTIN_BLOCKS.find((b) => b.slug === 'ruflo-opencode-reference'); + const brain = BUILTIN_BLOCKS.find((b) => b.slug === 'ruvnet-brain-opencode-reference'); + const preamble = BUILTIN_BLOCKS.find((b) => b.slug === 'ruflo-preamble'); + assert.deepEqual(oc.guidanceFiles, ['agents-opencode']); + assert.deepEqual(brain.guidanceFiles, ['agents-opencode']); + assert.deepEqual(preamble.guidanceFiles, ['claude', 'agents-opencode']); + // the claude-only twins must NOT bleed into the opencode file + const claudeRef = BUILTIN_BLOCKS.find((b) => b.slug === 'ruflo-reference'); + const claudeBrain = BUILTIN_BLOCKS.find((b) => b.slug === 'ruvnet-brain-reference'); + assert.ok(!(claudeRef.guidanceFiles ?? ['claude']).includes('agents-opencode')); + assert.ok(!(claudeBrain.guidanceFiles ?? ['claude']).includes('agents-opencode')); +}); From a6677a71ffde6f28693eb424ebb4ae95e9284c9c Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 11:46:33 -0700 Subject: [PATCH 02/11] fix(opencode-host): harden ownership + lifecycle after cross-vendor review (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all seven round-2 findings: - ownership: a collision the user later aligns to the desired value stays unmanaged (never adopted with a stale prior); desired-set shrink RESTORES priors instead of deleting entries ak didn't author; permission scalar origin is recorded explicitly (a pre-existing {"*": v} object survives undo as an object) - guidance: the opencode blocks are gated on enablement (opencodeEnabled flag, same mechanism as dualMode) — an installed-but-disabled host no longer receives guidance asserting active wiring, and off → sync strips it - lifecycle: uninstall --purge no longer recreates kit.json via the opencode teardown save; isDefault counts hosts.opencode (claude+opencode is not 'claude-only'); injected routing parts use schema-valid PartIDs (prt prefix) - tests: 6 round-2 cases (collision-align, shrink-restore, wildcard-object preservation, flag-gated blocks, isDefault, PartID) — 31 in the suite, 611+189 across the repo, tsc/eslint/markdownlint clean --- src/commands/setup.mjs | 2 +- src/commands/status.mjs | 2 +- src/commands/sync.mjs | 2 +- src/commands/uninstall.mjs | 5 +- src/lib/blocks.mjs | 11 +-- src/lib/nudge.mjs | 2 +- src/lib/opencode.mjs | 34 ++++++++-- src/lib/providers.mjs | 5 +- src/templates/opencode-ruflo-hooks.js | 3 +- tests/kit/opencode.test.mjs | 98 +++++++++++++++++++++++++++ 10 files changed, 144 insertions(+), 20 deletions(-) diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index fcd91c3..0fbf90a 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -190,7 +190,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const resolve = (r) => (r.custom ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) : path.join(pkgRoot, 'claude', r.template)); - const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; for (const t of guidanceTargets({ cwd: process.cwd(), cfg })) { if (t.name !== 'agents-opencode') continue; const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)]; diff --git a/src/commands/status.mjs b/src/commands/status.mjs index c497329..4b04a1e 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -537,7 +537,7 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { const resolve = (r) => (r.custom ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) : path.join(pkgRoot, 'claude', r.template)); - const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; for (const t of guidanceTargets({ cwd, cfg })) { const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; const res = await syncBlocks(t.file, treg, resolve, { dryRun: true, context: ctx }); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 936b6af..28449a2 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -168,7 +168,7 @@ export async function run({ flags, pkgRoot }) { // also force-strips blocks that no longer belong in it (retiredForTarget) — // the migration path that clears the dual block out of any project AGENTS.md // that still carries it after the re-scope (ADR-0008). - const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; for (const t of guidanceTargets({ cwd, cfg })) { const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 690aa9e..093c475 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -124,11 +124,12 @@ export async function run({ flags }) { } } { - // cfg comes from the top of run() (read before any purge of kit.json) + // cfg comes from the top of run() (read before any purge of kit.json). + // --purge removes kit.json above; persisting cfg here would recreate it. if (cfg.providers?.opencodeMcp === 'ak') { act('stripped ak-managed opencode.json wiring (mcp/skills/permissions)', () => { undoOpencode(cfg); - saveKitConfig(cfg); + if (!flags.purge) saveKitConfig(cfg); }); } if (fs.existsSync(paths.opencodeDir())) { diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index f09a6ab..d4cc942 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -46,22 +46,25 @@ export const BUILTIN_BLOCKS = [ // opencode's ruflo surface: MCP tools are `claude-flow_*` (not // `mcp__claude-flow__*`), hooks arrive via the plugins/ bridge, and agents // are converted subagents — a different enough story to warrant its own - // template rather than reusing ruflo-reference. Gated on the opencode CLI - // being on PATH (mirrors the codex/aqe command detectors). + // template rather than reusing ruflo-reference. Gated on ENABLEMENT (the + // opencodeEnabled flag, same mechanism as dualMode) — the template asserts + // active wiring, so an installed-but-disabled host must not receive it + // (codex-review r2; and `x provider off` → next sync strips it). slug: 'ruflo-opencode-reference', template: 'ruflo-opencode-reference.md', position: 'append', - detector: { type: 'command', target: 'opencode' }, + detector: { type: 'flag', target: 'opencodeEnabled' }, guidanceFiles: ['agents-opencode'], }, { // opencode twin of ruvnet-brain-reference (that slug stays claude-only): // same ground-before-assert rule, but the tool name is the opencode-style // `ruvnet-brain_search_ruvnet` and updates ride the stable-spine shim. + // Likewise enablement-gated: the tool exists in opencode only when wired. slug: 'ruvnet-brain-opencode-reference', template: 'ruvnet-brain-opencode-reference.md', position: 'append', - detector: { type: 'dir', target: '~/.cache/ruvnet-brain/kb' }, + detector: { type: 'flag', target: 'opencodeEnabled' }, guidanceFiles: ['agents-opencode'], }, { diff --git a/src/lib/nudge.mjs b/src/lib/nudge.mjs index a852053..c849002 100644 --- a/src/lib/nudge.mjs +++ b/src/lib/nudge.mjs @@ -40,7 +40,7 @@ export async function localDrift({ pkgRoot, cwd = process.cwd(), cfg, targets } const resolve = (r) => (r.custom ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) : path.join(pkgRoot, 'claude', r.template)); - const ctx = { flags: { dualMode: bothHostsEnabled(cfg) } }; + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; // The SHARED target list + retired-strip composition (blocks.mjs) — the // nudge's contract is "never disagrees with ak status", which a hardcoded // subset silently breaks every time a guidance target is added (codex's diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs index 5980b9a..ed5966a 100644 --- a/src/lib/opencode.mjs +++ b/src/lib/opencode.mjs @@ -156,7 +156,7 @@ function deepEqual(a, b) { * (legacy entries have unknown prior/written → treated conservatively: prior * null, written null → never auto-deleted, only re-recorded on next apply). */ function normalizeManaged(m) { - const out = { mcp: {}, paths: [], permissions: {} }; + const out = { mcp: {}, paths: [], permissions: {}, permissionScalar: null }; if (!m || typeof m !== 'object') return out; const legacyNames = Array.isArray(m.mcp) ? m.mcp : Object.keys(m.mcp ?? {}); for (const n of legacyNames) { @@ -169,6 +169,7 @@ function normalizeManaged(m) { const rec = Array.isArray(m.permissions) ? null : m.permissions[k]; out.permissions[k] = rec && typeof rec === 'object' && 'written' in rec ? rec : { prior: null, written: null }; } + out.permissionScalar = typeof m.permissionScalar === 'string' ? m.permissionScalar : null; return out; } @@ -210,11 +211,14 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op if (name in entries) continue; if (!(name in next.mcp)) continue; if (rec.written && deepEqual(next.mcp[name], rec.written)) { - delete next.mcp[name]; - pruned.push(name); + // RESTORE the prior when there was one (a user entry that happened to + // equal the old desired value is a user value, not ak's to delete); + // delete only what ak itself created (codex-review r2). + if (rec.prior != null) { next.mcp[name] = rec.prior; pruned.push(`${name} (prior restored)`); } + else { delete next.mcp[name]; pruned.push(name); } } // else: user edited (or legacy record) → leave it, keep no ownership } - const managed = { mcp: {}, paths: [], permissions: {} }; + const managed = { mcp: {}, paths: [], permissions: {}, permissionScalar: null }; for (const [name, want] of Object.entries(entries)) { const cur = next.mcp[name]; const priorRec = prevManaged.mcp[name]; @@ -223,6 +227,14 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op managed.mcp[name] = { prior: cur, written: null }; // tracked but NOT ak-owned continue; } + // A previously-colliding (never ak-authored) value the USER has since + // aligned to the desired one stays unmanaged: adopting it with the stale + // pre-collision prior would make undo overwrite the user's own later + // choice (codex-review r2). Noted, not owned. + if (priorRec && priorRec.written == null && cur !== undefined && deepEqual(cur, want)) { + managed.mcp[name] = { prior: priorRec.prior, written: null }; + continue; + } // prior is the ORIGINAL pre-ak value (kept across reapplies), never the // ak-written value currently in place. managed.mcp[name] = { prior: priorRec ? priorRec.prior : (cur ?? null), written: want }; @@ -246,6 +258,12 @@ export async function applyOpencode(cfg, { dryRun = false, configFile = paths.op } // ── permission: lift scalar shorthand, prune stale, merge desired ── + // Record scalar ORIGIN explicitly (codex-review r2): undo restores the + // scalar form only when the file actually started scalar — a pre-existing + // {"*":"ask"} object must survive as an object, not be "restored" to "ask". + managed.permissionScalar = typeof doc.permission === 'string' + ? doc.permission + : (prevManaged.permissionScalar ?? null); if (typeof next.permission === 'string') next.permission = { '*': next.permission }; next.permission = { ...(next.permission ?? {}) }; for (const [k, rec] of Object.entries(prevManaged.permissions)) { @@ -326,11 +344,13 @@ export function undoOpencode(cfg, { configFile = paths.opencodeConfigPath() } = } } - const hadScalarStar = typeof doc.permission === 'object' && doc.permission?.['*'] != null; + const scalarOrigin = managed.permissionScalar ?? null; for (const [k, rec] of Object.entries(managed.permissions)) restore(doc.permission, k, rec, `permission.${k}`); if (doc.permission && Object.keys(doc.permission).length === 0) delete doc.permission; - else if (doc.permission && hadScalarStar && Object.keys(doc.permission).length === 1 && doc.permission['*'] != null) { - doc.permission = doc.permission['*']; // restore the scalar shorthand we lifted + else if (doc.permission && scalarOrigin != null && Object.keys(doc.permission).length === 1 && doc.permission['*'] != null) { + // restore the scalar shorthand we lifted (only when scalar was the ORIGIN; + // the current '*' value is what collapses back — '*' is never ak-managed) + doc.permission = doc.permission['*']; changed = true; } diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 98e8179..0a108ee 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -326,10 +326,11 @@ function currentEnv(cwd) { /** True when providers config is untouched (claude host only, aqe left on its own * default). Keeps the heal a deliberate no-op so existing users see zero change - * until they opt in. */ + * until they opt in. ANY non-claude host (codex OR opencode) breaks the default — + * an enabled opencode host is not "claude-only" (codex-review r2). */ export function isDefault(cfg) { const p = cfg.providers ?? {}; - return !!p.hosts?.claude && !p.hosts?.codex && p.aqeProvider == null + return !!p.hosts?.claude && !p.hosts?.codex && !p.hosts?.opencode && p.aqeProvider == null && (!p.models || p.models.length === 0) && (p.maxBudgetUsd == null) && (!p.aqeFallback || p.aqeFallback.length === 0); } diff --git a/src/templates/opencode-ruflo-hooks.js b/src/templates/opencode-ruflo-hooks.js index 3e188e1..be6b7a9 100644 --- a/src/templates/opencode-ruflo-hooks.js +++ b/src/templates/opencode-ruflo-hooks.js @@ -147,7 +147,8 @@ const plugin = async ({ client }) => { // it as injected context, not user content. const messageID = output?.message?.id ?? input?.messageID output.parts.push({ - id: `part_ruflo_route_${Date.now().toString(36)}`, + // opencode PartID schema requires the "prt" prefix (schema.ts) + id: `prt_ruflo_route_${Date.now().toString(36)}`, messageID, sessionID: input?.sessionID ?? output?.message?.sessionID, type: "text", diff --git a/tests/kit/opencode.test.mjs b/tests/kit/opencode.test.mjs index aee6775..a1341cd 100644 --- a/tests/kit/opencode.test.mjs +++ b/tests/kit/opencode.test.mjs @@ -486,3 +486,101 @@ test('opencode blocks are scoped to agents-opencode; preamble is shared claude + assert.ok(!(claudeRef.guidanceFiles ?? ['claude']).includes('agents-opencode')); assert.ok(!(claudeBrain.guidanceFiles ?? ['claude']).includes('agents-opencode')); }); + +// ── codex-review round 2 ───────────────────────────────────────────────────── + +test('collision → user aligns to desired value: stays unmanaged, undo never restores the stale prior', async () => { + const d = tmp('ak-oc-align-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'absent-shim'); + const userEntry = { type: 'local', command: ['my', 'own'], timeout: 5 }; + fs.writeFileSync(file, JSON.stringify({ mcp: { 'claude-flow': userEntry } })); + const cfg = cfgOn(); + // 1. collide (user value differs) + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + // 2. user rewrites their entry to ak's exact desired value + const want = (await mcpEntriesFor({ brainShim: shim }))['claude-flow']; + fs.writeFileSync(file, JSON.stringify({ mcp: { 'claude-flow': want } })); + // 3. re-apply: must NOT adopt with the stale pre-collision prior + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + assert.equal(cfg.providers.opencodeManaged.mcp['claude-flow'].written, null, 'never owned (not ak-authored)'); + // 4. undo: the user's aligned value must survive — no stale-prior restore + undoOpencode(cfg, { configFile: file }); + assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')).mcp['claude-flow'], want); + rm(d); +}); + +test('desired-set shrink RESTORES a prior (user entry that equaled the old desired value is not deleted)', async () => { + const d = tmp('ak-oc-restore-'); + const file = path.join(d, 'opencode.json'); + const shim = path.join(d, 'brain.mjs'); + fs.writeFileSync(shim, '// shim\n'); + const cfg = cfgOn(); + // user pre-registers an entry that happens to equal ak's desired brain entry + const want = (await mcpEntriesFor({ brainShim: shim }))['ruvnet-brain']; + fs.writeFileSync(file, JSON.stringify({ mcp: { 'ruvnet-brain': want } })); + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + fs.rmSync(shim); // brain leaves the desired set + await applyOpencode(cfg, { configFile: file, brainShim: shim }); + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.deepEqual(doc.mcp['ruvnet-brain'], want, 'prior value restored, not deleted'); + rm(d); +}); + +test('a pre-existing {"*":"ask"} permission OBJECT survives undo as an object (no phantom scalar restore)', async () => { + const d = tmp('ak-oc-starobj-'); + const file = path.join(d, 'opencode.json'); + fs.writeFileSync(file, JSON.stringify({ permission: { '*': 'ask' } })); + const cfg = cfgOn(); + await applyOpencode(cfg, { configFile: file, brainShim: path.join(d, 'absent-shim') }); + assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).permission['claude-flow_*'], 'allow'); + undoOpencode(cfg, { configFile: file }); + const after = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.deepEqual(after.permission, { '*': 'ask' }, 'object form preserved, not collapsed to "ask"'); + rm(d); +}); + +test('opencode guidance blocks are enablement-gated (flag): absent when disabled, stripped on disable', async () => { + const { registry, syncBlocks, blocksForTarget, retiredForTarget } = await import('../../src/lib/blocks.mjs'); + const d = tmp('ak-oc-flag-'); + const file = path.join(d, 'AGENTS.md'); + const tplDir = path.join(d, 'tpl'); + fs.mkdirSync(tplDir); + fs.writeFileSync(path.join(tplDir, 'tpl.md'), '\nBLOCK\n\n'); + const rows = registry(); + const resolve = (row) => { + const body = fs.readFileSync(path.join(tplDir, 'tpl.md'), 'utf8').replaceAll('SLUG', row.slug); + const p = path.join(tplDir, `${row.slug}.md`); + fs.writeFileSync(p, body); + return p; + }; + const treg = [...blocksForTarget(rows, 'agents-opencode'), ...retiredForTarget(rows, 'agents-opencode')]; + // disabled → the enablement-gated blocks do NOT upsert (the host-agnostic + // preamble legitimately still can — it asserts no wiring) + const off = await syncBlocks(file, treg, resolve, { context: { flags: { opencodeEnabled: false } } }); + for (const slug of ['ruflo-opencode-reference', 'ruvnet-brain-opencode-reference']) { + assert.ok(!off.some((r) => r.slug === slug && r.action === 'upserted'), `${slug} must not upsert while disabled`); + } + // enabled → upserted + const on = await syncBlocks(file, treg, resolve, { context: { flags: { opencodeEnabled: true } } }); + assert.ok(on.some((r) => r.slug === 'ruflo-opencode-reference' && r.action === 'upserted')); + assert.ok(on.some((r) => r.slug === 'ruvnet-brain-opencode-reference' && r.action === 'upserted')); + // disabled again → stripped + const off2 = await syncBlocks(file, treg, resolve, { context: { flags: { opencodeEnabled: false } } }); + assert.ok(off2.some((r) => r.slug === 'ruflo-opencode-reference' && r.action === 'stripped')); + assert.ok(!fs.readFileSync(file, 'utf8').includes('BEGIN ruflo-opencode-reference')); + rm(d); +}); + +test('isDefault is false when the opencode host is enabled', async () => { + const { isDefault } = await import('../../src/lib/providers.mjs'); + assert.equal(isDefault(cfgOn()), false); + assert.equal(isDefault({ providers: { hosts: { claude: true, codex: false, opencode: false } } }), true); +}); + +test('the deployed plugin template uses schema-valid PartIDs (prt prefix) for injected parts', () => { + const tpl = fs.readFileSync(new URL('../../src/templates/opencode-ruflo-hooks.js', import.meta.url), 'utf8'); + const m = tpl.match(/id:\s*`([^`]+)`/); + assert.ok(m, 'template constructs a part id'); + assert.ok(m[1].startsWith('prt'), `part id must start with "prt" (opencode PartID schema), got: ${m[1]}`); +}); From 663de7274cbef81b42a633aee70189cba9611008 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 12:39:31 -0700 Subject: [PATCH 03/11] fix(opencode-host): just-work fresh-machine chain + restart guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcpCommandFor: claude-flow MCP command resolves bin-on-PATH → nested ruflo/node_modules/@claude-flow/cli/bin/mcp-server.js (absolute node path, no PATH/cwd dependence — the plain 'npm i -g ruflo' layout, verified) → ruflo mcp start. A fresh ruflo-only machine gets the purpose-built stdio server instead of the last-resort path. - setup prints the restart hint after wiring: opencode loads config/plugins/MCP/agents once at startup (observed live: sessions miss hooks/tools until restarted; the guidance template now says the same). - TROUBLESHOOTING.md: four opencode rows (startup loading, JSONC refusal, no-clobber 'foreign', missing catalog source). - test: three-way mcpCommandFor selection — 32 in the suite, 612+189 repo-wide. --- claude/ruflo-opencode-reference.md | 5 +++++ docs/TROUBLESHOOTING.md | 4 ++++ src/commands/setup.mjs | 3 +++ src/lib/opencode.mjs | 29 ++++++++++++++++++++++------- tests/kit/opencode.test.mjs | 11 +++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/claude/ruflo-opencode-reference.md b/claude/ruflo-opencode-reference.md index 7663104..65a716f 100644 --- a/claude/ruflo-opencode-reference.md +++ b/claude/ruflo-opencode-reference.md @@ -23,6 +23,11 @@ security). On this machine it is wired into opencode three ways (all managed by opencode subagents under `~/.config/opencode/agents/` (re-converted on every `ak sync` after a ruflo upgrade). +**Restart after wiring.** opencode loads config, plugins, MCP servers, and +agents once at startup. After `ak setup --opencode` (or any `ak sync` that +updates the plugin), quit and restart opencode — a running session will not +see new hooks, tools, or agents. + ### Most-used commands ```bash diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bffd5ff..8a30971 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -27,6 +27,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: `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/0011-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` 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 `providers.opencodeCatalogDir` / `$RUFLO_REPO` at a ruflo checkout | | `ruflo memory store` says OK but reads return nothing | Absolute-DB-path pin missing, or WAL not checkpointed, or the WASM fallback above | `ak setup` in the project re-pins + verifies a real write lands on disk | | `status` shows a `memory-pin` warning | `CLAUDE_FLOW_DB_PATH` is pinned to a dead or foreign path, so every memory op targets the wrong DB ("Database not initialized" beside a healthy in-repo DB). The pin may be deliberate, so `sync` never touches it | repoint (or remove) the pin in `.claude/settings.local.json` `env` | | `ak dual run` refuses to start ("ruflo's memory runtime lacks a native better-sqlite3 binding AND … active native WAL") | Pre-flight guard: the orchestrator's native WAL writer and the WASM `ruflo memory store` would share one DB and corrupt it. It refuses **before** spawning a worker rather than crashing mid-run | `ak sync` builds the native binding for ruflo's memory runtime, then retry the `dual run` | diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 0fbf90a..97575a1 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -198,6 +198,9 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const changed = res.filter((r) => r.action !== 'unchanged').map((r) => `${r.slug} ${r.action}`).join(', '); ok(`opencode guidance: ${changed || 'in sync'}`); } + // 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)'); } } diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs index ed5966a..5c3639b 100644 --- a/src/lib/opencode.mjs +++ b/src/lib/opencode.mjs @@ -55,18 +55,33 @@ export const PERMISSION_KEYS = ['claude-flow_*', 'claude_flow_*', 'ruvnet-brain_ /** The brain's stable-spine shim (same registration codex carries). */ export const brainShimPath = () => path.join(paths.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs'); +/** The dedicated stdio MCP server bundled inside a plain `npm i -g ruflo` + * install (nested dependency — present even when no claude-flow-mcp bin is + * on PATH). */ +export const nestedMcpServerPath = () => + path.join(paths.rufloNodeModules(), '@claude-flow', 'cli', 'bin', 'mcp-server.js'); + +/** The claude-flow MCP command, best-available-first: the claude-flow-mcp bin + * on PATH → the nested mcp-server.js via absolute node path (no PATH/cwd + * dependence — the fresh ruflo-only machine case) → `ruflo mcp start` (ak's + * claude/codex registration path, always present when ruflo is). Pure. */ +export function mcpCommandFor({ binPresent, nestedPath }) { + if (binPresent) return ['claude-flow-mcp']; + if (nestedPath && fs.existsSync(nestedPath)) return ['node', nestedPath]; + return ['ruflo', 'mcp', 'start']; +} + /** @typedef {{ kind: string, root: string, id: string, hasPlugins: boolean, hasPlatformSkill: boolean }} CatalogSource */ -/** The MCP server entries ak writes. `claude-flow` prefers the dedicated - * claude-flow-mcp bin (purpose-built stdio server) and falls back to - * `ruflo mcp start` (ak's claude/codex registration) when it is absent. - * ruvnet-brain is included only when its shim is on disk. - * @param {{ brainShim?: string }} [opts] */ -export async function mcpEntriesFor({ brainShim = brainShimPath() } = {}) { +/** 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() } = {}) { const entries = { 'claude-flow': { type: 'local', - command: (await have('claude-flow-mcp')) ? ['claude-flow-mcp'] : ['ruflo', 'mcp', 'start'], + command: mcpCommandFor({ binPresent: await have('claude-flow-mcp'), nestedPath }), enabled: true, timeout: 30000, environment: { ...RUFLO_MCP_ENV }, diff --git a/tests/kit/opencode.test.mjs b/tests/kit/opencode.test.mjs index a1341cd..018c43d 100644 --- a/tests/kit/opencode.test.mjs +++ b/tests/kit/opencode.test.mjs @@ -584,3 +584,14 @@ test('the deployed plugin template uses schema-valid PartIDs (prt prefix) for in assert.ok(m, 'template constructs a part id'); assert.ok(m[1].startsWith('prt'), `part id must start with "prt" (opencode PartID schema), got: ${m[1]}`); }); + +test('mcpCommandFor: bin on PATH → nested mcp-server.js → ruflo mcp start (fresh-machine chain)', async () => { + const { mcpCommandFor } = await import('../../src/lib/opencode.mjs'); + const d = tmp('ak-oc-mcpcmd-'); + const nested = path.join(d, 'mcp-server.js'); + fs.writeFileSync(nested, '// server\n'); + assert.deepEqual(mcpCommandFor({ binPresent: true, nestedPath: nested }), ['claude-flow-mcp'], 'bin wins when present'); + assert.deepEqual(mcpCommandFor({ binPresent: false, nestedPath: nested }), ['node', nested], 'nested absolute path when no bin (fresh ruflo-only machine)'); + assert.deepEqual(mcpCommandFor({ binPresent: false, nestedPath: path.join(d, 'absent.js') }), ['ruflo', 'mcp', 'start'], 'last resort matches the claude/codex registration'); + rm(d); +}); From bde0f0a2f45d02efed063d6f0829920c209c4f55 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 20:55:17 -0700 Subject: [PATCH 04/11] =?UTF-8?q?feat(opencode-host):=20two-tier=20host=20?= =?UTF-8?q?model=20=E2=80=94=20provider=20pick=20manages=20opencode=20as?= =?UTF-8?q?=20integration=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's core review point: the PR called opencode a third host but the ordinary host-management command refused it, leaving existing users no first-class adoption path. Model is now coherent: - Managed host integrations (install/config/guidance/status/sync/teardown): claude, codex, opencode — derived from the HOSTS[].routing capability flag, not hardcoded id lists (the seam issue #71's registry lands on). - Routing hosts (primaryHost + per-activity seeds): claude, codex only. pick: --host accepts opencode as the complete desired enabled-host set; --primary-host stays claude/codex; opencode never enters dualRouting; an unknown --host token is a hard error before any mutation; interactive defaults are enabled hosts ∪ newly-detected routing hosts (an enabled-but- absent host is never dropped by a bare enter, an installed-but-disabled integration host is never opted in by one). Excluding opencode disables it via the same marker-gated teardown as off/uninstall. Every pick rewrite preserves ALL ownership markers (codexMcp/rufloCodexMcp included — the data-loss class the review called out). setup/sync/pick/off/uninstall now share ONE owner-module composition (opencodeStack / retireOpencode / reconcileOpencodeGuidance) — no merge or ownership logic in commands. opencodeStack reports markersChanged so a converged file with stale markers still re-persists the teardown proof; retireOpencode clears markers on absent config and fails honestly on JSONC (markers retained, wiring reported active, manual remediation named). sync's blocks branch runs after the opencode branch with an opencode guard, so a fresh enable converges guidance in one sync (second sync is a true no-op). Also removes the unused versions.mjs imports that broke pnpm run check. Cross-vendor review (Codex, round 3): 4 findings, all fixed here or in the accompanying tests; codex-side bridge teardown on routing-host exclusion deliberately left as-is (pre-existing behavior; 'provider off' is the documented full reset). --- src/commands/setup.mjs | 44 ++++---- src/commands/status.mjs | 2 +- src/commands/sync.mjs | 79 ++++++++------ src/commands/uninstall.mjs | 19 ++-- src/commands/x/provider.mjs | 198 +++++++++++++++++++++++++++--------- src/lib/opencode.mjs | 89 +++++++++++++++- 6 files changed, 314 insertions(+), 117 deletions(-) diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 97575a1..ade1d0c 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -10,11 +10,11 @@ import readline from 'node:readline/promises'; import { run as runCmd, have } from '../lib/exec.mjs'; import * as heal from '../lib/heal.mjs'; import { fixStatusline } from '../lib/statusline.mjs'; -import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; +import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; -import { applyOpencode, deployPlugin, syncAgents, deploySkill, catalogSource } from '../lib/opencode.mjs'; +import { opencodeStack, reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { commandHosts, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; +import { HOSTS, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; @@ -58,7 +58,8 @@ Options: --opencode enable the opencode host during setup: wires opencode.json (claude-flow + ruvnet-brain MCP, skills paths, permissions), deploys the lifecycle plugin + platform skill, and converts - the ruflo agent set into opencode subagents. + the ruflo agent set into opencode subagents. Already set up? + Use: ak x provider pick --host claude,opencode --primary-host which host leads: claude|codex (default claude). Passing codex implies --codex and mirrors the routing defaults so codex drives with claude as the alternate. @@ -156,7 +157,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // 6. frontier hosts — install any ENABLED host that is entirely absent (default // enables claude only). External installs (mise/native/brew) are left alone. - for (const h of commandHosts()) { + for (const h of HOSTS) { if (!cfg.providers?.hosts?.[h.id]) continue; const st = await hostInstallState(h); if (st.method === 'absent') { @@ -177,27 +178,16 @@ export async function run_machine({ flags, pkgRoot, cfg }) { if (!(await have('opencode'))) { warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); } else { - const oc = await applyOpencode(cfg); - (oc.ok ? ok : warn)(`opencode: ${oc.detail}`); - ok(`opencode plugin: ${deployPlugin({ pkgRoot }).detail}`); - const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); - ok(`opencode agents: ${syncAgents({ source }).detail}`); - const sk = deploySkill({ source }); - if (sk.changed) ok(`opencode skill: ${sk.detail}`); + const stack = await opencodeStack(cfg, { pkgRoot }); + (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); + ok(`opencode plugin: ${stack.plugin.detail}`); + ok(`opencode agents: ${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. - const rows = registry(cfg.customBlocks); - const resolve = (r) => (r.custom - ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) - : path.join(pkgRoot, 'claude', r.template)); - const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; - for (const t of guidanceTargets({ cwd: process.cwd(), cfg })) { - if (t.name !== 'agents-opencode') continue; - const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)]; - const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); - const changed = res.filter((r) => r.action !== 'unchanged').map((r) => `${r.slug} ${r.action}`).join(', '); - ok(`opencode guidance: ${changed || 'in sync'}`); - } + // — not on the next status-driven reconcile. Same shared reconcile pick + // and off use, so every command converges guidance identically. + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); + 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)'); @@ -208,9 +198,9 @@ export async function run_machine({ flags, pkgRoot, cfg }) { if (!cfg.providers?.hosts?.codex && await have('codex')) { info('codex CLI detected — run `ak host pick` to let ruflo use both claude and codex'); } - // opencode hint — detected but not enabled (opt-in via `setup --opencode`) + // opencode hint — detected but not enabled (post-install opt-in via provider pick) if (!cfg.providers?.hosts?.opencode && await have('opencode')) { - info('opencode CLI detected — wire ruflo + ruvnet-brain into it with: ak setup --opencode'); + info('opencode CLI detected — wire ruflo + ruvnet-brain into it with: ak x provider pick --host claude,opencode'); } return true; } diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 4b04a1e..16ebc99 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -434,7 +434,7 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('providers', 'info', 'claude-only (default host)')); } if (!cfg.providers?.hosts?.opencode && await have('opencode')) { - rows.push(row('providers', 'info', 'opencode CLI installed but not enabled (`ak setup --opencode` wires it)')); + rows.push(row('providers', 'info', 'opencode CLI installed but not enabled (`ak x provider pick --host claude,opencode` wires it)')); } } else { const desired = managedEnv(cfg); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 28449a2..55505dd 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -8,7 +8,7 @@ import { have } from '../lib/exec.mjs'; import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; -import { applyOpencode, deployPlugin, syncAgents, deploySkill, catalogSource } from '../lib/opencode.mjs'; +import { opencodeStack } from '../lib/opencode.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedDualRoutingIfDualHost, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; @@ -155,30 +155,6 @@ export async function run({ flags, pkgRoot }) { (r.killed ? ok : warn)(`daemon pid=${r.pid}: ${r.killed ? 'reaped' : 'could not stop'}`); } } - if (subsystems.has('blocks') || subsystems.has('versions')) { - const rowsReg = registry(cfg.customBlocks); - const resolve = (r) => (r.custom - ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) - : path.join(pkgRoot, 'claude', r.template)); - // Three guidance targets (guidanceTargets): machine-wide ~/.claude/CLAUDE.md - // (claude), the project's own /AGENTS.md (agents), and — only when - // ~/.codex exists — machine-wide ~/.codex/AGENTS.md (agents-user). The - // dual-mode block's flag detector gates it on both hosts being enabled, so - // single-host setups leave the agents files untouched (no .bak). Each target - // also force-strips blocks that no longer belong in it (retiredForTarget) — - // the migration path that clears the dual block out of any project AGENTS.md - // that still carries it after the re-scope (ADR-0008). - const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; - for (const t of guidanceTargets({ cwd, cfg })) { - const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; - const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); - const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') - .map((r) => `${r.slug} ${r.action}`).join(', '); - // stay quiet on the agents targets unless they actually changed (single-host - // leaves them unmanaged); always report the claude target. - if (t.name === 'claude' || changed) ok(`blocks(${t.label}): ${changed || 'in sync'}`); - } - } // hosts: install any ENABLED host that is entirely absent (updates to // npm-managed hosts ride the versions branch above via driftReport). if (subsystems.has('hosts')) { @@ -193,18 +169,55 @@ export async function run({ flags, pkgRoot }) { // 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). + // Runs BEFORE the blocks branch: the agents-opencode guidance target is gated + // on the config home this branch creates — this order lets a fresh enable + // converge guidance in the SAME sync (a second sync is then a true no-op). if (subsystems.has('opencode') && cfg.providers?.hosts?.opencode) { if (!(await have('opencode'))) { info('opencode: enabled but CLI not installed — wiring skipped (hosts step installs it)'); } else { - const oc = await applyOpencode(cfg); - if (oc.changed) saveKitConfig(cfg); // persist opencodeMcp/opencodeManaged markers - if (oc.changed || !oc.ok) report('opencode', oc); - report('opencode plugin', deployPlugin({ pkgRoot })); - const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); - report('opencode agents', syncAgents({ source })); - const sk = deploySkill({ source }); - if (sk.changed || !sk.ok) report('opencode skill', sk); + const stack = await opencodeStack(cfg, { pkgRoot }); + // persist the markers on ANY refresh (a converged file whose kit.json + // markers are stale/missing still needs the save, or the next teardown + // cannot prove ownership — codex-review r3), not only on file changes. + 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); + if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill); + } + } + // The 'opencode' guard: the opencode branch above can CREATE the config home + // that activates the agents-opencode guidance target — a machine whose other + // guidance is already converged (no blocks drift rows) would otherwise skip + // this branch on a fresh enable and land the guidance one sync late + // (codex-review r3). When the CLI is absent the target's own config-home + // gate still refuses to fabricate anything. + if (subsystems.has('blocks') || subsystems.has('versions') || subsystems.has('opencode')) { + const rowsReg = registry(cfg.customBlocks); + const resolve = (r) => (r.custom + ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) + : path.join(pkgRoot, 'claude', r.template)); + // Guidance targets (guidanceTargets): machine-wide ~/.claude/CLAUDE.md + // (claude), the project's own /AGENTS.md (agents), machine-wide + // ~/.codex/AGENTS.md when ~/.codex exists (agents-user), and opencode's + // ~/.config/opencode/AGENTS.md when its config home exists + // (agents-opencode — created by the opencode branch above on a fresh + // enable). The dual-mode block's flag detector gates it on both hosts being + // enabled, so single-host setups leave the agents files untouched (no + // .bak). Each target also force-strips blocks that no longer belong in it + // (retiredForTarget) — the migration path that clears the dual block out of + // any project AGENTS.md that still carries it after the re-scope + // (ADR-0008). + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.providers?.hosts?.opencode } }; + for (const t of guidanceTargets({ cwd, cfg })) { + const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; + const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); + const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') + .map((r) => `${r.slug} ${r.action}`).join(', '); + // stay quiet on the agents targets unless they actually changed (single-host + // leaves them unmanaged); always report the claude target. + if (t.name === 'claude' || changed) ok(`blocks(${t.label}): ${changed || 'in sync'}`); } } if (subsystems.has('providers') || subsystems.has('routing') || subsystems.has('codex-mcp')) { diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 093c475..cd5af9c 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -9,7 +9,7 @@ import readline from 'node:readline/promises'; import { run as runCmd } from '../lib/exec.mjs'; import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs'; import { unregister } from '../lib/mcp.mjs'; -import { undoOpencode, removeArtifacts } from '../lib/opencode.mjs'; +import { retireOpencode } from '../lib/opencode.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { present as rbPresent } from '../lib/ruvnet-brain.mjs'; import * as paths from '../lib/paths.mjs'; @@ -127,13 +127,18 @@ export async function run({ flags }) { // cfg comes from the top of run() (read before any purge of kit.json). // --purge removes kit.json above; persisting cfg here would recreate it. if (cfg.providers?.opencodeMcp === 'ak') { - act('stripped ak-managed opencode.json wiring (mcp/skills/permissions)', () => { - undoOpencode(cfg); + if (dry) info('[dry-run] stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)'); + else { + const ret = retireOpencode(cfg); if (!flags.purge) saveKitConfig(cfg); - }); - } - if (fs.existsSync(paths.opencodeDir())) { - act('removed ak-deployed opencode artifacts (plugin/agents/skill)', () => removeArtifacts({})); + (ret.ok ? ok : warn)(ret.ok + ? 'stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)' + : `opencode teardown incomplete — ${ret.undo.detail}`); + } + } else if (fs.existsSync(paths.opencodeDir())) { + // Not ak-managed (or never enabled): artifacts are still marker-gated, so + // only ak-deployed files leave — user-owned agents/skills/plugins stay. + act('removed ak-deployed opencode artifacts (plugin/agents/skill)', () => { retireOpencode(cfg); }); } } diff --git a/src/commands/x/provider.mjs b/src/commands/x/provider.mjs index f5b7077..47126d7 100644 --- a/src/commands/x/provider.mjs +++ b/src/commands/x/provider.mjs @@ -16,9 +16,9 @@ import { } from '../../lib/providers.mjs'; import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; -import { undoOpencode, removeArtifacts } from '../../lib/opencode.mjs'; +import { opencodeStack, retireOpencode, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; +import { have } from '../../lib/exec.mjs'; import { ok, warn, fail, info, dim, bold, yellow } from '../../lib/output.mjs'; -import { installedVersion, cmpVersions } from '../../lib/versions.mjs'; import { repoRoot } from '../../lib/paths.mjs'; import { writeJsonWithBackup } from '../../lib/settings.mjs'; import { panelFromRouting, validatePanel, readQeCourtConfig, qeCourtConfigPath, vendorOf, qeCourtShipped } from '../../lib/qeCourt.mjs'; @@ -52,9 +52,18 @@ export const AQE_BILLING_HINT = 'billing: claude-code = your Claude subscription export const help = `ak host — frontier-host + LLM-provider detection and wiring -Two independent axes: which host CLI runs the ruflo loop (claude/codex, can be -both), and which LLM the routers use (aqe + ruflo). Mirrors \`ak x mcp\`: detect → -persist to kit.json → idempotent heal. \`ak sync\` reapplies your choice. +Two independent axes: which host CLIs run the ruflo loop, and which LLM the +routers use (aqe + ruflo). Mirrors \`ak x mcp\`: detect → persist to kit.json → +idempotent heal. \`ak sync\` reapplies your choice. + +Host model — three managed host integrations, two routing hosts: + claude, codex routing hosts: env-wired, one is primary, dual-host seeds the + per-activity routing policy (they drive QE work) + opencode managed integration host: config-file wiring (opencode.json + MCP + skills + permissions), lifecycle plugin, converted ruflo + agents, platform skill — never a routing target, never primary, + never an aqe provider +\`pick\` manages ALL THREE: enable/disable opencode here exactly like claude/codex. Subcommands: status (default) detected CLIs, aqe provider, ruflo providers, what's wired @@ -65,10 +74,15 @@ Subcommands: off reversible teardown (reset to claude-only; strip managed env keys) Options (pick, all optional — omit for interactive): - --host claude,codex enable these ruflo host CLIs - --primary-host claude|codex which host leads (default claude); codex-primary - mirrors the routing defaults so codex drives and - claude is the alternate + --host the complete desired enabled-host set, e.g. + claude,codex or claude,opencode (opencode is + wired + guided, never routed; excluding an + enabled host here DISABLES it — ak-managed + wiring is stripped, user config preserved) + --primary-host claude|codex which host leads (default claude; routing hosts + only); codex-primary mirrors the routing + defaults so codex drives and claude is the + alternate --aqe-provider set aqe's primary LLM (or 'none' to unset) billing: claude-code = Claude sub ($0), ollama/onnx = local ($0), all others = metered key @@ -95,6 +109,9 @@ your edits are preserved across syncs. ${formatModelHelp()} Examples: ak host show what's detected + wired + routing ak host pick --host claude,codex + ak host pick --host claude,opencode + ak host pick --host claude,codex,opencode + ak host pick --host claude disable codex + opencode; preserve user config ak host pick --route 'testing:claude:claude-sonnet-5' ak host refresh --activity architecture,design ak host off`; @@ -110,13 +127,13 @@ const parseFallback = (str) => str.split(';').map((s) => s.trim()).filter(Boolea return { provider: provider.trim().toLowerCase(), models: (models ?? '').split(',').map((m) => m.trim()).filter(Boolean) }; }); -export async function run({ flags, positionals }) { +export async function run({ flags, positionals, pkgRoot }) { const sub = positionals[0] ?? 'status'; const cwd = process.cwd(); if (sub === 'status') return status({ flags, cwd }); - if (sub === 'off') return off({ cwd }); - if (sub === 'pick') return pick({ flags, cwd }); + if (sub === 'off') return off({ cwd, pkgRoot }); + if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); if (sub === 'refresh') return refresh({ flags, cwd }); fail(`unknown provider subcommand: ${sub} (status|pick|refresh|off)`); @@ -145,10 +162,15 @@ async function status({ flags, cwd }) { : dflt ? 'enabled (default — ruflo default-on, no env written)' : d.wired ? 'enabled, wired' : 'enabled, not wired → ak sync'; + // The two host tiers, visible rather than implicit: claude/codex are the + // routing pair (primary + per-activity policy); opencode is a managed + // integration host — wired + guided, never a routing target. + const tier = h.id === 'opencode' ? dim(' · integration host (never routed)') + : dim(' · routing host'); // auth/billing axis — subscription ($0) vs metered key, per host. const auth = d.present ? hostAuthState(h.id, { present: true }) : null; const authStr = auth ? dim(` ${auth.mode}/${auth.billing === 'subscription' ? '$0' : auth.billing}`) : ''; - console.log(` ${h.id.padEnd(7)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}${authStr}`); + console.log(` ${h.id.padEnd(9)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}${authStr}${tier}`); } // agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain @@ -197,9 +219,11 @@ async function status({ flags, cwd }) { printQeCourtStatus(cwd); const codexIdle = hosts.codex.present && !cfg.providers.hosts.codex; + const ocIdle = hosts.opencode.present && !cfg.providers.hosts.opencode; console.log(''); if (codexIdle) info('codex is installed but disabled — enable it with: ak host pick'); - else ok('provider config reflects installed CLIs'); + if (ocIdle) info('opencode is installed but disabled — enable it with: ak host pick --host claude,opencode'); + if (!codexIdle && !ocIdle) ok('host/provider config reflects installed CLIs'); printDualHostTips(cfg); return 0; } @@ -279,21 +303,28 @@ async function refresh({ flags, cwd }) { return 0; } -async function off({ cwd }) { +async function off({ cwd, pkgRoot }) { const cfg = loadKitConfig(); const codexMcpManaged = cfg.providers?.codexMcp === 'ak'; const rufloCodexManaged = cfg.providers?.rufloCodexMcp === 'ak'; // opencode teardown reads the ownership markers from cfg — strip BEFORE the // reset below clears them (mirrors the codex managed-flag captures above). - const oc = undoOpencode(cfg); - const art = removeArtifacts({}); - cfg.providers = { hosts: { claude: true, codex: false, opencode: false }, primaryHost: 'claude', aqeProvider: null, aqeFallback: [], models: [], maxBudgetUsd: null, dualRouting: {}, codexMcp: null, rufloCodexMcp: null, opencodeMcp: null, opencodeManaged: null }; + // On a FAILED teardown (e.g. JSONC config) the markers are the only remaining + // proof — preserve them for the retry instead of nulling them into the reset. + const ret = retireOpencode(cfg); + const keptMarkers = ret.ok ? { opencodeMcp: null, opencodeManaged: null } + : { opencodeMcp: cfg.providers.opencodeMcp, opencodeManaged: cfg.providers.opencodeManaged }; + cfg.providers = { hosts: { claude: true, codex: false, opencode: false }, primaryHost: 'claude', aqeProvider: null, aqeFallback: [], models: [], maxBudgetUsd: null, dualRouting: {}, codexMcp: null, rufloCodexMcp: null, ...keptMarkers }; saveKitConfig(cfg); + // enablement-gated guidance strips regardless (user content preserved). + if (pkgRoot) await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: false }); const env = undoProviders(cwd); const router = undoAqeRouter(cwd); const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); - ok(`reset to claude-only default — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}; opencode: ${oc.detail}; ${art.detail}`); + const ocLine = ret.ok ? `opencode: ${ret.undo.detail}; ${ret.artifacts.detail}` + : `opencode teardown incomplete — ${ret.undo.detail}`; + (ret.ok ? ok : warn)(`reset to claude-only default — ${env.detail}; ${router.detail}; ${mcp.detail}; ${rmcp.detail}; ${ocLine}`); return 0; } @@ -345,14 +376,20 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv ok(`qe-court routing updated: ${changes.map(([role, p]) => `${role}→${p}`).join(', ')}`); } -async function pick({ flags, cwd }) { +async function pick({ flags, cwd, pkgRoot }) { const cfg = loadKitConfig(); const hosts = await detectHosts(cwd); - // pick is the claude↔codex ROUTING chooser: opencode (config-file wiring, - // ADR-0015) is deliberately NOT a choice here — selecting it would have no - // effect (routing seeds/projection cover only claude+codex). Its enablement - // flag is preserved verbatim in the cfg write below instead. - const PICKABLE = new Set(HOSTS.filter((h) => h.id !== 'opencode').map((h) => h.id)); + // pick manages ALL managed host integrations (ADR-0015 two-tier model): + // routing hosts — primary + per-activity policy seeds (HOSTS.routing) + // integration hosts — config-file wiring via opencode.mjs's owner module; + // never primary, never routed, never aqe + // The split is DERIVED from the host descriptors' capability flag, not a + // hardcoded id list (the seam issue #71's capability registry lands on). + // --host is the complete desired enabled-host set on BOTH tiers; excluding an + // enabled host disables it (ak-managed wiring stripped, user config kept). + const ROUTING = new Set(HOSTS.filter((h) => h.routing).map((h) => h.id)); + const INTEGRATION = new Set(HOSTS.filter((h) => !h.routing).map((h) => h.id)); + const prevOpencode = !!cfg.providers?.hosts?.opencode || cfg.providers?.opencodeMcp === 'ak'; let enabled; let aqeProvider = cfg.providers.aqeProvider ?? null; // A legacy chain written before provenance existed reads as 'user': we cannot @@ -379,12 +416,29 @@ async function pick({ flags, cwd }) { } if (flags.provider !== undefined) models = parseModels(flags.provider); } else { - const installed = HOSTS.filter((h) => hosts[h.id].present && PICKABLE.has(h.id)).map((h) => h.id); - if (installed.length === 0) { fail('no frontier CLI (claude/codex) found on PATH'); return 1; } - console.log(`Installed hosts: ${installed.join(', ')}`); + const installedRouting = HOSTS.filter((h) => hosts[h.id].present && ROUTING.has(h.id)).map((h) => h.id); + const installedIntegration = HOSTS.filter((h) => hosts[h.id].present && INTEGRATION.has(h.id)).map((h) => h.id); + if (installedRouting.length === 0 && installedIntegration.length === 0) { fail('no frontier CLI (claude/codex/opencode) found on PATH'); return 1; } + if (installedIntegration.length) { + console.log(`Installed hosts: ${[...installedRouting, ...installedIntegration].join(', ')}` + + dim(` (${installedIntegration.join(', ')} = integration host — wired + guided, never a routing target)`)); + } else { + console.log(`Installed hosts: ${installedRouting.join(', ')}`); + } + // Default: every currently ENABLED host (even one temporarily absent from + // PATH — a bare enter must never tear down an enabled host it simply can't + // see right now) ∪ newly detected routing hosts. An installed-but-disabled + // integration host is opt-in by typing it — a bare enter must not opt a + // third host's config home in sight unseen either (codex-review r3). + const enabledHosts = HOSTS.filter((h) => cfg.providers.hosts[h.id]).map((h) => h.id); + const dflt = [...new Set([...enabledHosts, ...installedRouting])]; + const absentEnabled = enabledHosts.filter((h) => !hosts[h].present); + if (absentEnabled.length) { + console.log(dim(` enabled but not detected right now: ${absentEnabled.join(', ')} (kept enabled on Enter)`)); + } const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${installed.join(',')}]: `)).trim(); - enabled = (hAns || installed.join(',')).split(',').map((s) => s.trim()).filter(Boolean); + const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${dflt.join(',')}]: `)).trim(); + enabled = (hAns || dflt.join(',')).split(',').map((s) => s.trim()).filter(Boolean); console.log(dim(` ${AQE_BILLING_HINT}`)); const aAns = (await rl.question(`agentic-qe primary LLM provider — ${AQE_PROVIDER_TYPES.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); aqeProvider = aAns ? aAns : null; @@ -400,22 +454,30 @@ async function pick({ flags, cwd }) { rl.close(); } - // validate hosts (opencode is not pickable — see PICKABLE above) + // validate hosts against the two tiers. An unknown token is a hard error, + // never a silent drop: `--host claude,opencdoe` must not "succeed" as + // claude-only and destructively tear the opencode host down (codex-review r3). const known = new Set(HOSTS.map((h) => h.id)); - enabled = enabled.filter((h) => { - if (!known.has(h)) return false; - if (!PICKABLE.has(h)) { warn(`'${h}' is not a routing host — its kit.json flag is preserved, not changed by pick`); return false; } - return true; - }); - if (!enabled.includes('claude') && !enabled.includes('codex')) enabled = ['claude']; - // primary host — which host leads (default claude); must be an enabled host. + const unknown = enabled.filter((h) => !known.has(h)); + if (unknown.length) { + fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`); + return 2; + } + // Split the tiers: routing hosts drive primary/seeds; integration hosts are + // wired + guided. The routing pair needs at least one member (primaryHost + // must be enabled) — fall back to claude, keeping any integration choice. + const routing = enabled.filter((h) => ROUTING.has(h)); + const integrations = enabled.filter((h) => INTEGRATION.has(h)); + if (!routing.length) routing.push('claude'); + enabled = [...routing, ...integrations]; + // primary host — which host leads (default claude); must be a ROUTING host. let primaryHost = prevPrimary; if (flags['primary-host'] !== undefined) { const v = flags['primary-host'].trim().toLowerCase(); if (PRIMARY_HOSTS.includes(v)) primaryHost = v; else warn(`unknown primary host '${v}' (valid: ${PRIMARY_HOSTS.join('|')}) — keeping ${primaryHost}`); } - if (!enabled.includes(primaryHost)) primaryHost = enabled[0] ?? DEFAULT_PRIMARY_HOST; + if (!routing.includes(primaryHost)) primaryHost = routing[0] ?? DEFAULT_PRIMARY_HOST; // re-seed when the primary changed AND the current policy is entirely seeded // (no user overrides to preserve) — so mirrored defaults reflect the new primary. const policyAllSeeded = Object.keys(oldPolicy).length > 0 && Object.values(oldPolicy).every((r) => r.source === 'seeded'); @@ -449,12 +511,9 @@ async function pick({ flags, cwd }) { cfg.providers = { hosts: { - claude: enabled.includes('claude'), - codex: enabled.includes('codex'), - // pick is the claude↔codex ROUTING tool; the opencode host (config-file - // wiring, ADR-0015) is not a routing choice — preserve it verbatim so a - // pick never silently unwires it. - opencode: cfg.providers?.hosts?.opencode ?? false, + claude: routing.includes('claude'), + codex: routing.includes('codex'), + opencode: integrations.includes('opencode'), }, aqeProvider, aqeFallback, @@ -462,7 +521,12 @@ async function pick({ flags, cwd }) { primaryHost, maxBudgetUsd: cfg.providers.maxBudgetUsd ?? null, dualRouting: reseedForPrimary ? {} : { ...oldPolicy }, - // opencode ownership markers survive too (teardown contract). + // Every ownership marker survives a retune (teardown contract): the codex + // MCP bridges AND the opencode wiring. Dropping these on rewrite would + // strand managed servers ak can no longer prove it owns — the data-loss + // class the ownership model exists to prevent. + codexMcp: cfg.providers?.codexMcp ?? null, + rufloCodexMcp: cfg.providers?.rufloCodexMcp ?? null, opencodeMcp: cfg.providers?.opencodeMcp ?? null, opencodeManaged: cfg.providers?.opencodeManaged ?? null, opencodeCatalogDir: cfg.providers?.opencodeCatalogDir ?? null, @@ -487,10 +551,50 @@ async function pick({ flags, cwd }) { (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); } + // 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 + + // guided" is one contract, not two). + // CLI-gated: an enabled-but-absent CLI never fabricates the config home. + if (cfg.providers.hosts.opencode) { + if (!(await have('opencode'))) { + warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); + } else { + const stack = await opencodeStack(cfg, { pkgRoot }); + // persist the markers on ANY refresh (converged file + stale markers is + // exactly the stranded-teardown case), not only on file changes. + 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.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)'); + } + } + } else if (prevOpencode) { + // Excluded from the desired set while previously enabled/managed → disable: + // strip ONLY ak-managed wiring/artifacts (priors restored, marker-gated), + // never the user's own opencode config. A teardown that cannot complete + // (e.g. a JSONC config) is reported honestly — markers stay for the retry + // and "disabled" is never claimed over still-active wiring. + const ret = retireOpencode(cfg); + saveKitConfig(cfg); // persist markers (nulled on success, retained on failure) + if (ret.ok) ok(`opencode disabled: ${ret.undo.detail}; ${ret.artifacts.detail}`); + else warn(`opencode disable incomplete — ${ret.undo.detail} (artifacts: ${ret.artifacts.detail})`); + // enablement-gated guidance strips regardless (user content preserved). + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: false }); + if (guidance.changed) ok(`opencode ${guidance.detail}`); + } + const h = applyHosts(cfg, cwd); (h.ok ? ok : fail)(`hosts: ${h.detail}`); if (primaryHost !== DEFAULT_PRIMARY_HOST) { - const alt = enabled.filter((e) => e !== primaryHost).join(', ') || 'none'; + const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none'; ok(`primary host: ${primaryHost} (alternate: ${alt})`); } if (aqeProvider) ok(`aqe provider: AQE_LLM_PROVIDER=${aqeProvider}`); diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs index 5c3639b..adbd7a2 100644 --- a/src/lib/opencode.mjs +++ b/src/lib/opencode.mjs @@ -34,6 +34,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { have } from './exec.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; +import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from './blocks.mjs'; import * as paths from './paths.mjs'; // ── config-file wiring (opencode.json) ────────────────────────────────────── @@ -331,9 +332,24 @@ export function undoOpencode(cfg, { configFile = paths.opencodeConfigPath() } = return { ok: true, changed: false, detail: 'opencode.json left as-is (not ak-managed)' }; } const managed = normalizeManaged(cfg.providers?.opencodeManaged); - if (!fs.existsSync(configFile)) return { ok: true, changed: false, detail: 'opencode.json absent — nothing to strip' }; + if (!fs.existsSync(configFile)) { + // Nothing left to strip — but the markers would otherwise survive as a lie + // (a later teardown would chase a phantom config). Clear them; the change + // is the marker cleanup itself (codex-review r3). + if (cfg.providers) { cfg.providers.opencodeMcp = null; cfg.providers.opencodeManaged = null; } + return { ok: true, changed: true, detail: 'opencode.json absent — ownership markers cleared (nothing to strip)' }; + } const { ok: parsedOk, doc } = readJsonStrict(configFile); - if (!parsedOk) return { ok: true, changed: false, detail: 'opencode.json unparseable — nothing to strip (refusing to touch JSONC)' }; + if (!parsedOk) { + // NOT ok: the ak wiring is still ACTIVE inside a file we refuse to parse, + // and the markers are the only teardown proof — keep both, fail honestly, + // and name the manual remediation. Never report "disabled" here, and never + // null the markers (codex-review r3). + return { + ok: false, changed: false, + detail: 'opencode.json is not plain JSON (JSONC comments?) — ak wiring left ACTIVE and ownership markers retained; remove the file or make it plain JSON, then re-run the teardown', + }; + } const kept = []; let changed = false; @@ -378,6 +394,75 @@ export function undoOpencode(cfg, { configFile = paths.opencodeConfigPath() } = return { ok: true, changed, detail }; } +// ── shared stack composition (the ONE owner-module operation) ──────────────── +// setup / sync / `x provider pick` all enable opencode the same way; off / +// uninstall / pick-disable all retire it the same way. The composition itself +// (which ops, in which order) is part of the ownership contract — three copies +// would drift (codex-review: the provider-picker rework must not duplicate +// merge/ownership logic in the command). Persistence of cfg stays with the +// 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 + * 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 + * EVERY run (a converged file with stale/missing markers in kit.json still + * needs persisting, or the next teardown cannot prove ownership) — callers + * must save cfg when `oc.changed || markersChanged`, not on `oc.changed` + * alone (codex-review r3). + * The destination seams exist for TESTS ONLY — production callers pass none + * and get the real config home; a test that forgets them writes to the + * developer's real machine (codex-review r4). + * @param {any} cfg @param {{ pkgRoot: string, configFile?: string, brainShim?: string, pluginsDir?: string, agentsDir?: string, skillsDir?: string }} opts */ +export async function opencodeStack(cfg, { pkgRoot, configFile, brainShim, pluginsDir, agentsDir, skillsDir }) { + const before = JSON.stringify([cfg.providers?.opencodeMcp ?? null, cfg.providers?.opencodeManaged ?? null]); + const oc = await applyOpencode(cfg, { ...(configFile ? { configFile } : {}), ...(brainShim ? { brainShim } : {}) }); + const markersChanged = JSON.stringify([cfg.providers?.opencodeMcp ?? null, cfg.providers?.opencodeManaged ?? null]) !== before; + const plugin = deployPlugin({ pkgRoot, ...(pluginsDir ? { pluginsDir } : {}) }); + const source = catalogSource({ override: cfg.providers?.opencodeCatalogDir }); + const agents = syncAgents({ source, ...(agentsDir ? { destDir: agentsDir } : {}) }); + const skill = deploySkill({ source, ...(skillsDir ? { skillsDir } : {}) }); + return { oc, plugin, agents, skill, source, markersChanged }; +} + +/** Retire path: strip the ak-managed opencode.json wiring (user priors + * restored; collisions and user-edited values left), then remove ak-deployed + * artifacts (marker-gated — user-owned files survive). undoOpencode nulls the + * ownership markers in cfg on success and keeps them on failure; the caller + * persists — and MUST honor undo.ok before claiming a disable (codex-review + * r3: a JSONC-refused config leaves active wiring behind). + * @param {any} cfg */ +export function retireOpencode(cfg) { + const undo = undoOpencode(cfg); + const artifacts = removeArtifacts({}); + return { undo, artifacts, ok: undo.ok }; +} + +/** Reconcile the opencode AGENTS.md guidance blocks for the current enablement + * state — the `agents-opencode` target only, never the claude/project files. + * Enable (`enabled: true`) upserts the enablement-gated blocks as soon as the + * config home exists; disable (`enabled: false`) strips them (the always-on + * preamble stays by design; user content is never touched). Shared by setup, + * `x provider pick` enable/disable, and `x provider off`, so every command + * converges guidance the same way sync's blocks branch does (codex-review r3). + * @param {{ pkgRoot: string, cfg: any, cwd?: string, enabled: boolean }} opts */ +export async function reconcileOpencodeGuidance({ pkgRoot, cfg, cwd = process.cwd(), enabled }) { + const target = guidanceTargets({ cwd }).find((t) => t.name === 'agents-opencode'); + if (!target) return { ok: true, changed: false, detail: 'no opencode config home — guidance skipped' }; + const rows = registry(cfg.customBlocks); + const resolve = (r) => (r.custom + ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) + : path.join(pkgRoot, 'claude', r.template)); + const ctx = { flags: { dualMode: !!cfg.providers?.hosts?.claude && !!cfg.providers?.hosts?.codex, opencodeEnabled: enabled } }; + const treg = [...blocksForTarget(rows, 'agents-opencode'), ...retiredForTarget(rows, 'agents-opencode')]; + const res = await syncBlocks(target.file, treg, resolve, { context: ctx }); + const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') + .map((r) => `${r.slug} ${r.action}`); + return { ok: true, changed: changed.length > 0, detail: changed.length ? `guidance: ${changed.join(', ')}` : 'guidance in sync' }; +} + // ── ruflo catalog source (agents + skills) ────────────────────────────────── /** Resolve where ruflo's agent/skill catalog comes from. Order: explicit From 171b97b33dfc66e0379a7b19491d5da68539774d Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 20:55:34 -0700 Subject: [PATCH 05/11] test(opencode-host): command-level lifecycle coverage + codex-review regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner-module suite alone could not catch orchestration mistakes, so each mutating command now has sandboxed end-to-end scenarios (home sandbox + fake-bin patterns; no real home/global npm/PATH is ever touched): - setup: --opencode persists the host and wires it when the CLI is present, restart guidance emitted; --dry-run writes nothing anywhere; an absent CLI never fabricates ~/.config/opencode. - provider pick (real CLI spawns): enable wires config/plugin/agents/skill + guidance and preserves user config; exclusion disables surgically; --primary-host opencode rejected; retune preserves every ownership marker; unknown --host fails before mutation; JSONC/absent-config disables report honestly; interactive defaults never drop an enabled-but-absent host nor opt in a disabled one; stale markers re-persist on a converged re-pick. - sync: enabled+drifted converges after hosts, before final verification; converged claude guidance + fresh enable lands opencode guidance on the SAME sync; second sync is a true no-op; absent CLI skips without creating the config home; disabled makes no wiring writes and strips only enablement-gated guidance; --dry-run mutates no opencode surface. - status: converged/drifted/JSONC-refused/foreign-plugin/CLI-absent rows are honest non-OK with a specific fix; disabled is silent with the pick hint; --json carries the dashboard-consumed shape; strictly read-only. - uninstall: restores owned config values, removes only marked artifacts, user-edited/marker-less files survive; repeated runs harmless; --purge reads ownership before removing kit.json; --dry-run writes nothing. - providers lib: routing capability flag pinned; dualRouting seeds claude/codex routes ONLY with opencode co-enabled. Plus codex-review regressions: marker persistence on converged files (opencodeStack markersChanged, with destination seams so the test can never escape the sandbox — r4) and npm-managed vs external opencode-ai update ownership (new opencode-version-drift suite). --- tests/dashboard.test.cjs | 58 +++- tests/kit/opencode-version-drift.test.mjs | 56 ++++ tests/kit/opencode.test.mjs | 38 +++ tests/kit/provider-cli.test.mjs | 305 ++++++++++++++++++++++ tests/kit/providers.test.mjs | 42 ++- tests/kit/setup-command.test.mjs | 99 ++++++- tests/kit/setup-host-flags.test.mjs | 24 ++ tests/kit/status-command.test.mjs | 134 ++++++++++ tests/kit/sync-command.test.mjs | 182 ++++++++++++- tests/kit/uninstall-command.test.mjs | 104 ++++++++ 10 files changed, 1035 insertions(+), 7 deletions(-) create mode 100644 tests/kit/opencode-version-drift.test.mjs diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 305e7ca..fa35f83 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -216,6 +216,62 @@ async function main() { contains(r.body, '"codex-mcp","opencode"'); }); + // ── RENDERED behavior, not served-source literals ──────────────────────── + // The grouping/card/notice logic is ./groups.mjs (pure) — the SAME function + // sources the served bundle interpolates. These exercise the real render + // path renderPanels/renderNotice use, without a browser. + + await test('an opencode status row renders under Hosts, ordered, with its level/message/fix verbatim', async () => { + const { catOf, groupRows, gridHtml } = await import('../src/lib/dashboard/groups.mjs'); + const rows = [ + { subsystem: 'natives', level: 'fail', message: 'WASM fallback', fix: 'sync installs native better-sqlite3' }, + { subsystem: 'opencode', level: 'warn', message: 'opencode.json wiring drifted (permission claude-flow_* not allowed)', fix: 'sync re-applies the opencode wiring' }, + { subsystem: 'codex-mcp', level: 'ok', message: 'codex MCP registered (mcp__codex__codex)', fix: null }, + { subsystem: 'hosts', level: 'ok', message: 'opencode 1.2.3 (npm)', fix: null }, + ]; + // grouped under Hosts & Routing + assert(catOf('opencode') === 'hosts', 'opencode must group under Hosts & Routing'); + const groups = groupRows(rows); + // ordered: fail first (natives), then warn (opencode), then oks by PREF (hosts < codex-mcp) + assert(JSON.stringify(groups.map((g) => g.subsystem)) === JSON.stringify(['natives', 'opencode', 'hosts', 'codex-mcp']), + 'worst-first, then the preferred display order, got ' + groups.map((g) => g.subsystem)); + // rendered: bucketed exactly as renderPanels does, with the original content intact + const hostsGroups = groups.filter((g) => catOf(g.subsystem) === 'hosts'); + assert(hostsGroups.some((g) => g.subsystem === 'opencode' && g.level === 'warn'), + 'the opencode group lands in the Hosts bucket with its worst level'); + const html = gridHtml(hostsGroups); + contains(html, 'data-level="warn"'); + contains(html, 'opencode.json wiring drifted (permission claude-flow_* not allowed)'); + contains(html, 'sync re-applies the opencode wiring'); + contains(html, 'opencode'); + }); + + await test('the served bundle parses and carries every interpolated groups.mjs function', async () => { + // The bundle is built by interpolating groups.mjs's function sources — + // a broken interpolation would serve a page that fails to parse at all. + const { JS } = await import('../src/lib/dashboard/client.mjs'); + new Function(JS); // parse-only (no execution) + for (const needle of ['function esc', 'function catOf', 'function groupRows', 'function rowLine', 'function groupCard', 'function gridHtml', 'function noticeHtml']) { + contains(JS, needle); + } + }); + + await test('the update banner names an outdated npm-managed opencode-ai, and stays silent otherwise', async () => { + const { noticeHtml } = await import('../src/lib/dashboard/groups.mjs'); + const html = noticeHtml([ + { pkg: 'ruflo', installed: '9.9.9', latest: '9.9.9', outdated: false }, + { pkg: 'opencode-ai', installed: '1.2.3', latest: '1.3.0', outdated: true }, + ]); + contains(html, 'opencode-ai'); + contains(html, '1.2.3'); + contains(html, '1.3.0'); + contains(html, 'ak sync'); + // current installs and absent drift render nothing (no fabricated banner) + assert(noticeHtml([{ pkg: 'opencode-ai', installed: '1.2.3', latest: '1.2.3', outdated: false }]) === '', + 'a current opencode-ai must not fabricate update drift'); + assert(noticeHtml(null) === '' && noticeHtml([]) === '', 'no drift payload → no banner'); + }); + await test('GET /api/status embeds improvement.json read off the fixture', async () => { const r = await get(url + 'api/status', token); const j = JSON.parse(r.body); @@ -1270,7 +1326,7 @@ async function main() { // is the suite where it matters most — the traversal-guard and credential- // leak tests live here and were the reviewer's cited example of a block // that could silently vanish with the old harness never noticing. - const EXPECTED = 58; + const EXPECTED = 61; if (passed + failed !== EXPECTED) { console.error(`\nPLAN MISMATCH: expected ${EXPECTED} tests, ran ${passed + failed}`); process.exit(1); diff --git a/tests/kit/opencode-version-drift.test.mjs b/tests/kit/opencode-version-drift.test.mjs new file mode 100644 index 0000000..eb65523 --- /dev/null +++ b/tests/kit/opencode-version-drift.test.mjs @@ -0,0 +1,56 @@ +// Managed-version contract for `opencode-ai` (the third host's npm package). +// driftReport keeps a frontier-host CLI current only when it is NPM-managed: +// an npm global package.json exists. External installs (mise/native/brew) have +// none → filtered out, so ak never claims to own an update it cannot apply — +// and the dashboard's update banner never fabricates one either. +// Hermetic: sandboxed HOME, faked npm global root, fresh versionCheck cache +// (no `npm view` ever spawns). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + sandboxHome, assertSandboxed, rmrf, writeKitConfig, offlineKitConfig, fakeGlobalRoot, +} from './helpers/home-sandbox.mjs'; + +const HOME = sandboxHome('ak-oc-drift'); +const paths = await import('../../src/lib/paths.mjs'); +const { driftReport } = await import('../../src/lib/versions.mjs'); +assertSandboxed(paths, HOME); + +function seed(seen, pkgs) { + rmrf(paths.configDir()); + writeKitConfig(HOME, offlineKitConfig({ + versionCheck: { ttlHours: 24, last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9', ...seen } }, + })); + paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9', ...pkgs })); +} + +const rowFor = (report, pkg) => report.find((r) => r.pkg === pkg); + +test('npm-managed opencode-ai, current: reported installed, not outdated', async () => { + seed({ 'opencode-ai': '1.2.3' }, { 'opencode-ai': '1.2.3' }); + const row = rowFor(await driftReport(), 'opencode-ai'); + assert.ok(row, 'an npm-managed opencode-ai is tracked'); + assert.equal(row.installed, '1.2.3'); + assert.equal(row.latest, '1.2.3'); + assert.equal(row.outdated, false, 'current install is not drift'); +}); + +test('npm-managed opencode-ai, outdated: reported as drift (the update banner input)', async () => { + seed({ 'opencode-ai': '1.3.0' }, { 'opencode-ai': '1.2.3' }); + const row = rowFor(await driftReport(), 'opencode-ai'); + assert.ok(row); + assert.equal(row.installed, '1.2.3'); + assert.equal(row.latest, '1.3.0'); + assert.equal(row.outdated, true, 'a newer npm release is update drift ak owns'); +}); + +test('external (non-npm) opencode install: no row at all — ak never claims to own its update', async () => { + // opencode on PATH but no npm global package.json → not in the fake root. + seed({}, {}); + const row = rowFor(await driftReport(), 'opencode-ai'); + assert.equal(row, undefined, 'external installs are not fabricated into npm-managed drift'); + // Sanity: the always-managed packages still report. + assert.ok(rowFor(await driftReport(), 'ruflo'), 'the managed set itself is unaffected'); +}); + +test.after(() => rmrf(HOME)); diff --git a/tests/kit/opencode.test.mjs b/tests/kit/opencode.test.mjs index 018c43d..902e4b1 100644 --- a/tests/kit/opencode.test.mjs +++ b/tests/kit/opencode.test.mjs @@ -595,3 +595,41 @@ test('mcpCommandFor: bin on PATH → nested mcp-server.js → ruflo mcp start (f assert.deepEqual(mcpCommandFor({ binPresent: false, nestedPath: path.join(d, 'absent.js') }), ['ruflo', 'mcp', 'start'], 'last resort matches the claude/codex registration'); rm(d); }); + +// codex-review r3: applyOpencode re-records the ownership markers on EVERY +// run, including a converged one. If kit.json's markers went stale/missing +// while the file stayed converged (hand-edit, legacy install), opencodeStack +// must report markersChanged so the caller persists them — otherwise the next +// teardown cannot prove ownership and strands ak-written keys. +// codex-review r4: this file is NOT home-sandboxed — the stack runs against +// EXPLICIT tmp destinations (the seams), never the production defaults. +test('opencodeStack reports markersChanged when a converged file has stale/missing markers', async () => { + const { opencodeStack } = await import('../../src/lib/opencode.mjs'); + const { fileURLToPath } = await import('node:url'); + const d = tmp('ak-oc-markers-'); + const srcRoot = makeCatalog(path.join(d, 'catalog')); + const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + // Every destination is inside the tmp dir — nothing escapes to the real home. + const seams = { + configFile: path.join(d, 'opencode.json'), + pluginsDir: path.join(d, 'plugins'), + agentsDir: path.join(d, 'agents'), + skillsDir: path.join(d, 'skills'), + }; + const cfg = cfgOn(); + cfg.providers.opencodeCatalogDir = srcRoot; + await opencodeStack(cfg, { pkgRoot, ...seams }); // initial wire (markers recorded) + assert.ok(fs.existsSync(seams.configFile), 'wiring landed in the tmp config, not the real one'); + // Simulate staleness: the markers vanish from kit.json while the file stays. + cfg.providers.opencodeMcp = null; + cfg.providers.opencodeManaged = null; + const second = await opencodeStack(cfg, { pkgRoot, ...seams }); + assert.equal(second.oc.changed, false, 'the file itself is already converged'); + assert.equal(second.markersChanged, true, 'but the refreshed markers must be persisted by the caller'); + assert.equal(cfg.providers.opencodeMcp, 'ak'); + assert.ok(cfg.providers.opencodeManaged?.mcp?.['claude-flow']?.written); + // A third run with truthful markers is then fully quiet. + const third = await opencodeStack(cfg, { pkgRoot, ...seams }); + assert.equal(third.markersChanged, false, 'no kit.json churn once the markers are truthful'); + rm(d); +}); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 0f290f9..04aee60 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -85,3 +85,308 @@ test('ak x provider status omits the dual-host guidance tips with only one host assert.ok(!r.stdout.includes(JUDGE_BIAS_TIP), 'judge-bias tip withheld'); rm(home, project); }); + +// ── pick: the two-tier host model (ADR-0015) ───────────────────────────────── +// pick manages ALL THREE managed host integrations; claude/codex remain the +// routing pair. These spawn the real CLI end-to-end: kit.json persistence, +// opencode.json wiring, artifact deploy/remove, and ownership markers are all +// asserted on disk in the sandbox. Fake `claude`/`opencode` shims on PATH make +// the CLIs "installed" (installState never 'absent', so no npm install is ever +// attempted); a fresh versionCheck cache keeps the post-command drift nudge +// offline; RUFLO_REPO points at a fixture catalog for agent conversion. + +/** Executable-noop shims for the CLIs pick probes (`which`, `--version`). */ +function fakeBins(dir) { + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + for (const name of ['claude', 'opencode']) { + fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + return bin; +} + +/** Minimal ruflo catalog fixture: one convertible agent + a platform skill. */ +function fakeCatalog(root) { + fs.mkdirSync(path.join(root, '.claude', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'skills', 'a-skill'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '9.9.9' })); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'coder.md'), + '---\nname: coder\ndescription: Implementation specialist\n---\n\nUse mcp__claude-flow__swarm_init.\n'); + fs.writeFileSync(path.join(root, 'SKILL.md'), '---\nname: ruflo\ndescription: platform\n---\n\n# Ruflo\n'); + return root; +} + +/** A sandbox whose kit.json carries a FRESH versionCheck cache (the post-command + * drift nudge then never reaches the network), plus fake claude/opencode CLIs + * and a fixture catalog. */ +function pickSandbox({ hosts, providers = {} }) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pick-home-')); + const cfgDir = path.join(home, '.config', 'agentic-kit'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync(path.join(cfgDir, 'kit.json'), JSON.stringify({ + providers: { hosts, ...providers }, + versionCheck: { + ttlHours: 24, last: Date.now(), + seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }, + self: { last: Date.now(), best: { version: '0.0.1', tag: 'latest' } }, + }, + })); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pick-proj-')); + fs.mkdirSync(path.join(project, '.git')); + const binDir = fakeBins(home); + const catalog = fakeCatalog(path.join(home, 'catalog')); + return { home, project, binDir, catalog }; +} + +function akPick(args, { cwd, home, binDir, catalog }, { input } = {}) { + return spawnSync(process.execPath, [BIN, ...args], { + encoding: 'utf8', + cwd, + input, + env: { + ...process.env, + NO_COLOR: '1', + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + APPDATA: path.join(home, '.config'), + // FULLY controlled PATH: only the fixture shims + the system dirs the + // `which` probe needs. Real CLIs/npm on the developer's machine can + // never leak in and make detection non-deterministic. + PATH: [binDir, '/usr/bin', '/bin'].join(path.delimiter), + RUFLO_REPO: catalog, + }, + }); +} + +const kitJson = (home) => JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8')); +const ocJsonPath = (home) => path.join(home, '.config', 'opencode', 'opencode.json'); +const ocJson = (home) => JSON.parse(fs.readFileSync(ocJsonPath(home), 'utf8')); + +test('pick --host claude,opencode enables + wires opencode (config, plugin, agents, skill), preserving user config', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + // Pre-existing user-owned opencode config that must survive the merge. + fs.mkdirSync(path.dirname(ocJsonPath(sb.home)), { recursive: true }); + fs.writeFileSync(ocJsonPath(sb.home), JSON.stringify({ + model: 'opencode/kimi-k3', + mcp: { 'my-server': { type: 'local', command: ['x'] } }, + }, null, 2)); + + const r = akPick(['x', 'provider', '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'); + + const doc = ocJson(sb.home); + assert.ok(doc.mcp['claude-flow'], 'claude-flow MCP wired into opencode.json'); + assert.deepEqual(doc.mcp['my-server'], { type: 'local', command: ['x'] }, 'user MCP server preserved'); + assert.equal(doc.model, 'opencode/kimi-k3', 'user model key preserved'); + assert.equal(doc.permission['claude-flow_*'], 'allow', 'permission patterns pre-approved'); + assert.ok(doc.skills.paths.some((p) => p.endsWith(path.join('.claude', 'skills'))), 'catalog skills path added'); + + 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 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'); + + const cfg = kitJson(sb.home); + assert.deepEqual(cfg.providers.hosts, { claude: true, codex: false, opencode: true }); + assert.equal(cfg.providers.opencodeMcp, 'ak', 'ownership marker persisted'); + assert.ok(cfg.providers.opencodeManaged?.mcp?.['claude-flow']?.written, 'value-precise ownership recorded'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('pick --host claude on an opencode-enabled machine disables it: ak wiring stripped, user config kept', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + fs.mkdirSync(path.dirname(ocJsonPath(sb.home)), { recursive: true }); + fs.writeFileSync(ocJsonPath(sb.home), JSON.stringify({ model: 'opencode/kimi-k3' }, null, 2)); + // A user-owned (marker-less) agent file that must survive every teardown. + const agentsDir = path.join(sb.home, '.config', 'opencode', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'my-agent.md'), '---\ndescription: mine\n---\n\nUser agent.\n'); + + const on = akPick(['x', 'provider', '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'); + + const off = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb); + assert.equal(off.status, 0, `disable failed\nstdout: ${off.stdout}\nstderr: ${off.stderr}`); + assert.match(off.stdout, /opencode disabled/, 'disable is reported, never silent'); + + const doc = ocJson(sb.home); + 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-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')), + 'ak plugin removed'); + + const cfg = kitJson(sb.home); + assert.deepEqual(cfg.providers.hosts, { claude: true, codex: false, opencode: false }); + assert.equal(cfg.providers.opencodeMcp, null, 'ownership markers nulled on disable'); + assert.equal(cfg.providers.opencodeManaged, null); + } finally { + rm(sb.home, sb.project); + } +}); + +test('pick --primary-host opencode is rejected; the prior primary is left unchanged', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false }, providers: { primaryHost: 'claude' } }); + try { + const r = akPick(['x', 'provider', 'pick', '--host', 'claude', '--primary-host', 'opencode', '--yes'], sb); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /unknown primary host 'opencode'/, 'rejection is explained'); + assert.notEqual(kitJson(sb.home).providers.primaryHost, 'opencode', 'opencode can never become primary'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('a provider retune preserves every ownership marker (codex MCP bridges + opencode wiring)', () => { + const sb = pickSandbox({ + hosts: { claude: true, codex: false, opencode: false }, + providers: { codexMcp: 'ak', rufloCodexMcp: 'ak', opencodeCatalogDir: '/custom/catalog' }, + }); + try { + const r = akPick(['x', 'provider', 'pick', '--aqe-provider', 'openai', '--yes'], sb); + assert.equal(r.status, 0, r.stderr); + const p = kitJson(sb.home).providers; + assert.equal(p.codexMcp, 'ak', 'codexMcp marker survives a rewrite'); + assert.equal(p.rufloCodexMcp, 'ak', 'rufloCodexMcp marker survives a rewrite'); + assert.equal(p.opencodeCatalogDir, '/custom/catalog', 'catalog override survives a rewrite'); + assert.equal(p.aqeProvider, 'openai', 'the actual retune landed'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('interactive pick: installed opencode is displayed as an integration host but only ENTER-enabled when already on', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + // Blank answers to every prompt: accept the defaults. + const r = akPick(['x', 'provider', 'pick'], sb, { input: '\n\n\n\n' }); + assert.equal(r.status, 0, `interactive pick failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.match(r.stdout, /integration host — wired \+ guided, never a routing target/, + 'opencode is displayed with its non-routing qualifier'); + assert.equal(kitJson(sb.home).providers.hosts.opencode, false, + 'a bare enter must not opt a third host in sight unseen'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('interactive pick: an already-enabled opencode stays enabled on a bare enter', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: true } }); + try { + const r = akPick(['x', 'provider', 'pick'], sb, { input: '\n\n\n\n' }); + assert.equal(r.status, 0, `interactive pick failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.equal(kitJson(sb.home).providers.hosts.opencode, true, + 'enabled opencode is part of the default set and survives a retune'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('an unknown --host token is a hard error BEFORE any mutation (a typo never tears a host down)', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + const on = akPick(['x', 'provider', 'pick', '--host', 'claude,opencode', '--yes'], sb); + assert.equal(on.status, 0, on.stderr); + const kitPath = path.join(sb.home, '.config', 'agentic-kit', 'kit.json'); + const wiredCfg = fs.readFileSync(kitPath, 'utf8'); + const wiredOc = fs.readFileSync(ocJsonPath(sb.home), 'utf8'); + + const r = akPick(['x', 'provider', 'pick', '--host', 'claude,opencdoe', '--yes'], sb); + assert.equal(r.status, 2, `a typo'd host set must fail, got ${r.status}`); + assert.match(r.stdout + r.stderr, /unknown host\(s\): opencdoe/); + assert.equal(fs.readFileSync(kitPath, 'utf8'), wiredCfg, 'kit.json untouched'); + assert.equal(fs.readFileSync(ocJsonPath(sb.home), 'utf8'), wiredOc, 'opencode wiring untouched'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('disable against a JSONC config warns honestly and RETAINS the markers (never claims disabled over live wiring)', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + const on = akPick(['x', 'provider', 'pick', '--host', 'claude,opencode', '--yes'], sb); + assert.equal(on.status, 0, on.stderr); + // Make the config unparseable (legal JSONC) after wiring. + fs.writeFileSync(ocJsonPath(sb.home), '{\n // user converted this file to JSONC\n "mcp": {}\n}\n'); + + const off = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb); + assert.equal(off.status, 0, off.stderr); + assert.match(off.stdout, /opencode disable incomplete/, 'the incomplete teardown is surfaced, never hidden'); + assert.ok(!/opencode disabled:/.test(off.stdout), '"disabled" is never claimed over active wiring'); + const p = kitJson(sb.home).providers; + assert.equal(p.hosts.opencode, false, 'the enablement intent still flips (user asked)'); + assert.equal(p.opencodeMcp, 'ak', 'markers RETAINED for the retry — teardown proof is never nulled over live wiring'); + assert.ok(p.opencodeManaged?.mcp?.['claude-flow'], 'value-precise records survive'); + assert.match(fs.readFileSync(ocJsonPath(sb.home), 'utf8'), /JSONC/, 'the JSONC file itself is untouched'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('disable with the config file absent clears the stale markers (no phantom ownership)', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + const on = akPick(['x', 'provider', 'pick', '--host', 'claude,opencode', '--yes'], sb); + assert.equal(on.status, 0, on.stderr); + fs.rmSync(ocJsonPath(sb.home), { force: true }); // user deleted the config + + const off = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb); + assert.equal(off.status, 0, off.stderr); + const p = kitJson(sb.home).providers; + assert.equal(p.opencodeMcp, null, 'markers cleared — nothing left to own'); + assert.equal(p.opencodeManaged, null); + } finally { + rm(sb.home, sb.project); + } +}); + +test('interactive pick: an enabled host absent from PATH right now is kept enabled on a bare enter', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: true } }); + try { + // Remove the opencode shim: the CLI is "temporarily absent" but enabled. + fs.rmSync(path.join(sb.binDir, 'opencode'), { force: true }); + const r = akPick(['x', 'provider', 'pick'], sb, { input: '\n\n\n\n' }); + assert.equal(r.status, 0, `interactive pick failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.match(r.stdout, /enabled but not detected right now: opencode \(kept enabled on Enter\)/); + assert.equal(kitJson(sb.home).providers.hosts.opencode, true, + 'an absent host is never torn down by invisibility'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('a converged re-pick re-persists stale/missing ownership markers (teardown keeps its proof)', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false } }); + try { + const on = akPick(['x', 'provider', 'pick', '--host', 'claude,opencode', '--yes'], sb); + assert.equal(on.status, 0, on.stderr); + // Stale the markers out of kit.json while the file stays converged. + const kitPath = path.join(sb.home, '.config', 'agentic-kit', 'kit.json'); + const cfg = kitJson(sb.home); + cfg.providers.opencodeMcp = null; + cfg.providers.opencodeManaged = null; + fs.writeFileSync(kitPath, JSON.stringify(cfg, null, 2)); + + const again = akPick(['x', 'provider', 'pick', '--host', 'claude,opencode', '--yes'], sb); + assert.equal(again.status, 0, again.stderr); + const after = kitJson(sb.home).providers; + assert.equal(after.opencodeMcp, 'ak', 'the marker is re-persisted even though the file was converged'); + assert.ok(after.opencodeManaged?.mcp?.['claude-flow']?.written, 'value-precise ownership restored'); + } finally { + rm(sb.home, sb.project); + } +}); diff --git a/tests/kit/providers.test.mjs b/tests/kit/providers.test.mjs index bd68efa..411702f 100644 --- a/tests/kit/providers.test.mjs +++ b/tests/kit/providers.test.mjs @@ -10,8 +10,9 @@ import { HOSTS, installHost, applyAqeRouter, undoAqeRouter, aqeRouterFile, CODEX_ADAPTER_PKG, codexAdapterAction, ensureCodexAdapter, AQE_PROVIDER_TYPES, bothHostsEnabled, suggestedFallbackFor, AQE_FALLBACK_CODEX_SUGGESTION, - PROVIDER_TOKEN_RE, + PROVIDER_TOKEN_RE, seedDualRoutingIfDualHost, } from '../../src/lib/providers.mjs'; +import * as paths from '../../src/lib/paths.mjs'; // security review Finding 2 / code-quality Finding 2: applyProviders() feeds // m.id/m.model into a ruflo subprocess argv. exec.mjs's shell:false fix is @@ -217,6 +218,15 @@ test('every host descriptor carries an npm package name for install/update', () for (const h of HOSTS) assert.equal(typeof h.pkg, 'string', `${h.id} has pkg`); }); +test('every host descriptor declares its routing capability explicitly (issue #71 seam)', () => { + for (const h of HOSTS) assert.equal(typeof h.routing, 'boolean', `${h.id} declares routing: true|false`); + // The current routing pair — a new host must opt in DELIBERATELY, and this + // test is the tripwire that keeps opencode (and future integration hosts) + // from becoming routable by accident. + assert.deepEqual(HOSTS.filter((h) => h.routing).map((h) => h.id), ['claude', 'codex']); + assert.deepEqual(HOSTS.filter((h) => !h.routing).map((h) => h.id), ['opencode']); +}); + test('installHost rejects an unknown host id without shelling out', async () => { const r = await installHost('bogus'); assert.equal(r.ok, false); @@ -314,6 +324,36 @@ test('suggestedFallbackFor returns null when codex is not among the enabled host assert.equal(suggestedFallbackFor([]), null); }); +// ADR-0015 two-tier model: all three hosts coexist in one enabled set, but the +// seeded per-activity policy only ever names the ROUTING pair. opencode is +// additive — never a substitute for either routing host, never a route target. +test('dual routing seeds claude/codex routes ONLY, even with opencode co-enabled', () => { + const realRoot = paths.globalRoot(); + const fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-seed-root-')); + // aqe ≥ 3.13.1 is the seed gate — fabricate just enough of the install. + fs.mkdirSync(path.join(fakeRoot, 'agentic-qe'), { recursive: true }); + fs.writeFileSync(path.join(fakeRoot, 'agentic-qe', 'package.json'), JSON.stringify({ name: 'agentic-qe', version: '3.13.1' })); + paths._setGlobalRootForTest(fakeRoot); + try { + const cfg = { providers: { hosts: { claude: true, codex: true, opencode: true }, primaryHost: 'claude', dualRouting: {} } }; + const seed = seedDualRoutingIfDualHost(cfg); + assert.equal(seed.seeded, true, 'dual-host seed runs with opencode alongside'); + const policy = cfg.providers.dualRouting; + assert.ok(Object.keys(policy).length > 0); + assert.ok(!JSON.stringify(policy).includes('opencode'), + 'opencode never appears in the routing policy (not a route target, not a model host)'); + for (const [activity, route] of Object.entries(policy)) { + assert.ok(['claude', 'codex'].includes(route.host), + `${activity} routes to a routing host, got ${route.host}`); + } + // …and the enablement flags themselves are untouched by seeding. + assert.equal(cfg.providers.hosts.opencode, true, 'opencode stays co-enabled — additive, not a substitute'); + } finally { + paths._setGlobalRootForTest(realRoot); + fs.rmSync(fakeRoot, { recursive: true, force: true }); + } +}); + test('settingsTarget from a repo SUBDIR anchors project scope at the ROOT', async () => { // A cwd-only .git probe here would fall through to USER scope — leaking // ENABLE_*/AQE_LLM_PROVIDER machine-wide from any subdir invocation, with diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index 776324a..f27d831 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -11,7 +11,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, - sandboxProject, writeKitConfig, offlineKitConfig, + sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, } from './helpers/home-sandbox.mjs'; const HOME = sandboxHome('ak-setup'); @@ -24,11 +24,11 @@ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ const FLAGS = (over = {}) => ({ 'dry-run': false, yes: false, minimal: false, project: false, 'no-aqe': false, 'no-ruvnet-brain': false, 'no-security': false, - codex: false, reconfigure: false, ...over, + codex: false, opencode: false, reconfigure: false, ...over, }); function seedHome(cfg = offlineKitConfig()) { - rmrf(paths.claudeDir(), paths.configDir()); + rmrf(paths.claudeDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); fs.mkdirSync(paths.claudeDir(), { recursive: true }); fs.writeFileSync(paths.claudeMdPath(), '# my machine notes\n'); writeKitConfig(HOME, cfg); @@ -143,10 +143,101 @@ test('--project forces project scope outside a git repo', async () => { test('every documented flag is declared in the parser options', () => { for (const flag of ['dry-run', 'yes', 'minimal', 'project', 'no-aqe', 'no-ruvnet-brain', - 'no-security', 'codex', 'primary-host', 'reconfigure']) { + 'no-security', 'codex', 'opencode', 'primary-host', 'reconfigure']) { assert.ok(flag in setup.options, `--${flag} is documented in help but not parseable`); assert.match(setup.help, new RegExp(`--${flag}\\b`), `--${flag} is parseable but undocumented`); } }); +// ── --opencode (the third host's bootstrap path) ───────────────────────────── +// Command-level orchestration: the flag persists the enabled host, the shared +// opencode stack runs only when the CLI is actually present (an absent CLI +// never fabricates ~/.config/opencode), and successful wiring prints the +// restart guidance. In-process against the sandboxed HOME; a fake `opencode` +// shim on PATH plays the installed CLI. + +/** Prepend a fake `opencode` bin to PATH for the duration of `fn`. The sandbox + * PATH is a single nonexistent dir, so /usr/bin:/bin are re-added for the + * `which` probe itself — npm stays unresolvable, keeping the test offline. */ +async function withOpencodeCli(fn) { + const bin = path.join(HOME, `fake-bin-${Math.random().toString(36).slice(2, 8)}`); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'opencode'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const prev = process.env.PATH; + process.env.PATH = [bin, '/usr/bin', '/bin'].join(path.delimiter); + try { return await fn(); } finally { process.env.PATH = prev; rmrf(bin); } +} + +/** A fixture ruflo catalog for agent conversion (RUFLO_REPO). */ +function seedCatalog() { + const root = path.join(HOME, 'catalog'); + rmrf(root); + fs.mkdirSync(path.join(root, '.claude', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'skills', 'a-skill'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '9.9.9' })); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'coder.md'), + '---\nname: coder\ndescription: Implementation specialist\n---\n\nBody.\n'); + fs.writeFileSync(path.join(root, 'SKILL.md'), '---\nname: ruflo\ndescription: platform\n---\n\n# Ruflo\n'); + return root; +} + +const ocHome = () => path.join(HOME, '.config', 'opencode'); + +test('ak setup --opencode --yes persists the host and wires it via the shared stack when the CLI is present', async () => { + seedHome(); + // ruflo/agentic-qe "installed" so machine scope skips the npm upgrade path. + paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); + const catalog = seedCatalog(); + const prevRepo = process.env.RUFLO_REPO; + process.env.RUFLO_REPO = catalog; + try { + await withOpencodeCli(async () => { + 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'); + }); + const cfg = loadKitConfig(); + assert.equal(cfg.providers.hosts.opencode, true, 'enabled host persisted to kit.json'); + assert.equal(cfg.providers.opencodeMcp, 'ak', 'ownership marker persisted'); + 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(), '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'); + assert.ok(fs.existsSync(agentsMd) && fs.readFileSync(agentsMd, 'utf8').includes('BEGIN ruflo-'), + 'opencode AGENTS.md guidance blocks written during setup'); + } finally { + if (prevRepo === undefined) delete process.env.RUFLO_REPO; + else process.env.RUFLO_REPO = prevRepo; + } +}); + +test('ak setup --opencode --dry-run writes nothing anywhere (kit.json, opencode home, artifacts)', async () => { + seedHome(); + const before = snapshot(HOME); + const { result, out } = await captureLog(() => + setup.run({ flags: FLAGS({ 'dry-run': true, minimal: true, opencode: true }), pkgRoot: PKG_ROOT })); + assert.equal(result, 0); + assert.match(out, /dry-run: --opencode would enable the opencode host/); + assert.equal(loadKitConfig().providers.hosts.opencode, false, 'a previewed --opencode must not persist'); + assert.ok(!fs.existsSync(ocHome()), 'no opencode config home fabricated by a dry run'); + assertUnchanged(before, HOME, '`ak setup --opencode --dry-run` must not touch the filesystem'); +}); + +test('ak setup --opencode with an ABSENT CLI never fabricates the config home', async () => { + seedHome(); + paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); + // sandbox PATH has no opencode — have('opencode') is false. + 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, /opencode: enabled but CLI not installed — wiring skipped/); + assert.equal(loadKitConfig().providers.hosts.opencode, true, + 'the enablement intent is still persisted (sync completes it once installed)'); + assert.ok(!fs.existsSync(ocHome()), 'no config home created for a host that is not there'); +}); + test.after(() => rmrf(HOME)); diff --git a/tests/kit/setup-host-flags.test.mjs b/tests/kit/setup-host-flags.test.mjs index 49fc600..d2da161 100644 --- a/tests/kit/setup-host-flags.test.mjs +++ b/tests/kit/setup-host-flags.test.mjs @@ -46,3 +46,27 @@ test('--codex is idempotent — a second application reports no change', () => { const r = applySetupHostFlags(cfg, { codex: true }); assert.equal(r.changed, false); }); + +test('--opencode opts the opencode host in without touching claude/codex or primary', () => { + const cfg = freshCfg(); + const r = applySetupHostFlags(cfg, { opencode: true }); + assert.equal(cfg.providers.hosts.opencode, true); + assert.equal(cfg.providers.hosts.claude, true, 'claude is not replaced'); + assert.equal(cfg.providers.hosts.codex, false, 'codex is not pulled in'); + assert.equal(cfg.providers.primaryHost, undefined, 'no primary is implied by an integration host'); + assert.equal(r.changed, true); +}); + +test('--opencode is idempotent — a second application reports no change', () => { + const cfg = { providers: { hosts: { claude: true, codex: false, opencode: true } } }; + const r = applySetupHostFlags(cfg, { opencode: true }); + assert.equal(r.changed, false); +}); + +test('--primary-host opencode is ignored with a warning (integration hosts never lead)', () => { + const cfg = freshCfg(); + const r = applySetupHostFlags(cfg, { opencode: true, 'primary-host': 'opencode' }); + assert.equal(cfg.providers.hosts.opencode, true, 'the enablement itself still lands'); + assert.notEqual(cfg.providers.primaryHost, 'opencode'); + assert.equal(r.warnings.length, 1); +}); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index ce8d612..1b292e0 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -16,6 +16,7 @@ import { const HOME = sandboxHome('ak-status'); const paths = await import('../../src/lib/paths.mjs'); const status = await import('../../src/commands/status.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); assertSandboxed(paths, HOME); const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -246,4 +247,137 @@ test('a corrupt kit.json degrades to defaults instead of throwing', async () => assert.ok(rows.length > 0, 'status still reports on an unreadable config'); }); +// ── opencode subsystem rows ────────────────────────────────────────────────── +// The third host's status surface: one row family covering config wiring, the +// lifecycle plugin, converted agents, and the platform skill — OK when +// converged, honest non-OK detail + a specific fix otherwise, silence when +// disabled. Strictly read-only throughout (proven by the suite-wide snapshot +// test above, which these scenarios also honor). + +const ocHome = () => path.join(HOME, '.config', 'opencode'); +const ocJsonPath = () => path.join(ocHome(), 'opencode.json'); + +/** Fake `opencode` CLI on PATH for the duration of `fn` (+ /usr/bin for the + * `which` probe itself; npm stays unresolvable so probes remain offline). */ +async function withOpencodeCli(fn) { + const bin = path.join(HOME, 'fake-bin-oc'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'opencode'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const prev = process.env.PATH; + process.env.PATH = [bin, '/usr/bin', '/bin'].join(path.delimiter); + try { return await fn(); } finally { process.env.PATH = prev; rmrf(bin); } +} + +/** Seed an enabled, CONVERGED opencode state by running the real stack. */ +async function seedConvergedOpencode() { + const catalog = path.join(HOME, 'catalog'); + rmrf(catalog); + fs.mkdirSync(path.join(catalog, '.claude', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(catalog, '.claude', 'skills', 'a-skill'), { recursive: true }); + fs.writeFileSync(path.join(catalog, 'package.json'), JSON.stringify({ name: 'fixture', version: '9.9.9' })); + fs.writeFileSync(path.join(catalog, '.claude', 'agents', 'coder.md'), + '---\nname: coder\ndescription: Implementation specialist\n---\n\nBody.\n'); + fs.writeFileSync(path.join(catalog, 'SKILL.md'), '---\nname: ruflo\ndescription: platform\n---\n\n# Ruflo\n'); + const { opencodeStack } = await import('../../src/lib/opencode.mjs'); + const cfg = loadKitConfig(); + cfg.providers = { + ...(cfg.providers ?? {}), + hosts: { claude: true, codex: false, opencode: true }, + opencodeCatalogDir: catalog, + }; + await withOpencodeCli(() => opencodeStack(cfg, { pkgRoot: PKG_ROOT })); + writeKitConfig(HOME, cfg); // persist the ownership markers the stack recorded + return cfg; +} + +test('enabled + converged: every opencode row is ok with no fix planned', async () => { + seedHome(); + await seedConvergedOpencode(); + const rows = await withOpencodeCli(() => collect()); + const oc = rowsFor(rows, 'opencode'); + assert.ok(oc.length >= 2, `expected opencode rows, got: ${rows.map((r) => r.subsystem)}`); + for (const r of oc) { + assert.equal(r.level, 'ok', `converged row must be ok: ${r.message}`); + assert.equal(r.fix, null, `converged row must never plan a fix: ${r.message}`); + } + assert.ok(oc.some((r) => /converged/.test(r.message)), 'the wiring row reports convergence'); +}); + +test('enabled + drifted: a warn row names the sync fix', async () => { + seedHome(); + await seedConvergedOpencode(); + const doc = JSON.parse(fs.readFileSync(ocJsonPath(), 'utf8')); + delete doc.permission['claude-flow_*']; // user/edit drift + fs.writeFileSync(ocJsonPath(), JSON.stringify(doc, null, 2)); + const rows = await withOpencodeCli(() => collect()); + const oc = rowsFor(rows, 'opencode'); + const drifted = oc.find((r) => r.level === 'warn'); + assert.ok(drifted, `a drift row must surface: ${oc.map((r) => r.message)}`); + assert.match(drifted.fix, /sync re-applies the opencode wiring/); +}); + +test('enabled + JSONC config: refused honestly with a manual-merge fix, never a clobber', async () => { + seedHome(); + await seedConvergedOpencode(); + fs.writeFileSync(ocJsonPath(), '{\n // legal JSONC comment\n "mcp": {}\n}\n'); + const rows = await withOpencodeCli(() => collect()); + const oc = rowsFor(rows, 'opencode').find((r) => r.level === 'warn'); + assert.ok(oc, 'a JSONC-refused row must surface'); + assert.match(oc.message, /not plain JSON/); + assert.match(oc.fix, /merge the ak wiring manually/); + // …and status left the file alone (read-only even here). + assert.match(fs.readFileSync(ocJsonPath(), 'utf8'), /legal JSONC comment/); +}); + +test('a user-owned plugin occupying the slot is an info row, not a nag to overwrite', async () => { + seedHome(); + await seedConvergedOpencode(); + fs.writeFileSync(path.join(ocHome(), 'plugins', 'ruflo-hooks.js'), '// my own plugin — no ak marker\n'); + const rows = await withOpencodeCli(() => collect()); + const oc = rowsFor(rows, 'opencode').find((r) => /user-owned ruflo-hooks\.js/.test(r.message)); + assert.ok(oc, `foreign-plugin row missing: ${rowsFor(rows, 'opencode').map((r) => r.message)}`); + assert.equal(oc.level, 'info'); + assert.equal(oc.fix, null, 'ak must not plan to overwrite a user-owned file'); +}); + +test('enabled + CLI absent: the hosts story, and no config-home probing beyond it', async () => { + seedHome(); + await seedConvergedOpencode(); + // No fake bin here — the sandbox PATH has no opencode. + const rows = await collect(); + const oc = rowsFor(rows, 'opencode').find((r) => r.level === 'warn'); + assert.ok(oc, 'enabled-but-absent must surface'); + assert.match(oc.message, /enabled but opencode CLI not installed/); + assert.match(oc.fix, /sync installs opencode-ai/); +}); + +test('disabled + installed: complete opencode-row silence + the pick hint on providers', async () => { + seedHome(); + const rows = await withOpencodeCli(() => collect()); + assert.equal(rowsFor(rows, 'opencode').length, 0, + 'a disabled host claims no active wiring — no opencode rows at all'); + const hint = rowsFor(rows, 'providers').find((r) => /opencode CLI installed but not enabled/.test(r.message)); + assert.ok(hint, 'the providers row carries the adoption hint'); + assert.match(hint.message, /ak x provider pick --host claude,opencode/); + assert.equal(hint.fix, null, 'advisory only — sync never opts a host in'); +}); + +test('--json carries the opencode rows with the same shape the dashboard consumes', async () => { + seedHome(); + await seedConvergedOpencode(); + const cwd = process.cwd(); + process.chdir(PROJECT); + try { + const r = await withOpencodeCli(() => captureLog(() => status.run({ flags: { json: true }, pkgRoot: PKG_ROOT }))); + const parsed = JSON.parse(r.out); + const oc = (parsed.rows ?? []).filter((x) => x.subsystem === 'opencode'); + assert.ok(oc.length >= 1, 'opencode rows present in --json'); + for (const x of oc) { + assert.equal(typeof x.level, 'string'); + assert.equal(typeof x.message, 'string'); + assert.ok(x.fix === null || typeof x.fix === 'string'); + } + } finally { process.chdir(cwd); } +}); + test.after(() => rmrf(HOME, PROJECT)); diff --git a/tests/kit/sync-command.test.mjs b/tests/kit/sync-command.test.mjs index d528ec6..db9ef3a 100644 --- a/tests/kit/sync-command.test.mjs +++ b/tests/kit/sync-command.test.mjs @@ -25,7 +25,7 @@ const PROJECT = sandboxProject('ak-sync'); const FLAGS = (over = {}) => ({ 'dry-run': false, 'no-upgrade': false, json: false, ...over }); function seedHome(cfg = offlineKitConfig(), pkgs = {}) { - rmrf(paths.claudeDir(), paths.configDir()); + rmrf(paths.claudeDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); fs.mkdirSync(paths.claudeDir(), { recursive: true }); fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n'); writeKitConfig(HOME, cfg); @@ -130,4 +130,184 @@ test('every documented flag is declared in the parser options', () => { } }); +// ── opencode convergence through a REAL sync ───────────────────────────────── +// The maintainer's command-level scenarios: enabled+drifted converges after the +// hosts step and before the final verification; enabled+absent never fabricates +// the config home; disabled makes no opencode writes; --dry-run mutates no +// opencode surface; a second sync is a no-op. A real sync.run is exercised here +// (the suite otherwise stays at the plan layer) — hermetic because the global +// root is faked (no upgrades planned), MCP/agentdb are opted out, and PATH has +// no npm. + +const ocHome = () => path.join(HOME, '.config', 'opencode'); + +/** Fake `opencode` + `claude` CLIs on PATH for the duration of `fn` (claude is + * the primary host — its absence is a fail-level row that would mask the + * opencode assertions). /usr/bin:/bin ride along for the `which` probe. */ +async function withOpencodeCli(fn) { + const bin = path.join(HOME, 'fake-bin-sync'); + fs.mkdirSync(bin, { recursive: true }); + for (const name of ['opencode', 'claude']) { + fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + const prev = process.env.PATH; + process.env.PATH = [bin, '/usr/bin', '/bin'].join(path.delimiter); + try { return await fn(); } finally { process.env.PATH = prev; rmrf(bin); } +} + +function seedCatalog() { + const root = path.join(HOME, 'catalog'); + rmrf(root); + fs.mkdirSync(path.join(root, '.claude', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'skills', 'a-skill'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '9.9.9' })); + fs.writeFileSync(path.join(root, '.claude', 'agents', 'coder.md'), + '---\nname: coder\ndescription: Implementation specialist\n---\n\nBody.\n'); + fs.writeFileSync(path.join(root, 'SKILL.md'), '---\nname: ruflo\ndescription: platform\n---\n\n# Ruflo\n'); + return root; +} + +/** A fake global root where nothing is upgrade-planned and the aqe native + * binding is fabricated (bsq3Root needs the package's package.json; the probe + * itself is an existsSync on the built .node file). */ +function fakeSyncRoot() { + const root = path.join(HOME, `fake-sync-root-${Math.random().toString(36).slice(2, 8)}`, 'node_modules'); + fs.mkdirSync(path.join(root, 'ruflo'), { recursive: true }); + fs.writeFileSync(path.join(root, 'ruflo', 'package.json'), JSON.stringify({ name: 'ruflo', version: '9.9.9' })); + const bsq3RootDir = path.join(root, 'agentic-qe', 'node_modules', 'better-sqlite3'); + fs.mkdirSync(path.join(bsq3RootDir, 'build', 'Release'), { recursive: true }); + fs.writeFileSync(path.join(root, 'agentic-qe', 'package.json'), JSON.stringify({ name: 'agentic-qe', version: '9.9.9' })); + fs.writeFileSync(path.join(bsq3RootDir, 'package.json'), JSON.stringify({ name: 'better-sqlite3', version: '12.0.0' })); + fs.writeFileSync(path.join(bsq3RootDir, 'build', 'Release', 'better_sqlite3.node'), 'fake native binding\n'); + return root; +} + +/** kit.json with opencode enabled, noisy subsystems opted out, fresh version cache. */ +function syncCfg(catalog) { + return offlineKitConfig({ + agentdb: false, + mcp: { register: false, excludeFamilies: [] }, + providers: { hosts: { claude: true, codex: false, opencode: true }, opencodeCatalogDir: catalog }, + }); +} + +async function realSync() { + const cwd = process.cwd(); + process.chdir(PROJECT); + try { + return await captureLog(() => sync.run({ flags: FLAGS(), pkgRoot: PKG_ROOT })); + } finally { process.chdir(cwd); } +} + +test('enabled + drifted: a real sync converges opencode after hosts, before final verification', async () => { + const catalog = seedCatalog(); + seedHome(syncCfg(catalog), { ruflo: '9.9.9' }); + paths._setGlobalRootForTest(fakeSyncRoot()); + const { result, out } = await withOpencodeCli(() => realSync()); + assert.equal(result, 0, out); + // wiring landed on disk + 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'); + // ordering: opencode steps ran before the convergence proof + const stepIdx = out.search(/opencode (plugin|agents):/); + 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}`); +}); + +test('a second sync is a no-op for every opencode surface', async () => { + const catalog = seedCatalog(); + seedHome(syncCfg(catalog), { ruflo: '9.9.9' }); + paths._setGlobalRootForTest(fakeSyncRoot()); + await withOpencodeCli(() => realSync()); + const convergedHome = snapshot(ocHome()); + 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), + 'the opencode branch is not even entered once every row reports converged'); +}); + +// codex-review r3: the blocks branch must run when the opencode branch ran, +// because a fresh enable creates the config home that activates the +// agents-opencode guidance target. The pre-fix scenario: claude guidance +// ALREADY converged (no blocks drift in the plan) — guidance must still land +// on the SAME sync that creates the config home. +test('converged claude guidance + fresh opencode enable: opencode guidance lands on the SAME sync', async () => { + const catalog = seedCatalog(); + // Step 1: converge everything except opencode (opencode disabled here). + seedHome(syncCfg(catalog), { ruflo: '9.9.9' }); + const disabledCfg = syncCfg(catalog); + disabledCfg.providers.hosts = { claude: true, codex: false, opencode: false }; + writeKitConfig(HOME, disabledCfg); + paths._setGlobalRootForTest(fakeSyncRoot()); + await withOpencodeCli(() => realSync()); // CLAUDE.md blocks now converged + assert.ok(!fs.existsSync(ocHome()), 'opencode disabled: no config home yet'); + + // Step 2: enable opencode in kit.json (as `pick` would persist it), sync again. + const enabledCfg = syncCfg(catalog); + writeKitConfig(HOME, enabledCfg); + const { result, out } = await withOpencodeCli(() => realSync()); + assert.equal(result, 0, out); + const agentsMd = path.join(ocHome(), 'AGENTS.md'); + assert.ok(fs.existsSync(agentsMd), + 'guidance must land on the SAME sync that creates the config home, not one sync late'); + assert.match(fs.readFileSync(agentsMd, 'utf8'), /BEGIN ruflo-preamble/); + + // Step 3: the follow-up sync is then a TRUE no-op on the whole opencode home. + const converged = snapshot(ocHome()); + const third = await withOpencodeCli(() => realSync()); + assert.equal(third.result, 0, third.out); + assertUnchanged(converged, ocHome(), 'once converged, sync rewrites nothing'); +}); + +test('enabled + absent CLI: the install is attempted by hosts, the wiring is skipped, no config home appears', async () => { + const catalog = seedCatalog(); + seedHome(syncCfg(catalog), { ruflo: '9.9.9' }); + paths._setGlobalRootForTest(fakeSyncRoot()); + // No fake bin — opencode is not on PATH, and npm is unresolvable (install fails honestly). + const { out } = await realSync(); + assert.match(out, /opencode: enabled but CLI not installed — wiring skipped/); + assert.ok(!fs.existsSync(ocHome()), 'the config home is never fabricated for an absent host'); +}); + +test('disabled + installed: no wiring writes, and enablement-gated guidance is stripped — user config untouched', async () => { + seedHome(offlineKitConfig({ + agentdb: false, + mcp: { register: false, excludeFamilies: [] }, + providers: { hosts: { claude: true, codex: false, opencode: false } }, + }), { ruflo: '9.9.9' }); + paths._setGlobalRootForTest(fakeSyncRoot()); + // A pre-existing, user-owned opencode home that still carries an + // enablement-gated block from a previous enablement. + fs.mkdirSync(ocHome(), { recursive: true }); + const userConfig = JSON.stringify({ model: 'opencode/kimi-k3' }, null, 2) + '\n'; + fs.writeFileSync(path.join(ocHome(), 'opencode.json'), userConfig); + fs.writeFileSync(path.join(ocHome(), 'AGENTS.md'), + '# my notes\n\n\nstale gated guidance\n\n'); + const { out } = await withOpencodeCli(() => realSync()); + assert.ok(!/\[opencode\]/.test(out.split('sync plan')[1] ?? ''), 'no opencode work is planned when disabled'); + assert.equal(fs.readFileSync(path.join(ocHome(), 'opencode.json'), 'utf8'), userConfig, + 'user opencode.json is byte-identical — no wiring writes when disabled'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'plugins')), 'no plugin deployed when disabled'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'agents')), 'no agents deployed when disabled'); + const md = fs.readFileSync(path.join(ocHome(), 'AGENTS.md'), 'utf8'); + assert.ok(!md.includes('ruflo-opencode-reference'), 'enablement-gated guidance stripped'); + assert.ok(md.includes('# my notes'), 'user guidance content preserved'); +}); + +test('--dry-run reports the opencode repair without mutating any opencode surface', async () => { + const catalog = seedCatalog(); + seedHome(syncCfg(catalog), { ruflo: '9.9.9' }); + paths._setGlobalRootForTest(fakeSyncRoot()); + const before = snapshot(HOME); + const { result, out } = await withOpencodeCli(() => dryRun()); + assert.equal(result, 0); + assert.ok(/\[opencode\]/.test(out), 'opencode repair appears in the plan'); + assert.ok(!fs.existsSync(ocHome()), 'no config written on a dry run'); + assertUnchanged(before, HOME, 'sync --dry-run mutates nothing, opencode included'); +}); + test.after(() => rmrf(HOME, PROJECT)); diff --git a/tests/kit/uninstall-command.test.mjs b/tests/kit/uninstall-command.test.mjs index df4e077..53db4a5 100644 --- a/tests/kit/uninstall-command.test.mjs +++ b/tests/kit/uninstall-command.test.mjs @@ -199,4 +199,108 @@ test('a missing ~/.claude/CLAUDE.md is not an error', async () => { assert.equal(result, 0, 'uninstalling from a machine that was never set up still succeeds'); }); +// ── opencode teardown ──────────────────────────────────────────────────────── +// The third host's footprint: opencode.json wiring (value-precise — user priors +// restored), ak-marked artifacts (plugin/agents/skill), and the AGENTS.md +// guidance blocks. User-edited and marker-less files always survive; repeated +// runs are harmless; --dry-run writes nothing; --purge still honors ownership +// because the markers are read BEFORE kit.json is removed. + +const ocHome = () => path.join(HOME, '.config', 'opencode'); + +/** Seed an ak-managed opencode state (wiring + artifacts + markers), plus a + * user-owned agent and user config keys that must survive every teardown. */ +function seedManagedOpencode() { + const cfgDir = ocHome(); + fs.mkdirSync(path.join(cfgDir, 'agents'), { recursive: true }); + fs.mkdirSync(path.join(cfgDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(cfgDir, 'skills', 'ruflo'), { recursive: true }); + fs.writeFileSync(path.join(cfgDir, 'opencode.json'), JSON.stringify({ + model: 'opencode/kimi-k3', + mcp: { + 'my-server': { type: 'local', command: ['x'] }, + 'claude-flow': { type: 'local', command: ['ruflo', 'mcp', 'start'], enabled: true }, + }, + permission: { 'claude-flow_*': 'allow', edit: 'ask' }, + }, null, 2)); + fs.writeFileSync(path.join(cfgDir, 'plugins', 'ruflo-hooks.js'), + '// from src/templates/opencode-ruflo-hooks.js — ak-managed\n'); + fs.writeFileSync(path.join(cfgDir, 'agents', 'coder.md'), + '---\ndescription: x\n---\n\n\nbody\n'); + fs.writeFileSync(path.join(cfgDir, 'agents', '.ak-agents-stamp.json'), '{"source":"x"}\n'); + fs.writeFileSync(path.join(cfgDir, 'agents', 'my-agent.md'), '---\ndescription: mine\n---\n\nUser agent.\n'); + fs.writeFileSync(path.join(cfgDir, 'skills', 'ruflo', 'SKILL.md'), + '# Ruflo\n\n from fixture@9.9.9\n'); + fs.writeFileSync(path.join(cfgDir, 'AGENTS.md'), + '# my notes\n\n\nguidance\n\n'); + writeKitConfig(HOME, { + aqe: true, + providers: { + hosts: { claude: true, codex: false, opencode: true }, + opencodeMcp: 'ak', + opencodeManaged: { + mcp: { 'claude-flow': { prior: null, written: { type: 'local', command: ['ruflo', 'mcp', 'start'], enabled: true } } }, + paths: [], + permissions: { 'claude-flow_*': { prior: null, written: 'allow' } }, + permissionScalar: null, + }, + }, + }); +} + +test('default uninstall strips ak opencode wiring + artifacts and restores user config', async () => { + seedHome(); + seedManagedOpencode(); + const { result } = await captureLog(() => uninstall.run({ flags: { yes: true } })); + assert.equal(result, 0); + const doc = JSON.parse(fs.readFileSync(path.join(ocHome(), 'opencode.json'), 'utf8')); + assert.ok(!doc.mcp?.['claude-flow'], 'ak MCP entry stripped'); + assert.deepEqual(doc.mcp?.['my-server'], { type: 'local', command: ['x'] }, 'user MCP server survives'); + assert.equal(doc.model, 'opencode/kimi-k3', 'user model key survives'); + assert.equal(doc.permission?.edit, 'ask', 'user permission survives'); + assert.ok(!doc.permission?.['claude-flow_*'], 'ak permission pattern stripped'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-hooks.js')), 'ak plugin removed'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'agents', 'coder.md')), 'ak agent removed'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'agents', '.ak-agents-stamp.json')), 'agent stamp removed'); + assert.ok(fs.existsSync(path.join(ocHome(), 'agents', 'my-agent.md')), 'user-owned agent survives'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'skills', 'ruflo', 'SKILL.md')), 'ak skill removed'); + const md = fs.readFileSync(path.join(ocHome(), 'AGENTS.md'), 'utf8'); + assert.ok(!md.includes('ruflo-opencode-reference'), 'guidance block stripped'); + assert.ok(md.includes('# my notes'), 'user guidance survives'); + const cfg = JSON.parse(fs.readFileSync(paths.kitConfigPath(), 'utf8')); + assert.equal(cfg.providers.opencodeMcp, null, 'ownership markers nulled (kit.json kept without --purge)'); +}); + +test('repeated uninstall is harmless for the opencode footprint', async () => { + seedHome(); + seedManagedOpencode(); + await captureLog(() => uninstall.run({ flags: { yes: true } })); + const after = snapshot(ocHome()); + const { result } = await captureLog(() => uninstall.run({ flags: { yes: true } })); + assert.equal(result, 0, 'second uninstall succeeds with nothing left to strip'); + assertUnchanged(after, ocHome(), 'second uninstall changes nothing'); +}); + +test('uninstall --dry-run writes and removes nothing on the opencode surfaces', async () => { + seedHome(); + seedManagedOpencode(); + const before = snapshot(HOME); + const { result, out } = await captureLog(() => uninstall.run({ flags: { 'dry-run': true, yes: true } })); + assert.equal(result, 0); + assert.match(out, /\[dry-run\] stripped ak-managed opencode wiring/, 'dry-run reports the opencode teardown'); + assertUnchanged(before, HOME, 'dry-run must not touch the filesystem'); +}); + +test('uninstall --purge removes kit.json AFTER reading ownership — opencode wiring is still stripped', async () => { + seedHome(); + seedManagedOpencode(); + const { result } = await captureLog(() => uninstall.run({ flags: { yes: true, purge: true } })); + assert.equal(result, 0); + assert.ok(!fs.existsSync(paths.kitConfigPath()), 'kit.json purged'); + const doc = JSON.parse(fs.readFileSync(path.join(ocHome(), 'opencode.json'), 'utf8')); + assert.ok(!doc.mcp?.['claude-flow'], 'wiring stripped even under purge (ownership read first)'); + assert.ok(!fs.existsSync(path.join(ocHome(), 'plugins', 'ruflo-hooks.js')), 'artifacts removed under purge'); + assert.ok(!fs.existsSync(paths.kitConfigPath()), 'purge must not recreate kit.json'); +}); + test.after(() => rmrf(HOME)); From 80ab1a0aa22e31b5bc02c92d2e2731f4768f4ba9 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Tue, 28 Jul 2026 20:55:46 -0700 Subject: [PATCH 06/11] =?UTF-8?q?feat(dashboard):=20pure=20groups.mjs=20se?= =?UTF-8?q?am=20=E2=80=94=20rendered=20opencode=20rows=20+=20update=20bann?= =?UTF-8?q?er=20under=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tests proved payload pass-through and the presence of mapping literals, not rendered behavior. The classification/grouping/card/notice logic now lives in src/lib/dashboard/groups.mjs (pure, no DOM) — one source of truth imported by node tests and serialized verbatim into the served