mirror of
https://github.com/ruvnet/RuView
synced 2026-07-05 14:33:19 +00:00
e6f26e9ac9
* docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies
ADR-263 — @ruvnet/ruview@0.1.0 harness review (O1–O9):
- HIGH: claim-check CLI fails open on empty input (no --text/--file -> PASS exit 0)
- HIGH: MCP stdio server head-of-line blocking (spawnSync verify/calibrate up to 600s)
- MEASURED: optionalDependencies triple the cold npx install (4 pkgs/620kB/71 files
vs 1 pkg/172kB/22 files with --omit=optional) for a path that never imports them
- maxBuffer truncation, python -c port interpolation, version drift, duplicate skills,
guardrail METRIC_TERMS substring false positives ('map'/'F1' — found by dogfooding
claim-check on these very ADRs), zero CI
ADR-264 — @ruvnet/rvagent@0.1.0 + @ruv/ruview-cli review (O1–O9), verified against
the published registry tarball:
- HIGH: exports.require -> dist/index.cjs which is never built nor published
- MEASURED: 44 dead source-map files = 62,698B of the 188kB unpacked payload
- stdio-only server described as dual-transport; mixed dot/underscore tool names;
double Zod validation + hand-duplicated advertised schemas; 2-fd leak per training
job; unbounded body in the unwired HTTP scaffold; dead detectCogBinary candidates;
ruview bin-name collision
ADR-265 — cross-cutting npm distribution strategy: npm-packages.yml CI matrix
(test + pack-content/size gate + tarball-install smoke test), publish-from-CI-only
with npm provenance, version single-sourcing from package.json, bin/namespace
ownership (ruview bin belongs to @ruvnet/ruview), claim-check on package READMEs.
Docs only — no runtime code changed. Index/CHANGELOG/CLAUDE.md/README counts updated.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate
ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
(MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins
ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
manifest suite); stdio handshake + HTTP session flow smoke-tested live
ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
version-literal grep (D3), pack-content/size gate, tarball-install smoke
test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
@ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20
ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* perf(rvagent): lazy-load HTTP transport + memoize generated tool schemas
stdio time-to-first-response ~242ms -> ~189ms (-22%; MEASURED, median of
repeated initialize round-trips against dist/index.js in this container).
- ./http-transport.js now imported lazily inside the RVAGENT_HTTP_PORT
branch: it chain-loads the MCP SDK streamableHttp module (~48ms MEASURED
via per-module import() timing) which the default stdio path never uses
- toolInputJsonSchema memoized per tool: schemas are static for the process
lifetime; under the session-per-server HTTP model every session calls
tools/list, so stop re-walking the Zod tree each time
No behavior change: 99/99 jest tests; HTTP session flow re-smoke-tested
through the lazy import path (initialize -> 200 + mcp-session-id).
Profiled @ruvnet/ruview too and left it alone: 50ms CLI startup vs ~29ms
bare 'node -e ""' floor on the same box (MEASURED) — already near the
interpreter floor with zero dependencies.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* ci(ruview-cli): pass jest --passWithNoTests so the private no-test package doesn't fail the npm-packages matrix
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish
harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
metric tokens followed by ':' or a nearby number are no longer scrubbed
(fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
(hardware/mutating tools never overlap) while ping/tools/list stay
immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups
tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
any-port convenience applies only with no configured allowlist);
session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch
.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.
Tests: harness 30→36, rvagent 99→112, all passing.
Co-Authored-By: claude-flow <ruv@ruv.net>
---------
Co-authored-by: Claude <noreply@anthropic.com>
302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
// SPDX-License-Identifier: MIT
|
|
// RuView harness — the `ruview.*` tool registry.
|
|
//
|
|
// One registry consumed by BOTH the CLI (`npx ruview <tool>`) and the MCP server
|
|
// (`npx ruview mcp start`). Every handler returns structured JSON and is
|
|
// FAIL-CLOSED: when a prerequisite (the RuView repo, python+pyserial, the
|
|
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
|
|
// negative — never a fabricated success. This mirrors the project's "prove
|
|
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
|
|
//
|
|
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
|
|
// server keeps answering ping/tools/list while a long verify/calibrate runs.
|
|
// Canonical tool names use underscores (host tool-name regexes commonly enforce
|
|
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync, accessSync, constants } from 'node:fs';
|
|
import { join, dirname, resolve, delimiter } from 'node:path';
|
|
import { claimCheck, summarize } from './guardrails.js';
|
|
|
|
/** Walk up from `start` to find the RuView monorepo root (or null). */
|
|
export function findRepoRoot(start = process.cwd()) {
|
|
let dir = resolve(start);
|
|
for (let i = 0; i < 8; i++) {
|
|
const hasProof = existsSync(join(dir, 'archive', 'v1', 'data', 'proof', 'verify.py'));
|
|
const hasV2 = existsSync(join(dir, 'v2', 'Cargo.toml'));
|
|
if (hasProof || hasV2) return dir;
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
|
|
// are memoized: a miss can resolve later in a long-lived MCP session (the
|
|
// operator installs python/the CLI mid-run), so misses are re-probed each call.
|
|
const whichCache = new Map();
|
|
export function which(cmd) {
|
|
if (whichCache.has(cmd)) return whichCache.get(cmd);
|
|
const isWin = process.platform === 'win32';
|
|
const exts = isWin
|
|
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
: [''];
|
|
let found = null;
|
|
outer:
|
|
for (const dir of (process.env.PATH || '').split(delimiter)) {
|
|
if (!dir) continue;
|
|
for (const ext of isWin ? ['', ...exts] : exts) {
|
|
const p = join(dir, cmd + ext);
|
|
try {
|
|
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
|
|
found = p;
|
|
break outer;
|
|
} catch { /* keep scanning */ }
|
|
}
|
|
}
|
|
if (found !== null) whichCache.set(cmd, found);
|
|
return found;
|
|
}
|
|
|
|
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
|
|
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
|
|
// keep rolling tails instead of the full stream.
|
|
const STDOUT_TAIL = 65536;
|
|
const STDERR_TAIL = 16384;
|
|
|
|
/** Promise-based spawn with timeout + rolling output tails. */
|
|
export function run(cmd, args, opts = {}) {
|
|
const timeout = opts.timeout ?? 120000;
|
|
return new Promise((resolvePromise) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let child;
|
|
try {
|
|
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch (e) {
|
|
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
|
|
return;
|
|
}
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
|
|
child.stdout.on('data', (d) => {
|
|
stdout = (stdout + d).slice(-STDOUT_TAIL);
|
|
});
|
|
child.stderr.on('data', (d) => {
|
|
stderr = (stderr + d).slice(-STDERR_TAIL);
|
|
});
|
|
child.on('error', (e) => {
|
|
clearTimeout(timer);
|
|
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
|
|
});
|
|
child.on('close', (status) => {
|
|
clearTimeout(timer);
|
|
resolvePromise({
|
|
status,
|
|
ok: status === 0,
|
|
stdout,
|
|
stderr,
|
|
error: timedOut ? `timed out after ${timeout} ms` : null,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const ONBOARD_PATHS = {
|
|
'docker-demo': 'Fastest. `docker run -p 8000:8000 ruvnet/wifi-densepose` → open the dashboard. No hardware; replays sample CSI. Good for "what does it look like".',
|
|
'repo-build': 'Build from source. `cd v2 && cargo test --workspace --no-default-features` (1,031+ tests). Then `cargo run -p wifi-densepose-cli -- --help`. Good for developers.',
|
|
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
|
|
};
|
|
|
|
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
|
|
// never spliced into interpreter source).
|
|
const MONITOR_SCRIPT = [
|
|
'import sys,time',
|
|
'try:',
|
|
' import serial',
|
|
'except Exception as e:',
|
|
" print('NO_PYSERIAL'); sys.exit(3)",
|
|
'port=sys.argv[1]',
|
|
'dur=float(sys.argv[2])',
|
|
'ser=serial.Serial(port,115200,timeout=1)',
|
|
'csi=0; n=0; t=time.time()',
|
|
'while time.time()-t<dur:',
|
|
' ln=ser.readline()',
|
|
' if not ln: continue',
|
|
" s=ln.decode('utf-8','replace')",
|
|
' n+=1',
|
|
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
|
|
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
|
|
'ser.close()',
|
|
"print(f'LINES={n} CSI={csi}')",
|
|
].join('\n');
|
|
|
|
/**
|
|
* The tool registry. Each entry: { title, description, inputSchema, handler }.
|
|
* inputSchema is JSON-Schema (object). handler(args) → JSON-serializable result
|
|
* (sync or promise). Canonical names are underscore-form.
|
|
*/
|
|
export const TOOLS = {
|
|
ruview_onboard: {
|
|
title: 'Onboard',
|
|
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: { path: { type: 'string', enum: Object.keys(ONBOARD_PATHS), description: 'Which setup path. Omit to list all.' } },
|
|
},
|
|
handler(args = {}) {
|
|
const repo = findRepoRoot();
|
|
if (args.path && ONBOARD_PATHS[args.path]) {
|
|
return { ok: true, path: args.path, next: ONBOARD_PATHS[args.path], in_ruview_repo: !!repo };
|
|
}
|
|
return {
|
|
ok: true,
|
|
in_ruview_repo: !!repo,
|
|
repo_root: repo,
|
|
paths: ONBOARD_PATHS,
|
|
recommend: repo ? 'repo-build' : 'docker-demo',
|
|
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
|
|
};
|
|
},
|
|
},
|
|
|
|
ruview_claim_check: {
|
|
title: 'Claim check',
|
|
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
required: ['text'],
|
|
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
|
|
},
|
|
handler(args = {}) {
|
|
const text = typeof args.text === 'string' ? args.text : '';
|
|
if (text.trim().length === 0) {
|
|
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
|
|
}
|
|
const result = claimCheck(text);
|
|
return { ...result, summary: summarize(result) };
|
|
},
|
|
},
|
|
|
|
ruview_verify: {
|
|
title: 'Verify (witness)',
|
|
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
|
|
},
|
|
async handler(args = {}) {
|
|
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
|
|
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
|
|
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
|
|
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
|
|
const py = which('python') || which('python3');
|
|
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
|
|
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
|
|
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
|
|
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
|
|
},
|
|
},
|
|
|
|
ruview_node_monitor: {
|
|
title: 'Node monitor',
|
|
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
port: { type: 'string', description: 'Serial port, e.g. COM8 or /dev/ttyUSB0.' },
|
|
seconds: { type: 'number', description: 'Capture window (default 12).' },
|
|
},
|
|
},
|
|
async handler(args = {}) {
|
|
const port = args.port;
|
|
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
|
|
const py = which('python') || which('python3');
|
|
if (!py) return { ok: false, reason: 'python_missing' };
|
|
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
|
|
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
|
|
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
|
|
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
|
|
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
|
|
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
|
|
return { ok: csi > 0, csi_callbacks: csi, mgmt_data_upgrade: upgraded, raw: r.stdout.trim() };
|
|
},
|
|
},
|
|
|
|
ruview_calibrate: {
|
|
title: 'Calibrate room',
|
|
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
step: { type: 'string', enum: ['baseline', 'enroll', 'train-room', 'room-watch'], description: 'Which calibration step.' },
|
|
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
|
|
},
|
|
},
|
|
async handler(args = {}) {
|
|
const step = args.step || 'baseline';
|
|
const bin = which('wifi-densepose');
|
|
const repo = findRepoRoot();
|
|
if (!bin && !repo) return { ok: false, reason: 'cli_missing', hint: 'Install the wifi-densepose CLI or run in the repo (cargo run -p wifi-densepose-cli).' };
|
|
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
|
|
// Prefer the installed binary; otherwise cargo-run from the repo.
|
|
const r = bin
|
|
? await run(bin, [step, ...passthru], { timeout: 300000 })
|
|
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
|
|
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
|
|
},
|
|
},
|
|
|
|
ruview_node_flash: {
|
|
title: 'Node flash',
|
|
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
port: { type: 'string', description: 'Target port, e.g. COM8.' },
|
|
variant: { type: 'string', enum: ['s3-8mb', 's3-4mb', 'c6'], description: 'Firmware variant.' },
|
|
confirm: { type: 'boolean', description: 'Must be true to actually flash (guard).' },
|
|
},
|
|
},
|
|
handler(args = {}) {
|
|
if (process.platform !== 'win32') {
|
|
return { ok: false, reason: 'unsupported_platform', detail: 'The ESP-IDF flash flow is Windows-subprocess-specific today (see CLAUDE.local.md).' };
|
|
}
|
|
if (!args.confirm) {
|
|
return { ok: false, reason: 'not_confirmed', detail: 'Mutating hardware op — re-call with {confirm:true}.', would_flash: { port: args.port, variant: args.variant || 's3-8mb' } };
|
|
}
|
|
return { ok: false, reason: 'manual_step_required', detail: 'Flashing uses the pinned ESP-IDF subprocess in CLAUDE.local.md. This tool returns the exact command rather than running an unattended flash.', see: 'skills/provision-node.md' };
|
|
},
|
|
},
|
|
};
|
|
|
|
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
|
|
// underscore form is what tools/list advertises.
|
|
export const TOOL_ALIASES = Object.fromEntries(
|
|
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
|
|
);
|
|
|
|
/** Resolve a canonical or aliased tool name (or null). */
|
|
export function resolveToolName(name) {
|
|
if (TOOLS[name]) return name;
|
|
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
|
|
return null;
|
|
}
|
|
|
|
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
|
|
export async function runTool(name, args) {
|
|
const canonical = resolveToolName(name);
|
|
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
|
|
try {
|
|
return await TOOLS[canonical].handler(args || {});
|
|
} catch (err) {
|
|
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
|
|
}
|
|
}
|
|
|
|
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
|
|
export function listTools() {
|
|
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
|
|
}
|