diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index cd1d38c1..737126dd 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -37,6 +37,7 @@ const PLUMBING = Object.assign(Object.create(null), { 'mcp': () => import('../src/commands/x/mcp.mjs'), 'host': () => import('../src/commands/x/host.mjs'), 'reference': () => import('../src/commands/x/reference.mjs'), + 'ruflo-mcp': () => import('../src/commands/x/ruflo-mcp.mjs'), 'statusline': () => import('../src/commands/x/statusline.mjs'), 'verify': () => import('../src/commands/x/verify.mjs'), }); @@ -176,7 +177,7 @@ async function main() { // setup and host own complete mutation/reporting flows. Running the generic // nudge after a declined trust preflight could write version-cache state and // violate their "before any changes" boundary. - if (!values.json && !values['dry-run'] && !['sync', 'usage', 'setup', 'host'].includes(cmd)) { + if (!values.json && !values['dry-run'] && !['sync', 'usage', 'setup', 'host', 'ruflo-mcp'].includes(cmd)) { try { const { driftReport } = await import('../src/lib/versions.mjs'); for (const r of await driftReport()) { diff --git a/docs/SETUP.md b/docs/SETUP.md index 10b8f251..a8d50af8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -122,6 +122,20 @@ the project Claude-to-Codex MCP bridge, the user-scope Codex-to-Ruflo MCP registration, and the AQE Codex integration that project setup will create. Agentic-kit does not alter Codex's sandbox or approval policy. +Codex also retains exclusive ownership of third-party plugins. Agentic-kit never +installs or enables a Codex plugin (including `security-guidance`), and setup/sync +never rewrites Codex's plugin tables or cache. `ak status` only reads enabled +bundles to report known hook and skill portability problems. + +All enabled hosts converge on the same project-scoped Ruflo memory contract. +Claude receives the absolute `CLAUDE_FLOW_DB_PATH` in project settings. Codex's +user-scoped Ruflo MCP registration launches `ak x ruflo-mcp`, which derives the +pin from the workspace at process start. OpenCode's managed MCP gateway and +lifecycle bridge receive its project directory and set the same absolute pin. +Ruflo's native bridge may write `.swarm/agentdb-memory.db` beside the pinned +`.swarm/memory.db`; that sibling is the active native store, not configuration +drift. `ak x verify memory` proves the actual writer with a disposable round trip. + OpenCode's user-scope manifest names all four wildcard tool approvals, the Ruflo and optional Brain MCP registrations, the lifecycle plugin, and the managed agent/skill/guidance projection. These are workspace-trust grants, not diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ab7b963c..5a16c8ae 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -38,8 +38,8 @@ ak sync # apply it | opencode: `status` reports a later `opencode.jsonc` override | stock OpenCode loads that file after `opencode.json`, so it can shadow the exact MCP/permission values ak receipts; ak cannot verify JSONC without rewriting user comments | merge the Agentic Kit entries into the later file and remove the duplicate override, or keep the override and use direct user-managed wiring; ak preserves both files and does not deploy its gateway against ambiguous effective config | | opencode: an agent/skill/plugin file you created yourself keeps ak's version away | deploys are no-clobber: only exact receipt-matching bytes are repairable; an unreceipted or edited destination is user-owned and preserved (`status` reports it as `foreign`) | rename yours (or remove it and run `ak sync` to get ak's managed copy) | | opencode: `status` says `no ruflo catalog source` | the agent/skill catalog resolves override → `$RUFLO_REPO` → claude marketplace clone → `@claude-flow/cli` (direct, then nested under ruflo) — all missing | install ruflo (`ak setup` does), or point `integrations.ownership.opencode.catalogDir` / `$RUFLO_REPO` at a ruflo checkout | -| `ruflo memory store` says OK but reads return nothing | Absolute-DB-path pin missing, or an older check looked only at `.swarm/memory.db` while the native bridge selected `.swarm/agentdb-memory.db` | Run `ak x verify memory` for an isolated store/retrieve/on-disk/purge proof; `ak setup` re-pins and verifies the runtime-selected store | -| `status` shows a `codex-plugins` warning such as unknown field `_note` | An enabled plugin's newest cached hook file does not match Codex's `description` + `hooks` top-level schema | Open Codex `/plugins`, refresh or disable the named plugin, then start a new session. `ak sync` deliberately does not rewrite Codex-owned cache | +| `ruflo memory store` says OK but reads return nothing | Absolute project pin missing, a legacy Codex MCP launcher inherited the wrong cwd, or an older check looked only at `.swarm/memory.db` while the native bridge selected `.swarm/agentdb-memory.db` | Run `ak sync` to migrate an ak-owned Codex registration, then `ak x verify memory` for an isolated store/retrieve/on-disk/purge proof; `ak setup` pins Claude, Codex, and OpenCode to the project while accepting the runtime-selected native sibling | +| `status` shows a `codex-plugins` warning | An enabled plugin's newest cached hooks or skills fail a known Codex compatibility check. Examples include Claude-only hook metadata, a `SKILL.md` without YAML frontmatter, and `security-guidance` 2.0.7 emitting a Stop result Codex rejects | Open Codex `/plugins`, refresh or disable the named plugin, then start a new session. `ak setup` and `ak sync` never install, enable, refresh, or rewrite Codex-owned plugins | | `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` | | Want to run `ak sync` but Claude/Codex/OpenCode sessions are open in other terminals | Upgrade-bearing syncs stop **all** ruflo daemons machine-wide and swap the global npm trees live sessions execute hooks/statusline/MCP calls from; converged syncs touch nothing | `ak sync --dry-run` first; a `versions` row means idle the other sessions or use `ak sync --no-upgrade`; see [Running `ak sync` while sessions are live](UPGRADING.md#running-ak-sync-while-sessions-are-live) | | Suspicious token burn | Background automation vs interactive usage | ask Claude to run the **ruflo-token-audit** skill (deployed by `setup`) | diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 630ec6cd..7e31a02c 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -4,7 +4,7 @@ [ADR-0020](0020-ga-stable-surfaces.md); closed-registry clause superseded by [ADR-0029](0029-host-adapter-extension-point.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-15 +- **Updated:** 2026-08-20 - **Update note:** Added read-only Codex plugin-hook compatibility facts, runtime-selected Ruflo project-memory store proofs, and the non-correlatable OpenRouter account-analytics boundary; removed the pre-GA compatibility command, @@ -25,6 +25,12 @@ flag; every other property that clause protected (zero-runtime-dependency, offline-first normal operation, no in-process third-party code) remains intact. + 2026-08-20: the read-only Codex plugin fact now covers portable skill + frontmatter and version-bounded runtime-output advisories as well as hook + documents. Setup and sync explicitly install or enable no Codex plugins. The + project-memory pin is now projected through host-specific launch context: + Claude project env, Codex's workspace-aware MCP launcher, and OpenCode's + project-aware MCP/lifecycle processes. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), @@ -222,10 +228,11 @@ weakening it. #### Externally-owned plugin cache and runtime-selected memory -Codex plugin configuration and `~/.codex/plugins/cache` are externally owned. Agentic-kit reads -every explicitly enabled plugin's newest cached manifest, follows its declared hook paths (or the -default `hooks/hooks.json`), and validates the Codex hook-file contract. It does not refresh, -rewrite, delete, or adopt any plugin cache entry. An invalid newest cached bundle is a diagnostic fact +Codex plugin configuration and `~/.codex/plugins/cache` are externally owned. Agentic-kit installs +or enables no Codex plugin. It reads every explicitly enabled plugin's newest cached manifest, +follows its declared hook paths (or the default `hooks/hooks.json`), validates the Codex hook-file +contract and portable skill frontmatter, and applies version-bounded runtime-output advisories. It +does not refresh, rewrite, delete, or adopt any plugin cache entry. An invalid newest cached bundle is a diagnostic fact with native remediation: open Codex `/plugins` to refresh or disable the plugin, then start a new session. The row has no `sync` fix. @@ -237,6 +244,12 @@ compatibility store may coexist without representing drift. Read-only status ide writer and counts observable entries. Setup and `ak x verify memory` prove persistence by storing a disposable row, locating it in the runtime-selected store, retrieving it through the real CLI, and removing it. File or package presence alone is never reported as a persistence proof. +Claude carries the absolute compatibility path in project settings. Codex's user-scoped MCP entry +uses an agentic-kit launcher that derives the same absolute pin from each runtime workspace; +agentic-kit migrates only a legacy entry it previously registered and preserves user-owned Codex +entries. OpenCode's receipt-owned gateway and lifecycle processes set their cwd and pin from the +host-provided project directory. These are host projections of one project-memory contract, not +separate stores. ### 5. Normalize field-level facts before rendering conclusions diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 6402c7d4..ed6d6ba3 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -24,6 +24,7 @@ import * as adb from '../lib/agentdb.mjs'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; import { withDb } from '../lib/sqlite.mjs'; import { findMemoryEntry } from '../lib/project-memory.mjs'; +import { projectMemoryEnv } from '../lib/ruflo-memory.mjs'; import { setupTrustManifest, trustManifestLines, } from '../lib/trust-manifest.mjs'; @@ -329,7 +330,7 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { ok(`CLAUDE_FLOW_DB_PATH pinned → ${dbPath}`); // 5. activate memory + swarm with the pin exported - const env = { CLAUDE_FLOW_DB_PATH: dbPath }; + const env = projectMemoryEnv(root); (await runCmd('ruflo', ['memory', 'init'], { cwd: root, env })).code === 0 ? ok('memory initialized') : warn('ruflo memory init failed'); (await runCmd('ruflo', ['swarm', 'init', '--v3-mode'], { cwd: root, env })).code === 0 diff --git a/src/commands/status.mjs b/src/commands/status.mjs index b1fe77db..ba2c88c9 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -553,10 +553,16 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { // reverse bridge: ruflo MCP → codex (a codex-driven session reaches ruflo's // tools). The mirror of the claude→codex row above; makes the bridge two-way. try { - const { registered, owned } = rufloCodexMcpStatus(cfg); - if (registered) { + const { registered, owned, command, args } = rufloCodexMcpStatus(cfg); + const workspacePinned = command === 'ak' + && JSON.stringify(args) === JSON.stringify(['x', 'ruflo-mcp']); + if (registered && owned && !workspacePinned) { + rows.push(row('codex-mcp', 'warn', + 'ak-owned ruflo MCP in codex uses the legacy cwd-only launcher', + 'sync migrates it to workspace-pinned project memory')); + } else if (registered) { rows.push(row('codex-mcp', 'ok', - `ruflo MCP registered in codex ([mcp_servers.ruflo])${owned ? '' : ' — pre-existing (not ak-managed)'}`)); + `ruflo MCP registered in codex ([mcp_servers.ruflo])${owned ? ' — workspace memory pinned' : ' — pre-existing (not ak-managed)'}`)); } else if (await have('codex')) { rows.push(row('codex-mcp', 'warn', 'codex enabled but ruflo MCP not registered in codex', 'sync registers the ruflo MCP into codex')); @@ -566,9 +572,10 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } } - // Codex owns plugin installation and refresh. Inspect every explicitly - // enabled cached plugin, but never attach a sync fix: the supported repair - // surface is Codex's /plugins UI followed by a fresh session. + // Codex owns plugin installation, enablement, and refresh. Inspect every + // explicitly enabled cached plugin's hooks and skills, but never attach a + // sync fix: the supported repair surface is Codex's /plugins UI followed by + // a fresh session. try { const plugins = inspectCodexPlugins(); if (plugins.enabled.length && plugins.issues.length) { @@ -578,7 +585,7 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } else if (plugins.enabled.length) { const versions = plugins.plugins.map((plugin) => `${plugin.ref} (${plugin.version})`).join(', '); rows.push(row('codex-plugins', 'ok', - `${plugins.enabled.length} enabled Codex plugin(s); newest cached hook configs compatible (${versions})`)); + `${plugins.enabled.length} enabled Codex plugin(s); newest cached hooks and skills pass known compatibility checks (${versions})`)); } } catch (e) { rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`)); diff --git a/src/commands/x/ruflo-mcp.mjs b/src/commands/x/ruflo-mcp.mjs new file mode 100644 index 00000000..9b630249 --- /dev/null +++ b/src/commands/x/ruflo-mcp.mjs @@ -0,0 +1,27 @@ +// Internal stdio launcher used by Codex's user-scoped MCP registration. The +// registration is global, but every process launch is pinned to the workspace +// Codex started it from, so projects never share a database accidentally. +import { spawn } from 'node:child_process'; +import { rufloMcpLaunch } from '../../lib/ruflo-memory.mjs'; + +export const options = {}; +export const help = `ak x ruflo-mcp — internal workspace-aware Ruflo MCP launcher + +Used by agentic-kit's Codex registration. It pins CLAUDE_FLOW_DB_PATH to the +current repository before starting Ruflo's stdio MCP server. + +Examples: + ak x ruflo-mcp start the stdio server (normally invoked by Codex)`; + +export async function run() { + const spec = rufloMcpLaunch(); + return new Promise((resolve) => { + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: 'inherit', + }); + child.once('error', () => resolve(1)); + child.once('exit', (code) => resolve(code ?? 1)); + }); +} diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index 71aea00e..9d92385c 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -7,8 +7,9 @@ import path from 'node:path'; import { run as runCmd, have } from '../../lib/exec.mjs'; import { aidefencePresent, securityPresent } from '../../lib/natives.mjs'; import { scanRvf } from '../../lib/rvf.mjs'; -import { projectAqeDir, projectMemoryDb } from '../../lib/paths.mjs'; +import { projectAqeDir } from '../../lib/paths.mjs'; import { findMemoryEntry } from '../../lib/project-memory.mjs'; +import { projectMemoryEnv } from '../../lib/ruflo-memory.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { HOSTS, collectIntegrationFacts, aqeRouterFile } from '../../lib/providers.mjs'; import { readJson } from '../../lib/settings.mjs'; @@ -67,10 +68,9 @@ async function verifyMemory() { const namespace = `agentic-kit-verify-${process.pid}-${Date.now()}`; const key = 'roundtrip'; const value = `memory-proof-${process.pid}-${Date.now()}`; - const env = { - CLAUDE_FLOW_DB_PATH: projectMemoryDb(tmp), + const env = projectMemoryEnv(tmp, { RUFLO_DAEMON_AUTOSTART: '0', - }; + }); let stored = false; let purged = false; try { diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index 37d89dc5..9c76fa06 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -193,7 +193,7 @@ const hostEntries = [ approvalPolicy: 'unchanged', changes: [ { id: 'claude-to-codex-mcp', kind: 'mcp-registration', scope: 'project', owner: 'agentic-kit', value: 'codex mcp-server', effect: 'expose Codex to Claude Code as mcp__codex__codex in this project', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, - { id: 'codex-to-ruflo-mcp', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'ruflo mcp start', effect: 'register the Ruflo MCP server in Codex configuration', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, + { id: 'codex-to-ruflo-mcp', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'ak x ruflo-mcp', effect: 'register the Ruflo MCP server in Codex with workspace-pinned project memory', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, { id: 'aqe-codex-integration', kind: 'host-integration', scope: 'project', owner: 'agentic-qe', value: 'aqe init --with-codex', effect: 'project Agentic-QE Codex skills and configuration', operations: ['setup'], features: ['project', 'aqe'] }, ], }, diff --git a/src/lib/codex-plugins.mjs b/src/lib/codex-plugins.mjs index 489e1671..384f5605 100644 --- a/src/lib/codex-plugins.mjs +++ b/src/lib/codex-plugins.mjs @@ -7,6 +7,12 @@ import * as paths from './paths.mjs'; import { cmpVersions } from './versions.mjs'; const HOOK_KEYS = new Set(['description', 'hooks']); +const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ADVISORIES = [{ + ref: 'security-guidance@claude-plugins-official', + through: '2.0.7', + message: 'Stop hook can emit a top-level "metrics" field that Codex rejects; disable it in /plugins and restart Codex', +}]; function readJson(file) { try { @@ -72,6 +78,74 @@ function validateHookDocument(doc, file) { return issues; } +function skillMetadata(source, file) { + const normalized = source.replaceAll('\r\n', '\n'); + if (!normalized.startsWith('---\n')) { + return { metadata: null, issues: [`${file}: missing YAML frontmatter delimited by ---`] }; + } + const closing = normalized.indexOf('\n---\n', 4); + if (closing < 0) { + return { metadata: null, issues: [`${file}: missing closing YAML frontmatter delimiter ---`] }; + } + const metadata = normalized.slice(4, closing); + const field = (name) => { + const lines = metadata.split('\n'); + const index = lines.findIndex((line) => line.startsWith(`${name}:`)); + if (index < 0) return ''; + const inline = lines[index].slice(name.length + 1).trim(); + if (inline && inline !== '>' && inline !== '|') { + return inline.replace(/^(['"])(.*)\1$/, '$2').trim(); + } + const continuation = []; + for (const line of lines.slice(index + 1)) { + if (line && !/^\s/.test(line)) break; + if (line.trim()) continuation.push(line.trim()); + } + return continuation.join(' ').trim(); + }; + return { metadata: { name: field('name'), description: field('description') }, issues: [] }; +} + +function inspectSkills(root) { + const skillsDir = path.join(root, 'skills'); + let directories; + try { + directories = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()); + } catch { + return { files: [], issues: [] }; + } + const files = []; + const issues = []; + for (const directory of directories) { + const file = path.join(skillsDir, directory.name, 'SKILL.md'); + if (!fs.existsSync(file)) { + issues.push(`${file}: missing SKILL.md`); + continue; + } + files.push(file); + const parsed = skillMetadata(fs.readFileSync(file, 'utf8'), file); + issues.push(...parsed.issues); + if (!parsed.metadata) continue; + const { name, description } = parsed.metadata; + if (!name) issues.push(`${file}: frontmatter requires a non-empty name`); + if (!description) issues.push(`${file}: frontmatter requires a non-empty description`); + if (name && name !== directory.name) { + issues.push(`${file}: frontmatter name "${name}" must match directory "${directory.name}"`); + } + if (name && (!SKILL_NAME.test(name) || name.length > 63)) { + issues.push(`${file}: frontmatter name must be lowercase kebab-case and at most 63 characters`); + } + } + return { files, issues }; +} + +function advisoryIssues(ref, version) { + return ADVISORIES + .filter((advisory) => advisory.ref === ref && cmpVersions(version, advisory.through) <= 0) + .map((advisory) => `${ref} ${version}: ${advisory.message}`); +} + function hookTargets(hooks, root) { if (hooks === undefined) { const conventional = path.join(root, 'hooks', 'hooks.json'); @@ -89,11 +163,11 @@ function hookTargets(hooks, root) { function inspectPlugin(ref, cacheDir) { const parsed = splitRef(ref); - if (!parsed) return { ref, version: null, root: null, hookFiles: [], issues: [`invalid plugin reference "${ref}"`] }; + if (!parsed) return { ref, version: null, root: null, hookFiles: [], skillFiles: [], issues: [`invalid plugin reference "${ref}"`] }; const base = path.join(cacheDir, parsed.marketplace, parsed.plugin); const version = newestVersionDir(base); if (!version) { - return { ref, version: null, root: null, hookFiles: [], issues: [`${ref}: enabled but no cached version is installed`] }; + return { ref, version: null, root: null, hookFiles: [], skillFiles: [], issues: [`${ref}: enabled but no cached version is installed`] }; } const root = path.join(base, version); const manifestFile = [ @@ -103,17 +177,17 @@ function inspectPlugin(ref, cacheDir) { ].find((file) => fs.existsSync(file)); if (!manifestFile) { return { - ref, version, root, hookFiles: [], + ref, version, root, hookFiles: [], skillFiles: [], issues: [`${ref}: cached generation ${version} has no supported plugin manifest`], }; } const manifest = readJson(manifestFile); if (manifest.error) { - return { ref, version, root, hookFiles: [], issues: [`${manifestFile}: ${manifest.error}`] }; + return { ref, version, root, hookFiles: [], skillFiles: [], issues: [`${manifestFile}: ${manifest.error}`] }; } const hookFiles = []; - const issues = []; + const issues = advisoryIssues(ref, version); for (const target of hookTargets(manifest.value?.hooks, root)) { if (target.kind === 'outside') { issues.push(`${ref}: hook path escapes the plugin root: ${target.value}`); @@ -132,7 +206,9 @@ function inspectPlugin(ref, cacheDir) { if (hook.error) issues.push(`${target.value}: ${hook.error}`); else issues.push(...validateHookDocument(hook.value, target.value)); } - return { ref, version, root, hookFiles, issues }; + const skills = inspectSkills(root); + issues.push(...skills.issues); + return { ref, version, root, hookFiles, skillFiles: skills.files, issues }; } /** Inspect every explicitly enabled plugin's newest cached generation. */ diff --git a/src/lib/mcp.mjs b/src/lib/mcp.mjs index 60ac0b85..b363480d 100644 --- a/src/lib/mcp.mjs +++ b/src/lib/mcp.mjs @@ -70,17 +70,36 @@ export function codexMcpStatus(cfg, cwd = process.cwd()) { * Reverse-bridge state: is the ruflo MCP registered INTO Codex? `ensureRufloMcpInCodex` * runs `codex mcp add ruflo …`, which writes a `[mcp_servers.ruflo]` table into * ~/.codex/config.toml — so a spawn-free presence check reads that file (mirrors - * codexMcpStatus's file-read approach; no TOML parser needed for a header check). + * codexMcpStatus's file-read approach). The command and args facts let sync + * migrate only an ak-owned legacy registration to the workspace-aware launcher. * `owned` reflects kit.json's ak-ownership marker * (`integrations.ownership.codex.reverseMcp === 'ak'`). - * @returns {{ registered: boolean, owned: boolean }} + * @returns {{ registered: boolean, owned: boolean, command: string|null, args: string[]|null }} */ export function rufloCodexMcpStatus(cfg, { home = os.homedir() } = {}) { let registered = false; + let command = null; + let args = null; try { - registered = /^\s*\[mcp_servers\.ruflo\]/m.test(fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8')); + const source = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'); + const header = /^\s*\[mcp_servers\.(?:ruflo|"ruflo")\]\s*$/m.exec(source); + registered = !!header; + if (header) { + const rest = source.slice(header.index + header[0].length); + const next = rest.search(/^\s*\[/m); + const body = rest.slice(0, next < 0 ? rest.length : next); + const commandMatch = /^\s*command\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m.exec(body); + const argsMatch = /^\s*args\s*=\s*(\[[^\n]*\])\s*$/m.exec(body); + try { if (commandMatch) command = JSON.parse(commandMatch[1]); } catch { /* non-canonical TOML */ } + try { if (argsMatch) args = JSON.parse(argsMatch[1]); } catch { /* non-canonical TOML */ } + } } catch { /* config absent → not registered */ } - return { registered, owned: cfg?.integrations?.ownership?.codex?.reverseMcp === 'ak' }; + return { + registered, + owned: cfg?.integrations?.ownership?.codex?.reverseMcp === 'ak', + command, + args, + }; } export async function register() { diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 25cc1212..0fabec2b 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -36,6 +36,7 @@ import { } from './adapters/index.mjs'; import { CURRENT_INTEGRATIONS_VERSION } from './adapters/config.mjs'; import { opencodeMcpStatus } from './opencode.mjs'; +import { rufloCodexMcpStatus } from './mcp.mjs'; import { DEFAULT_PRIMARY_HOST, ROUTING_SCHEMA_VERSION, @@ -649,21 +650,39 @@ export async function undoCodexMcp(cwd = process.cwd(), { managed = false, runne // This is the MIRROR: register the ruflo MCP server INTO Codex so a Codex-driven // session can reach ruflo's tools — the codex→ruflo half that makes the bridge // bidirectional (ambidextrous parity). The reverse bridge intentionally uses: -// `codex mcp add ruflo -- mcp start` writes a [mcp_servers.ruflo] table into -// ~/.codex/config.toml. aqe's own codex MCP is handled by `aqe init --with-codex` +// `codex mcp add ruflo -- ak x ruflo-mcp` writes a [mcp_servers.ruflo] table into +// ~/.codex/config.toml; the launcher pins memory from each runtime workspace. +// aqe's own codex MCP is handled by `aqe init --with-codex` // (setup runs it), and Claude Code is not itself an MCP server, so those two legs // live elsewhere; this owns the ruflo leg. Best-effort; a failure never fails the // caller. Ownership marker: integrations.ownership.codex.reverseMcp === 'ak'. -export async function ensureRufloMcpInCodex(cfg, cwd = process.cwd()) { +export async function ensureRufloMcpInCodex(cfg, cwd = process.cwd(), { + runner = run, haveFn = have, inspect = rufloCodexMcpStatus, +} = {}) { if (!cfg.integrations?.hosts?.codex) return { ok: true, changed: false, detail: 'codex not enabled — ruflo→codex MCP unmanaged' }; - if (!(await have('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' }; - if (!(await have('ruflo'))) return { ok: true, changed: false, detail: 'ruflo not on PATH — ruflo→codex MCP skipped' }; - const r = await run('codex', ['mcp', 'add', 'ruflo', '--', 'ruflo', 'mcp', 'start'], { cwd }); + if (!(await haveFn('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' }; + if (!(await haveFn('ruflo'))) return { ok: true, changed: false, detail: 'ruflo not on PATH — ruflo→codex MCP skipped' }; + const current = inspect(cfg); + const desired = current.command === 'ak' + && JSON.stringify(current.args) === JSON.stringify(['x', 'ruflo-mcp']); + if (current.registered && desired) { + return { ok: true, changed: false, detail: 'ruflo MCP already registered in codex (workspace memory pinned)' }; + } + if (current.registered && !current.owned) { + return { ok: true, changed: false, detail: 'ruflo MCP already registered in codex (user-owned; left unchanged)' }; + } + if (current.registered) { + const removed = await runner('codex', ['mcp', 'remove', 'ruflo'], { cwd }); + if (removed.code !== 0) { + return { ok: false, changed: false, detail: 'ruflo→codex MCP migration could not remove the ak-owned legacy registration' }; + } + } + const r = await runner('codex', ['mcp', 'add', 'ruflo', '--', 'ak', 'x', 'ruflo-mcp'], { cwd }); if (r.code === 0) { cfg.integrations.ownership ??= {}; cfg.integrations.ownership.codex ??= {}; cfg.integrations.ownership.codex.reverseMcp = 'ak'; - return { ok: true, changed: true, detail: 'ruflo MCP registered into codex ([mcp_servers.ruflo])' }; + return { ok: true, changed: true, detail: 'ruflo MCP registered into codex with workspace-pinned project memory ([mcp_servers.ruflo])' }; } if (/already exists|already configured/i.test(`${r.stderr}${r.stdout}`)) { return { ok: true, changed: false, detail: 'ruflo MCP already registered in codex' }; diff --git a/src/lib/ruflo-memory.mjs b/src/lib/ruflo-memory.mjs new file mode 100644 index 00000000..301c3b3d --- /dev/null +++ b/src/lib/ruflo-memory.mjs @@ -0,0 +1,24 @@ +// One project-memory launch contract for every host. Ruflo accepts the +// compatibility path through CLAUDE_FLOW_DB_PATH; its native AgentDB bridge +// may derive and write the sibling agentdb-memory.db from that same project. +import fs from 'node:fs'; +import * as paths from './paths.mjs'; + +export function memoryProjectRoot(cwd = process.cwd()) { + return fs.realpathSync(paths.repoRoot(cwd) ?? cwd); +} + +export function projectMemoryEnv(cwd = process.cwd(), env = {}) { + const root = memoryProjectRoot(cwd); + return { ...env, CLAUDE_FLOW_DB_PATH: paths.projectMemoryDb(root) }; +} + +export function rufloMcpLaunch(cwd = process.cwd(), env = process.env) { + const root = memoryProjectRoot(cwd); + return { + command: 'ruflo', + args: ['mcp', 'start'], + cwd: root, + env: projectMemoryEnv(root, env), + }; +} diff --git a/src/templates/opencode-ruflo-gateway.js b/src/templates/opencode-ruflo-gateway.js index e206a935..dbf946e8 100644 --- a/src/templates/opencode-ruflo-gateway.js +++ b/src/templates/opencode-ruflo-gateway.js @@ -19,6 +19,8 @@ import { spawn } from "node:child_process" import { createInterface } from "node:readline" +import fs from "node:fs" +import path from "node:path" import { tool } from "@opencode-ai/plugin" const configuredTimeout = Number(process.env.AK_OPENCODE_GATEWAY_TIMEOUT_MS) @@ -262,8 +264,10 @@ function hideDirectFamily(permission, patterns) { } class RufloGatewayClient { - constructor(label) { + constructor(label, directory) { this.label = label + const resolved = path.resolve(directory) + try { this.directory = fs.realpathSync(resolved) } catch { this.directory = resolved } this.command = null this.args = [] this.environment = {} @@ -289,9 +293,10 @@ class RufloGatewayClient { if (this.child || this.starting) throw new Error(`${this.label} gateway cannot be reconfigured after use`) this.command = command[0] this.args = command.slice(1) - this.environment = entry.environment && typeof entry.environment === "object" - ? { ...entry.environment } - : {} + this.environment = { + ...(entry.environment && typeof entry.environment === "object" ? entry.environment : {}), + CLAUDE_FLOW_DB_PATH: path.join(this.directory, ".swarm", "memory.db"), + } return true } @@ -307,6 +312,7 @@ class RufloGatewayClient { async startInner() { const child = spawn(this.command, this.args, { + cwd: this.directory, env: { ...process.env, ...this.environment }, stdio: ["pipe", "pipe", "pipe"], detached: false, @@ -496,9 +502,9 @@ function renderToolResult(result, errorPrefix) { } /** @type {import("@opencode-ai/plugin").Plugin} */ -export default async function rufloGateway() { - const rufloClient = new RufloGatewayClient("Ruflo") - const aqeClient = new RufloGatewayClient("Agentic QE") +export default async function rufloGateway({ directory = process.cwd() } = {}) { + const rufloClient = new RufloGatewayClient("Ruflo", directory) + const aqeClient = new RufloGatewayClient("Agentic QE", directory) const skillCatalogs = new Map() let available = { ruflo: false, aqe: false, brain: false, agents: false } const plugin = { diff --git a/src/templates/opencode-ruflo-hooks.js b/src/templates/opencode-ruflo-hooks.js index 427a6977..e5bd98e3 100644 --- a/src/templates/opencode-ruflo-hooks.js +++ b/src/templates/opencode-ruflo-hooks.js @@ -132,7 +132,17 @@ function resolveHookHandler() { const HANDLER = resolveHookHandler() -function runHook(verb, payload) { +function projectHookEnv(directory, env = process.env) { + const resolved = path.resolve(directory) + let root = resolved + try { root = fs.realpathSync(resolved) } catch { /* preserve the resolved path */ } + return { + ...env, + CLAUDE_FLOW_DB_PATH: path.join(root, ".swarm", "memory.db"), + } +} + +function runHook(verb, payload, directory = process.cwd()) { return new Promise((resolve) => { if (!HANDLER) return resolve({ ok: false, stdout: "", stderr: "no handler" }) let stdout = "" @@ -147,9 +157,13 @@ function runHook(verb, payload) { } let child try { + const resolved = path.resolve(directory) + let cwd = resolved + try { cwd = fs.realpathSync(resolved) } catch { /* preserve the resolved path */ } child = spawn("node", [HANDLER, verb], { + cwd, stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: projectHookEnv(directory), }) } catch { return done({ ok: false, stdout: "", stderr: "spawn failed" }) @@ -172,8 +186,8 @@ function runHook(verb, payload) { } // Fire-and-forget wrapper: never throws, never blocks the event loop turn. -function fire(verb, payload) { - runHook(verb, payload).catch(() => {}) +function fire(verb, payload, directory) { + runHook(verb, payload, directory).catch(() => {}) } function promptText(parts) { @@ -191,7 +205,7 @@ function directOpenCodeReferences(text) { .replace(/mcp__(?:agentic-qe|agentic_qe)__([A-Za-z0-9_*-]+)/g, "agentic-qe_$1") } -const plugin = async ({ client }) => { +const plugin = async ({ client, directory = process.cwd() }) => { const toolLoopGuard = createToolLoopGuard() await client.app.log({ body: { @@ -206,14 +220,14 @@ const plugin = async ({ client }) => { try { switch (event?.type) { case "session.created": - fire("session-restore") + fire("session-restore", undefined, directory) break case "session.compacted": - fire("session-restore") + fire("session-restore", undefined, directory) break case "session.deleted": toolLoopGuard.reset(event?.properties?.info?.id ?? event?.properties?.sessionID) - fire("session-end") + fire("session-end", undefined, directory) break } } catch { /* never break opencode */ } @@ -224,7 +238,7 @@ const plugin = async ({ client }) => { toolLoopGuard.reset(input.sessionID) const prompt = promptText(output?.parts) if (prompt.length < ROUTE_MIN_PROMPT || TRIVIAL_PROMPT.test(prompt)) return - const res = await runHook("route", { prompt }) + const res = await runHook("route", { prompt }, directory) 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 @@ -261,13 +275,13 @@ const plugin = async ({ client }) => { const res = await runHook("pre-bash", { tool_name: "Bash", tool_input: { command }, - }) + }, directory) 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) }) + fire("pre-task", { prompt: String(description).slice(0, 500) }, directory) } } catch (e) { if (e && e.message && e.message.startsWith("[ruflo]")) throw e @@ -291,11 +305,11 @@ const plugin = async ({ client }) => { tool_name: input.tool, tool_input: { file_path: filePath }, tool_response: typeof output?.output === "string" ? output.output.slice(0, 2000) : "", - }) + }, directory) } else if (input.tool === "task") { fire("post-task", { tool_response: typeof output?.output === "string" ? output.output.slice(0, 2000) : "", - }) + }, directory) } } catch { /* learning hooks are best-effort */ } }, @@ -303,4 +317,4 @@ const plugin = async ({ client }) => { } export default plugin -export { canonicalJson, createToolLoopGuard, plugin as RufloHooks } +export { canonicalJson, createToolLoopGuard, plugin as RufloHooks, projectHookEnv } diff --git a/tests/kit/codex-plugins.test.mjs b/tests/kit/codex-plugins.test.mjs index 4cf0b7b5..7169b741 100644 --- a/tests/kit/codex-plugins.test.mjs +++ b/tests/kit/codex-plugins.test.mjs @@ -20,6 +20,7 @@ function seedPlugin({ manifest = {}, manifestDir = '.codex-plugin', hook, + skills = [], }) { const root = pluginRoot(marketplace, plugin, version); fs.mkdirSync(path.join(root, manifestDir), { recursive: true }); @@ -30,6 +31,11 @@ function seedPlugin({ fs.mkdirSync(path.dirname(hookFile), { recursive: true }); fs.writeFileSync(hookFile, JSON.stringify(hook)); } + for (const skill of skills) { + const skillDir = path.join(root, 'skills', skill.directory); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.source); + } return root; } @@ -109,4 +115,36 @@ test('missing cache and unsafe manifest paths produce actionable facts', () => { assert.match(inspect().issues[0], /must start with "\.\/"/); }); +test('skills without portable YAML frontmatter are reported', () => { + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.writeFileSync(configFile, '[plugins."spring-m11n@market"]\nenabled = true\n'); + seedPlugin({ + marketplace: 'market', plugin: 'spring-m11n', version: '1.0.0', + skills: [ + { directory: 'valid-skill', source: '---\nname: valid-skill\ndescription: Works in both hosts\n---\n\n# Valid\n' }, + { directory: 'missing-frontmatter', source: '# Claude-only skill metadata\n' }, + ], + }); + const result = inspect(); + assert.equal(result.plugins[0].skillFiles.length, 2); + assert.equal(result.issues.length, 1); + assert.match(result.issues[0], /missing-frontmatter[\\/]SKILL\.md: missing YAML frontmatter/); +}); + +test('known Codex runtime-output incompatibilities are version-bounded advisories', () => { + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.writeFileSync(configFile, + '[plugins."security-guidance@claude-plugins-official"]\nenabled = true\n'); + seedPlugin({ + marketplace: 'claude-plugins-official', plugin: 'security-guidance', version: '2.0.7', + }); + assert.match(inspect().issues[0], /top-level "metrics" field that Codex rejects/); + + fs.rmSync(cacheDir, { recursive: true, force: true }); + seedPlugin({ + marketplace: 'claude-plugins-official', plugin: 'security-guidance', version: '2.0.8', + }); + assert.deepEqual(inspect().issues, [], 'a later release is not presumed broken'); +}); + test.after(() => fs.rmSync(ROOT, { recursive: true, force: true })); diff --git a/tests/kit/opencode-hooks.test.mjs b/tests/kit/opencode-hooks.test.mjs index afdf1342..d80bb8bf 100644 --- a/tests/kit/opencode-hooks.test.mjs +++ b/tests/kit/opencode-hooks.test.mjs @@ -1,12 +1,22 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import path from 'node:path'; import { canonicalJson, createToolLoopGuard, RufloHooks, + projectHookEnv, } from '../../src/templates/opencode-ruflo-hooks.js'; +test('OpenCode lifecycle hooks pin Ruflo memory to their project directory', () => { + const directory = path.resolve('/work/project'); + assert.deepEqual(projectHookEnv(directory, { KEEP: 'yes' }), { + KEEP: 'yes', + CLAUDE_FLOW_DB_PATH: path.join(directory, '.swarm', 'memory.db'), + }); +}); + function complete(guard, { sessionID = 'ses_1', callID, diff --git a/tests/kit/opencode-ruflo-gateway.test.mjs b/tests/kit/opencode-ruflo-gateway.test.mjs index 591d8c84..0de5a925 100644 --- a/tests/kit/opencode-ruflo-gateway.test.mjs +++ b/tests/kit/opencode-ruflo-gateway.test.mjs @@ -51,6 +51,11 @@ const initDelay = Number(process.env.AK_FAKE_MCP_INIT_DELAY_MS || 0) const hangMarker = process.env.AK_FAKE_MCP_FIRST_INIT_HANG_MARKER const termDelay = Number(process.env.AK_FAKE_MCP_TERM_DELAY_MS || 0) const pidFile = process.env.AK_FAKE_MCP_PID_FILE +const contextFile = process.env.AK_FAKE_MCP_CONTEXT_FILE +if (contextFile) fs.writeFileSync(contextFile, JSON.stringify({ + cwd: process.cwd(), + db: process.env.CLAUDE_FLOW_DB_PATH, +})) if (pidFile) fs.writeFileSync(pidFile, String(process.pid)) if (process.env.AK_FAKE_MCP_IGNORE_TERM === '1') { process.on('SIGTERM', () => {}) @@ -125,11 +130,12 @@ test('gateway uses the ak-managed MCP command lazily and preserves the full live const server = path.join(root, 'fake-mcp.mjs'); const log = path.join(root, 'mcp.log'); const aqeLog = path.join(root, 'aqe-mcp.log'); + const contextFile = path.join(root, 'mcp-context.json'); writeFakeMcp(server); const mcp = { 'claude-flow': { type: 'local', command: [process.execPath, server], enabled: true, - environment: { AK_FAKE_MCP_LOG: log }, + environment: { AK_FAKE_MCP_LOG: log, AK_FAKE_MCP_CONTEXT_FILE: contextFile }, }, 'agentic-qe': { type: 'local', command: [process.execPath, server], enabled: true, @@ -137,7 +143,7 @@ test('gateway uses the ak-managed MCP command lazily and preserves the full live }, }; const mod = await loadGateway(root, mcp); - const hooks = await mod.default(); + const hooks = await mod.default({ directory: root }); t.after(() => hooks.dispose()); const cfg = { mcp, @@ -169,6 +175,11 @@ test('gateway uses the ak-managed MCP command lazily and preserves the full live const context = toolContext(); const search = await hooks.tool.ak_ruflo_search.execute({ query: 'semantic memory', limit: 4 }, context); const searchResult = JSON.parse(search); + const canonicalRoot = fs.realpathSync(root); + assert.deepEqual(JSON.parse(fs.readFileSync(contextFile, 'utf8')), { + cwd: canonicalRoot, + db: path.join(canonicalRoot, '.swarm', 'memory.db'), + }); assert.match(searchResult.instruction, /arguments_json/); assert.equal(searchResult.matches[0].name, 'memory_search'); assert.deepEqual(searchResult.matches[0].inputSchema.properties.query, { type: 'string' }); diff --git a/tests/kit/reverse-bridge.test.mjs b/tests/kit/reverse-bridge.test.mjs index 17f45feb..be8df35c 100644 --- a/tests/kit/reverse-bridge.test.mjs +++ b/tests/kit/reverse-bridge.test.mjs @@ -19,12 +19,16 @@ test('rufloCodexMcpStatus detects a registered ruflo MCP server', () => { const home = tempHome('[mcp_servers.ruflo]\ncommand = "ruflo"\nargs = ["mcp", "start"]\n'); const s = rufloCodexMcpStatus({}, { home }); assert.equal(s.registered, true); + assert.equal(s.command, 'ruflo'); + assert.deepEqual(s.args, ['mcp', 'start']); }); test('rufloCodexMcpStatus reports not-registered when the table is absent', () => { const home = tempHome('[mcp_servers.other]\ncommand = "x"\n'); const s = rufloCodexMcpStatus({}, { home }); assert.equal(s.registered, false); + assert.equal(s.command, null); + assert.equal(s.args, null); }); test('rufloCodexMcpStatus reports not-registered when config.toml is missing', () => { diff --git a/tests/kit/routing-projection.test.mjs b/tests/kit/routing-projection.test.mjs index 66659498..ace8635c 100644 --- a/tests/kit/routing-projection.test.mjs +++ b/tests/kit/routing-projection.test.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, undoCodexMcp, undoRufloMcpInCodex } from '../../src/lib/providers.mjs'; +import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, ensureRufloMcpInCodex, undoCodexMcp, undoRufloMcpInCodex } from '../../src/lib/providers.mjs'; import { seedActivityRoutes } from '../../src/lib/routing.mjs'; import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; @@ -176,6 +176,40 @@ test('owned bridge teardown sends the precise safe argv on every platform', asyn ]); }); +test('codex reverse bridge uses the workspace-aware memory launcher and migrates only ak-owned state', async () => { + const calls = []; + const runner = async (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return { code: 0, stdout: '', stderr: '' }; + }; + const cfg = { + integrations: { + hosts: { codex: true }, + ownership: { codex: { reverseMcp: 'ak' } }, + }, + }; + const result = await ensureRufloMcpInCodex(cfg, '/work/project', { + runner, + haveFn: async () => true, + inspect: () => ({ registered: true, owned: true, command: 'ruflo', args: ['mcp', 'start'] }), + }); + assert.equal(result.changed, true); + assert.deepEqual(calls, [ + { cmd: 'codex', args: ['mcp', 'remove', 'ruflo'], opts: { cwd: '/work/project' } }, + { cmd: 'codex', args: ['mcp', 'add', 'ruflo', '--', 'ak', 'x', 'ruflo-mcp'], opts: { cwd: '/work/project' } }, + ]); + + calls.length = 0; + const preserved = await ensureRufloMcpInCodex(cfg, '/work/project', { + runner, + haveFn: async () => true, + inspect: () => ({ registered: true, owned: false, command: 'custom', args: [] }), + }); + assert.equal(preserved.changed, false); + assert.match(preserved.detail, /user-owned; left unchanged/); + assert.deepEqual(calls, []); +}); + test('undoAqeRouter removes the ak-created file (agentOverrides included)', () => { const groot = fakeAqe('3.13.1'); const dir = tmpProject(); diff --git a/tests/kit/ruflo-memory.test.mjs b/tests/kit/ruflo-memory.test.mjs new file mode 100644 index 00000000..a03577ca --- /dev/null +++ b/tests/kit/ruflo-memory.test.mjs @@ -0,0 +1,30 @@ +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 { + memoryProjectRoot, projectMemoryEnv, rufloMcpLaunch, +} from '../../src/lib/ruflo-memory.mjs'; + +test('every host launch resolves one absolute memory pin from the repository root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-ruflo-memory-')); + const nested = path.join(root, 'src', 'nested'); + fs.mkdirSync(path.join(root, '.git')); + fs.mkdirSync(nested, { recursive: true }); + try { + const canonical = fs.realpathSync(root); + assert.equal(memoryProjectRoot(nested), canonical); + assert.deepEqual(projectMemoryEnv(nested, { KEEP: 'yes', CLAUDE_FLOW_DB_PATH: '/wrong' }), { + KEEP: 'yes', + CLAUDE_FLOW_DB_PATH: path.join(canonical, '.swarm', 'memory.db'), + }); + const launch = rufloMcpLaunch(nested, { KEEP: 'yes' }); + assert.deepEqual({ command: launch.command, args: launch.args, cwd: launch.cwd }, { + command: 'ruflo', args: ['mcp', 'start'], cwd: canonical, + }); + assert.equal(launch.env.CLAUDE_FLOW_DB_PATH, path.join(canonical, '.swarm', 'memory.db')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index 4f489197..7183acb6 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -193,7 +193,7 @@ test('--codex project dry-run discloses registrations while preserving Codex pol assert.equal(result, 0); assert.match(out, /OpenAI Codex — approval\/sandbox policy unchanged/); assert.match(out, /\[project\] mcp-registration: codex mcp-server/); - assert.match(out, /\[user\] mcp-registration: ruflo mcp start/); + assert.match(out, /\[user\] mcp-registration: ak x ruflo-mcp/); } finally { process.chdir(cwd); rmrf(project); } });