From a43000b50c822fd5a5bf73cb2345f9cb319100c4 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 30 Jul 2026 13:10:03 -0700 Subject: [PATCH] fix(status): validate Codex plugins and memory stores Diagnose enabled Codex plugin hook compatibility without mutating Codex-owned cache. Recognize Ruflo's split project-memory stores and add an isolated memory round-trip verifier. --- docs/TROUBLESHOOTING.md | 3 +- ...-capability-driven-integration-adapters.md | 24 +++ src/commands/setup.mjs | 24 +-- src/commands/status.mjs | 42 +++++ src/commands/x/verify.mjs | 64 ++++++- src/lib/codex-plugins.mjs | 157 ++++++++++++++++++ src/lib/dashboard/groups.mjs | 6 +- src/lib/paths.mjs | 2 + src/lib/project-memory.mjs | 53 ++++++ tests/dashboard.test.cjs | 4 +- tests/kit/codex-plugins.test.mjs | 112 +++++++++++++ tests/kit/project-memory.test.mjs | 79 +++++++++ tests/kit/status-command.test.mjs | 42 ++++- tests/kit/verify-command.test.mjs | 21 ++- 14 files changed, 609 insertions(+), 24 deletions(-) create mode 100644 src/lib/codex-plugins.mjs create mode 100644 src/lib/project-memory.mjs create mode 100644 tests/kit/codex-plugins.test.mjs create mode 100644 tests/kit/project-memory.test.mjs diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ccd70c60..59a07e74 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -31,7 +31,8 @@ ak sync # apply it | opencode: `status` says `opencode.json is not plain JSON` | opencode legally allows JSONC comments; ak refuses to rewrite a file it can't parse rather than normalize (and silently drop) your comments | hand-merge the ak entries (`mcp`, `skills.paths`, `permission`) per `docs/adr/0017-opencode-host.md`, or remove the comments and run `ak sync` | | opencode: an agent/skill/plugin file you created yourself keeps ak's version away | deploys are no-clobber: a file without ak's generated marker at the destination is treated as user-owned and preserved (`status` reports it as `foreign`) | rename yours (or delete it and `ak sync` to get ak's managed copy) | | opencode: `status` 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 | +| `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 | | `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` | | Deprecated `ak dual run` refuses to start ("ruflo's memory runtime lacks a native better-sqlite3 binding AND … active native WAL") | Pre-flight guard: the legacy 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 | Prefer `ak run` for new work. If an existing dual-run workflow must continue, `ak sync` builds the native binding, then retry it. | | `ak provider` or `ak x provider` prints a deprecation warning | Alpha namespace correction: execution-host lifecycle and selection now belong to `host`; inference providers and bindings remain separate concepts | Use `ak host` (or `ak x host` for plumbing). The provider aliases will be removed before stable | diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index d21287f3..4f016aeb 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -2,6 +2,9 @@ - **Status:** Accepted - **Date:** 2026-07-28 +- **Updated:** 2026-07-30 +- **Update note:** Added read-only Codex plugin-hook compatibility facts and + runtime-selected Ruflo project-memory store proofs. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), @@ -191,6 +194,24 @@ installation remains detectable and usable but unmanaged. This extends PR #67 an no-clobber behavior across JSON, TOML, environment projections, and CLI-managed surfaces without 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 +with native remediation: open Codex `/plugins` to refresh or disable the plugin, then start a new +session. The row has no `sync` fix. + +Project memory is also detected at fact level rather than inferred from package presence or a +single historical filename. Current native Ruflo bridges can preserve a compatibility/sql.js (or +encrypted) `.swarm/memory.db` while writing native plaintext rows to the sibling +`.swarm/agentdb-memory.db`. When the native sibling exists it is the active writer; the +compatibility store may coexist without representing drift. Read-only status identifies the active +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. + ### 5. Normalize field-level facts before rendering conclusions Detection and observation return facts, not pre-rendered status rows: @@ -409,6 +430,8 @@ not write real home/global configuration. - It does not claim provider provenance from host evidence alone. - It does not add dashboard writes or controls. - It does not silently adopt or overwrite externally managed configuration. +- It does not mutate Codex's plugin cache; plugin refresh and disable remain Codex-native actions. +- It does not collapse Ruflo's compatibility and native project-memory stores into one database. - It does not implement or make unverified Ollama execution, usage-pricing, catalogue-metadata, or transcript-fidelity claims; those remain gated independently by ADR-0011. @@ -440,6 +463,7 @@ but less truthful. | npm-managed vs external ownership is truthful | Section 4 | | API keys are never persisted | Section 7 and serialization tests | | Dry-run/idempotence/undo/no-clobber are shared and tested | Sections 3 and 4; conformance suite | +| External plugin hooks and runtime-selected memory are truthful | Section 4; read-only plugin diagnostics and isolated memory round-trip | | Issue #59 can consume the abstraction without being subsumed | Sections 5 and 8 | | Documentation uses one vocabulary | Section 9 | diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 84b06b77..e88b4dc6 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -20,7 +20,8 @@ import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; -import { checkpoint, withDb } from '../lib/sqlite.mjs'; +import { withDb } from '../lib/sqlite.mjs'; +import { findMemoryEntry } from '../lib/project-memory.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, heading, bold, dim } from '../lib/output.mjs'; @@ -271,20 +272,19 @@ export async function run_project({ flags, cfg }) { ok('claudeFlow.daemon.autoStart → false (explicit start only)'); } - // 7. WAL checkpoint + write-verification (store → on-disk row, then clean up) - checkpoint(dbPath); - const probeKey = `_setup/verify-${process.pid}`; + // 7. Write-verification (store → actual on-disk row, then clean up). Native + // bridges may select agentdb-memory.db beside the pinned compatibility DB. + const probeKey = `_setup/verify-${process.pid}-${Date.now()}`; const stored = (await runCmd('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env })).code === 0; - // Bound parameters, not string interpolation — probeKey is pid-derived and - // safe today, but interpolated SQL is a habit this codebase doesn't keep. - const onDisk = stored && withDb(dbPath, - (db) => db.prepare('SELECT COUNT(*) AS n FROM memory_entries WHERE key = ?').get(probeKey)?.n) === 1; - if (onDisk) { - withDb(dbPath, (db) => { - db.prepare('DELETE FROM memory_entries WHERE key = ?').run(probeKey); + const landed = stored ? findMemoryEntry(root, '_setup', probeKey) : null; + if (landed) { + // Bound parameters, not interpolation. Delete only this disposable probe + // from the store that actually received it. + withDb(landed.file, (db) => { + db.prepare('DELETE FROM memory_entries WHERE namespace = ? AND key = ?').run('_setup', probeKey); db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); }, null, { readonly: false }); - ok('memory write VERIFIED (store → on-disk row confirmed)'); + ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); } else { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); } diff --git a/src/commands/status.mjs b/src/commands/status.mjs index a9e848ce..1f1e3818 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -25,6 +25,8 @@ import { policyToAgentOverrides, routingSummary, divergedRoutes } from '../lib/r import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; import { drift as ruvectorDrift } from '../lib/ruvector.mjs'; import { statuslineDrift } from '../lib/codex-statusline.mjs'; +import { inspectCodexPlugins } from '../lib/codex-plugins.mjs'; +import { projectMemoryStatus } from '../lib/project-memory.mjs'; export const options = { json: { type: 'boolean', default: false }, @@ -194,6 +196,28 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } } catch { /* pin check is best-effort — never blocks status */ } + // Project memory may legitimately have two stores: the compatibility/sql.js + // memory.db and the native bridge's plaintext agentdb-memory.db sibling. + // Presence is a quick signal only; `ak x verify memory` performs the write + // round-trip proof. + try { + const memory = projectMemoryStatus(cwd); + if (!memory.active) { + rows.push(row('memory', 'info', 'no project memory store yet (run setup here to initialize)')); + } else if (!memory.active.readable) { + rows.push(row('memory', 'warn', + `active ${memory.active.kind} store is unreadable (${memory.active.file}) — run: ak x verify memory`)); + } else { + const sibling = memory.secondary + ? `; ${memory.secondary.kind} compatibility store also present` + : ''; + rows.push(row('memory', 'ok', + `${memory.active.kind} active writer: ${memory.active.entries} active entr${memory.active.entries === 1 ? 'y' : 'ies'}${sibling}`)); + } + } catch (e) { + rows.push(row('memory', 'warn', `project memory check unavailable: ${e.message}`)); + } + // npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the // statusline/hooks execute these verbatim, keeping retired defects alive) try { @@ -325,6 +349,24 @@ 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. + try { + const plugins = inspectCodexPlugins(); + if (plugins.enabled.length && plugins.issues.length) { + rows.push(row('codex-plugins', 'warn', + `${plugins.issues.length} Codex plugin compatibility issue(s): ${plugins.issues[0]}; ` + + 'open Codex /plugins to refresh or disable it, then start a new session')); + } 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})`)); + } + } catch (e) { + rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`)); + } + // 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 diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index 5219cd56..27ae3d8a 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -1,4 +1,4 @@ -// x verify [learning|security|aqe|all] — the deep proofs (slow, spawn real +// x verify [learning|memory|security|aqe|all] — the deep proofs (slow, spawn real // CLIs). Ports of ruflo-learning-verify, ruflo-security-verify's defend // exercise, and ruflo-verify-aqe's live checks. import fs from 'node:fs'; @@ -7,7 +7,8 @@ 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 } from '../../lib/paths.mjs'; +import { projectAqeDir, projectMemoryDb } from '../../lib/paths.mjs'; +import { findMemoryEntry } from '../../lib/project-memory.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { HOSTS, collectIntegrationFacts, aqeRouterFile } from '../../lib/providers.mjs'; import { readJson } from '../../lib/settings.mjs'; @@ -25,6 +26,7 @@ Usage: ak x verify [suite] Suites: learning train a cycle in a temp dir; assert patterns persist + memory store/retrieve/purge in a temp dir; confirm the actual DB writer security packages load; defend flags injection / passes clean aqe RVF store healthy; aqe status has no FsyncFailed providers kit config matches installed CLIs; ruflo/aqe see the wiring @@ -54,6 +56,60 @@ async function verifyLearning() { } } +async function verifyMemory() { + heading('memory — store, retrieve, inspect the actual writer, and purge in an isolated dir'); + if (!(await have('ruflo'))) { fail('ruflo CLI not installed — cannot prove project memory'); return false; } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentic-kit-memory-')); + 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), + RUFLO_DAEMON_AUTOSTART: '0', + }; + let stored = false; + let purged = false; + try { + const init = await runCmd('ruflo', ['memory', 'init'], { cwd: tmp, env, timeout: 120_000 }); + if (init.code !== 0) { fail('ruflo memory init failed'); return false; } + const put = await runCmd('ruflo', + ['memory', 'store', '-k', key, '--value', value, '-n', namespace], + { cwd: tmp, env, timeout: 120_000 }); + if (put.code !== 0) { fail(`ruflo memory store failed: ${(put.stderr || '').slice(0, 160)}`); return false; } + stored = true; + + const get = await runCmd('ruflo', + ['memory', 'retrieve', '-k', key, '-n', namespace, '--value-only'], + { cwd: tmp, env, timeout: 120_000 }); + if (get.code !== 0 || !get.stdout.includes(value)) { + fail('ruflo memory retrieve did not return the exact stored value'); + return false; + } + ok('CLI store → retrieve returned the exact value'); + + const landed = findMemoryEntry(tmp, namespace, key); + if (!landed) { fail('stored value was not observable in either supported project DB'); return false; } + ok(`on-disk row confirmed in ${path.basename(landed.file)} (${landed.kind})`); + + const purge = await runCmd('ruflo', + ['memory', 'purge', '--namespace', namespace, '--force'], + { cwd: tmp, env, timeout: 120_000 }); + purged = purge.code === 0 && !findMemoryEntry(tmp, namespace, key); + if (!purged) { fail('isolated namespace purge did not remove the proof row'); return false; } + ok('isolated proof namespace purged'); + return true; + } catch (e) { + fail(`memory verify error: ${e.message}`); + return false; + } finally { + if (stored && !purged) { + await runCmd('ruflo', ['memory', 'purge', '--namespace', namespace, '--force'], + { cwd: tmp, env, timeout: 120_000 }); + } + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + async function verifySecurity() { heading('security — packages load, defend flags injection / passes clean'); let good = true; @@ -153,9 +209,9 @@ async function verifyHarvest() { export async function run({ positionals }) { const which = positionals[0] ?? 'all'; - const suites = { learning: verifyLearning, security: verifySecurity, aqe: verifyAqe, providers: verifyProviders, harvest: verifyHarvest }; + const suites = { learning: verifyLearning, memory: verifyMemory, security: verifySecurity, aqe: verifyAqe, providers: verifyProviders, harvest: verifyHarvest }; const selected = which === 'all' ? Object.entries(suites) : [[which, suites[which]]]; - if (!selected.every(([, fn]) => fn)) { fail(`unknown suite: ${which} (learning|security|aqe|providers|harvest|all)`); return 2; } + if (!selected.every(([, fn]) => fn)) { fail(`unknown suite: ${which} (learning|memory|security|aqe|providers|harvest|all)`); return 2; } let allGood = true; for (const [, fn] of selected) allGood = (await fn()) && allGood; console.log(''); diff --git a/src/lib/codex-plugins.mjs b/src/lib/codex-plugins.mjs new file mode 100644 index 00000000..489e1671 --- /dev/null +++ b/src/lib/codex-plugins.mjs @@ -0,0 +1,157 @@ +// Read-only Codex plugin compatibility inspection. Codex owns config.toml and +// its plugin cache; agentic-kit observes them but never refreshes, rewrites, or +// adopts either surface. +import fs from 'node:fs'; +import path from 'node:path'; +import * as paths from './paths.mjs'; +import { cmpVersions } from './versions.mjs'; + +const HOOK_KEYS = new Set(['description', 'hooks']); + +function readJson(file) { + try { + return { value: JSON.parse(fs.readFileSync(file, 'utf8')), error: null }; + } catch (error) { + return { value: null, error: error.message }; + } +} + +/** Explicitly enabled `plugin@marketplace` refs from Codex's TOML. */ +export function enabledPluginRefs(source) { + const refs = []; + const table = /^\[\s*plugins\s*\.\s*(?:"((?:[^"\\]|\\.)+)"|'([^']+)')\s*\]\s*$/gm; + let match; + while ((match = table.exec(source)) !== null) { + const next = source.slice(table.lastIndex).search(/^\[/m); + const body = source.slice(table.lastIndex, next < 0 ? source.length : table.lastIndex + next); + if (/^\s*enabled\s*=\s*true(?:\s*#.*)?$/m.test(body)) { + const ref = match[1] ?? match[2]; + refs.push(match[1] ? ref.replace(/\\"/g, '"').replace(/\\\\/g, '\\') : ref); + } + } + return refs; +} + +function splitRef(ref) { + const at = ref.lastIndexOf('@'); + return at > 0 && at < ref.length - 1 + ? { plugin: ref.slice(0, at), marketplace: ref.slice(at + 1) } + : null; +} + +function newestVersionDir(base) { + let entries; + try { + entries = fs.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + } catch { + return null; + } + if (!entries.length) return null; + const semver = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + entries.sort((a, b) => { + const av = semver.test(a.name); const bv = semver.test(b.name); + if (av && bv) return cmpVersions(b.name, a.name); + if (av !== bv) return av ? -1 : 1; + const mtime = (entry) => { + try { return fs.statSync(path.join(base, entry.name)).mtimeMs; } catch { return 0; } + }; + return mtime(b) - mtime(a) || b.name.localeCompare(a.name); + }); + return entries[0].name; +} + +function validateHookDocument(doc, file) { + if (!doc || Array.isArray(doc) || typeof doc !== 'object') { + return [`${file}: hook config must be a JSON object`]; + } + const extra = Object.keys(doc).filter((key) => !HOOK_KEYS.has(key)); + const issues = extra.length ? [`${file}: unsupported top-level field(s): ${extra.join(', ')}`] : []; + if (!doc.hooks || Array.isArray(doc.hooks) || typeof doc.hooks !== 'object') { + issues.push(`${file}: top-level "hooks" object is required`); + } + return issues; +} + +function hookTargets(hooks, root) { + if (hooks === undefined) { + const conventional = path.join(root, 'hooks', 'hooks.json'); + return fs.existsSync(conventional) ? [{ kind: 'file', value: conventional }] : []; + } + const values = Array.isArray(hooks) ? hooks : [hooks]; + return values.map((value) => { + if (typeof value !== 'string') return { kind: 'inline', value }; + if (!value.startsWith('./')) return { kind: 'invalid-path', value }; + const resolved = path.resolve(root, value); + const inside = resolved === root || resolved.startsWith(`${root}${path.sep}`); + return inside ? { kind: 'file', value: resolved } : { kind: 'outside', value }; + }); +} + +function inspectPlugin(ref, cacheDir) { + const parsed = splitRef(ref); + if (!parsed) return { ref, version: null, root: null, hookFiles: [], 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`] }; + } + const root = path.join(base, version); + const manifestFile = [ + path.join(root, '.codex-plugin', 'plugin.json'), + path.join(root, '.agent-plugin', 'plugin.json'), + path.join(root, '.claude-plugin', 'plugin.json'), + ].find((file) => fs.existsSync(file)); + if (!manifestFile) { + return { + ref, version, root, hookFiles: [], + 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}`] }; + } + + const hookFiles = []; + const issues = []; + for (const target of hookTargets(manifest.value?.hooks, root)) { + if (target.kind === 'outside') { + issues.push(`${ref}: hook path escapes the plugin root: ${target.value}`); + continue; + } + if (target.kind === 'invalid-path') { + issues.push(`${ref}: hook path must start with "./": ${target.value}`); + continue; + } + if (target.kind === 'inline') { + issues.push(...validateHookDocument(target.value, `${ref} inline hooks`)); + continue; + } + hookFiles.push(target.value); + const hook = readJson(target.value); + if (hook.error) issues.push(`${target.value}: ${hook.error}`); + else issues.push(...validateHookDocument(hook.value, target.value)); + } + return { ref, version, root, hookFiles, issues }; +} + +/** Inspect every explicitly enabled plugin's newest cached generation. */ +export function inspectCodexPlugins({ + configFile = paths.codexConfigPath(), + cacheDir = paths.codexPluginCacheDir(), +} = {}) { + let source; + try { + source = fs.readFileSync(configFile, 'utf8'); + } catch { + return { configPresent: false, enabled: [], plugins: [], issues: [] }; + } + const enabled = enabledPluginRefs(source); + const plugins = enabled.map((ref) => inspectPlugin(ref, cacheDir)); + return { + configPresent: true, + enabled, + plugins, + issues: plugins.flatMap((plugin) => plugin.issues), + }; +} diff --git a/src/lib/dashboard/groups.mjs b/src/lib/dashboard/groups.mjs index 1d48730c..604e917f 100644 --- a/src/lib/dashboard/groups.mjs +++ b/src/lib/dashboard/groups.mjs @@ -16,9 +16,9 @@ export function esc(s) { * subsystems fall back to Runtime so nothing is ever dropped. Overview * aggregates all attention cards regardless of category. */ export const CAT = { - hosts: 'hosts', mcp: 'hosts', 'codex-mcp': 'hosts', opencode: 'hosts', routing: 'hosts', + hosts: 'hosts', mcp: 'hosts', 'codex-mcp': 'hosts', 'codex-plugins': 'hosts', opencode: 'hosts', routing: 'hosts', providers: 'providers', - learning: 'intel', 'ruvnet-brain': 'intel', 'ruvnet-brain-nightly': 'intel', aqe: 'intel', agentdb: 'intel', + learning: 'intel', memory: 'intel', 'ruvnet-brain': 'intel', 'ruvnet-brain-nightly': 'intel', aqe: 'intel', agentdb: 'intel', ruvector: 'intel', }; @@ -27,7 +27,7 @@ export function catOf(s) { return CAT[s] || 'runtime'; } /** Severity rank for rollups + triage sort; PREF breaks ties (display order). */ export const RANK = { fail: 3, warn: 2, ok: 1, info: 0, unknown: 0 }; -export const PREF = ['versions', 'self', 'natives', 'security', 'learning', 'providers', 'hosts', 'routing', 'mcp', 'codex-mcp', 'opencode', 'ruvnet-brain', 'ruvnet-brain-nightly', 'ruvector', 'aqe', 'daemons', 'blocks', 'statusline', 'npx']; +export const PREF = ['versions', 'self', 'natives', 'security', 'learning', 'memory', 'providers', 'hosts', 'routing', 'mcp', 'codex-mcp', 'codex-plugins', '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/paths.mjs b/src/lib/paths.mjs index 8e914e60..5dbd1615 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -35,6 +35,7 @@ export const claudeSkillsDir = () => path.join(claudeDir(), 'skills'); export const codexDir = () => path.join(home, '.codex'); export const codexAgentsMdPath = () => path.join(codexDir(), 'AGENTS.md'); export const codexConfigPath = () => path.join(codexDir(), 'config.toml'); +export const codexPluginCacheDir = () => path.join(codexDir(), 'plugins', 'cache'); /** opencode user-level locations (XDG config home, same base as the kit's own * configDir). `~/.config/opencode` is opencode's global config home; like @@ -51,6 +52,7 @@ export const projectSettings = (root) => path.join(root, '.claude', 'settings.js export const projectSettingsLocal = (root) => path.join(root, '.claude', 'settings.local.json'); export const projectStatusline = (root) => path.join(root, '.claude', 'helpers', 'statusline.cjs'); export const projectMemoryDb = (root) => path.join(root, '.swarm', 'memory.db'); +export const projectAgentDbMemoryDb = (root) => path.join(root, '.swarm', 'agentdb-memory.db'); export const projectClaudeFlowDir = (root) => path.join(root, '.claude-flow'); export const projectAqeDir = (root) => path.join(root, '.agentic-qe'); diff --git a/src/lib/project-memory.mjs b/src/lib/project-memory.mjs new file mode 100644 index 00000000..72baeaa1 --- /dev/null +++ b/src/lib/project-memory.mjs @@ -0,0 +1,53 @@ +// Project-memory observability. The native bridge keeps its plaintext store in +// agentdb-memory.db while the compatibility/sql.js surface remains memory.db. +// Both are legitimate; the native sibling is the active writer when present. +import fs from 'node:fs'; +import * as paths from './paths.mjs'; +import { withDb } from './sqlite.mjs'; + +function activityAt(file) { + let latest = 0; + for (const candidate of [file, `${file}-wal`]) { + try { latest = Math.max(latest, fs.statSync(candidate).mtimeMs); } catch { /* absent */ } + } + return latest || null; +} + +function inspectStore(file, kind) { + if (!fs.existsSync(file)) { + return { kind, file, present: false, readable: false, entries: null, activityAt: null }; + } + const observed = withDb(file, (db) => { + const columns = db.prepare('PRAGMA table_info(memory_entries)').all().map((column) => column.name); + if (!columns.length) return { readable: false, entries: null }; + const where = columns.includes('status') ? " WHERE status = 'active' OR status IS NULL" : ''; + const entries = db.prepare(`SELECT COUNT(*) AS n FROM memory_entries${where}`).get()?.n ?? 0; + return { readable: true, entries: Number(entries) }; + }, { readable: false, entries: null }); + return { kind, file, present: true, ...observed, activityAt: activityAt(file) }; +} + +export function projectMemoryStatus(root) { + const sqljs = inspectStore(paths.projectMemoryDb(root), 'sqljs'); + const native = inspectStore(paths.projectAgentDbMemoryDb(root), 'native-agentdb'); + const active = native.present ? native : sqljs.present ? sqljs : null; + const secondary = active === native && sqljs.present ? sqljs + : active === sqljs && native.present ? native : null; + return { active, secondary, stores: [sqljs, native] }; +} + +export function memoryEntryExists(file, namespace, key) { + return withDb(file, (db) => { + const row = db.prepare( + 'SELECT 1 AS found FROM memory_entries WHERE namespace = ? AND key = ? LIMIT 1', + ).get(namespace, key); + return row?.found === 1; + }, false); +} + +export function findMemoryEntry(root, namespace, key) { + const status = projectMemoryStatus(root); + return status.stores.find((store) => store.present + && store.readable + && memoryEntryExists(store.file, namespace, key)) ?? null; +} diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 7923b0d3..94fe808a 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -212,8 +212,10 @@ async function main() { 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"'); + contains(r.body, '"codex-plugins":"hosts"'); + contains(r.body, 'memory:"intel"'); // and it must sort with the host MCP subsystems, not at the unknown end - contains(r.body, '"codex-mcp","opencode"'); + contains(r.body, '"codex-mcp","codex-plugins","opencode"'); }); // ── RENDERED behavior, not served-source literals ──────────────────────── diff --git a/tests/kit/codex-plugins.test.mjs b/tests/kit/codex-plugins.test.mjs new file mode 100644 index 00000000..4cf0b7b5 --- /dev/null +++ b/tests/kit/codex-plugins.test.mjs @@ -0,0 +1,112 @@ +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 { enabledPluginRefs, inspectCodexPlugins } from '../../src/lib/codex-plugins.mjs'; + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-codex-plugins-')); +const configFile = path.join(ROOT, 'config.toml'); +const cacheDir = path.join(ROOT, 'cache'); + +function pluginRoot(marketplace, plugin, version) { + return path.join(cacheDir, marketplace, plugin, version); +} + +function seedPlugin({ + marketplace = 'ruflo', + plugin = 'ruflo-core', + version, + manifest = {}, + manifestDir = '.codex-plugin', + hook, +}) { + const root = pluginRoot(marketplace, plugin, version); + fs.mkdirSync(path.join(root, manifestDir), { recursive: true }); + fs.writeFileSync(path.join(root, manifestDir, 'plugin.json'), + JSON.stringify({ name: plugin, version, ...manifest })); + if (hook !== undefined) { + const hookFile = path.join(root, manifest.hooks ?? 'hooks/hooks.json'); + fs.mkdirSync(path.dirname(hookFile), { recursive: true }); + fs.writeFileSync(hookFile, JSON.stringify(hook)); + } + return root; +} + +function inspect() { + return inspectCodexPlugins({ configFile, cacheDir }); +} + +test('enabledPluginRefs reads only explicit enabled plugin tables', () => { + const refs = enabledPluginRefs(` +[plugins."one@market"] +enabled = true +[plugins."two@market"] +enabled = false +[plugins.'three@market'] +enabled = true +[hooks.state."one@market:hooks/hooks.json:stop:0:0"] +trusted_hash = "sha256:x" +`); + assert.deepEqual(refs, ['one@market', 'three@market']); +}); + +test('the newest cached generation is the compatibility target', () => { + fs.writeFileSync(configFile, '[plugins."ruflo-core@ruflo"]\nenabled = true\n'); + seedPlugin({ + version: '0.2.5', + hook: { _note: 'old incompatible metadata', hooks: { Stop: [] } }, + }); + seedPlugin({ + version: '0.2.6', + manifestDir: '.claude-plugin', + hook: { description: 'Codex-compatible', hooks: { Stop: [] } }, + }); + const result = inspect(); + assert.equal(result.plugins[0].version, '0.2.6'); + assert.deepEqual(result.issues, []); +}); + +test('unsupported hook metadata is reported with no schema guess', () => { + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.writeFileSync(configFile, '[plugins."ruflo-core@ruflo"]\nenabled = true\n'); + seedPlugin({ + version: '0.2.5', + hook: { _note: 'Claude-only metadata', hooks: { Stop: [] } }, + }); + assert.match(inspect().issues[0], /unsupported top-level field\(s\): _note/); +}); + +test('a manifest hook override wins over an incompatible conventional file', () => { + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.writeFileSync(configFile, '[plugins."brain@local"]\nenabled = true\n'); + const root = seedPlugin({ + marketplace: 'local', + plugin: 'brain', + version: '4.0.1', + manifest: { hooks: './hooks/codex-hooks.json' }, + hook: { description: 'Codex hooks', hooks: { Stop: [] } }, + }); + fs.writeFileSync(path.join(root, 'hooks', 'hooks.json'), + JSON.stringify({ _note: 'not the manifest-selected file', hooks: {} })); + const result = inspect(); + assert.deepEqual(result.issues, []); + assert.match(result.plugins[0].hookFiles[0], /codex-hooks\.json$/); +}); + +test('missing cache and unsafe manifest paths produce actionable facts', () => { + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.writeFileSync(configFile, '[plugins."missing@market"]\nenabled = true\n'); + assert.match(inspect().issues[0], /enabled but no cached version/); + + fs.writeFileSync(configFile, '[plugins."unsafe@market"]\nenabled = true\n'); + seedPlugin({ + marketplace: 'market', + plugin: 'unsafe', + version: '1.0.0', + manifest: { hooks: '../outside.json' }, + }); + assert.match(inspect().issues[0], /must start with "\.\/"/); +}); + +test.after(() => fs.rmSync(ROOT, { recursive: true, force: true })); diff --git a/tests/kit/project-memory.test.mjs b/tests/kit/project-memory.test.mjs new file mode 100644 index 00000000..6d79aa7b --- /dev/null +++ b/tests/kit/project-memory.test.mjs @@ -0,0 +1,79 @@ +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 { DatabaseSync } from 'node:sqlite'; +import { + findMemoryEntry, memoryEntryExists, projectMemoryStatus, +} from '../../src/lib/project-memory.mjs'; + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-project-memory-')); + +function seed(file, rows = []) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE memory_entries ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + namespace TEXT, + content TEXT NOT NULL, + status TEXT + ) + `); + const put = db.prepare( + 'INSERT INTO memory_entries (id, key, namespace, content, status) VALUES (?, ?, ?, ?, ?)', + ); + for (const [id, key, namespace, content, status = 'active'] of rows) { + put.run(id, key, namespace, content, status); + } + db.close(); +} + +test('no store is an honest uninitialized state', () => { + const status = projectMemoryStatus(ROOT); + assert.equal(status.active, null); + assert.equal(status.stores.every((store) => !store.present), true); +}); + +test('memory.db alone is the compatibility writer and counts active rows', () => { + const file = path.join(ROOT, '.swarm', 'memory.db'); + seed(file, [ + ['1', 'live', 'test', 'value', 'active'], + ['2', 'gone', 'test', 'value', 'deleted'], + ]); + const status = projectMemoryStatus(ROOT); + assert.equal(status.active.kind, 'sqljs'); + assert.equal(status.active.entries, 1); + assert.equal(memoryEntryExists(file, 'test', 'live'), true); + assert.equal(memoryEntryExists(file, 'test', 'missing'), false); + fs.rmSync(path.join(ROOT, '.swarm'), { recursive: true, force: true }); +}); + +test('the native sibling is active when both legitimate stores coexist', () => { + const compat = path.join(ROOT, '.swarm', 'memory.db'); + const native = path.join(ROOT, '.swarm', 'agentdb-memory.db'); + seed(compat, [['1', 'compat', 'test', 'old']]); + seed(native, [['2', 'native', 'test', 'new']]); + const status = projectMemoryStatus(ROOT); + assert.equal(status.active.kind, 'native-agentdb'); + assert.equal(status.active.file, native); + assert.equal(status.secondary.kind, 'sqljs'); + assert.equal(findMemoryEntry(ROOT, 'test', 'native').file, native); + assert.equal(findMemoryEntry(ROOT, 'test', 'compat').file, compat); + fs.rmSync(path.join(ROOT, '.swarm'), { recursive: true, force: true }); +}); + +test('an unreadable native sibling is surfaced instead of falling back silently', () => { + const compat = path.join(ROOT, '.swarm', 'memory.db'); + const native = path.join(ROOT, '.swarm', 'agentdb-memory.db'); + seed(compat, [['1', 'compat', 'test', 'old']]); + fs.writeFileSync(native, 'not sqlite'); + const status = projectMemoryStatus(ROOT); + assert.equal(status.active.kind, 'native-agentdb'); + assert.equal(status.active.readable, false); + assert.equal(status.secondary.readable, true); +}); + +test.after(() => fs.rmSync(ROOT, { recursive: true, force: true })); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 534df425..87d24dc0 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, @@ -28,7 +29,7 @@ const PROJECT = sandboxProject('ak-status'); paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); function seedHome(cfg = offlineKitConfig()) { - rmrf(paths.claudeDir(), paths.configDir()); + rmrf(paths.claudeDir(), paths.codexDir(), paths.configDir()); fs.mkdirSync(paths.claudeDir(), { recursive: true }); fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n'); writeKitConfig(HOME, cfg); @@ -200,6 +201,45 @@ test('project-scope rows degrade to info in a project that was never set up', as assert.equal(one(rows, 'statusline').level, 'info'); }); +test('an incompatible enabled Codex plugin warns without offering a sync mutation', async () => { + seedHome(); + fs.mkdirSync(paths.codexDir(), { recursive: true }); + fs.writeFileSync(paths.codexConfigPath(), + '[plugins."core@market"]\nenabled = true\n'); + const root = path.join(paths.codexPluginCacheDir(), 'market', 'core', '1.0.0'); + fs.mkdirSync(path.join(root, '.codex-plugin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(root, '.codex-plugin', 'plugin.json'), + JSON.stringify({ name: 'core', version: '1.0.0' })); + fs.writeFileSync(path.join(root, 'hooks', 'hooks.json'), + JSON.stringify({ _note: 'unsupported by Codex', hooks: { Stop: [] } })); + const plugin = one(await collect(), 'codex-plugins'); + assert.equal(plugin.level, 'warn'); + assert.match(plugin.message, /unsupported top-level field\(s\): _note/); + assert.match(plugin.message, /Codex \/plugins/); + assert.equal(plugin.fix, null, 'sync must never rewrite Codex-owned plugin cache'); +}); + +test('status identifies the native project-memory writer when both stores exist', async () => { + seedHome(); + const swarm = path.join(PROJECT, '.swarm'); + fs.mkdirSync(swarm, { recursive: true }); + for (const [file, key] of [ + [paths.projectMemoryDb(PROJECT), 'compat'], + [paths.projectAgentDbMemoryDb(PROJECT), 'native'], + ]) { + const db = new DatabaseSync(file); + db.exec('CREATE TABLE memory_entries (key TEXT, namespace TEXT, status TEXT)'); + db.prepare('INSERT INTO memory_entries VALUES (?, ?, ?)').run(key, 'test', 'active'); + db.close(); + } + const memory = one(await collect(), 'memory'); + assert.equal(memory.level, 'ok'); + assert.match(memory.message, /native-agentdb active writer: 1 active entry/); + assert.match(memory.message, /sqljs compatibility store also present/); + rmrf(swarm); +}); + test('owned Codex statusline reports independently without enabling Codex MCP routing', async () => { const preset = [ 'model-with-reasoning', 'project-name', 'git-branch', 'run-state', diff --git a/tests/kit/verify-command.test.mjs b/tests/kit/verify-command.test.mjs index 422d369f..85f0c734 100644 --- a/tests/kit/verify-command.test.mjs +++ b/tests/kit/verify-command.test.mjs @@ -39,7 +39,7 @@ test('an unknown suite exits 2 and names the valid suites', async () => { seedHome(); const { result, out } = await runVerify(['bogus']); assert.equal(result, 2, 'a usage error is exit 2, distinct from a failed proof (1)'); - assert.match(out, /unknown suite: bogus \(learning\|security\|aqe\|providers\|harvest\|all\)/); + assert.match(out, /unknown suite: bogus \(learning\|memory\|security\|aqe\|providers\|harvest\|all\)/); assert.ok(!/all selected proofs passed/.test(out), 'a usage error must not claim success'); }); @@ -91,6 +91,23 @@ test('the learning suite leaves no temp directory behind', async () => { 'the isolated training dir must be cleaned up even on failure'); }); +test('the memory suite fails honestly when ruflo cannot be run', async () => { + seedHome(); + const { result, out } = await runVerify(['memory']); + assert.equal(result, 1); + assert.match(out, /ruflo CLI not installed — cannot prove project memory/); +}); + +test('the memory suite leaves no temp directory behind', async () => { + seedHome(); + const os = await import('node:os'); + const before = new Set(fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('agentic-kit-memory-'))); + await runVerify(['memory']); + const after = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('agentic-kit-memory-')); + assert.deepEqual(after.filter((n) => !before.has(n)), [], + 'the isolated memory proof dir must be cleaned up even on failure'); +}); + test('the aqe suite fails on an oversized RVF store rather than probing further', async () => { seedHome(); const aqeDir = paths.projectAqeDir(PROJECT); @@ -140,7 +157,7 @@ test('`all` runs every suite and fails if any single proof failed', async () => seedHome(); const { result, out } = await runVerify([]); assert.equal(result, 1); - for (const heading of ['learning —', 'security —', 'aqe —', 'providers —', 'harvest —']) { + for (const heading of ['learning —', 'memory —', 'security —', 'aqe —', 'providers —', 'harvest —']) { assert.ok(out.includes(heading), `the default run must include the ${heading} suite`); } assert.match(out, /verification failed — see above/);