From 0f53165a275911a4be5c91f88d06c11dec05f569 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 17 Jul 2026 09:50:21 -0700 Subject: [PATCH 1/2] feat: local dashboard, health-history, brain row, agentdb fold-in + real harvest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four operator-facing capabilities and folds agentdb in as a first-class managed subsystem, closing the gap with (and exceeding) the ruflo-aqe-kit comparison. All new surfaces are read-only or opt-in; the learning-write path is proven live against real CLIs with no mocks. Dashboard (ak x dashboard): - Zero-dep node:http server, 127.0.0.1 only, never detaches. Serves a self-contained page (all CSS/JS inline, no external fetches) + GET /api/status reusing `ak status --json` via shell-out (no seam duplication). - Refined-instrument design: serif+mono, theme-aware (dark/light + icon toggle persisted to localStorage), grouped one-card-per-subsystem with severity triage (attention-first, healthy recessed), SVG sparklines, XSS-escaped. health-history ring: - Pure append/detectRegression over a kit.json ring; sync appends a post-heal snapshot, status flags backslides (learning shrank, native slots dropped, drift/security regressed). statusline 🧿 brain row: - Honesty-gated RuvNet-Brain segment (KB presence/size) in the activation footer. agentdb (managed subsystem): - New agentdb.mjs: presence/version + coherence guard. The global CLI is pinned to ruflo's BUNDLED agentdb so the shared cognitive store never skews on the core version (the FsyncFailed/brain.rvf corruption class). Wired into config (agentdb:true), setup (install), sync (repin), status + dashboard (live row). ak x harvest (opt-in learning-write) β€” no mocks: - Drives only grounded, present CLIs (ruflo hooks post-task, agentdb skill consolidate), parses their REAL output into structured evidence (skills created/updated, avg reward), skips honestly when agentdb is absent. - Default-safe: off unless kit.json harvest:true; --dry-run writes nothing. - ak x verify harvest: live end-to-end proof β€” seeds real episodes, consolidates a real skill, reads it back. Unit tests assert the parser against real captured output (no fake runner). Gate: typecheck 0, lint 0, 94 unit checks across 6 files, live verify harvest green. --- bin/agentic-kit.mjs | 4 + eslint.config.mjs | 2 +- src/commands/setup.mjs | 12 + src/commands/status.mjs | 26 +- src/commands/sync.mjs | 25 +- src/commands/x/dashboard.mjs | 70 +++ src/commands/x/harvest.mjs | 95 ++++ src/commands/x/verify.mjs | 43 +- src/lib/agentdb.mjs | 75 +++ src/lib/config.mjs | 4 + src/lib/dashboard-server.mjs | 689 ++++++++++++++++++++++++++++ src/lib/harvest.mjs | 138 ++++++ src/lib/heal.mjs | 22 + src/lib/health-history.mjs | 100 ++++ src/templates/statusline-footer.cjs | 53 +++ tests/agentdb.test.cjs | 61 +++ tests/dashboard.test.cjs | 156 +++++++ tests/harvest.test.cjs | 91 ++++ tests/health-history.test.cjs | 225 +++++++++ tests/statusline-brain.test.cjs | 103 +++++ 20 files changed, 1989 insertions(+), 5 deletions(-) create mode 100644 src/commands/x/dashboard.mjs create mode 100644 src/commands/x/harvest.mjs create mode 100644 src/lib/agentdb.mjs create mode 100644 src/lib/dashboard-server.mjs create mode 100644 src/lib/harvest.mjs create mode 100644 src/lib/health-history.mjs create mode 100644 tests/agentdb.test.cjs create mode 100644 tests/dashboard.test.cjs create mode 100644 tests/harvest.test.cjs create mode 100644 tests/health-history.test.cjs create mode 100644 tests/statusline-brain.test.cjs diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index 368be09c..080a7ea3 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -17,6 +17,8 @@ const PORCELAIN = { const PLUMBING = { 'daemon-gc': () => import('../src/commands/x/daemon-gc.mjs'), + 'dashboard': () => import('../src/commands/x/dashboard.mjs'), + 'harvest': () => import('../src/commands/x/harvest.mjs'), 'mcp': () => import('../src/commands/x/mcp.mjs'), 'provider': () => import('../src/commands/x/provider.mjs'), 'reference': () => import('../src/commands/x/reference.mjs'), @@ -46,6 +48,8 @@ const HELP_ALL = `${HELP} Plumbing (power users) β€” each takes --help: ak x daemon-gc [--kill] list/stop stale ruflo daemons + ak x dashboard [--port N] read-only local health dashboard (localhost only) + ak x harvest [--dry-run] opt-in learning-write: replay experiences into the substrate ak x mcp [status|pick|off] MCP registration + tool-family deny rules ak x provider [status|pick|off] detect claude/codex CLIs; wire ruflo + aqe hosts/providers ak x reference [diff|sync] CLAUDE.md managed-block inspection/reconcile diff --git a/eslint.config.mjs b/eslint.config.mjs index b86b3222..5c2a92ba 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,7 +45,7 @@ export default [ // deliberately old-school (var, literal ANSI escape bytes, bare catch bindings) // because the snippet runs embedded in the user's shell, not as normal source. // Relax the idiom rules here; syntax/undef checks still apply. - files: ['src/templates/statusline-footer.cjs', 'tests/statusline-segments.test.cjs'], + files: ['src/templates/statusline-footer.cjs', 'tests/statusline-segments.test.cjs', 'tests/statusline-brain.test.cjs'], // getStdinData is injected by the host statusline runtime (guarded with typeof). languageOptions: { globals: { getStdinData: 'readonly' } }, rules: { diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 0aa6beda..a2d1e9a1 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -16,6 +16,7 @@ import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { HOSTS, applyHosts, applyProviders, ensureDualAgents, hostInstallState, installHost, applyAqeRouter } 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'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; import { scalar, checkpoint, withDb } from '../lib/sqlite.mjs'; import * as paths from '../lib/paths.mjs'; @@ -81,6 +82,17 @@ export async function run_machine({ flags, pkgRoot, cfg }) { (r.ok ? ok : warn)(`agentic-qe: ${r.detail}`); } else ok(`agentic-qe ${installedVersion('agentic-qe')} present`); } + if (cfg.agentdb) { + const c = adb.coherence(); + if (!c.present) { + info("installing agentdb globally (harvest write path; pinned to ruflo's bundled version)…"); + const r = await heal.healAgentdb(); + (r.ok ? ok : warn)(`agentdb: ${r.detail}`); + } else if (c.skew === 'core') { + const r = await heal.healAgentdb(); + (r.ok ? ok : warn)(`agentdb: ${r.detail}`); + } else ok(`agentdb ${c.global} present (coherent with ruflo)`); + } if (cfg.ruvnetBrain) { if (!rb.present()) { if (await ask('Install the RuvNet Brain (~512 MB offline KB, powers the search_ruvnet MCP)?', true, flags.yes)) { diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 0ff4e6eb..61059140 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -3,7 +3,8 @@ // (set by bare invocation) appends exactly one suggested next action. import fs from 'node:fs'; import path from 'node:path'; -import { glyph, dim, bold } from '../lib/output.mjs'; +import { glyph, dim, bold, warn } from '../lib/output.mjs'; +import { loadRing, detectRegression } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; import { nativesStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; import { scanNpxStale } from '../lib/npx.mjs'; @@ -15,6 +16,7 @@ import { loadKitConfig } from '../lib/config.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { upstreamCveCounterFabricated, fixStatusline } from '../lib/statusline.mjs'; import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs'; +import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, aqeRouterFile } from '../lib/providers.mjs'; @@ -174,6 +176,25 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('aqe', 'info', 'agentic-qe not initialized in this project')); } + // agentdb (data-plane CLI `ak x harvest` drives). Pinned to ruflo's BUNDLED + // agentdb so the shared cognitive store never skews on the core version. + if (cfg.agentdb === false) { + rows.push(row('agentdb', 'info', 'agentdb management disabled in kit.json')); + } else { + const c = adbCoherence(); + if (!c.present) { + rows.push(row('agentdb', 'warn', 'agentdb CLI not installed (harvest write path unavailable)', + "setup/sync installs it (pinned to ruflo's bundled agentdb)")); + } else if (c.skew === 'core') { + rows.push(row('agentdb', 'warn', + `agentdb ${c.global} skewed from ruflo-bundled ${c.bundled} β€” shared-store corruption risk`, + "sync repins agentdb to ruflo's bundled version")); + } else { + rows.push(row('agentdb', 'ok', + `agentdb ${c.global}${c.bundled ? ` (coherent with ruflo${c.skew === 'prerelease' ? ' β€” prerelease diff' : ''})` : ''}`)); + } + } + // MCP const mcp = registrationStatus(); if (mcp.claudeFlow) { @@ -333,6 +354,9 @@ export async function run({ flags, pkgRoot }) { console.log(` ${glyph(r.level)} ${label.padEnd(11)} ${r.message}${r.fix ? dim(` β†’ ${r.fix}`) : ''}`); } + // health-history: alarm on any backslide since the previous sync snapshot. + for (const reg of detectRegression(loadRing(loadKitConfig()))) warn(`regression: ${reg.message}`); + if (flags.hint) { const actionable = rows.filter((r) => r.fix); console.log(''); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 5c36a744..ffac44cc 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -8,10 +8,13 @@ import { fixStatusline } from '../lib/statusline.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; -import { loadKitConfig } from '../lib/config.mjs'; +import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { pruneNpxStale } from '../lib/npx.mjs'; +import { nativesStatus, securityPresent } from '../lib/natives.mjs'; +import { readJson } from '../lib/settings.mjs'; +import { appendToConfig } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, bold, dim } from '../lib/output.mjs'; @@ -94,6 +97,11 @@ export async function run({ flags, pkgRoot }) { if (subsystems.has('aqe')) { report('rvf', heal.healRvf(paths.projectAqeDir(cwd))); } + // agentdb: install/repin the standalone CLI to ruflo's bundled version so the + // shared cognitive store stays coherent (harvest's write path depends on it). + if (subsystems.has('agentdb') && cfg.agentdb !== false) { + report('agentdb', await heal.healAgentdb()); + } if (subsystems.has('mcp') && cfg.mcp.register) { const okReg = await mcpRegister(); if (okReg) { @@ -147,6 +155,21 @@ export async function run({ flags, pkgRoot }) { // converge proof console.log(''); const after = await collect({ pkgRoot, cwd }); + + // health-history: append one post-heal snapshot so `status` can flag backslides + // (learning shrank, native slots dropped, drift/security regressed) across syncs. + try { + const stats = readJson(path.join(paths.projectClaudeFlowDir(cwd), 'neural', 'stats.json')); + appendToConfig(cfg, { + ts: Math.floor(Date.now() / 1000), + learningRows: stats?.patternsLearned ?? 0, + nativeSlots: nativesStatus()?.locations?.length ?? 0, + driftOutdated: (await driftReport()).some((r) => !r.installed || r.outdated), + securityPresent: securityPresent(), + }); + saveKitConfig(cfg); + } catch { /* health snapshot is best-effort β€” never fail a sync over it */ } + const remaining = after.filter((r) => r.level === 'fail'); if (remaining.length === 0) { ok(bold('converged β€” no failing subsystems')); return 0; } for (const r of remaining) fail(`still failing: [${r.subsystem}] ${r.message}`); diff --git a/src/commands/x/dashboard.mjs b/src/commands/x/dashboard.mjs new file mode 100644 index 00000000..107d72b7 --- /dev/null +++ b/src/commands/x/dashboard.mjs @@ -0,0 +1,70 @@ +// x dashboard β€” a read-only local web dashboard for the kit's health. +// +// Boots a loopback-only HTTP server (127.0.0.1) that serves a single +// self-contained page plus a /api/status JSON endpoint mirroring +// `ak status --json` (plus version drift, improvement.json, and the health +// ring). Runs FOREGROUND and blocks until Ctrl-C; nothing is detached and +// nothing mutates state. +import { startDashboard } from '../../lib/dashboard-server.mjs'; +import { ok, info, dim, warn } from '../../lib/output.mjs'; + +export const options = { + port: { type: 'string' }, +}; + +export const help = `ak x dashboard β€” read-only local health dashboard (localhost only) + +Serves a self-contained web panel that visualizes the same subsystem rows +\`ak status\` reports β€” versions, natives, security, learning, providers, hosts, +mcp, ruvnet-brain, aqe β€” plus version drift and a learning-history sparkline. +Bound to 127.0.0.1; auto-refreshes every 5s. Read-only: it never changes state. + +Runs in the foreground β€” press Ctrl-C to stop. + +Usage: ak x dashboard [options] + +Options: + --port N port to bind on 127.0.0.1 (default 7431; 0 = ephemeral) + +Examples: + ak x dashboard open on http://127.0.0.1:7431 + ak x dashboard --port 8080 pick a port`; + +export async function run({ flags }) { + let port = 7431; + if (flags.port !== undefined) { + const p = Number(flags.port); + if (!Number.isInteger(p) || p < 0 || p > 65535) { + warn(`invalid --port ${flags.port}; using ${port}`); + } else { + port = p; + } + } + + let server; + try { + server = await startDashboard({ port, cwd: process.cwd() }); + } catch (e) { + warn(`could not start dashboard: ${e.message}`); + if (e.code === 'EADDRINUSE') info(`port ${port} is busy β€” try: ak x dashboard --port 0`); + return 1; + } + + ok(`dashboard live at ${server.url}`); + info(dim('read-only Β· localhost only Β· Ctrl-C to stop')); + + // Block foreground until an interrupt, then close cleanly. + return await new Promise((resolve) => { + let closing = false; + const shutdown = async () => { + if (closing) return; + closing = true; + await server.close(); + console.log(''); + ok('dashboard stopped'); + resolve(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} diff --git a/src/commands/x/harvest.mjs b/src/commands/x/harvest.mjs new file mode 100644 index 00000000..3058cdf8 --- /dev/null +++ b/src/commands/x/harvest.mjs @@ -0,0 +1,95 @@ +// x harvest β€” opt-in, FOREGROUND, budget-gated learning-WRITE. +// +// DEFAULT-SAFE: does NOTHING that writes unless the kit.json opt-in flag +// (`harvest: true`) is set. Off by default it explains how to enable and exits 0. +// --dry-run prints the plan and exits 0 (writes nothing) regardless of opt-in. +// Only opt-in ON + no --dry-run executes the two grounded verbs, in the +// foreground. It NEVER starts a daemon and NEVER backgrounds anything. +import { loadKitConfig } from '../../lib/config.mjs'; +import { planHarvest, runHarvest } from '../../lib/harvest.mjs'; +import { ok, fail, warn, info, dim, heading } from '../../lib/output.mjs'; + +export const options = { + 'dry-run': { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, +}; + +export const help = `ak x harvest β€” opt-in, foreground learning-WRITE (no daemon, ever) + +Records this session's outcome into ruflo's SONA store and consolidates +accumulated episodes into durable skills, then reports the REAL data the tools +hand back (skills created/updated, avg reward). + +It drives ONLY grounded, present CLIs, foreground and in order: + 1. ruflo hooks post-task --task-id --success true + 2. agentdb skill consolidate true + (skipped with a note if agentdb isn't installed β€” run \`ak sync\`) + +OPT-IN + SAFE BY DEFAULT: it writes to your learning stores, so it is OFF +until you enable it. With opt-in off it only explains how to turn it on. + +Usage: ak x harvest [options] + +Options: + --dry-run print the plan and exit β€” writes nothing (works with opt-in off) + --json emit the plan/result as JSON + +Enable it: + set "harvest": true in ~/.config/agentic-kit/kit.json, then re-run + +Examples: + ak x harvest --dry-run preview the two verbs (no writes) + ak x harvest run the write path (only when opted in)`; + +export async function run({ flags }) { + const cwd = process.cwd(); + const cfg = loadKitConfig(); + const enabled = cfg.harvest === true; + + // --dry-run: show the plan, run nothing β€” regardless of opt-in state. + if (flags['dry-run']) { + const steps = planHarvest(); + if (flags.json) { + console.log(JSON.stringify({ dryRun: true, optIn: enabled, steps }, null, 2)); + return 0; + } + heading('ak x harvest β€” plan (dry-run Β· nothing runs)'); + for (const s of steps) info(`${s.name}: ${s.cmd} ${s.args.join(' ')} ${dim('β€” ' + s.desc)}`); + if (!enabled) info('opt-in is OFF β€” set "harvest": true in kit.json to actually run this.'); + return 0; + } + + // Default-safe gate: opt-in OFF β†’ explain, write nothing. + if (!enabled) { + if (flags.json) { + console.log(JSON.stringify({ ranWrites: false, optIn: false }, null, 2)); + return 0; + } + info('ak x harvest is opt-in β€” it WRITES to your learning stores and is OFF by default.'); + info('Enable it: set "harvest": true in ~/.config/agentic-kit/kit.json, then re-run.'); + info('Preview it now without writing: ak x harvest --dry-run'); + return 0; + } + + // Opted in, no --dry-run: execute foreground. + const res = await runHarvest({ cwd }); + if (flags.json) { + console.log(JSON.stringify(res, null, 2)); + return res.ok ? 0 : 1; + } + heading('ak x harvest β€” learning write (foreground)'); + for (const s of res.steps) { + if (s.skipped) warn(`${s.name}: ${s.detail}`); + else (s.ok ? ok : fail)(`${s.name}: ${s.detail}`); + } + const h = res.harvested; + if (h && (h.skillsCreated || h.skillsUpdated)) { + info(`harvested: ${h.skillsCreated} skill(s) created, ${h.skillsUpdated} updated` + + (h.avgReward != null ? ` Β· avg reward ${h.avgReward}` : '')); + } else if (!res.agentdb) { + warn('no skills consolidated β€” agentdb CLI absent; run `ak sync` to install it, then re-harvest.'); + } else { + info('no new skills this pass (no episodes cleared the thresholds yet).'); + } + return res.ok ? 0 : 1; +} diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index b2507936..2d545a20 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -11,6 +11,7 @@ import { projectAqeDir } from '../../lib/paths.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { HOSTS, detectHosts, aqeRouterFile } from '../../lib/providers.mjs'; import { readJson } from '../../lib/settings.mjs'; +import { runHarvest } from '../../lib/harvest.mjs'; import { ok, warn, fail, heading } from '../../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false } }; @@ -27,6 +28,7 @@ Suites: 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 + harvest seed real episodes, run the write path, assert real skills come back all (default) run every suite Examples: @@ -112,11 +114,48 @@ async function verifyProviders() { return good; } +async function verifyHarvest() { + heading('harvest β€” seed REAL episodes, run the write path, assert real skills come back'); + if (!(await have('agentdb'))) { warn('agentdb CLI not installed β€” skipping harvest proof (run: ak sync)'); return true; } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentic-kit-harvest-')); + try { + // Seed real episodes into agentdb's default store (./agentdb.db in cwd). + for (let i = 1; i <= 3; i++) { + const r = await runCmd('agentdb', + ['reflexion', 'store', `verify-ep-${i}`, 'implement_feature', '0.9', 'true', `did the work ${i}`], + { cwd: tmp, timeout: 120_000 }); + if (r.code !== 0) { fail(`agentdb reflexion store failed: ${(r.stderr || '').slice(0, 140)}`); return false; } + } + ok('seeded 3 real episodes via agentdb reflexion store'); + // Run the REAL write path (no mock) with low thresholds so the seeds qualify. + const res = await runHarvest({ cwd: tmp, minAttempts: 1, minReward: 0.5, days: 365 }); + const created = res.harvested?.skillsCreated ?? 0; + if (created > 0) { + ok(`harvest consolidated REAL skills: created ${created}` + + (res.harvested.avgReward != null ? ` (avg reward ${res.harvested.avgReward})` : '')); + } else { + const step = res.steps.find((s) => s.name === 'consolidate-skills'); + fail(`harvest ran but consolidated 0 skills β€” ${step ? step.detail : 'no consolidate step'}`); + return false; + } + // Round-trip: the consolidated skill is searchable (real data back). + const search = await runCmd('agentdb', ['skill', 'search', 'implement', '5'], { cwd: tmp, timeout: 120_000 }); + const found = /Found\s+([1-9]\d*)\s+matching/i.test(`${search.stdout}${search.stderr}`); + (found ? ok : warn)('agentdb skill search reads the consolidated skill back'); + return true; + } catch (e) { + fail(`harvest verify error: ${e.message}`); + return false; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + export async function run({ positionals }) { const which = positionals[0] ?? 'all'; - const suites = { learning: verifyLearning, security: verifySecurity, aqe: verifyAqe, providers: verifyProviders }; + const suites = { learning: verifyLearning, 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|all)`); return 2; } + if (!selected.every(([, fn]) => fn)) { fail(`unknown suite: ${which} (learning|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/agentdb.mjs b/src/lib/agentdb.mjs new file mode 100644 index 00000000..e50e4dfe --- /dev/null +++ b/src/lib/agentdb.mjs @@ -0,0 +1,75 @@ +// agentdb β€” a first-class, folded-in data-plane tool. +// +// agentdb ships TWO ways: (1) as a LIBRARY bundled inside ruflo's tree +// (ruflo/node_modules/agentdb β€” used programmatically, no CLI), and (2) as a +// standalone npm package `agentdb` whose global bin exposes the CLI +// (`skill consolidate`, `reflexion store`, `skill search`) that `ak x harvest` +// drives. The kit manages the standalone global. +// +// The catch: agentdb is a DATA-PLANE tool β€” its CLI writes the same cognitive +// store (.rvf / sql.js) that ruflo's bundled agentdb reads and writes. A global +// whose store schema DIVERGES from ruflo's bundled copy is the corruption class +// the kit already firefights (the brain.rvf / FsyncFailed saga). So instead of +// chasing npm-latest, the kit pins the global to ruflo's BUNDLED version β€” the +// store stays coherent by construction β€” and the coherence guard warns if they +// ever skew on the core (major.minor.patch) version. +import fs from 'node:fs'; +import path from 'node:path'; +import { rufloNodeModules } from './paths.mjs'; +import { installedVersion } from './versions.mjs'; + +export const PKG = 'agentdb'; + +/** Base version (drops any prerelease tail): "3.0.0-alpha.17" β†’ "3.0.0". */ +const base = (v) => String(v).split('-')[0]; + +/** Installed global agentdb version, or null. `agentdb` is a normal global npm + * package, so the shared `installedVersion` (globalRoot/pkg/package.json) works. */ +export function globalVersion() { + return installedVersion(PKG); +} + +/** The agentdb version ruflo BUNDLES β€” the schema authority for the shared + * store. Null if ruflo isn't installed or its tree lacks agentdb. */ +export function bundledVersion() { + try { + return JSON.parse( + fs.readFileSync(path.join(rufloNodeModules(), 'agentdb', 'package.json'), 'utf8'), + ).version; + } catch { + return null; + } +} + +/** Is the standalone agentdb CLI installed on the global? */ +export function present() { + return globalVersion() != null; +} + +/** + * Coherence between the managed global and ruflo's bundled agentdb. + * skew: null β€” identical, or bundled unknown so nothing to compare + * 'prerelease' β€” same core (3.0.0), different prerelease β€” tolerated + * 'core' β€” different major.minor.patch β€” STORE-CORRUPTION RISK + * `ok` is false only for a core skew. `target` is the version the kit installs + * to (ruflo's bundled version; null when unknown β†’ caller falls back to latest). + */ +export function classifyCoherence({ global, bundled }) { + if (!global) return { present: false, ok: true, global: null, bundled: bundled ?? null, skew: null, target: bundled ?? null }; + if (!bundled) return { present: true, ok: true, global, bundled: null, skew: null, target: null }; + if (base(global) !== base(bundled)) { + return { present: true, ok: false, global, bundled, skew: 'core', target: bundled }; + } + const skew = global === bundled ? null : 'prerelease'; + return { present: true, ok: true, global, bundled, skew, target: bundled }; +} + +export function coherence() { + return classifyCoherence({ global: globalVersion(), bundled: bundledVersion() }); +} + +/** The version the kit should install/repair the global TO: ruflo's bundled + * version (keeps the store coherent), or null when bundled is unknown. */ +export function targetVersion() { + return bundledVersion(); +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs index ba473f03..71c9b95b 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -7,8 +7,11 @@ import { kitConfigPath, legacyKitConfigPath } from './paths.mjs'; const DEFAULTS = { aqe: true, // manage agentic-qe alongside ruflo + agentdb: true, // manage the standalone agentdb CLI (harvest's write path), pinned to ruflo's bundled version ruvnetBrain: true, // install/manage the RuvNet Brain (offline KB + search_ruvnet MCP) security: true, // run the security verification surface by default + harvest: false, // opt-in learning-write (`ak x harvest`); off = never runs writes + health: { ring: [] }, // persisted stack-health snapshot ring (see health-history.mjs) mcp: { register: true, excludeFamilies: [] }, // Frontier hosts + LLM providers (prompts-once via `ak x provider pick`). // Default = claude-only, codex opt-in β€” preserves today's behavior exactly: @@ -33,6 +36,7 @@ export function loadKitConfig(file = kitConfigPath()) { return { ...structuredClone(DEFAULTS), ...parsed, + health: { ...DEFAULTS.health, ...parsed.health }, mcp: { ...DEFAULTS.mcp, ...parsed.mcp }, providers: { ...DEFAULTS.providers, diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs new file mode 100644 index 00000000..c7497f0e --- /dev/null +++ b/src/lib/dashboard-server.mjs @@ -0,0 +1,689 @@ +// dashboard-server.mjs β€” a read-only, localhost-only web dashboard for the kit. +// +// Zero runtime deps: a plain node:http server bound to 127.0.0.1. Two routes: +// GET / β†’ one self-contained HTML document (all CSS + JS inline, +// no external fetches β€” offline-first, matches the kit ethos) +// GET /api/status β†’ JSON: the same subsystem rows `ak status --json` emits, +// PLUS version drift, the project's .claude-flow/improvement.json +// (if present), and the health-history ring (if present). +// +// The status rows are gathered by SHELLING OUT to the installed CLI +// (`node bin/agentic-kit.mjs status --json`) so we never duplicate status.mjs's +// collector logic and never touch the shared seam files. `fetchStatus` can be +// injected (tests, embedding) to bypass the shell-out. +// +// startDashboard() NEVER detaches β€” the caller runs it foreground and calls +// close() on SIGINT. +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { driftReport } from './versions.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(HERE, '..', '..'); + +function readJsonSafe(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } +} + +/** The health-history ring: an array of point samples over time. Accepts either + * a bare array or `{ samples: [...] }`. Returns null when absent/unreadable. */ +function readHealthRing(cwd) { + const raw = readJsonSafe(path.join(cwd, '.claude-flow', 'health-history.json')); + if (!raw) return null; + const arr = Array.isArray(raw) ? raw : Array.isArray(raw.samples) ? raw.samples : null; + return arr && arr.length ? arr : null; +} + +/** Default status provider: shell out to the installed CLI and parse its JSON. + * Resilient β€” a spawn/parse failure resolves to an honest empty payload rather + * than rejecting, so /api/status always answers with valid JSON. */ +function shellOutStatus(cwd) { + return () => new Promise((resolve) => { + execFile( + process.execPath, + [path.join(PKG_ROOT, 'bin', 'agentic-kit.mjs'), 'status', '--json'], + { cwd, timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } }, + (err, stdout) => { + try { + const parsed = JSON.parse(stdout); + if (parsed && Array.isArray(parsed.rows)) return resolve(parsed); + throw new Error('unexpected shape'); + } catch { + resolve({ overall: 'unknown', rows: [], error: err ? String(err.message || err) : 'status --json unparseable' }); + } + }, + ); + }); +} + +/** Assemble the full /api/status payload. */ +async function collectData({ cwd, fetchStatus }) { + let status; + try { status = await fetchStatus(); } catch (e) { status = { overall: 'unknown', rows: [], error: String(e && e.message || e) }; } + const rows = Array.isArray(status?.rows) ? status.rows : []; + const overall = status?.overall ?? 'unknown'; + + // Version drift: prefer what the status payload already carried; otherwise + // ask versions.mjs directly (TTL-cached, so no extra network within the window). + let drift = Array.isArray(status?.drift) ? status.drift : null; + if (!drift) { try { drift = await driftReport(); } catch { drift = null; } } + + return { + generatedAt: new Date().toISOString(), + kit: { name: '@pacphi/agentic-kit', version: kitVersion() }, + overall, + error: status?.error ?? null, + rows, + drift, + improvement: readJsonSafe(path.join(cwd, '.claude-flow', 'improvement.json')), + health: readHealthRing(cwd), + }; +} + +function kitVersion() { + const pj = readJsonSafe(path.join(PKG_ROOT, 'package.json')); + return pj?.version ?? '0.0.0'; +} + +/** + * Start the dashboard HTTP server, bound to loopback only. + * @param {{ port?: number, cwd?: string, fetchStatus?: () => Promise }} [opts] + * @returns {Promise<{ url: string, port: number, close: () => Promise }>} + */ +export function startDashboard({ port = 7431, cwd = process.cwd(), fetchStatus } = {}) { + const provide = fetchStatus || shellOutStatus(cwd); + const html = renderPage({ name: '@pacphi/agentic-kit', version: kitVersion() }); + + const server = http.createServer(async (req, res) => { + const url = (req.url || '/').split('?')[0]; + if (req.method !== 'GET') { res.writeHead(405).end('method not allowed'); return; } + + if (url === '/' || url === '/index.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + res.end(html); + return; + } + if (url === '/api/status') { + let payload; + try { payload = await collectData({ cwd, fetchStatus: provide }); } + catch (e) { payload = { generatedAt: new Date().toISOString(), overall: 'unknown', rows: [], drift: null, improvement: null, health: null, error: String(e && e.message || e) }; } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(JSON.stringify(payload)); + return; + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('not found'); + }); + + return new Promise((resolve, reject) => { + server.on('error', reject); + // Loopback ONLY β€” never expose the panel beyond this machine. + server.listen(port, '127.0.0.1', () => { + const addr = server.address(); + const actual = addr && typeof addr === 'object' ? addr.port : port; + resolve({ + url: `http://127.0.0.1:${actual}/`, + port: actual, + close: () => new Promise((res) => server.close(() => res())), + }); + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// The page. One document, everything inline. Only `name` and `version` are +// interpolated server-side; the client fetches /api/status and renders live. +// ───────────────────────────────────────────────────────────────────────────── +function renderPage({ name, version }) { + return ` + + + + + +agentic-kit Β· dashboard + + + +
+
+ +
+

${escapeHtml(name)}

+
v${escapeHtml(version)}Β·local diagnostic panel
+
+
+
+ + connecting… +
+
+
+ + β€” +
+ +
+
+ + + +
+ +
+ + + +
+ read-only Β· 127.0.0.1 Β· nothing here mutates state +
+
+ + + +`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +// ── Styles ─────────────────────────────────────────────────────────────────── +// Design: "refined technical instrument". Editorial serif for display, a mono +// stack for all data/labels β€” that serif+mono contrast is the signature. One +// slate ground + a single teal signal accent; status semantics carry their own +// calm green / amber / red / muted. CSS variables drive BOTH themes. +const CSS = ` +:root{ + --serif:"Iowan Old Style","Palatino Linotype","Palatino","Georgia",serif; + --mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code","Menlo",monospace; + --r:14px; --r-sm:9px; +} +:root[data-theme="dark"]{ + --bg:#0d1017; --bg-2:#0a0c11; + --panel:#151b23; --panel-2:#1a212b; --raised:#1e2732; + --ink:#e8e4d8; --ink-2:#b7bdc6; --ink-dim:#7f8895; + --line:rgba(255,255,255,.075); --line-2:rgba(255,255,255,.13); + --accent:#4fb6a8; --accent-soft:rgba(79,182,168,.16); + --ok:#5fbf82; --warn:#e0a83e; --fail:#e46b64; --info:#8a93a0; + --shadow:0 1px 0 rgba(255,255,255,.03),0 12px 30px -12px rgba(0,0,0,.7); + --grain:rgba(255,255,255,.018); +} +:root[data-theme="light"]{ + --bg:#f2efe6; --bg-2:#eae6da; + --panel:#fbfaf5; --panel-2:#f5f2ea; --raised:#ffffff; + --ink:#242830; --ink-2:#454b54; --ink-dim:#767c85; + --line:rgba(20,24,30,.10); --line-2:rgba(20,24,30,.18); + --accent:#2c8578; --accent-soft:rgba(44,133,120,.13); + --ok:#2f8b52; --warn:#a9741a; --fail:#c04a44; --info:#6c727b; + --shadow:0 1px 0 rgba(255,255,255,.6),0 14px 30px -16px rgba(40,40,50,.35); + --grain:rgba(20,24,30,.02); +} +@media (prefers-color-scheme:light){ + :root:not([data-theme]){ color-scheme:light; } +} +*{box-sizing:border-box} +html,body{margin:0;padding:0} +body{ + background:var(--bg); + color:var(--ink); + font-family:var(--mono); + font-size:14px; line-height:1.55; + -webkit-font-smoothing:antialiased; + font-variant-numeric:tabular-nums; + min-height:100vh; + overflow-x:hidden; + background-image: + radial-gradient(1200px 600px at 15% -10%, var(--accent-soft), transparent 60%), + radial-gradient(900px 500px at 110% 0%, rgba(0,0,0,.10), transparent 55%); + background-attachment:fixed; +} +body::before{ + content:""; position:fixed; inset:0; pointer-events:none; z-index:0; + background-image:radial-gradient(var(--grain) 1px, transparent 1px); + background-size:3px 3px; opacity:.9; +} +.mono{font-family:var(--mono)} + +/* ── header band ── */ +.band{ + position:relative; z-index:1; + display:flex; align-items:center; gap:20px; flex-wrap:wrap; + padding:20px clamp(16px,4vw,40px); + border-bottom:1px solid var(--line); + background:linear-gradient(180deg,var(--panel),transparent); +} +.band-lead{display:flex; align-items:center; gap:15px; min-width:0} +.mark{ + width:34px; height:34px; flex:none; border-radius:9px; + background: + linear-gradient(145deg,var(--accent),transparent 70%), + var(--raised); + border:1px solid var(--line-2); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 6px 16px -8px var(--accent); + position:relative; +} +.mark::after{ + content:""; position:absolute; inset:9px; border-radius:3px; + border:1.5px solid var(--accent); opacity:.85; +} +.band-titles{min-width:0} +.kit-name{ + font-family:var(--serif); font-weight:600; font-style:italic; + font-size:clamp(20px,2.6vw,27px); line-height:1.05; margin:0; + letter-spacing:.2px; +} +.kit-sub{color:var(--ink-dim); font-size:12px; display:flex; gap:8px; align-items:center; margin-top:3px} +.kit-sub .sep{opacity:.5} +.ver{color:var(--accent)} +.band-verdict{ + display:flex; align-items:center; gap:10px; margin-left:auto; + padding:8px 15px; border:1px solid var(--line); border-radius:100px; + background:var(--panel-2); +} +.verdict-text{font-size:13px; letter-spacing:.3px} +.band-tools{display:flex; align-items:center; gap:16px} +.refresh{display:flex; align-items:center; gap:8px; color:var(--ink-dim); font-size:12px} +.pulse{ + width:8px; height:8px; border-radius:50%; background:var(--accent); + box-shadow:0 0 0 0 var(--accent); animation:pulse 2.4s ease-out infinite; +} +@keyframes pulse{ + 0%{box-shadow:0 0 0 0 var(--accent-soft)} + 70%{box-shadow:0 0 0 7px transparent} + 100%{box-shadow:0 0 0 0 transparent} +} +.toggle{ + display:inline-flex; align-items:center; justify-content:center; + width:36px; height:36px; padding:0; + color:var(--ink-2); background:var(--panel-2); + border:1px solid var(--line); border-radius:50%; cursor:pointer; + transition:border-color .2s ease, color .2s ease, background .2s ease; +} +.toggle:hover{border-color:var(--line-2); color:var(--accent); background:var(--raised)} +.toggle:focus-visible{outline:2px solid var(--accent); outline-offset:2px} +.toggle .icon{display:inline-flex} +.toggle .icon svg{width:17px; height:17px; display:block} + +/* ── drift banner ── */ +.drift-banner{ + position:relative; z-index:1; + margin:14px clamp(16px,4vw,40px) 0; + padding:11px 16px; border-radius:var(--r-sm); + border:1px solid var(--warn); color:var(--ink); + background:linear-gradient(180deg,rgba(224,168,62,.12),transparent); + font-size:13px; display:flex; gap:10px; align-items:baseline; +} +.drift-banner b{color:var(--warn); font-family:var(--mono)} + +/* ── layout ── */ +.wrap{position:relative; z-index:1; padding:clamp(16px,4vw,40px); max-width:1180px; margin:0 auto} +.grid{ + display:grid; gap:14px; + grid-template-columns:repeat(auto-fill,minmax(272px,1fr)); +} + +/* ── card ── */ +.card{ + position:relative; + background:var(--panel); border:1px solid var(--line); + border-radius:var(--r); padding:16px 17px 15px; + box-shadow:var(--shadow); + opacity:0; transform:translateY(8px); + animation:rise .5s cubic-bezier(.2,.7,.3,1) forwards; + overflow:hidden; +} +.card::before{ + content:""; position:absolute; left:0; top:0; bottom:0; width:3px; + background:var(--lvl,var(--info)); opacity:.75; +} +@keyframes rise{to{opacity:1; transform:none}} +.card-top{display:flex; align-items:center; gap:10px; margin-bottom:9px} +.dot{ + width:11px; height:11px; border-radius:50%; flex:none; + background:var(--lvl,var(--info)); + box-shadow:0 0 0 3px color-mix(in srgb,var(--lvl,var(--info)) 22%, transparent), + 0 0 10px -1px var(--lvl,var(--info)); +} +.card[data-level="ok"]{--lvl:var(--ok)} +.card[data-level="warn"]{--lvl:var(--warn)} +.card[data-level="fail"]{--lvl:var(--fail)} +.card[data-level="info"]{--lvl:var(--info)} +.card[data-level="unknown"]{--lvl:var(--ink-dim)} +.dot[data-level="ok"]{--lvl:var(--ok)} +.dot[data-level="warn"]{--lvl:var(--warn)} +.dot[data-level="fail"]{--lvl:var(--fail)} +.dot[data-level="info"]{--lvl:var(--info)} +.dot[data-level="unknown"]{--lvl:var(--ink-dim)} +.card-name{ + font-family:var(--serif); font-size:17px; font-weight:600; + letter-spacing:.2px; color:var(--ink); +} +.card-level{ + margin-left:auto; font-size:10.5px; letter-spacing:.14em; text-transform:uppercase; + color:var(--lvl,var(--info)); +} +.card-msg{color:var(--ink-2); font-size:13px; line-height:1.5; word-break:break-word} +.card-fix{ + margin-top:10px; padding-top:9px; border-top:1px solid var(--line); + font-size:12px; color:var(--ink-dim); display:flex; gap:7px; align-items:baseline; +} +.card-fix .arrow{color:var(--accent)} +.card-fix code{color:var(--ink-2)} + +/* ── triage summary strip ── */ +.summary{ + position:relative; z-index:1; + display:flex; flex-wrap:wrap; gap:9px; align-items:center; + margin-bottom:18px; font-size:12.5px; +} +.pill{ + display:inline-flex; align-items:center; gap:7px; + padding:5px 12px; border-radius:100px; + border:1px solid var(--line); background:var(--panel); + color:var(--ink-2); letter-spacing:.2px; +} +.pill .dot{width:8px; height:8px} +.pill b{color:var(--ink); font-family:var(--mono)} +.pill[data-level="fail"]{border-color:color-mix(in srgb,var(--fail) 55%,transparent)} +.pill[data-level="warn"]{border-color:color-mix(in srgb,var(--warn) 50%,transparent)} +.pill[data-tone="calm"]{opacity:.7} + +/* ── grouped subsystem card ── */ +.card-count{ + margin-left:auto; font-size:11px; color:var(--ink-dim); + border:1px solid var(--line); border-radius:100px; padding:1px 8px; +} +.card-count + .card-level{margin-left:8px} +.rows{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:8px} +.rows .row{display:flex; gap:9px; align-items:flex-start; font-size:13px; color:var(--ink-2); line-height:1.5} +.row-dot{ + width:7px; height:7px; border-radius:50%; flex:none; margin-top:6px; + background:var(--lvl,var(--info)); +} +.row[data-level="ok"]{--lvl:var(--ok)} +.row[data-level="warn"]{--lvl:var(--warn)} +.row[data-level="fail"]{--lvl:var(--fail)} +.row[data-level="info"]{--lvl:var(--info)} +.row[data-level="unknown"]{--lvl:var(--ink-dim)} +.row-msg{min-width:0; word-break:break-word} +.row-fix{display:block; margin-top:3px; color:var(--ink-dim); font-size:12px} +.row-fix .arrow{color:var(--accent); margin-right:5px} +.row-fix code{color:var(--ink-2)} + +/* healthy section: present but recessed so problems dominate the eye */ +.section-label{ + grid-column:1/-1; + display:flex; align-items:center; gap:12px; + margin:28px 0 4px; color:var(--ink-dim); font-size:11px; + letter-spacing:.16em; text-transform:uppercase; +} +.section-label::after{content:""; flex:1; height:1px; background:var(--line)} +.grid.calm .card{opacity:.62; transition:opacity .2s ease} +.grid.calm .card:hover,.grid.calm .card:focus-within{opacity:1} + +/* ── history strip ── */ +.strip{ + margin-top:26px; padding:20px clamp(16px,3vw,26px); + background:var(--panel); border:1px solid var(--line); + border-radius:var(--r); box-shadow:var(--shadow); +} +.strip-head{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:14px} +.strip-title{font-family:var(--serif); font-size:18px; font-weight:600; margin:0; letter-spacing:.2px} +.strip-note{color:var(--ink-dim); font-size:12px} +.spark-row{display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:20px} +.spark figcaption{color:var(--ink-dim); font-size:11px; letter-spacing:.06em; text-transform:uppercase; margin-bottom:6px} +.spark-svg{width:100%; overflow-x:auto} +.spark-svg svg{display:block; width:100%; height:auto} + +/* ── footer ── */ +.foot{margin-top:24px; padding-top:16px; border-top:1px solid var(--line); color:var(--ink-dim); font-size:12px} + +.empty{color:var(--ink-dim); font-size:13px; padding:30px 4px} + +@media (max-width:560px){ + .band{gap:12px} + .band-verdict{margin-left:0; order:3; width:100%; justify-content:center} +} +@media (prefers-reduced-motion:reduce){ + *{animation:none !important; transition:none !important} + .card{opacity:1; transform:none} +} +`; + +// ── Client script ──────────────────────────────────────────────────────────── +// No backticks and no ${ } anywhere below β€” this whole string is embedded inside +// a server-side template literal, so those tokens would be misparsed. Plain +// string concatenation only. +const JS = ` +(function(){ + "use strict"; + var root=document.documentElement; + var LS="ak-dash-theme"; + + // theme: stored choice wins; otherwise follow the OS. + function sysTheme(){return window.matchMedia&&window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";} + var MOON=''; + var SUN=''; + function applyTheme(t){ + root.setAttribute("data-theme",t); + var ic=document.getElementById("theme-icon"); if(ic)ic.innerHTML=(t==="dark"?MOON:SUN); + var btn=document.getElementById("theme-toggle"); if(btn)btn.setAttribute("aria-label",t==="dark"?"switch to light theme":"switch to dark theme"); + } + var stored=null; try{stored=localStorage.getItem(LS);}catch(e){} + applyTheme(stored||sysTheme()); + var tbtn=document.getElementById("theme-toggle"); + if(tbtn)tbtn.addEventListener("click",function(){ + var next=root.getAttribute("data-theme")==="dark"?"light":"dark"; + applyTheme(next); try{localStorage.setItem(LS,next);}catch(e){} + render(LAST); // re-tint the sparklines to the new palette + }); + + var LEVEL_WORD={ok:"all systems nominal",warn:"attention advised",fail:"action required",unknown:"status unknown"}; + var LAST=null, lastUpdated=0; + + function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});} + + // 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","mcp","ruvnet-brain","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. + function groupRows(rows){ + var map={}, seq=[]; + for(var i=0;i(RANK[map[k].level]||0))map[k].level=r.level; + } + var groups=seq.map(function(k){return map[k];}); + groups.sort(function(a,b){ + var d=(RANK[b.level]||0)-(RANK[a.level]||0); if(d)return d; + var ia=PREF.indexOf(a.subsystem), ib=PREF.indexOf(b.subsystem); + return (ia<0?99:ia)-(ib<0?99:ib); + }); + return groups; + } + + function rowLine(r){ + var lvl=r.level||"info"; + var fix=r.fix?('→'+esc(r.fix)+""):""; + return '
  • ' + +'' + +''+esc(r.message)+fix+"" + +"
  • "; + } + + function groupCard(g){ + var lvl=g.level||"info", calm=(lvl==="ok"||lvl==="info"); + var count=g.rows.length>1?(''+g.rows.length+""):""; + var badge=calm?"":(''+esc(lvl)+""); + return '
    ' + +'
    ' + +'' + +''+esc(g.subsystem)+"" + +count+badge + +"
    " + +'
      '+g.rows.map(rowLine).join("")+"
    " + +"
    "; + } + + function gridHtml(groups,calm){ + return '
    '+groups.map(groupCard).join("")+"
    "; + } + + function renderSummary(groups){ + var el=document.getElementById("summary"); + var f=0,w=0,g=0; + for(var i=0;i'+f+" failing"); + if(w)pills.push(''+w+" warning"+(w>1?"s":"")+""); + pills.push(''+g+" nominal"); + el.innerHTML=pills.join(""); + el.hidden=false; + } + + function renderCards(rows){ + var el=document.getElementById("cards"); + if(!rows||!rows.length){el.innerHTML='
    no subsystem rows reported.
    ';return;} + var groups=groupRows(rows); + renderSummary(groups); + var attn=groups.filter(function(x){return x.level==="fail"||x.level==="warn";}); + var calm=groups.filter(function(x){return x.level!=="fail"&&x.level!=="warn";}); + var html=""; + if(attn.length)html+=gridHtml(attn,false); + if(calm.length)html+=(attn.length?'":"")+gridHtml(calm,true); + el.innerHTML=html; + // staggered reveal, attention cards first + var cards=el.querySelectorAll(".card"); + for(var i=0;i"+esc(d.pkg)+" "+esc(d.installed)+" → "+esc(d.latest);}); + b.innerHTML='⍝update available: '+parts.join("  Β·  ")+' — run ak sync'; + b.hidden=false; + } + + // ── sparkline (pure SVG) ── + function accent(){return getComputedStyle(root).getPropertyValue("--accent").trim()||"#4fb6a8";} + function dimc(){return getComputedStyle(root).getPropertyValue("--ink-dim").trim()||"#7f8895";} + function sparkline(values){ + var W=100,H=32,pad=3; + if(!values.length)return ""; + var min=Math.min.apply(null,values),max=Math.max.apply(null,values); + var span=max-min||1; + var n=values.length; + var x=function(i){return pad+(n===1?0:(i/(n-1))*(W-2*pad));}; + var y=function(v){return H-pad-((v-min)/span)*(H-2*pad);}; + var d="",area=""; + for(var i=0;i' + +'' + +'' + +'' + +"" + +'' + +'' + +'' + +""; + } + function flat(msg){return '
    '+esc(msg)+"
    ";} + + function renderHistory(data){ + var strip=document.getElementById("history"); + var note=document.getElementById("strip-note"); + var series=[]; + if(data.health&&data.health.length){series=data.health;} + var pats=[],deltas=[]; + for(var i=0;i1?sparkline(pats):flat(pats.length?String(pats[0])+" (one sample)":"no data"); + document.getElementById("spark-delta").innerHTML=deltas.length>1?sparkline(deltas):flat(deltas.length?(deltas[0]>=0?"+":"")+deltas[0]+"pp (one sample)":"no data"); + } + + function render(data){ + if(!data)return; + LAST=data; + renderVerdict(data.overall); + renderDrift(data.drift); + renderCards(data.rows); + renderHistory(data); + } + + function ago(sec){ + if(sec<2)return "just now"; + if(sec<60)return sec+"s ago"; + var m=Math.floor(sec/60); if(m<60)return m+"m ago"; + var h=Math.floor(m/60); return h+"h ago"; + } + function tickClock(){ + var el=document.getElementById("updated"); + if(!lastUpdated){el.textContent="β€”";return;} + el.textContent="updated "+ago(Math.round((Date.now()-lastUpdated)/1000)); + } + + function poll(){ + fetch("/api/status",{cache:"no-store"}).then(function(r){return r.json();}).then(function(d){ + lastUpdated=Date.now(); render(d); tickClock(); + }).catch(function(){ + var t=document.getElementById("verdict-text"); if(t)t.textContent="server unreachable"; + }); + } + + poll(); + setInterval(poll,5000); + setInterval(tickClock,1000); +})(); +`; diff --git a/src/lib/harvest.mjs b/src/lib/harvest.mjs new file mode 100644 index 00000000..98843bf2 --- /dev/null +++ b/src/lib/harvest.mjs @@ -0,0 +1,138 @@ +// harvest β€” the OPT-IN, FOREGROUND, budget-gated learning-WRITE path. +// +// Where `sync`/heal.mjs converge *installs*, harvest converges *learning*: it +// records the session's outcome into ruflo's SONA store and consolidates +// accumulated episodes into durable skills, then reports the REAL data the +// tools hand back. It drives ONLY grounded, present CLIs (verified live against +// the installed binaries β€” not a doc, not a mock): +// 1. `ruflo hooks post-task --task-id --success true` β€” record a SONA +// trajectory/outcome (ruflo's real signature; -q/--quality also exists). +// 2. `agentdb skill consolidate true` β€” +// promote qualifying episodes into skills. Real output looks like: +// "βœ… Created 1 new skills, updated 0 existing skills in 11ms" +// We PARSE that into { created, updated, avgReward } β€” the harvested value. +// +// agentdb is a managed-but-optional dependency (see agentdb.mjs). If it isn't +// installed, its step is SKIPPED with an honest note (never faked) and the user +// is pointed at `ak sync` to install it. +// +// NEVER starts a daemon, NEVER backgrounds anything. `runner` is injectable so +// `ak x verify harvest` can drive it against a sandbox cwd β€” the parsers below +// are pure and are unit-tested against REAL captured tool output, no stubs. +import { run } from './exec.mjs'; +import { present as adbPresent } from './agentdb.mjs'; + +// Conservative defaults. agentdb's own consolidate default is (3, 0.7, 7); we +// match it so harvest promotes only well-evidenced episodes. +const DEFAULTS = { taskId: 'ak-harvest', minAttempts: 3, minReward: 0.7, days: 7 }; + +// ANSI SGR stripper. The ESC byte is built via fromCharCode (not a literal +// control char in a regex) so this stays clean under eslint no-control-regex. +const ANSI = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g'); +const stripAnsi = (s) => String(s == null ? '' : s).replace(ANSI, ''); + +const failTail = (r) => + `FAILED (${(r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200)})`; + +/** Pure parser over the REAL `agentdb skill consolidate` output. Returns the + * harvested counts + avg reward, or nulls when the line is absent. */ +export function parseConsolidate(out) { + const s = stripAnsi(out); + const m = s.match(/Created\s+(\d+)\s+new\s+skills?,\s*updated\s+(\d+)\s+existing\s+skills?/i); + const avg = s.match(/Avg Reward:\s*([\d.]+)/i); + return { + created: m ? Number(m[1]) : null, + updated: m ? Number(m[2]) : null, + avgReward: avg ? Number(avg[1]) : null, + noEpisodes: /No episodes met the criteria/i.test(s), + }; +} + +/** Pure parser over `agentdb reflexion store` β€” the seeding acknowledgement. */ +export function parseStored(out) { + const m = stripAnsi(out).match(/Stored episode #(\d+)/i); + return { episode: m ? Number(m[1]) : null }; +} + +/** The ordered write steps. Each: { name, tool, cmd, args, desc, parse }. + * `tool:'agentdb'` steps are skipped when agentdb is absent. Pure. */ +export function planHarvest(opts = {}) { + const { taskId, minAttempts, minReward, days } = { ...DEFAULTS, ...opts }; + return [ + { + name: 'record-outcome', + tool: 'ruflo', + cmd: 'ruflo', + args: ['hooks', 'post-task', '--task-id', String(taskId), '--success', 'true'], + desc: 'record a SONA trajectory/outcome for this task', + parse: null, + }, + { + name: 'consolidate-skills', + tool: 'agentdb', + cmd: 'agentdb', + args: ['skill', 'consolidate', String(minAttempts), String(minReward), String(days), 'true'], + desc: 'consolidate qualifying episodes into durable skills', + parse: 'consolidate', + }, + ]; +} + +/** + * Execute the harvest, capturing and parsing the tools' REAL output. + * Foreground only β€” never spawns a daemon. With dryRun:true it runs NOTHING and + * returns the planned steps. Returns: + * { ok, dryRun, agentdb, steps:[{name,ok,skipped,evidence,detail}], harvested } + * where harvested = { skillsCreated, skillsUpdated, avgReward } aggregated from + * the parsed consolidate output. + * @param {{ runner?: Function, cwd?: string, dryRun?: boolean, taskId?: string, + * minAttempts?: number, minReward?: number, days?: number }} [o] + */ +export async function runHarvest({ runner = run, cwd = process.cwd(), dryRun = false, ...opts } = {}) { + const steps = planHarvest(opts); + const haveAdb = adbPresent(); + + if (dryRun) { + return { + ok: true, dryRun: true, agentdb: haveAdb, harvested: null, + steps: steps.map((s) => ({ + name: s.name, ok: true, skipped: s.tool === 'agentdb' && !haveAdb, + detail: (s.tool === 'agentdb' && !haveAdb) + ? 'would SKIP β€” agentdb not installed (ak sync installs it)' + : `would run: ${s.cmd} ${s.args.join(' ')}`, + })), + }; + } + + const results = []; + const harvested = { skillsCreated: 0, skillsUpdated: 0, avgReward: null }; + for (const step of steps) { + if (step.tool === 'agentdb' && !haveAdb) { + results.push({ name: step.name, ok: true, skipped: true, evidence: null, + detail: 'agentdb not installed β€” skipped (run `ak sync` to install its CLI)' }); + continue; + } + const r = await runner(step.cmd, step.args, { timeout: 120_000, cwd }); + const okStep = r.code === 0; + let evidence = null; + let detail; + if (okStep && step.parse === 'consolidate') { + evidence = parseConsolidate(`${r.stdout || ''}\n${r.stderr || ''}`); + if (evidence.created != null) { + harvested.skillsCreated += evidence.created; + harvested.skillsUpdated += evidence.updated || 0; + if (evidence.avgReward != null) harvested.avgReward = evidence.avgReward; + } + detail = evidence.noEpisodes + ? 'no episodes qualified yet (nothing to consolidate)' + : `created ${evidence.created ?? '?'} skill(s), updated ${evidence.updated ?? '?'}` + + (evidence.avgReward != null ? ` Β· avg reward ${evidence.avgReward}` : ''); + } else { + detail = okStep ? step.desc : failTail(r); + } + results.push({ name: step.name, ok: okStep, skipped: false, evidence, detail }); + } + + const ran = results.filter((s) => !s.skipped); + return { ok: ran.every((s) => s.ok), dryRun: false, agentdb: haveAdb, steps: results, harvested }; +} diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 51296349..1c409850 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -11,6 +11,7 @@ import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './na import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; import { INSTALL_SPEC, INSTALL_ARGS, present as rbPresent, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; +import { PKG as ADB_PKG, present as adbPresent, coherence as adbCoherence } from './agentdb.mjs'; // Packages whose install scripts must run for natives to build (npm >=11.17 // blocks them by default). Curated on the live 3.28/3.12.2 upgrade. @@ -153,6 +154,27 @@ export async function installRuvnetBrain({ force = false } = {}) { return { ok: rbPresent(), detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200) }; } +/** Ensure the standalone agentdb CLI is present AND coherent with ruflo's + * bundled agentdb. Pins the global to the bundled version (not npm-latest) so + * the shared cognitive store never skews on the core version β€” a core skew is + * the corruption risk this heal exists to prevent. Idempotent: a no-op when + * already present and coherent. */ +export async function healAgentdb() { + const c = adbCoherence(); + // Already present and coherent (identical or prerelease-only diff) β†’ nothing. + if (c.present && c.ok && c.skew !== 'core') { + return { ok: true, detail: `present ${c.global}${c.skew === 'prerelease' ? ` (bundled ${c.bundled}; prerelease diff ok)` : ' (coherent with ruflo)'}` }; + } + // Pin to ruflo's bundled version; fall back to latest only when unknown. + const spec = c.target ? `${ADB_PKG}@${c.target}` : `${ADB_PKG}@latest`; + const r = await run('npm', ['install', '-g', `--allow-scripts=${ALLOW_SCRIPTS}`, spec], { timeout: 600_000 }); + if (r.code !== 0) { + return { ok: adbPresent(), detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200) }; + } + const verb = !c.present ? 'installed' : 'repaired coherence β†’'; + return { ok: true, detail: `${verb} ${c.target ?? 'latest'} (matches ruflo's bundled agentdb)` }; +} + /** Stop all ruflo daemons before an upgrade (3.27+; best-effort). */ export async function stopAllDaemons() { const r = await run('ruflo', ['daemon', 'stop', '--all'], { timeout: 60_000 }); diff --git a/src/lib/health-history.mjs b/src/lib/health-history.mjs new file mode 100644 index 00000000..725bf92c --- /dev/null +++ b/src/lib/health-history.mjs @@ -0,0 +1,100 @@ +// health-history.mjs β€” a persisted ring of stack-health snapshots + regression +// detection. One entry is appended per `sync` convergence; `status` compares the +// last two and alarms on any backslide (learning shrank, native agentdb slots +// dropped, drift regressed currentβ†’outdated, security presentβ†’absent). +// +// The core (append / summarize / detectRegression) is PURE β€” no file I/O. The +// loadRing / appendToConfig shims only read/mutate a plain cfg object so the +// caller can persist via saveKitConfig; they have no side effects beyond the cfg. +// +// An entry looks like: +// { ts, learningRows, nativeSlots, driftOutdated: bool, securityPresent: bool } + +const DEFAULT_CAP = 30; + +/** Coerce a possibly-missing numeric field to a finite number (default 0). */ +const num = (v) => (Number.isFinite(v) ? v : 0); + +/** + * Append `entry` to `ring`, returning a NEW array capped at `cap` entries. + * Oldest entries past the cap are dropped (FIFO). Never mutates the input. + */ +export function append(ring, entry, cap = DEFAULT_CAP) { + const next = [...(Array.isArray(ring) ? ring : []), entry]; + return next.length > cap ? next.slice(next.length - cap) : next; +} + +/** Project an entry down to just the tracked scalar fields. */ +export function summarize(entry = {}) { + return { + learningRows: num(entry.learningRows), + nativeSlots: num(entry.nativeSlots), + driftOutdated: Boolean(entry.driftOutdated), + securityPresent: Boolean(entry.securityPresent), + }; +} + +/** + * Compare the last two entries of `ring` and return an array of regressions: + * { metric, from, to, message } + * Regressions: learningRows shrank, nativeSlots dropped, drift currentβ†’outdated, + * security presentβ†’absent. Recoveries (the reverse) are never flagged. Fewer than + * two entries β†’ []. Missing numeric fields count as 0; missing bools as falsy. + */ +export function detectRegression(ring) { + if (!Array.isArray(ring) || ring.length < 2) return []; + const prev = summarize(ring[ring.length - 2]); + const curr = summarize(ring[ring.length - 1]); + const out = []; + + if (curr.learningRows < prev.learningRows) { + out.push({ + metric: 'learningRows', + from: prev.learningRows, + to: curr.learningRows, + message: `learning rows shrank ${prev.learningRows} β†’ ${curr.learningRows}`, + }); + } + if (curr.nativeSlots < prev.nativeSlots) { + out.push({ + metric: 'nativeSlots', + from: prev.nativeSlots, + to: curr.nativeSlots, + message: `native agentdb slots dropped ${prev.nativeSlots} β†’ ${curr.nativeSlots}`, + }); + } + if (!prev.driftOutdated && curr.driftOutdated) { + out.push({ + metric: 'drift', + from: false, + to: true, + message: 'drift regressed current β†’ outdated', + }); + } + if (prev.securityPresent && !curr.securityPresent) { + out.push({ + metric: 'security', + from: true, + to: false, + message: 'security surface went present β†’ absent', + }); + } + return out; +} + +/** Read the ring out of a kit cfg (cfg.health.ring), defaulting to []. */ +export function loadRing(cfg) { + const ring = cfg?.health?.ring; + return Array.isArray(ring) ? ring : []; +} + +/** + * Append `entry` to cfg.health.ring in place (seeding cfg.health / .ring if + * absent), capped at `cap`. Returns the same cfg for chaining. The only mutation + * is on the passed cfg β€” the caller persists it via saveKitConfig. + */ +export function appendToConfig(cfg, entry, cap = DEFAULT_CAP) { + if (!cfg.health || typeof cfg.health !== 'object') cfg.health = { ring: [] }; + cfg.health.ring = append(loadRing(cfg), entry, cap); + return cfg; +} diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 24ee6802..54cdf48c 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -253,6 +253,58 @@ function rufloActivationSegments(cwd){ } } } catch(e){} + // ── RuvNet Brain (🧿): offline rUv-stack knowledge base β€” honesty-gated, fs-only ── + // The brain is NOT an npm package β€” `npx github:stuinfla/ruvnet-brain` drops a + // ~2GB offline knowledge base at ~/.cache/ruvnet-brain/kb (honors RUVNET_BRAIN_KB) + // and wires a user-scope Claude Code plugin. Presence probe MIRRORS + // src/lib/ruvnet-brain.mjs exactly: existence of the KB's forge-mcp-all.mjs + // entrypoint. Render NOTHING when absent β€” never a fabricated row. The KB is a flat + // dir of data files, so the true size is a shallow sum of its top-level files + // (the __MACOSX zip-artifact dir is a directory, so isFile() correctly excludes it); + // that sum is TTL-cached machine-globally in tmpdir (like the βš™ daemon / πŸŽ“ QE + // chips) so ~600 stat() calls run at most once per window, not per render. The πŸ’Ύ + // chip reuses the QE size formatting. The plugin semver (marketplace manifest, + // best-effort) rides next to the label like "RuFlo V" / "Agentic QE V". + var brain = ""; + try { + var os2 = require("os"); + var kbDir = process.env.RUVNET_BRAIN_KB || path.join(os2.homedir(), ".cache", "ruvnet-brain", "kb"); + if (fs.existsSync(path.join(kbDir, "forge-mcp-all.mjs"))) { + // plugin version β€” best-effort, empty on any failure (never blocks the row). + var bver = ""; + try { + var bpkg = path.join(os2.homedir(), ".claude", "plugins", "marketplaces", + "ruvnet-brain", "plugin", ".claude-plugin", "plugin.json"); + var bv = JSON.parse(fs.readFileSync(bpkg, "utf8")).version; + if (bv) bver = " V" + String(bv).replace(/^v/, ""); + } catch(e){} + // KB size β€” TTL-cached shallow sum of top-level files, keyed on kbDir so an + // env-overridden path (or a moved KB) never serves a stale foreign size. + var bBytes = null; + try { + var bCache = path.join(os2.tmpdir(), "ruvnet-brain-kb-size.json"); + var bTtl = Number(process.env.RUVNET_BRAIN_KB_TTL_MS || 300000); + try { + var bc = JSON.parse(fs.readFileSync(bCache, "utf8")); + if (bc && bc.dir === kbDir && typeof bc.bytes === "number" && bTtl > 0 && (Date.now() - bc.ts) < bTtl) bBytes = bc.bytes; + } catch(e){} + if (bBytes === null) { + var sum = 0; + fs.readdirSync(kbDir).forEach(function(f){ + try { var s = fs.statSync(path.join(kbDir, f)); if (s.isFile()) sum += s.size; } catch(e){} + }); + bBytes = sum; + try { fs.writeFileSync(bCache, JSON.stringify({ts: Date.now(), dir: kbDir, bytes: sum})); } catch(e){} + } + } catch(e){} + var bp = []; + if (bBytes && bBytes > 0) { + var bkb = Math.round(bBytes / 1024); + bp.push("πŸ’Ύ " + (bkb >= 1024 ? (bkb/1024).toFixed(1) + "MB" : bkb + "KB")); + } + brain = C + "🧿 RuvNet Brain" + bver + R + " " + (bp.length ? bp.join(DIM + " Β· " + R) : G + "βœ“" + R); + } + } catch(e){} // ── agentic-qe β€” TTL-cached; one sqlite3 spawn only on a cache miss (issue #3) ── var qe = ""; try { @@ -321,6 +373,7 @@ function rufloActivationSegments(cwd){ if (proof) out.push(proof); if (sec) out.push(sec); if (daemon) out.push(daemon); + if (brain) out.push(brain); if (out.length && qe) out.push(DIM + "─".repeat(53) + R); if (qe) out.push(qe); if (!out.length) return ""; diff --git a/tests/agentdb.test.cjs b/tests/agentdb.test.cjs new file mode 100644 index 00000000..9359acc2 --- /dev/null +++ b/tests/agentdb.test.cjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// +// agentdb.test.cjs β€” unit tests for the agentdb coherence classifier +// (src/lib/agentdb.mjs). agentdb is a data-plane CLI whose global copy writes +// the SAME cognitive store ruflo's bundled agentdb writes; a CORE version skew +// between them is a store-corruption risk. classifyCoherence() is a PURE +// function over {global, bundled} version strings β€” tested here against real +// inputs (no stubs), covering every branch. +// +// Run: node tests/agentdb.test.cjs (exit 0 = pass, 1 = fail) + +const path = require('path'); +const { classifyCoherence } = require(path.resolve(__dirname, '..', 'src', 'lib', 'agentdb.mjs')); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); console.log(' \x1b[32mβœ“\x1b[0m ' + name); passed++; } + catch (e) { console.log(' \x1b[31mβœ—\x1b[0m ' + name + '\n ' + e.message); failed++; } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } +function eq(a, b, msg) { assert(a === b, (msg || 'not equal') + ` (got ${JSON.stringify(a)}, expected ${JSON.stringify(b)})`); } + +console.log('agentdb coherence classifier (src/lib/agentdb.mjs)'); + +test('absent global β†’ not present, ok, target = bundled', () => { + const c = classifyCoherence({ global: null, bundled: '3.0.0-alpha.17' }); + eq(c.present, false); eq(c.ok, true); eq(c.target, '3.0.0-alpha.17'); +}); + +test('present global but unknown bundled β†’ ok, no target (nothing to pin to)', () => { + const c = classifyCoherence({ global: '3.0.0-alpha.17', bundled: null }); + eq(c.present, true); eq(c.ok, true); eq(c.skew, null); eq(c.target, null); +}); + +test('identical versions β†’ coherent (skew null, ok)', () => { + const c = classifyCoherence({ global: '3.0.0-alpha.17', bundled: '3.0.0-alpha.17' }); + eq(c.ok, true); eq(c.skew, null); eq(c.target, '3.0.0-alpha.17'); +}); + +test('same core, different prerelease β†’ tolerated (skew prerelease, ok)', () => { + const c = classifyCoherence({ global: '3.0.0-alpha.17', bundled: '3.0.0-alpha.12' }); + eq(c.ok, true); eq(c.skew, 'prerelease'); eq(c.target, '3.0.0-alpha.12'); +}); + +test('different core (minor) β†’ CORE SKEW, not ok, target = bundled', () => { + const c = classifyCoherence({ global: '3.1.0-alpha.1', bundled: '3.0.0-alpha.17' }); + eq(c.ok, false); eq(c.skew, 'core'); eq(c.target, '3.0.0-alpha.17'); +}); + +test('different core (major) β†’ CORE SKEW, not ok', () => { + const c = classifyCoherence({ global: '4.0.0', bundled: '3.0.0-alpha.17' }); + eq(c.ok, false); eq(c.skew, 'core'); +}); + +test('different core (patch) β†’ CORE SKEW (store schema authority is exact base)', () => { + const c = classifyCoherence({ global: '3.0.1', bundled: '3.0.0' }); + eq(c.ok, false); eq(c.skew, 'core'); +}); + +console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs new file mode 100644 index 00000000..9e217943 --- /dev/null +++ b/tests/dashboard.test.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// +// dashboard.test.cjs β€” unit tests for the read-only local web dashboard server +// (src/lib/dashboard-server.mjs). Zero-dep: boots the server on an ephemeral +// port over a fixture project dir, exercises both routes over real HTTP, and +// asserts the shapes the browser client depends on. +// +// The status collector is INJECTED (fetchStatus) so the test never shells out +// to the global `ak status --json` (which would hit the network via driftReport +// and make this flaky). The server still reads improvement.json / the health +// ring off the fixture itself β€” that path is exercised for real. +// +// Run: node tests/dashboard.test.cjs (exit 0 = pass, 1 = fail) + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); + +const ROOT = path.resolve(__dirname, '..'); +const MOD = path.join(ROOT, 'src', 'lib', 'dashboard-server.mjs'); + +// ── tiny harness ───────────────────────────────────────────────────────────── +let passed = 0, failed = 0; +function test(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => { console.log(' \x1b[32mβœ“\x1b[0m ' + name); passed++; }) + .catch((e) => { console.log(' \x1b[31mβœ—\x1b[0m ' + name + '\n ' + (e && e.message)); failed++; }); +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } +function contains(hay, needle) { + assert(String(hay).includes(needle), `expected output to contain ${JSON.stringify(needle)}`); +} + +function mkFixture(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dash-test-')); + for (const [rel, data] of Object.entries(files)) { + const fp = path.join(dir, rel); + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, typeof data === 'string' ? data : JSON.stringify(data)); + } + return dir; +} + +// GET helper β†’ { status, headers, body } +function get(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (c) => { body += c; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }).on('error', reject); + }); +} + +async function main() { + const { startDashboard } = await import('file://' + MOD); + + const STUB_STATUS = { + overall: 'warn', + rows: [ + { 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 }, + ], + drift: [{ pkg: 'ruflo', installed: '4.0.0', latest: '4.0.0', outdated: false }], + }; + + const fixture = mkFixture({ + '.claude-flow/improvement.json': { verdict: 'PASS', deltaPP: 33, ci95: 5, pValue: 0.001, cohensD: 1.2, ts: 1700000000 }, + '.claude-flow/health-history.json': [ + { ts: 1700000000, patternsLearned: 10, deltaPP: 5 }, + { ts: 1700000600, patternsLearned: 22, deltaPP: 18 }, + { ts: 1700001200, patternsLearned: 40, deltaPP: 33 }, + ], + }); + + const { url, close } = await startDashboard({ + port: 0, + cwd: fixture, + fetchStatus: async () => STUB_STATUS, + }); + + try { + assert(/^http:\/\/127\.0\.0\.1:\d+\/$/.test(url), 'url must be a 127.0.0.1 loopback URL, got ' + url); + + await test('GET / β†’ 200 text/html with the header band', async () => { + const r = await get(url); + assert(r.status === 200, 'expected 200, got ' + r.status); + contains(r.headers['content-type'] || '', 'text/html'); + contains(r.body, 'agentic-kit'); // kit name in the header band + contains(r.body, 'class="band"'); // the header band itself + contains(r.body, '/api/status'); // client polls the JSON endpoint + }); + + await test('GET / is self-contained β€” no external fetches', async () => { + const r = await get(url); + assert(!/https?:\/\/(?!127\.0\.0\.1)/.test(r.body.replace(/https?:\/\/[^"'\s]*w3\.org/g, '')), + 'page must not reference external http(s) hosts'); + assert(!/]+stylesheet/i.test(r.body), 'no external stylesheet links'); + assert(!/]+src=/i.test(r.body), 'no external script src'); + }); + + await test('GET /api/status β†’ 200 valid JSON with rows + overall', async () => { + const r = await get(url + 'api/status'); + assert(r.status === 200, 'expected 200, got ' + r.status); + contains(r.headers['content-type'] || '', 'application/json'); + 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); + }); + + await test('GET /api/status embeds improvement.json read off the fixture', async () => { + const r = await get(url + 'api/status'); + const j = JSON.parse(r.body); + assert(j.improvement && j.improvement.verdict === 'PASS', 'improvement.json must be embedded'); + assert(j.improvement.deltaPP === 33, 'improvement fields must survive'); + }); + + await test('GET /api/status embeds the health-history ring', async () => { + const r = await get(url + 'api/status'); + const j = JSON.parse(r.body); + assert(Array.isArray(j.health) && j.health.length === 3, 'health ring must be embedded as an array'); + }); + + await test('unknown route β†’ 404', async () => { + const r = await get(url + 'nope'); + assert(r.status === 404, 'expected 404, got ' + r.status); + }); + } finally { + await close(); + } + + // A second fixture WITHOUT improvement.json / health ring: those keys must be + // null/absent, never a crash. + const bare = mkFixture({}); + const srv2 = await startDashboard({ port: 0, cwd: bare, fetchStatus: async () => ({ overall: 'ok', rows: [] }) }); + try { + await test('missing improvement.json / health ring β†’ null, no crash', async () => { + const r = await get(srv2.url + 'api/status'); + const j = JSON.parse(r.body); + assert(j.improvement === null, 'improvement must be null when absent'); + assert(j.health === null, 'health must be null when absent'); + }); + } finally { + await srv2.close(); + } + + console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/harvest.test.cjs b/tests/harvest.test.cjs new file mode 100644 index 00000000..dd61c661 --- /dev/null +++ b/tests/harvest.test.cjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// +// harvest.test.cjs β€” unit tests for `ak x harvest` orchestration (src/lib/harvest.mjs). +// +// NO MOCKS, NO STUBS. harvest's runtime path drives REAL CLIs (ruflo/agentdb) and +// is proven end-to-end, against real data, by `ak x verify harvest`. These unit +// tests cover only the PURE, deterministic pieces: +// 1. planHarvest β€” the exact grounded verbs + args it will run; +// 2. parseConsolidate / parseStored β€” parsers asserted against REAL captured +// output from the installed agentdb CLI (ANSI codes included), so a change +// in the tool's output shape fails here instead of silently harvesting nothing. +// +// Run: node tests/harvest.test.cjs (exit 0 = pass, 1 = fail) + +const path = require('path'); +const { planHarvest, parseConsolidate, parseStored } = + require(path.resolve(__dirname, '..', 'src', 'lib', 'harvest.mjs')); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); console.log(' \x1b[32mβœ“\x1b[0m ' + name); passed++; } + catch (e) { console.log(' \x1b[31mβœ—\x1b[0m ' + name + '\n ' + e.message); failed++; } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } +function eq(a, b, msg) { + const A = JSON.stringify(a), B = JSON.stringify(b); + assert(A === B, (msg || 'not equal') + `\n got: ${A}\n expected: ${B}`); +} + +console.log('harvest orchestration (src/lib/harvest.mjs)'); + +// ── planHarvest: the exact grounded verbs, in order ───────────────────────── +test('planHarvest returns the two grounded verbs, in order', () => { + const steps = planHarvest(); + eq(steps.length, 2); + eq(steps[0].cmd, 'ruflo'); + eq(steps[0].args.slice(0, 2), ['hooks', 'post-task'], 'step 1 = ruflo hooks post-task'); + assert(steps[0].args.includes('--success') && steps[0].args.includes('true'), 'records --success true'); + eq(steps[1].cmd, 'agentdb'); + eq(steps[1].args.slice(0, 2), ['skill', 'consolidate'], 'step 2 = agentdb skill consolidate'); + eq(steps[1].args[steps[1].args.length - 1], 'true', 'consolidate persists (trailing true)'); + eq(steps[1].tool, 'agentdb', 'step 2 is tagged agentdb so it can be skipped when absent'); +}); + +test('planHarvest honors overridden params + default minReward is agentdb-native 0.7', () => { + eq(planHarvest()[1].args, ['skill', 'consolidate', '3', '0.7', '7', 'true'], 'defaults match agentdb (3,0.7,7)'); + const s = planHarvest({ taskId: 'sess-9', minAttempts: 5, minReward: 0.75, days: 14 }); + assert(s[0].args.includes('sess-9'), 'taskId threaded into post-task'); + eq(s[1].args, ['skill', 'consolidate', '5', '0.75', '14', 'true']); +}); + +test('no daemon/background/swarm verb appears in the plan', () => { + planHarvest().forEach((s) => { + const joined = [s.cmd, ...s.args].join(' '); + assert(!/\bdaemon\b|\bstart\b|\bswarm\b/.test(joined), 'no backgrounding verb: ' + joined); + }); +}); + +// ── parseConsolidate: REAL captured agentdb output (with ANSI) ────────────── +// Captured live from `agentdb skill consolidate 1 0.5 30 true` on a seeded store. +const REAL_CREATED = + '\x1b[1m\x1b[36m\nπŸ”„ Consolidating Episodes into Skills with Pattern Extraction\x1b[0m\n' + + '\x1b[34mβ„Ή Min Reward: 0.5\x1b[0m\n' + + '\x1b[32mβœ… Created 1 new skills, updated 0 existing skills in 11ms\x1b[0m\n' + + 'Extracted Patterns:\n Avg Reward: 0.88\n'; +const REAL_NONE = + '\x1b[32mβœ… Created 0 new skills, updated 0 existing skills in 2ms\x1b[0m\n' + + '\x1b[33m⚠ No episodes met the criteria for skill consolidation\x1b[0m\n'; + +test('parseConsolidate extracts created/updated/avgReward from REAL output', () => { + const p = parseConsolidate(REAL_CREATED); + eq(p.created, 1); eq(p.updated, 0); eq(p.avgReward, 0.88); eq(p.noEpisodes, false); +}); + +test('parseConsolidate flags the real "no episodes" case (created 0, noEpisodes true)', () => { + const p = parseConsolidate(REAL_NONE); + eq(p.created, 0); eq(p.updated, 0); eq(p.noEpisodes, true); +}); + +test('parseConsolidate returns nulls (never fabricated 0s) when the line is absent', () => { + const p = parseConsolidate('some unrelated output with no consolidate line'); + eq(p.created, null); eq(p.updated, null); eq(p.avgReward, null); +}); + +test('parseStored reads the real "Stored episode #N" acknowledgement', () => { + eq(parseStored('\x1b[32mβœ… Stored episode #2\x1b[0m').episode, 2); + eq(parseStored('no episode line here').episode, null); +}); + +console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/health-history.test.cjs b/tests/health-history.test.cjs new file mode 100644 index 00000000..817520e9 --- /dev/null +++ b/tests/health-history.test.cjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// +// health-history.test.cjs β€” unit tests for the persisted health-history ring. +// +// The ring records one entry per `sync` convergence: learning-row count, native +// agentdb slot count, drift state, and security presence. detectRegression() +// compares the last two entries and surfaces backslides (learning shrank, slots +// dropped, drift regressed currentβ†’outdated, security presentβ†’absent) so `status` +// can alarm on them. These are PURE functions β€” no file I/O in the core β€” so the +// test exercises them directly. loadRing/appendToConfig are the thin cfg shims the +// integrator persists via saveKitConfig. +// +// Run: node tests/health-history.test.cjs (exit 0 = pass, 1 = fail) + +const path = require('path'); +const { + append, summarize, detectRegression, loadRing, appendToConfig, +} = require(path.resolve(__dirname, '..', 'src', 'lib', 'health-history.mjs')); + +// ── harness ────────────────────────────────────────────────────────────────── +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); console.log(' \x1b[32mβœ“\x1b[0m ' + name); passed++; } + catch (e) { console.log(' \x1b[31mβœ—\x1b[0m ' + name + '\n ' + e.message); failed++; } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } +function eq(a, b, msg) { + const A = JSON.stringify(a), B = JSON.stringify(b); + assert(A === B, (msg || 'not equal') + `\n got: ${A}\n expected: ${B}`); +} +const entry = (o = {}) => ({ + ts: 1000, learningRows: 10, nativeSlots: 5, driftOutdated: false, securityPresent: true, ...o, +}); + +// ── append: cap behavior + immutability ────────────────────────────────────── +console.log('append (ring cap + immutability)'); + +test('append returns a NEW array and does not mutate the input', () => { + const ring = [entry({ ts: 1 })]; + const out = append(ring, entry({ ts: 2 })); + assert(out !== ring, 'must return a new array reference'); + eq(ring.length, 1, 'input ring must be untouched'); + eq(out.length, 2, 'new ring has the appended entry'); + eq(out[1].ts, 2, 'appended entry is last'); +}); + +test('append keeps insertion order (oldest first, newest last)', () => { + let ring = []; + for (let i = 1; i <= 4; i++) ring = append(ring, entry({ ts: i })); + eq(ring.map((e) => e.ts), [1, 2, 3, 4]); +}); + +test('append drops the OLDEST once past the cap', () => { + let ring = []; + for (let i = 1; i <= 5; i++) ring = append(ring, entry({ ts: i }), 3); + eq(ring.length, 3, 'ring capped at 3'); + eq(ring.map((e) => e.ts), [3, 4, 5], 'oldest dropped, newest kept'); +}); + +test('append default cap is 30', () => { + let ring = []; + for (let i = 1; i <= 35; i++) ring = append(ring, entry({ ts: i })); + eq(ring.length, 30, 'default cap 30'); + eq(ring[0].ts, 6, 'entries 1–5 dropped'); + eq(ring[29].ts, 35, 'newest retained'); +}); + +test('append onto undefined/missing ring treats it as empty', () => { + const out = append(undefined, entry({ ts: 7 })); + eq(out.length, 1); + eq(out[0].ts, 7); +}); + +// ── summarize ──────────────────────────────────────────────────────────────── +console.log('\nsummarize'); + +test('summarize projects an entry to the tracked scalar fields', () => { + const s = summarize({ ts: 9, learningRows: 3, nativeSlots: 2, driftOutdated: true, securityPresent: false, junk: 'x' }); + eq(s.learningRows, 3); + eq(s.nativeSlots, 2); + eq(s.driftOutdated, true); + eq(s.securityPresent, false); + assert(!('junk' in s), 'summarize drops untracked fields'); +}); + +// ── detectRegression: every branch ─────────────────────────────────────────── +console.log('\ndetectRegression (every branch)'); + +test('no regression when nothing worsened', () => { + const ring = [entry({ ts: 1 }), entry({ ts: 2 })]; + eq(detectRegression(ring), []); +}); + +test('no regression when metrics IMPROVE', () => { + const ring = [ + entry({ learningRows: 5, nativeSlots: 2, driftOutdated: true, securityPresent: false }), + entry({ learningRows: 9, nativeSlots: 6, driftOutdated: false, securityPresent: true }), + ]; + eq(detectRegression(ring), []); +}); + +test('learningRows shrank β†’ one regression', () => { + const ring = [entry({ learningRows: 20 }), entry({ learningRows: 12 })]; + const r = detectRegression(ring); + eq(r.length, 1); + eq(r[0].metric, 'learningRows'); + eq(r[0].from, 20); + eq(r[0].to, 12); + assert(/learning/i.test(r[0].message), 'message mentions learning: ' + r[0].message); +}); + +test('native agentdb slot count dropped β†’ one regression', () => { + const ring = [entry({ nativeSlots: 8 }), entry({ nativeSlots: 3 })]; + const r = detectRegression(ring); + eq(r.length, 1); + eq(r[0].metric, 'nativeSlots'); + eq(r[0].from, 8); + eq(r[0].to, 3); +}); + +test('drift currentβ†’outdated β†’ one regression', () => { + const ring = [entry({ driftOutdated: false }), entry({ driftOutdated: true })]; + const r = detectRegression(ring); + eq(r.length, 1); + eq(r[0].metric, 'drift'); + eq(r[0].from, false); + eq(r[0].to, true); + assert(/outdated/i.test(r[0].message), 'message mentions outdated: ' + r[0].message); +}); + +test('drift outdatedβ†’current is NOT a regression (recovery)', () => { + const ring = [entry({ driftOutdated: true }), entry({ driftOutdated: false })]; + eq(detectRegression(ring), []); +}); + +test('security presentβ†’absent β†’ one regression', () => { + const ring = [entry({ securityPresent: true }), entry({ securityPresent: false })]; + const r = detectRegression(ring); + eq(r.length, 1); + eq(r[0].metric, 'security'); + eq(r[0].from, true); + eq(r[0].to, false); + assert(/security/i.test(r[0].message), 'message mentions security: ' + r[0].message); +}); + +test('security absentβ†’present is NOT a regression (recovery)', () => { + const ring = [entry({ securityPresent: false }), entry({ securityPresent: true })]; + eq(detectRegression(ring), []); +}); + +test('multiple simultaneous regressions all surface', () => { + const ring = [ + entry({ learningRows: 30, nativeSlots: 9, driftOutdated: false, securityPresent: true }), + entry({ learningRows: 10, nativeSlots: 4, driftOutdated: true, securityPresent: false }), + ]; + const r = detectRegression(ring); + eq(r.length, 4, 'all four backslides detected'); +}); + +test('multiple regressions cover each metric exactly once', () => { + const ring = [ + entry({ learningRows: 30, nativeSlots: 9, driftOutdated: false, securityPresent: true }), + entry({ learningRows: 10, nativeSlots: 4, driftOutdated: true, securityPresent: false }), + ]; + const metrics = detectRegression(ring).map((x) => x.metric).sort(); + eq(metrics, ['drift', 'learningRows', 'nativeSlots', 'security']); +}); + +test('fewer than two entries β†’ no regression (nothing to compare)', () => { + eq(detectRegression([]), []); + eq(detectRegression([entry()]), []); + eq(detectRegression(undefined), []); +}); + +test('only the LAST two entries are compared', () => { + const ring = [ + entry({ ts: 1, learningRows: 100 }), // ancient, ignored + entry({ ts: 2, learningRows: 5 }), + entry({ ts: 3, learningRows: 6 }), // grew vs prev β†’ no regression + ]; + eq(detectRegression(ring), []); +}); + +test('missing numeric fields are treated as 0 (no spurious regression, no crash)', () => { + const ring = [{ ts: 1 }, { ts: 2 }]; + eq(detectRegression(ring), []); +}); + +// ── loadRing / appendToConfig (thin cfg shims) ─────────────────────────────── +console.log('\nloadRing / appendToConfig'); + +test('loadRing returns [] when cfg has no health', () => { + eq(loadRing({}), []); + eq(loadRing({ health: {} }), []); + eq(loadRing(undefined), []); +}); + +test('loadRing returns the stored ring', () => { + const ring = [entry({ ts: 1 })]; + eq(loadRing({ health: { ring } }), ring); +}); + +test('appendToConfig seeds cfg.health.ring and returns the cfg', () => { + const cfg = {}; + const out = appendToConfig(cfg, entry({ ts: 1 })); + assert(out === cfg, 'returns the same cfg object for chaining'); + eq(cfg.health.ring.length, 1); + eq(cfg.health.ring[0].ts, 1); +}); + +test('appendToConfig appends onto an existing ring and respects the cap', () => { + const cfg = { health: { ring: [] } }; + for (let i = 1; i <= 35; i++) appendToConfig(cfg, entry({ ts: i })); + eq(cfg.health.ring.length, 30); + eq(cfg.health.ring[0].ts, 6); +}); + +test('appendToConfig preserves other cfg.health keys', () => { + const cfg = { health: { ring: [], somethingElse: 'keep' } }; + appendToConfig(cfg, entry({ ts: 1 })); + eq(cfg.health.somethingElse, 'keep'); +}); + +console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/statusline-brain.test.cjs b/tests/statusline-brain.test.cjs new file mode 100644 index 00000000..9071786a --- /dev/null +++ b/tests/statusline-brain.test.cjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// +// statusline-brain.test.cjs β€” unit tests for the 🧿 RuvNet-Brain footer segment. +// +// Mirrors statusline-segments.test.cjs: extracts the `rufloActivationSegments(cwd)` +// source block from the kit template, evals it, and exercises it against fixture +// KB dirs pointed at via RUVNET_BRAIN_KB. Asserts the brain segment RENDERS when a +// fixture KB dir contains the forge-mcp-all.mjs entrypoint (the same presence probe +// as src/lib/ruvnet-brain.mjs) and is ABSENT (honesty-gated β€” never fabricated) when +// the KB is missing or the entrypoint is not there. +// +// Run: node tests/statusline-brain.test.cjs (exit 0 = pass, 1 = fail) + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'src', 'templates', 'statusline-footer.cjs'); + +// ── Load the renderer source from the kit template ─────────────────────────── +const sh = fs.readFileSync(SRC, 'utf8'); +const m = sh.match(/\/\* ruflo-seg:BEGIN \*\/([\s\S]*?)\/\* ruflo-seg:END \*\//); +if (!m) { console.error('FATAL: could not find ruflo-seg block in ' + SRC); process.exit(2); } +const block = m[1]; + +let rufloActivationSegments; +try { + // eslint-disable-next-line no-eval + rufloActivationSegments = eval('(function(){' + block + '\nreturn rufloActivationSegments;})()'); +} catch (e) { + console.error('FATAL: extracted segment block is not valid JS:', e.message); + process.exit(2); +} + +// ── Test harness ──────────────────────────────────────────────────────────── +let passed = 0, failed = 0; +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); +function mkdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'brain-test-')); } +function test(name, fn) { + try { fn(); console.log(' \x1b[32mβœ“\x1b[0m ' + name); passed++; } + catch (e) { console.log(' \x1b[31mβœ—\x1b[0m ' + name + '\n ' + e.message); failed++; } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } +function contains(hay, needle) { assert(hay.includes(needle), `expected output to contain ${JSON.stringify(needle)}\n got: ${JSON.stringify(hay)}`); } +function absent(hay, needle) { assert(!hay.includes(needle), `expected output NOT to contain ${JSON.stringify(needle)}\n got: ${JSON.stringify(hay)}`); } + +// Render an empty project fixture with RUVNET_BRAIN_KB pointed at `kb` (or unset when null). +function renderWithKb(kb) { + const prev = process.env.RUVNET_BRAIN_KB; + if (kb === null) delete process.env.RUVNET_BRAIN_KB; else process.env.RUVNET_BRAIN_KB = kb; + try { return strip(rufloActivationSegments(mkdir())); } + finally { if (prev === undefined) delete process.env.RUVNET_BRAIN_KB; else process.env.RUVNET_BRAIN_KB = prev; } +} + +// Build a fixture KB dir. `entry` controls whether the forge-mcp-all.mjs probe file exists. +function mkKb({ entry = true, dataBytes = 0 } = {}) { + const kb = mkdir(); + if (entry) fs.writeFileSync(path.join(kb, 'forge-mcp-all.mjs'), '// entrypoint\n'); + if (dataBytes > 0) fs.writeFileSync(path.join(kb, 'agentdb.rvf'), Buffer.alloc(dataBytes)); + // A __MACOSX artifact dir must never be counted toward KB size (it is a directory). + fs.mkdirSync(path.join(kb, '__MACOSX'), { recursive: true }); + return kb; +} + +console.log('statusline RuvNet-Brain segment (🧿)'); + +// ── honesty gate: absent ──────────────────────────────────────────────────── +test('no KB dir (RUVNET_BRAIN_KB β†’ empty temp) β†’ no 🧿 brain row', () => { + absent(renderWithKb(mkdir()), '🧿'); +}); + +test('KB dir without forge-mcp-all.mjs entrypoint β†’ no 🧿 (presence probe gates)', () => { + const kb = mkKb({ entry: false, dataBytes: 4096 }); + absent(renderWithKb(kb), '🧿'); +}); + +test('RUVNET_BRAIN_KB pointing at a nonexistent path β†’ no 🧿', () => { + absent(renderWithKb(path.join(os.tmpdir(), 'brain-nope-' + Date.now())), '🧿'); +}); + +// ── present ───────────────────────────────────────────────────────────────── +test('KB with forge-mcp-all.mjs entrypoint β†’ 🧿 brain row renders', () => { + const out = renderWithKb(mkKb({ entry: true })); + contains(out, '🧿'); +}); + +test('KB with sized data files β†’ πŸ’Ύ size chip (mirrors the QE size logic)', () => { + const out = renderWithKb(mkKb({ entry: true, dataBytes: 2 * 1024 * 1024 })); + contains(out, '🧿'); + contains(out, 'πŸ’Ύ'); + contains(out, 'MB'); +}); + +test('brain row occupies its OWN line, never merged with other segments', () => { + const out = renderWithKb(mkKb({ entry: true, dataBytes: 1024 })); + const lines = out.split('\n').filter(Boolean); + const bl = lines.find((l) => l.includes('🧿')); + assert(bl, 'expected a 🧿 line, got: ' + JSON.stringify(lines)); +}); + +console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); From 69d1e4e118cfbc06a0fd65f52609a16f6b457757 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 17 Jul 2026 09:52:32 -0700 Subject: [PATCH 2/2] docs(readme): add dashboard, agentdb, harvest, health-history + brain-row to the feature list --- README.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8ae7a7df..5e8d15d0 100644 --- a/README.md +++ b/README.md @@ -53,21 +53,33 @@ What the verbs cover: | Verb | What it does | | ------ | -------------- | -| **setup** | Installs/updates ruflo + agentic-qe globally (handling npm β‰₯11.17's `allow-scripts` so natives build), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP β€” a ~512 MB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into `~/.claude/CLAUDE.md`, offers one-time MCP registration (user scope, with a tool-family picker), and β€” inside a repo β€” initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** storeβ†’disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` directory in the current folder; without one it's skipped with a note. `--project` forces it anyway (e.g. a not-yet-`git init`-ed folder), `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. | -| **status** | Per-subsystem βœ“/⚠/βœ— (versions, the kit's own version, **ruvnet-brain** (present + release drift, or "not installed"), natives, security, learning, aqe/RVF, MCP, **hosts** (claude/codex β€” version + install method, or "enabled but not installed"), **providers** (host wiring + aqe fallback chain, or "drifted"/claude-only default), daemons, CLAUDE.md blocks, statusline), each drift row naming what `sync` would do about it. | -| **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex) that's entirely absent β€” never touching an external (mise/brew/native) install β€” and **re-applies provider wiring** (the `ENABLE_*` host env, the aqe fallback chain, and ruflo API providers) whenever it has drifted. It also **re-runs the RuvNet Brain installer** to pull the latest release when the on-disk KB has drifted (or installs it if absent, when enabled). It also **self-updates the kit**: when a newer `@pacphi/agentic-kit` exists it installs it as the *last* step (the new code applies from the next `ak` run, never mid-sync). Prerelease installs (`4.0.0-alpha.*`) track the `next` npm dist-tag as well as `latest`, so alphas see their successors; stable installs only ever follow `latest`. `--no-upgrade` skips the self-update along with the package upgrades. | +| **setup** | Installs/updates ruflo + agentic-qe + the **agentdb** CLI globally (handling npm β‰₯11.17's `allow-scripts` so natives build; agentdb is pinned to ruflo's bundled version so the shared learning store stays coherent), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP β€” a ~512 MB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into `~/.claude/CLAUDE.md`, offers one-time MCP registration (user scope, with a tool-family picker), and β€” inside a repo β€” initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** storeβ†’disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` directory in the current folder; without one it's skipped with a note. `--project` forces it anyway (e.g. a not-yet-`git init`-ed folder), `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. | +| **status** | Per-subsystem βœ“/⚠/βœ— (versions, the kit's own version, **ruvnet-brain** (present + release drift, or "not installed"), natives, security, learning, aqe/RVF, **agentdb** (CLI present + coherent with ruflo's bundled version, or a store-skew warning), MCP, **hosts** (claude/codex β€” version + install method, or "enabled but not installed"), **providers** (host wiring + aqe fallback chain, or "drifted"/claude-only default), daemons, CLAUDE.md blocks, statusline), each drift row naming what `sync` would do about it β€” plus a **health-history** line that flags regressions since the last sync (learning shrank, native slots dropped, drift/security backslid). | +| **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex) that's entirely absent β€” never touching an external (mise/brew/native) install β€” and **re-applies provider wiring** (the `ENABLE_*` host env, the aqe fallback chain, and ruflo API providers) whenever it has drifted. It also **installs/repins the standalone `agentdb` CLI** to ruflo's bundled version (keeping the shared cognitive store coherent) and appends a **health-history snapshot** so `status` can flag regressions across syncs. It also **re-runs the RuvNet Brain installer** to pull the latest release when the on-disk KB has drifted (or installs it if absent, when enabled). It also **self-updates the kit**: when a newer `@pacphi/agentic-kit` exists it installs it as the *last* step (the new code applies from the next `ak` run, never mid-sync). Prerelease installs (`4.0.0-alpha.*`) track the `next` npm dist-tag as well as `latest`, so alphas see their successors; stable installs only ever follow `latest`. `--no-upgrade` skips the self-update along with the package upgrades. | | **uninstall** | Removes the kit's footprint (and any legacy shell-kit install); project data is never touched; `--purge` also offers to remove the global packages. | -Power-user mechanisms live under `ak x …` (`daemon-gc`, `mcp pick|off`, -`provider status|pick|off`, `reference diff|sync`, `verify learning|security|aqe`, -`improvement-eval`) β€” see `ak --help --all`. +Power-user mechanisms live under `ak x …` (`daemon-gc`, `dashboard`, `harvest`, +`mcp pick|off`, `provider status|pick|off`, `reference diff|sync`, +`verify learning|security|aqe|providers|harvest`, `improvement-eval`) β€” see `ak --help --all`. + +Two of those are worth calling out: + +- **`ak x dashboard`** β€” a read-only local web dashboard (`127.0.0.1:7431`, localhost-only, + never detaches) that renders the same subsystem view as `ak status`, grouped one card per + subsystem with severity triage and a learning-over-time strip. Self-contained and offline β€” + no external fetches. Stop with Ctrl-C. +- **`ak x harvest`** β€” an **opt-in** (`kit.json` `harvest:true`), foreground learning-*write*: + it records the session outcome and consolidates accumulated episodes into durable skills via + the real `ruflo hooks post-task` / `agentdb skill consolidate` verbs, reporting the actual + skills created/updated. Off and `--dry-run`-safe by default; no daemon, ever. + `ak x verify harvest` proves the whole path end-to-end against real CLIs. ## The status line Projects set up by the kit get an append-only footer under ruflo's own status line, each segment shown **only when genuinely active**: 🧠 SONA patterns/trajectories (+ -live micro-LoRA Ξ”β€–Wβ€–), πŸ“ˆ route-RL metrics, πŸ›‘ aidefence, βš™ machine-wide daemon -count, and πŸŽ“ Agentic-QE stats. +live micro-LoRA Ξ”β€–Wβ€–), πŸ“ˆ route-RL metrics, πŸ›‘ aidefence, 🧿 RuvNet Brain KB, +βš™ machine-wide daemon count, and πŸŽ“ Agentic-QE stats. ## Requirements