fix: resolve current runtime and verification defects for v3.32.36 (#2850)

* fix(cli): make runtime status and learning signals truthful

* fix(metaharness): accept padded scan severities

* fix(codex): make core hooks and status skill native

* fix(security): make witness verification hermetic

* fix(metrics): expose grounded learning outcomes

* fix(hooks): deduplicate project and plugin events

* fix(verification): preserve security audit payloads

* fix(runtime): stabilize dual memory skills and MCP schemas

* fix(cli): harden helper signing and reflexion health

* test(release): align helper and container invariants

* chore(release): prepare v3.32.36

* test(funnel): align gates with cold-start seed pool

* fix(ci): document MCP environment precedence
This commit is contained in:
rUv authored and GitHub committed 2026-07-29 15:08:51 -04:00
1 parent 3d9bca6d44
commit 67ff9898c0
81 files changed
+2998 -1380

No files matched your search

+134 -3
View File
@@ -89,9 +89,6 @@ function spawnDetachedHookRefresh(subcommand) {
detached: true,
stdio: 'ignore',
env: process.env,
// Windows: without this, npx.cmd's cmd.exe wrapper flashes a visible
// console window every time a hook fires a background refresh. Runs
// hidden instead. No-op on POSIX.
windowsHide: true,
});
child.unref();
@@ -102,6 +99,105 @@ function spawnDetachedFunnelRefresh() {
spawnDetachedHookRefresh('refresh-funnel');
}
// ADR-318/319: first-run auto-enable of spinner verbs (default ON) +
// announcements (default OFF, explicit opt-in). Fires ONCE per install
// (marker at ~/.ruflo/first-run-enabled.json), then never again —
// subsequent sessions see the marker and skip.
//
// Split posture rationale:
// - Spinner verbs = low-intrusion (per-spin flash, append-mode mix
// with Claude Code defaults, moderate visibility over time). Default ON.
// Disclosure notification + one-command disable satisfies the ethical bar.
// - Announcements = higher-intrusion (prominent line at every Claude Code
// startup, more attention per view). Default OFF; opt-in via
// RUFLO_AUTO_ENABLE_ANNOUNCEMENTS=1 or explicit `ruflo announcements enable`.
//
// Gates (spinner is default-on unless any is TRUE):
// - RUFLO_NO_AUTO_ENABLE truthy (master opt-out — kills both)
// - RUFLO_NO_AUTO_ENABLE_SPINNER truthy (spinner-only opt-out)
// - CI / GITHUB_ACTIONS truthy
// - stdout is not a TTY (piped, non-interactive)
// - Marker file already exists
//
// Announcements needs the same gates PLUS RUFLO_AUTO_ENABLE_ANNOUNCEMENTS=1.
//
// Marker is written even if the spawns fail — auto-enable is a "we tried once"
// contract, not "keep trying until success." Users can run enable manually.
function firstRunAutoEnableIfEligible() {
try {
const path = require('path');
const fs = require('fs');
const os = require('os');
const truthy = (v) => v && !/^(0|false|off|no)$/i.test(String(v));
if (truthy(process.env.RUFLO_NO_AUTO_ENABLE)) return;
if (process.env.CI || process.env.GITHUB_ACTIONS) return;
if (process.stdout && process.stdout.isTTY === false) return;
const markerPath = path.join(os.homedir(), '.ruflo', 'first-run-enabled.json');
if (fs.existsSync(markerPath)) return;
const enableSpinner = !truthy(process.env.RUFLO_NO_AUTO_ENABLE_SPINNER);
const enableAnnouncements = truthy(process.env.RUFLO_AUTO_ENABLE_ANNOUNCEMENTS);
// Nothing to do — user opted out of both. Skip marker write so if they
// change their mind and re-enable env vars later, first-run still fires.
if (!enableSpinner && !enableAnnouncements) return;
// Spawn the enable commands detached + non-blocking. Any failure
// (missing CLI, refused state, etc.) is silent — auto-enable is
// best-effort and MUST NOT block session-restore. Uses execPath +
// resolved cli.js directly rather than spawnDetachedHookRefresh
// because that helper hardcodes 'hooks' as the top-level command.
const { spawn } = require('child_process');
const cliBin = resolveCliBinForHook();
const runDetached = (args) => {
try {
const spawnArgs = cliBin
? [process.execPath, [cliBin, ...args]]
: [process.platform === 'win32' ? 'npx.cmd' : 'npx',
['--prefer-offline', '@claude-flow/cli', ...args]];
const child = spawn(spawnArgs[0], spawnArgs[1], {
detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
});
child.unref();
} catch { /* best-effort */ }
};
if (enableSpinner) runDetached(['spinner', 'enable', '--yes']);
if (enableAnnouncements) runDetached(['announcements', 'enable', '--yes']);
try {
fs.mkdirSync(path.dirname(markerPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
markerPath,
JSON.stringify({
_ts: Date.now(),
source: 'session-restore-hook',
enabled: { spinner: enableSpinner, announcements: enableAnnouncements },
}, null, 2),
{ encoding: 'utf-8', mode: 0o600 }
);
} catch { /* ignore — best effort */ }
// Notification tells user exactly what happened. stderr so it doesn't
// corrupt any downstream JSON stdout consumer.
try {
const parts = [];
if (enableSpinner) parts.push('spinner verbs');
if (enableAnnouncements) parts.push('startup announcements');
const disableCmds = [];
if (enableSpinner) disableCmds.push('`ruflo spinner disable`');
if (enableAnnouncements) disableCmds.push('`ruflo announcements disable`');
process.stderr.write(
'[ruflo] First-run: enabled ' + parts.join(' + ') + '. ' +
'Disable anytime with ' + disableCmds.join(' / ') + '. ' +
(enableSpinner && !enableAnnouncements
? 'Announcements stay opt-in — set RUFLO_AUTO_ENABLE_ANNOUNCEMENTS=1 to enable those too. '
: '') +
'Restart Claude Code once to see the changes take effect.\n'
);
} catch { /* ignore */ }
} catch { /* auto-enable must never break session-restore */ }
}
// Same fallback-aware pattern as spawnDetachedFunnelRefresh() above, for
// ADR-316's co-pilot advisor tip. Safe to call on EVERY session-restore:
// refresh-advisor's own action checks consent + a 24h TTL BEFORE spending
@@ -190,6 +286,31 @@ async function readStdin() {
});
}
function claimSideEffectEvent(family, stdinData, event) {
if (/^(1|true|yes|on)$/i.test(process.env.RUFLO_DISABLE_HOOK_DEDUP || '')) return true;
try {
const crypto = require('crypto');
const eventId = event?.tool_use_id || event?.toolUseId ||
event?.session_id || event?.sessionId || event?.hook_event_id;
const payloadIdentity = eventId
? `event:${eventId}`
: `payload:${(stdinData || '').trim()}|bucket:${Math.floor(Date.now() / 2000)}`;
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const digest = crypto.createHash('sha256')
.update(`ruflo-hook-dedup-v1\0${path.resolve(projectRoot)}\0${family}\0${payloadIdentity}`)
.digest('hex');
const dir = process.env.RUFLO_HOOK_DEDUP_DIR ||
path.join(os.tmpdir(), 'ruflo-hook-dedup-v1');
fs.mkdirSync(dir, { recursive: true });
const fd = fs.openSync(path.join(dir, digest), 'wx', 0o600);
fs.writeFileSync(fd, String(Date.now()));
fs.closeSync(fd);
return true;
} catch (error) {
return error?.code === 'EEXIST' ? false : true;
}
}
async function main() {
// Global safety timeout: hooks must NEVER hang (#1530, #1531)
const safetyTimer = setTimeout(() => {
@@ -206,6 +327,11 @@ async function main() {
try { hookInput = JSON.parse(stdinData); } catch (e) { /* ignore parse errors */ }
}
if ((command === 'post-edit' || command === 'session-end') &&
!claimSideEffectEvent(command, stdinData, hookInput)) {
return;
}
// Normalize snake_case/camelCase: Claude Code sends tool_input/tool_name (snake_case)
const toolInput = hookInput.toolInput || hookInput.tool_input || {};
const toolName = hookInput.toolName || hookInput.tool_name || '';
@@ -349,6 +475,11 @@ const handlers = {
},
'session-restore': async () => {
// ADR-318/319 first-run auto-enable — fire once per install, never
// re-fires after user disables. Respects RUFLO_NO_AUTO_ENABLE + CI.
// Fully non-blocking (detached spawn) so session-restore latency
// is unchanged.
firstRunAutoEnableIfEligible();
if (session) {
// Try restore first, fall back to start
const existing = session.restore && session.restore();
+47 -4
View File
@@ -17,12 +17,30 @@
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(process.cwd(), '.claude-flow', 'data');
function resolveProjectRoot(startDir) {
if (process.env.CLAUDE_PROJECT_DIR) {
return path.resolve(process.env.CLAUDE_PROJECT_DIR);
}
let dir = path.resolve(startDir || process.cwd());
while (true) {
if (fs.existsSync(path.join(dir, '.git')) ||
fs.existsSync(path.join(dir, '.claude-flow'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) return path.resolve(startDir || process.cwd());
dir = parent;
}
}
const PROJECT_ROOT = resolveProjectRoot(process.cwd());
const DATA_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'data');
const STORE_PATH = path.join(DATA_DIR, 'auto-memory-store.json');
const GRAPH_PATH = path.join(DATA_DIR, 'graph-state.json');
const RANKED_PATH = path.join(DATA_DIR, 'ranked-context.json');
const PENDING_PATH = path.join(DATA_DIR, 'pending-insights.jsonl');
const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions');
const LEGACY_PENDING_PATH = path.join(process.cwd(), '.claude-flow', 'data', 'pending-insights.jsonl');
const SESSION_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'sessions');
const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
// ── Safety limits (fixes #1530, #1531) ─────────────────────────────────────
@@ -47,6 +65,22 @@ const STOP_WORDS = new Set([
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
// Recover signal written by older helpers under a subdirectory cwd. Keep
// the legacy file intact and append only lines not already present.
if (path.resolve(LEGACY_PENDING_PATH) !== path.resolve(PENDING_PATH) &&
fs.existsSync(LEGACY_PENDING_PATH)) {
try {
const existing = fs.existsSync(PENDING_PATH)
? new Set(fs.readFileSync(PENDING_PATH, 'utf-8').split('\n').filter(Boolean))
: new Set();
const recovered = fs.readFileSync(LEGACY_PENDING_PATH, 'utf-8')
.split('\n')
.filter(line => line && !existing.has(line));
if (recovered.length > 0) {
fs.appendFileSync(PENDING_PATH, recovered.join('\n') + '\n', 'utf-8');
}
} catch { /* migration is best-effort; never block hook execution */ }
}
}
function readJSON(filePath) {
@@ -300,7 +334,7 @@ function buildEdges(entries) {
*/
function bootstrapFromMemoryFiles() {
const entries = [];
const cwd = process.cwd();
const cwd = PROJECT_ROOT;
// Search for auto-memory directories
const candidates = [
@@ -1032,7 +1066,16 @@ function stats(outputJson) {
return report;
}
module.exports = { init, getContext, recordEdit, feedback, consolidate, stats };
module.exports = {
init,
getContext,
recordEdit,
feedback,
consolidate,
stats,
resolveProjectRoot,
projectRoot: PROJECT_ROOT,
};
// ── CLI entrypoint ──────────────────────────────────────────────────────────
if (require.main === module) {
@@ -20,6 +20,7 @@ on:
- 'v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json'
- 'v3/@claude-flow/cli/scripts/sign-helpers.mjs'
- 'v3/@claude-flow/cli/scripts/verify-helpers.mjs'
- 'scripts/smoke-helper-signing-security.mjs'
- 'v3/@claude-flow/cli/scripts/prepare-publish.mjs'
- 'v3/@claude-flow/cli/package.json'
- '.github/workflows/helpers-manifest-guard.yml'
@@ -29,6 +30,7 @@ on:
- 'v3/@claude-flow/cli/.claude/helpers/**'
- 'v3/@claude-flow/cli/scripts/sign-helpers.mjs'
- 'v3/@claude-flow/cli/scripts/verify-helpers.mjs'
- 'scripts/smoke-helper-signing-security.mjs'
- 'v3/@claude-flow/cli/scripts/prepare-publish.mjs'
- 'v3/@claude-flow/cli/package.json'
@@ -50,6 +52,9 @@ jobs:
test -f v3/@claude-flow/cli/scripts/sign-helpers.mjs || { echo '::error::scripts/sign-helpers.mjs missing (#2593)'; exit 1; }
test -f v3/@claude-flow/cli/scripts/verify-helpers.mjs || { echo '::error::scripts/verify-helpers.mjs missing (#2593)'; exit 1; }
- name: Helper signing key transport + single-source guard
run: node scripts/smoke-helper-signing-security.mjs
# 2. prepublishOnly MUST invoke a preparation script that invokes BOTH
# sign-helpers and verify-helpers.
# This is the exact regression from #2593 — sign existed but was
+12 -11
View File
@@ -994,18 +994,19 @@ RUFLO_HELPERS_SIGNING_SECRET=ruflo-helpers-signing-key RUFLO_HELPERS_SIGNING_PRO
(`ruv-dev` also holds `ruflo-config-signing-key`; do not replace the existing
authenticated npm session with a token from another project.)
**Handling the signing key without leaking it (learned 2026-07-14, hard way):** when
sign-helpers.mjs runs via `execFileSync('gcloud', ...)` on Windows, Node fails to find
`gcloud` (needs the `.cmd` suffix), so the script bails and users reach for
`gcloud secrets versions access latest --secret=ruflo-helpers-signing-key` in the shell —
which by default prints the PEM to stdout, which becomes tool-call output in Claude
Code and lands in the session transcript. That happened, the key was leaked, GCP secret
v1 was destroyed and a fresh v2 was rotated in (commit 0052b1b06 / PR #2673). **Rules:**
**Handling the signing key without leaking it (learned 2026-07-14, hard way):**
an earlier Windows path invoked `gcloud` without its required `.cmd` suffix. The
fallback command printed the PEM into captured tool output and a session transcript.
GCP secret v1 was destroyed and a fresh v2 was rotated in (commit 0052b1b06 /
PR #2673). `sign-helpers.mjs` now selects `gcloud.cmd` on Windows and supports a
stdin-only fallback. **Rules:**
- NEVER invoke `gcloud secrets versions access` in a way that lets the payload reach
tool output. Always redirect to a file in the same command: `gcloud … > ~/.ruflo/helpers-signing.key 2>&1 | grep -v BEGIN`.
- On Windows, prefer `RUFLO_HELPERS_SIGNING_KEY=~/.ruflo/helpers-signing.key` over the
GCP env var, because the fallback file path doesn't go through the broken
`execFileSync('gcloud')` path.
tool output. Use the built-in `RUFLO_HELPERS_SIGNING_SECRET` path above, or pipe
directly into the signer:
`gcloud secrets versions access latest --secret=ruflo-helpers-signing-key --project=ruv-dev | node scripts/sign-helpers.mjs --stdin-key`.
- `--stdin-key` refuses interactive entry, validates Ed25519 key type, and never
echoes parser input. A local file via `RUFLO_HELPERS_SIGNING_KEY` remains the
air-gapped fallback.
- If a rotation IS needed, keep the private half in `~/.ruflo/helpers-signing.key`
only, print ONLY the public half (via `Ed25519 pub export` from Node crypto), upload
new private via `gcloud secrets versions add … --data-file=`, then
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-flow",
"version": "3.32.35",
"version": "3.32.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-flow",
"version": "3.32.35",
"version": "3.32.36",
"bundleDependencies": [
"@claude-flow/codex",
"@claude-flow/plugin-agent-federation",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-flow",
"version": "3.32.35",
"version": "3.32.36",
"workspaces": [
"v3/@claude-flow/codex",
"v3/@claude-flow/plugin-agent-federation",
@@ -1,7 +1,7 @@
{
"name": "ruflo-core",
"description": "Foundation plugin — registers the ruflo MCP server (300+ tools across memory/agentdb/embeddings/hooks/aidefence/neural/autopilot/browser/agent/swarm), provides 4 generalist agents (coder/researcher/reviewer/witness-curator), 4 first-run skills (discover-plugins/init-project/ruflo-doctor/witness), and a curated plugin-discovery catalog",
"version": "0.2.4",
"description": "Foundation plugin — registers the ruflo MCP server (300+ tools across memory/agentdb/embeddings/hooks/aidefence/neural/autopilot/browser/agent/swarm), provides 4 generalist agents (coder/researcher/reviewer/witness-curator), 5 skills (discover-plugins/init-project/ruflo-doctor/ruflo-status/witness), and a curated plugin-discovery catalog",
"version": "0.2.5",
"author": {
"name": "ruvnet",
"url": "https://github.com/ruvnet"
+4
View File
@@ -21,6 +21,10 @@ node plugins/ruflo-core/scripts/witness/regen.mjs \
# Verify against live tree
node plugins/ruflo-core/scripts/witness/verify.mjs --manifest verification.md.json
# Verify without dependencies or generated dist/ artifacts
node plugins/ruflo-core/scripts/witness/verify.mjs \
--manifest verification.md.json --source-only
# Temporal queries
node plugins/ruflo-core/scripts/witness/history.mjs --history verification-history.jsonl summary
node plugins/ruflo-core/scripts/witness/history.mjs --history verification-history.jsonl regressions
@@ -3,7 +3,7 @@ id: ADR-0001
title: ruflo-core plugin contract — pinning, MCP server contract, plugin-catalog discovery, smoke as contract
status: Accepted
date: 2026-05-04
updated: 2026-05-09
updated: 2026-07-29
authors:
- reviewer (Claude Code)
tags: [plugin, core, mcp, foundation, smoke-test]
@@ -13,14 +13,15 @@ tags: [plugin, core, mcp, foundation, smoke-test]
`ruflo-core` is the **foundation plugin**. Every other plugin (`ruflo-ruvector`, `ruflo-agentdb`, `ruflo-browser`, `ruflo-intelligence`, `ruflo-adr`, `ruflo-aidefence`, `ruflo-autopilot`, plus 25 others) depends on the MCP server it registers via `.mcp.json` and the orchestration patterns it documents.
Today's plugin (v0.1.0):
The current plugin contract (v0.2.5):
- `.claude-plugin/plugin.json:4` — `version: "0.1.0"`, keywords `mcp, orchestration, claude-code`
- `.claude-plugin/plugin.json` — `version: "0.2.5"`, foundation and discovery metadata
- `.mcp.json` — registers `ruflo` MCP server via `npx -y @claude-flow/cli@latest`
- `agents/` — 3 generalists (`coder`, `researcher`, `reviewer`)
- `skills/` — 3 first-run helpers (`init-project`, `ruflo-doctor`, `discover-plugins`)
- `commands/ruflo-status.md` — system status one-liner
- `README.md` — terse: "What's Included" + Configuration only
- `agents/` — 4 generalists (`coder`, `researcher`, `reviewer`, `witness-curator`)
- `skills/` — 5 native skills (`init-project`, `ruflo-doctor`, `ruflo-status`, `discover-plugins`, `witness`)
- `commands/` — Claude commands for `ruflo-status` and `witness`, each paired with a same-name Codex skill
- `README.md` — documents installation, compatibility, MCP contract, sibling
contracts, architecture decisions, and verification
The discover-plugins skill is a substantial asset — a curated 32-plugin catalog with decision guides. That stays.
@@ -34,7 +35,7 @@ What's missing matches the cadence we've established:
## Decision
### 1. Add this ADR (Proposed)
### 1. Maintain this ADR (Accepted)
`docs/adrs/0001-core-contract.md`. Cross-links the seven sibling ADRs.
@@ -53,18 +54,30 @@ Append:
### 4. Smoke contract (`scripts/smoke.sh`)
10 checks:
11 checks:
1. plugin.json declares `0.2.0` with the new keywords.
1. plugin.json declares `0.2.5` with the foundation keywords.
2. `.mcp.json` exists and registers a `ruflo` MCP server.
3. All 3 agents present (`coder`, `researcher`, `reviewer`) with valid frontmatter.
4. All 3 skills present (`init-project`, `ruflo-doctor`, `discover-plugins`) with valid frontmatter.
3. All 4 agents are present with valid frontmatter.
4. All 5 skills are present with valid frontmatter.
5. `discover-plugins` skill catalog references at least 25 sibling plugins (the curated catalog).
6. README pins to `@claude-flow/cli` v3.6.
7. README cross-references sibling contracts (namespace convention, 3-gate pattern, 4-step pipeline).
8. ADR-0001 exists with status `Proposed`.
8. ADR-0001 exists with status `Accepted`.
9. `commands/ruflo-status.md` invokes `doctor` and `status`.
10. No skill grants wildcard tool access.
11. Every Claude command has a same-name Codex skill or a documented explicit exemption.
### 5. Cross-host hook and command compatibility
- PreToolUse telemetry always runs.
- Cursor retains its `{"permission":"allow"}` response.
- Codex plugin hooks are positively detected through Codex-specific
`PLUGIN_ROOT` / `PLUGIN_DATA` variables and allow with exit 0 plus empty
stdout, avoiding an invalid bare permission object.
- `/ruflo-status` remains unchanged for Claude Code. Codex discovers the
same workflow through `skills/ruflo-status/SKILL.md`: run `doctor`, then
`status`; run `doctor --fix` only after an explicit repair request.
## Consequences
@@ -77,13 +90,14 @@ Append:
- `discover-plugins` catalog must be kept in sync as plugins are added. Today there are 33 plugins (including this one); the catalog covers ~32. Drift remediation is a separate, mechanical task.
**Neutral:**
- No new MCP tools, no new skills, no new agents. Documentation + smoke only.
- No new MCP tools or agents. The status skill exposes an existing command
workflow to Codex without changing the Claude command.
## Verification
```bash
bash plugins/ruflo-core/scripts/smoke.sh
# Expected: "10 passed, 0 failed"
# Expected: "11 passed, 0 failed"
```
## Related
@@ -99,4 +113,4 @@ bash plugins/ruflo-core/scripts/smoke.sh
## Implementation status
Plugin version v0.2.1 shipped and listed in marketplace.json. Source exists at `plugins/ruflo-core/`. Contract elements implemented: `.mcp.json` registers `ruflo` server via `npx -y @claude-flow/cli@latest`; plugin-catalog discovery skill present; 3 generalist agents shipped; smoke-as-contract gate defined in `scripts/smoke.sh`.
Plugin source version v0.2.5 is listed by path in `.claude-plugin/marketplace.json`, so the marketplace artifact includes the five native skills and host-aware hook shim. Contract elements implemented: `.mcp.json` registers the `ruflo` server via `npx -y @claude-flow/cli@latest`; plugin-catalog discovery and Codex-native status skills are present; four generalist agents ship; command/skill parity and the remaining inventory are enforced by `scripts/smoke.sh`.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"_note": "#1921/#2613/#2721 — hook commands invoke scripts/ruflo-hook.cjs (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, and always exits 0 so a CLI/install failure (e.g. arborist `Invalid Version` on npm 10.8.x) never surfaces an error in Claude Code or blocks a turn. PreToolUse commands additionally echo a valid `{\"permission\":\"allow\"}` verdict on stdout: Claude Code ignores it (JSON-shaped stdout is still valid), while Cursor (which imports third-party hooks under its stricter preToolUse contract and requires JSON-verdict stdout) receives a well-formed permission response and does not fail-closed. DO NOT revert to a bare `npx <pkg>@alpha hooks …` per fire.",
"_note": "#1921/#2613/#2721/#2816 — hook commands invoke scripts/ruflo-hook.cjs (resilient shim): prefers a locally-installed `ruflo`/`claude-flow` binary, falls back to `npx --prefer-offline`, and always exits 0 so a CLI/install failure (e.g. arborist `Invalid Version` on npm 10.8.x) never surfaces an error or blocks a turn. PreToolUse commands emit Cursor's valid `{\"permission\":\"allow\"}` verdict except when Codex's plugin-only PLUGIN_ROOT/PLUGIN_DATA variables are present; Codex unconditional allow is exit 0 with empty stdout. Telemetry runs in both paths. DO NOT revert to a bare `npx <pkg>@alpha hooks …` per fire.",
"_platform_note": "#2721 — every command below is a `node -e` bootstrap: it resolves this plugin's scripts/ruflo-hook.cjs from process.env.CLAUDE_PLUGIN_ROOT INSIDE Node (no shell `${VAR}`/`%VAR%` expansion, so the exact same command string runs unchanged on Windows, macOS, and Linux), then requires it with process.argv pre-seeded for the target subcommand. No bash, sh, cmd.exe, jq, or .sh scripts are involved.",
"hooks": {
"PreToolUse": [
@@ -0,0 +1,9 @@
#!/usr/bin/env node
'use strict';
// Execution fixture for test-hooks.mjs. The hook shim invokes CLI commands as
// `<cli> hooks <subcommand> ...`; surfacing that subcommand proves telemetry
// was attempted without coupling the host-protocol regression to CLI startup.
const [, , group, subcommand] = process.argv;
if (group !== 'hooks' || !subcommand) process.exit(2);
process.stdout.write(`hook-telemetry:${subcommand}`);
+58 -7
View File
@@ -11,8 +11,10 @@
* every OS (see the `node -e` bootstrap command in ../hooks/hooks.json).
* It replicates, in pure Node with no shell/jq dependency:
* - modify-bash / modify-file (PreToolUse) — best-effort CLI call, then
* ALWAYS echo `{"permission":"allow"}` on stdout (Cursor's PreToolUse
* contract requires valid-JSON stdout; Claude Code ignores it).
* emit `{"permission":"allow"}` for Cursor/Claude compatibility. Codex
* plugin hooks are detected by their Codex-specific PLUGIN_ROOT /
* PLUGIN_DATA variables and intentionally receive empty stdout: a bare
* Cursor permission object is not valid Codex hook JSON (#2816).
* - post-command / post-edit (PostToolUse) — parse the hook event JSON
* from stdin (no jq), extract the same fields the bash version pulled
* with jq, and forward them as CLI flags.
@@ -37,6 +39,9 @@
const { spawnSync, execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
/** Exit 0 unconditionally — hooks must never block a turn */
function done() {
@@ -165,6 +170,46 @@ function parseEventJson(stdinData) {
}
}
/**
* Project-installed hooks and marketplace-plugin hooks can receive the same
* event. Claim side-effecting events atomically so post-edit learning and
* session-end consolidation execute exactly once (#2640).
*/
function claimSideEffectEvent(family, stdinData, event) {
if (/^(1|true|yes|on)$/i.test(process.env.RUFLO_DISABLE_HOOK_DEDUP || '')) return true;
try {
const eventId = event?.tool_use_id || event?.toolUseId ||
event?.session_id || event?.sessionId || event?.hook_event_id;
const payloadIdentity = eventId
? `event:${eventId}`
: `payload:${(stdinData || '').trim()}|bucket:${Math.floor(Date.now() / 2000)}`;
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const digest = crypto.createHash('sha256')
.update(`ruflo-hook-dedup-v1\0${path.resolve(projectRoot)}\0${family}\0${payloadIdentity}`)
.digest('hex');
const dir = process.env.RUFLO_HOOK_DEDUP_DIR ||
path.join(os.tmpdir(), 'ruflo-hook-dedup-v1');
fs.mkdirSync(dir, { recursive: true });
const fd = fs.openSync(path.join(dir, digest), 'wx', 0o600);
fs.writeFileSync(fd, String(Date.now()));
fs.closeSync(fd);
return true;
} catch (error) {
return error?.code === 'EEXIST' ? false : true;
}
}
/**
* Codex sets PLUGIN_ROOT and PLUGIN_DATA for plugin-bundled hooks, in
* addition to the cross-host CLAUDE_PLUGIN_* compatibility variables.
* Cursor and Claude Code use CLAUDE_PLUGIN_ROOT without these Codex-specific
* variables. Keep this positive Codex check narrow so existing Cursor
* installations retain their permission response.
*/
function isCodexPluginHost() {
return Boolean(process.env.PLUGIN_ROOT || process.env.PLUGIN_DATA);
}
/**
* PreCompact guidance text — matches the bash `echo` lines verbatim.
* Not a CLI call at all; pure stdout guidance for the transcript/context.
@@ -214,10 +259,15 @@ function main() {
}
const stdinData = readStdinRaw();
const event = parseEventJson(stdinData);
if ((subcommand === 'post-edit' || subcommand === 'session-end') &&
!claimSideEffectEvent(subcommand, stdinData, event)) {
done();
}
// PostToolUse: derive CLI flags from the hook event JSON (replaces jq).
if (subcommand === 'post-command') {
const event = parseEventJson(stdinData);
const cmd = event?.tool_input?.command;
if (!cmd) done(); // bash version: `[ -z "$CMD" ] && exit 0`
const exitCode = event?.tool_response?.exit_code ?? 0;
@@ -225,18 +275,19 @@ function main() {
done();
}
if (subcommand === 'post-edit') {
const event = parseEventJson(stdinData);
const file = event?.tool_input?.file_path ?? event?.tool_input?.path;
if (!file) done(); // bash version: `[ -z "$FILE" ] && exit 0`
invokeCli('post-edit', ['-f', String(file), '-s', 'true'], stdinData);
done();
}
// PreToolUse: best-effort CLI call, then ALWAYS echo the permission verdict
// (Cursor's stricter preToolUse contract requires valid-JSON stdout).
// PreToolUse: telemetry always runs. Cursor retains its permission verdict;
// Codex unconditional allow is exit 0 with empty stdout (#2816).
if (subcommand === 'modify-bash' || subcommand === 'modify-file') {
invokeCli(subcommand, [], stdinData);
process.stdout.write('{"permission":"allow"}');
if (!isCodexPluginHost()) {
process.stdout.write('{"permission":"allow"}');
}
done();
}
+23 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Structural smoke test for ruflo-core v0.2.4 (ADR-0001).
# Structural smoke test for ruflo-core v0.2.5 (ADR-0001).
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PASS=0
@@ -8,10 +8,10 @@ step() { printf "→ %s ... " "$1"; }
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
step "1. plugin.json declares 0.2.4 with new keywords"
step "1. plugin.json declares 0.2.5 with new keywords"
v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [[ "$v" != "0.2.4" ]]; then
bad "expected 0.2.4, got '$v'"
if [[ "$v" != "0.2.5" ]]; then
bad "expected 0.2.5, got '$v'"
else
miss=""
for k in foundation mcp-server plugin-catalog discovery; do
@@ -28,9 +28,9 @@ else
bad ".mcp.json missing or no ruflo server registration"
fi
step "3. all 3 agents present with valid frontmatter"
step "3. all 4 agents present with valid frontmatter"
miss=""
for a in coder researcher reviewer; do
for a in coder researcher reviewer witness-curator; do
f="$ROOT/agents/$a.md"
[[ -f "$f" ]] || { miss="$miss missing-$a"; continue; }
for k in 'name:' 'description:'; do
@@ -39,9 +39,9 @@ for a in coder researcher reviewer; do
done
[[ -z "$miss" ]] && ok || bad "$miss"
step "4. all 3 skills present with valid frontmatter"
step "4. all 5 skills present with valid frontmatter"
miss=""
for s in init-project ruflo-doctor discover-plugins; do
for s in init-project ruflo-doctor ruflo-status discover-plugins witness; do
f="$ROOT/skills/$s/SKILL.md"
[[ -f "$f" ]] || { miss="$miss missing-$s"; continue; }
for k in 'name:' 'description:' 'allowed-tools:'; do
@@ -91,5 +91,20 @@ for f in "$ROOT"/skills/*/SKILL.md; do
done
[[ -z "$bad_skills" ]] && ok || bad "wildcard:$bad_skills"
step "11. every Claude command has a same-name Codex skill"
miss=""
# Explicit exemptions must be recorded here as "command: reason". None exist.
command_skill_exemptions=()
for command_file in "$ROOT"/commands/*.md; do
command_name="$(basename "$command_file" .md)"
[[ -f "$ROOT/skills/$command_name/SKILL.md" ]] && continue
exempt=""
for entry in "${command_skill_exemptions[@]}"; do
[[ "$entry" == "$command_name: "* ]] && exempt="$entry"
done
[[ -n "$exempt" ]] || miss="$miss $command_name"
done
[[ -z "$miss" ]] && ok || bad "commands missing same-name skills:$miss"
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]] || exit 1
+100 -20
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Regression guard for ruvnet/ruflo#1859, #1862, #2721.
* Regression guard for ruvnet/ruflo#1859, #1862, #2721, #2816.
*
* Drives every hook command from `hooks/hooks.json` — PreToolUse,
* PostToolUse, PreCompact, Stop — with synthetic Claude-Code-style stdin,
@@ -24,8 +24,9 @@
* - Exit code 0 (no parser errors like "Invalid value for --format")
* - Output records the *intended* value (the file path / command), not a
* stray boolean like "true" — the symptom that #1859 reported
* - PreToolUse hooks always emit valid `{"permission":"allow"}` JSON on
* stdout (Cursor's stricter PreToolUse contract, #2613)
* - Cursor PreToolUse hooks emit valid `{"permission":"allow"}` JSON
* - Codex PreToolUse hooks exit 0 with empty stdout, while both telemetry
* subcommands are still invoked (#2816)
* - PostToolUse hooks silently no-op (exit 0, no CLI call) when the
* expected field is missing from the event JSON
* - Malformed / empty stdin never causes a nonzero exit
@@ -37,14 +38,16 @@
* (windows-latest, macos-latest, ubuntu-latest).
*/
import { readFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..');
const HOOKS_JSON = join(PLUGIN_ROOT, 'hooks', 'hooks.json');
const HOOK_RECORDER = join(__dirname, 'fixtures', 'hook-cli-recorder.cjs');
// `cliInvoke` is the literal token-string that should run the CLI — caller
// passes the full thing so this script doesn't need to guess shebangs:
@@ -79,18 +82,23 @@ const cmdStop = findHook('Stop', undefined);
let failed = 0;
const cases = [];
const run = (name, cmd, stdin, assertions) => {
const run = (name, cmd, stdin, assertions, options = {}) => {
const env = {
...process.env,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
RUFLO_HOOK_CLI_OVERRIDE: options.cliOverride ?? cliInvoke,
RUFLO_HOOK_SKIP_NPX: '1',
...options.env,
};
for (const key of options.unsetEnv ?? []) delete env[key];
if (options.debug !== false) env.RUFLO_HOOK_DEBUG_STDOUT = '1';
else delete env.RUFLO_HOOK_DEBUG_STDOUT;
const r = spawnSync(cmd, {
shell: true,
input: stdin,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
RUFLO_HOOK_CLI_OVERRIDE: cliInvoke,
RUFLO_HOOK_SKIP_NPX: '1',
RUFLO_HOOK_DEBUG_STDOUT: '1',
},
env,
// A clean CI runner may spend more than 15 seconds starting the CLI and
// policy/memory backends for its first Edit hook. Keep a finite timeout,
// but allow that measured cold-start path to complete.
@@ -103,6 +111,9 @@ const run = (name, cmd, stdin, assertions) => {
for (const a of assertions) {
if (a.contains && !combined.includes(a.contains)) errors.push(`missing "${a.contains}" in output`);
if (a.absent && combined.includes(a.absent)) errors.push(`unexpected "${a.absent}" in output`);
if (Object.hasOwn(a, 'stdoutEquals') && (r.stdout ?? '') !== a.stdoutEquals) {
errors.push(`stdout was ${JSON.stringify(r.stdout ?? '')}, expected ${JSON.stringify(a.stdoutEquals)}`);
}
}
if (errors.length === 0) {
console.log(`ok: ${name}`);
@@ -119,25 +130,62 @@ const run = (name, cmd, stdin, assertions) => {
};
// --- PreToolUse: modify-bash / modify-file ---
run('PreToolUse (Bash) always emits permission-allow JSON',
const cursorEnv = { unsetEnv: ['PLUGIN_ROOT', 'PLUGIN_DATA'] };
const codexEnv = {
env: {
PLUGIN_ROOT,
PLUGIN_DATA: join(PLUGIN_ROOT, '.test-data'),
},
cliOverride: `${process.execPath} ${HOOK_RECORDER}`,
};
run('Cursor PreToolUse (Bash) emits permission-allow JSON',
cmdModifyBash,
'{"tool_input":{"command":"echo hi"}}',
[{ contains: '{"permission":"allow"}' }]);
[{ contains: '{"permission":"allow"}' }],
cursorEnv);
run('PreToolUse (Edit) always emits permission-allow JSON',
run('Cursor PreToolUse (Edit) emits permission-allow JSON',
cmdModifyFile,
'{"tool_input":{"file_path":"/tmp/foo.ts"}}',
[{ contains: '{"permission":"allow"}' }]);
[{ contains: '{"permission":"allow"}' }],
cursorEnv);
run('PreToolUse (Bash) emits permission-allow even with empty stdin',
run('Cursor PreToolUse (Bash) emits permission-allow even with empty stdin',
cmdModifyBash,
'',
[{ contains: '{"permission":"allow"}' }]);
[{ contains: '{"permission":"allow"}' }],
cursorEnv);
run('PreToolUse (Bash) emits permission-allow even with malformed JSON',
run('Cursor PreToolUse (Bash) emits permission-allow even with malformed JSON',
cmdModifyBash,
'{not json',
[{ contains: '{"permission":"allow"}' }]);
[{ contains: '{"permission":"allow"}' }],
cursorEnv);
run('Codex PreToolUse (Bash) exits 0 with empty stdout',
cmdModifyBash,
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}',
[{ stdoutEquals: '' }],
{ ...codexEnv, debug: false });
run('Codex PreToolUse (Edit) exits 0 with empty stdout',
cmdModifyFile,
'{"hook_event_name":"PreToolUse","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch"}}',
[{ stdoutEquals: '' }],
{ ...codexEnv, debug: false });
run('Codex PreToolUse (Bash) still invokes telemetry',
cmdModifyBash,
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}',
[{ contains: 'hook-telemetry:modify-bash' }, { absent: '{"permission":"allow"}' }],
codexEnv);
run('Codex PreToolUse (Edit) still invokes telemetry',
cmdModifyFile,
'{"hook_event_name":"PreToolUse","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch"}}',
[{ contains: 'hook-telemetry:modify-file' }, { absent: '{"permission":"allow"}' }],
codexEnv);
// --- PostToolUse: post-edit ---
run('Edit hook records file_path (regression #1859: was "true")',
@@ -208,5 +256,37 @@ run('Stop hook runs session-end without error',
'{}',
[{ absent: 'Required option missing' }, { absent: 'Invalid value' }]);
// #2640 — project settings + marketplace plugin can dispatch the same event.
// The shared atomic claim makes the second side-effecting invocation a no-op.
{
const dedupDir = mkdtempSync(join(tmpdir(), 'ruflo-hook-dedup-test-'));
const payload = '{"tool_use_id":"toolu_dedup_2640","tool_input":{"file_path":"/tmp/dedup.ts"}}';
const env = {
...process.env,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
CLAUDE_PROJECT_DIR: PLUGIN_ROOT,
RUFLO_HOOK_CLI_OVERRIDE: `${process.execPath} ${HOOK_RECORDER}`,
RUFLO_HOOK_DEBUG_STDOUT: '1',
RUFLO_HOOK_SKIP_NPX: '1',
RUFLO_HOOK_DEDUP_DIR: dedupDir,
};
try {
const first = spawnSync(cmdPostEdit, { shell: true, input: payload, encoding: 'utf8', env });
const second = spawnSync(cmdPostEdit, { shell: true, input: payload, encoding: 'utf8', env });
const ok = first.status === 0 && second.status === 0 &&
(first.stdout || '').includes('hook-telemetry:post-edit') &&
!(second.stdout || '').includes('hook-telemetry:post-edit');
if (ok) {
console.log('ok: duplicate post-edit event executes side effects exactly once');
} else {
console.error('FAIL: duplicate post-edit event was not deduplicated');
failed++;
}
cases.push('duplicate post-edit event executes side effects exactly once');
} finally {
rmSync(dedupDir, { recursive: true, force: true });
}
}
console.log(`\n${cases.length - failed}/${cases.length} passed`);
process.exit(failed === 0 ? 0 : 1);
+86 -87
View File
@@ -5,23 +5,26 @@
* Project-agnostic — works without ruflo CLI being installed.
*
* Usage:
* node verify.mjs --manifest <path> [--root <path>] [--json]
* node verify.mjs --manifest <path> [--root <path>] [--source-only] [--json]
*
* Exit codes:
* 0 — signature valid + all fixes pass or drift (marker present)
* 1 — signature invalid OR any fix regressed/missing (real failure)
* 2 — bad arguments / file not found OR precondition not met
* (e.g. @noble/ed25519 not installed, or dist files not built —
* source-only checkout without `npm ci && npm run build`).
* (e.g. dist files not built during full-tree verification).
* Issue #1880: scheduled runners use this to distinguish a
* "needs install+build" environment from a real verification
* failure, so we stop filing recurring issues on every cron run.
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, join, sep } from 'node:path';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { resolve, join } from 'node:path';
import {
createHash,
createPrivateKey,
createPublicKey,
verify as verifyEd25519,
} from 'node:crypto';
import { fileSha256, fileContains } from './lib.mjs';
const args = parseArgs(process.argv.slice(2));
@@ -32,28 +35,33 @@ if (!existsSync(manifestPath)) { console.error(`not found: ${manifestPath}`); pr
const repoRoot = resolve(args.root ?? process.cwd());
const asJson = !!args.json;
const sourceOnly = !!args['source-only'];
const witness = JSON.parse(readFileSync(manifestPath, 'utf8'));
// ─── signature ────────────────────────────────────────────────────
const sig = await verifySignature(witness, repoRoot);
const sig = verifySignature(witness);
// Issue #1880 — if @noble/ed25519 isn't installed, this is a
// precondition failure, not a verification failure. Exit 2 so the
// scheduled runner can distinguish "install needed" from a real
// regression and stop filing duplicate issues every 12 hours.
if (sig.reason === 'noble-ed25519-not-installed') {
if (asJson) {
console.log(JSON.stringify(
{ ok: false, precondition: 'noble-ed25519-not-installed', signature: sig },
null, 2
));
}
// ─── per-fix marker check ─────────────────────────────────────────
const allFixes = witness.manifest.fixes;
const selectedFixes = sourceOnly
? allFixes.filter((fix) => !isGeneratedDistPath(fix.file))
: allFixes;
const skippedGenerated = allFixes.length - selectedFixes.length;
if (sourceOnly && selectedFixes.length === 0) {
const result = {
ok: false,
scope: 'source-only',
precondition: 'no-source-entries',
signature: sig,
};
if (asJson) console.log(JSON.stringify(result, null, 2));
else console.error('verify.mjs: manifest contains no source entries to verify');
process.exit(2);
}
// ─── per-fix marker check ─────────────────────────────────────────
const fileResults = witness.manifest.fixes.map((fix) => {
const fileResults = selectedFixes.map((fix) => {
const installed = join(repoRoot, fix.file);
if (!existsSync(installed)) {
return { ...fix, status: 'missing', sha256Match: false, markerPresent: false };
@@ -71,6 +79,7 @@ const summary = {
drift: fileResults.filter(r => r.status === 'drift').length,
regressed: fileResults.filter(r => r.status === 'regressed').length,
missing: fileResults.filter(r => r.status === 'missing').length,
skippedGenerated,
};
// Issue #1880 / #2528 — heuristic: if the only missing entries are
@@ -83,16 +92,12 @@ const allMissing = fileResults.length > 0
&& summary.missing === fileResults.length;
const missingResults = fileResults.filter(r => r.status === 'missing');
const missingOnlyDist = missingResults.length > 0
&& missingResults.every(r => r.file && (
r.file.includes(`${sep}dist${sep}`) || r.file.includes('/dist/')
));
const referencesDist = fileResults.some(r => r.file && (
r.file.includes(`${sep}dist${sep}`) || r.file.includes('/dist/')
));
if ((allMissing && referencesDist) || (missingOnlyDist && summary.regressed === 0)) {
&& missingResults.every(r => isGeneratedDistPath(r.file));
const referencesDist = fileResults.some(r => isGeneratedDistPath(r.file));
if (!sourceOnly && ((allMissing && referencesDist) || (missingOnlyDist && summary.regressed === 0))) {
if (asJson) {
console.log(JSON.stringify(
{ ok: false, precondition: 'dist-not-built', signature: sig, summary },
{ ok: false, scope: 'full', precondition: 'dist-not-built', signature: sig, summary },
null, 2
));
} else {
@@ -112,14 +117,19 @@ const ok = sig.signatureValid && sig.manifestHashOk && sig.publicKeyReproducible
&& summary.regressed === 0 && summary.missing === 0;
if (asJson) {
console.log(JSON.stringify({ ok, signature: sig, summary, results: fileResults }, null, 2));
console.log(JSON.stringify(
{ ok, scope: sourceOnly ? 'source-only' : 'full', signature: sig, summary, results: fileResults },
null,
2
));
} else {
console.log(`Verification scope: ${sourceOnly ? 'source-only' : 'full'}`);
console.log('Manifest signature:');
console.log(` hash matches: ${sig.manifestHashOk ? 'yes' : 'NO'}`);
console.log(` public key reproducible: ${sig.publicKeyReproducible ? 'yes' : 'NO'}`);
console.log(` Ed25519 signature valid: ${sig.signatureValid ? 'yes' : 'NO'}`);
console.log('');
console.log(`Summary: pass=${summary.pass} drift=${summary.drift} regressed=${summary.regressed} missing=${summary.missing}`);
console.log(`Summary: pass=${summary.pass} drift=${summary.drift} regressed=${summary.regressed} missing=${summary.missing} skipped-generated=${summary.skippedGenerated}`);
if (summary.regressed > 0) {
console.log('\nRegressed:');
for (const r of fileResults.filter(r => r.status === 'regressed')) {
@@ -137,71 +147,60 @@ if (asJson) {
process.exit(ok ? 0 : 1);
// ─── ed25519 helpers ─────────────────────────────────────────────
async function verifySignature(witness, repoRoot) {
// Probe multiple plausible install roots — pnpm's isolated linker
// doesn't hoist transitive deps to v3/node_modules, so we also check
// workspace packages that declare @noble/ed25519 directly. A user's
// flat npm install satisfies the first probe; pnpm satisfies the latter.
let ed;
let probeErr;
const probes = [
repoRoot,
join(repoRoot, 'v3'),
join(repoRoot, 'v3/@claude-flow/cli'),
join(repoRoot, 'v3/@claude-flow/plugin-agent-federation'),
];
for (const root of probes) {
try { ed = createRequire(join(root, 'noop.js'))('@noble/ed25519'); break; }
catch (e) { probeErr = e; }
}
if (!ed) {
// ruflo#1880 — the scheduled 12h verification has bounced off this
// 6+ times. Spell out the fix in the error message instead of
// leaving the operator to chase it.
console.error(
`verify.mjs: could not load @noble/ed25519 from any of:\n` +
` ${probes.join('\n ')}\n` +
` last error: ${probeErr?.message ?? '?'}\n` +
`\n` +
`Fix: from the repo root, run \`npm install\` (the dep is declared\n` +
`in the root package.json under @noble/ed25519). If your runner\n` +
`is a source-only checkout, your verification pipeline must run\n` +
`\`npm ci && npm run build\` before invoking this script. See #1880\n` +
`for the full diagnosis.`
function verifySignature(witness) {
try {
const recomputed = createHash('sha256').update(JSON.stringify(witness.manifest)).digest('hex');
const manifestHashOk = recomputed === witness.integrity.manifestHash;
const seed = createHash('sha256')
.update(witness.manifest.gitCommit + ':ruflo-witness/v1')
.digest();
// RFC 8410 DER wrappers let Node/OpenSSL derive and verify Ed25519 keys
// without a package install. The witness format remains byte-compatible
// with manifests produced by @noble/ed25519.
const privateKey = createPrivateKey({
key: Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'),
seed,
]),
format: 'der',
type: 'pkcs8',
});
const reproducedKey = createPublicKey(privateKey)
.export({ format: 'der', type: 'spki' })
.subarray(-32);
const publicKeyReproducible =
reproducedKey.toString('hex') === witness.integrity.publicKey;
const declaredKey = createPublicKey({
key: Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'),
Buffer.from(witness.integrity.publicKey, 'hex'),
]),
format: 'der',
type: 'spki',
});
const signatureValid = verifyEd25519(
null,
Buffer.from(witness.integrity.manifestHash, 'hex'),
declaredKey,
Buffer.from(witness.integrity.signature, 'hex'),
);
return { manifestHashOk, publicKeyReproducible, signatureValid, verifier: 'node:crypto' };
} catch (error) {
return {
manifestHashOk: false,
publicKeyReproducible: false,
signatureValid: false,
// Machine-parseable hint for the scheduled runner so it can
// distinguish "missing dep" from a real signature failure.
reason: 'noble-ed25519-not-installed',
verifier: 'node:crypto',
reason: 'signature-verification-error',
error: error instanceof Error ? error.message : String(error),
};
}
}
// noble/ed25519 v2 freezes `etc` and ships sync verify by default — the
// sha512Sync shim is only needed for v1. Guard the assignment so it works
// on both major versions (#2274).
if (!ed.etc.sha512Sync) {
try {
ed.etc.sha512Sync = (...m) => { const h = createHash('sha512'); for (const x of m) h.update(x); return h.digest(); };
} catch {
// v2 freezes `etc`; assignment is unnecessary because sha512Sync
// is already wired internally. Swallow the TypeError and continue.
}
}
const recomputed = createHash('sha256').update(JSON.stringify(witness.manifest)).digest('hex');
const manifestHashOk = recomputed === witness.integrity.manifestHash;
const seed = createHash('sha256').update(witness.manifest.gitCommit + ':ruflo-witness/v1').digest();
const reKey = ed.getPublicKey(seed);
const publicKeyReproducible = Buffer.from(reKey).toString('hex') === witness.integrity.publicKey;
const signatureValid = ed.verify(
Buffer.from(witness.integrity.signature, 'hex'),
Buffer.from(witness.integrity.manifestHash, 'hex'),
Buffer.from(witness.integrity.publicKey, 'hex'),
);
return { manifestHashOk, publicKeyReproducible, signatureValid };
function isGeneratedDistPath(file) {
return typeof file === 'string' && file.split(/[\\/]/).includes('dist');
}
function parseArgs(argv) {
@@ -0,0 +1,36 @@
---
name: ruflo-status
description: Diagnose Ruflo health, then report system, MCP server, and active-agent status without changing the installation
argument-hint: "[--fix]"
allowed-tools: Bash(npx *)
---
# Ruflo status
Use this skill when the user asks for Ruflo health, diagnostics, MCP server
state, or active-agent status.
## Default read-only workflow
Run these commands in order:
```bash
npx @claude-flow/cli@latest doctor
npx @claude-flow/cli@latest status
```
Summarize the diagnostics and status together. Do not repair, reset, start,
stop, install, or otherwise change anything during the default workflow.
## Explicit repair workflow
Only when the user explicitly asks to fix or auto-repair the installation,
replace the first command with:
```bash
npx @claude-flow/cli@latest doctor --fix
npx @claude-flow/cli@latest status
```
Report what the repair changed and the resulting status. Never infer
authorization for `doctor --fix` from a request to check or diagnose Ruflo.
+7 -1
View File
@@ -35,7 +35,7 @@ node plugins/ruflo-core/scripts/witness/init.mjs --root .
# while the fix is present. If someone reverts the fix, the marker
# disappears and `verify` reports it as `regressed`.
# Regenerate the manifest (signs with Ed25519 from current gitCommit)
# Regenerate the manifest (signing requires @noble/ed25519)
npm i @noble/ed25519
node plugins/ruflo-core/scripts/witness/regen.mjs \
--manifest verification.md.json \
@@ -45,6 +45,11 @@ node plugins/ruflo-core/scripts/witness/regen.mjs \
# Verify markers are present in the live tree
node plugins/ruflo-core/scripts/witness/verify.mjs \
--manifest verification.md.json
# Or authenticate the manifest and check source markers in a clean clone.
# Generated dist/ entries are explicitly reported as skipped.
node plugins/ruflo-core/scripts/witness/verify.mjs \
--manifest verification.md.json --source-only
```
## Temporal queries (ADR-103)
@@ -100,3 +105,4 @@ and before `publish`. Failure modes:
| `signatureValid: no` | manifest hand-edited; re-run regen |
| `regressed: > 0` | a documented fix lost its marker since issuance |
| `missing: > 0` | a cited dist file no longer exists; rebuild or remove the entry |
| `scope: source-only` | signature + source markers checked; generated entries intentionally skipped |
@@ -279,6 +279,7 @@ export function rankSeverity(s) {
* harness mcp-scan — <path>
*
* [INFO] No MCP security issues found
* [LOW ] Padded severity labels are also emitted by newer releases
* Policy is default-deny with safe capability grants and an audit log.
*
* Result: INFO (1 finding, 0 high)
@@ -293,7 +294,10 @@ export function parseMcpScanText(stdout) {
const lines = (stdout || '').split('\n');
let current = null;
for (const line of lines) {
const m = /^\s*\[([A-Z]+)\]\s+(.+?)\s*$/.exec(line);
// metaharness <=0.3.0 emitted `[LOW]`; newer releases pad labels to a
// fixed-width column (`[LOW ]`). Accept optional whitespace before the
// closing bracket so both installed versions remain compatible.
const m = /^\s*\[([A-Z]+)\s*\]\s+(.+?)\s*$/.exec(line);
if (m) {
if (current) findings.push(current);
current = { severity: m[1].toLowerCase(), message: m[2] };
+12 -13
View File
@@ -12,10 +12,9 @@
// - classifyDegraded() timeout vs not-available distinction, per-package
// reason prefix
// - injectJson() append --json unless caller opted out / present
// - parseTrailingJson() LAST-{...}-block extraction (the _darwin variant;
// the old _harness FIRST-block variant was a live
// bug — a progress line containing `{...}` would
// shadow the final structured result)
// - parseTrailingJson() complete trailing-object extraction (progress
// lines before the JSON are ignored, while nested
// objects remain part of the root payload)
// - ensureCachedInstall() one-time `npm install --prefix ~/.ruflo/<name>-cache-<pin>`
// of a PINNED range — generalizes _redblue.mjs /
// gepa.mjs; the versioned dir means pin bumps
@@ -69,18 +68,18 @@ export function injectJson(args, wantJson) {
/**
* Parse the trailing JSON object from mixed stdout. CLIs in this family emit
* human-readable progress first and a structured JSON object LAST — so grab
* the last parseable {...} block, falling back to a greedy whole-span match
* for nested objects the lazy regex fragments.
* human-readable progress first and a structured JSON object LAST.
*
* Trying candidate opening braces against the complete trailing substring is
* intentional. A regex that selects the "last parseable {...} block" returns
* the final nested finding from pretty-printed payloads instead of the root
* object, silently discarding findings[], worst, and other security fields.
*/
export function parseTrailingJson(stdout) {
const s = stdout || '';
const matches = [...s.matchAll(/\{[\s\S]*?\}/g)];
for (let i = matches.length - 1; i >= 0; i--) {
try { return JSON.parse(matches[i][0]); } catch { /* try previous */ }
const s = String(stdout || '').trimEnd();
for (let i = s.lastIndexOf('{'); i >= 0; i = s.lastIndexOf('{', i - 1)) {
try { return JSON.parse(s.slice(i)); } catch { /* try the enclosing object */ }
}
const greedy = /\{[\s\S]*\}/.exec(s);
if (greedy) { try { return JSON.parse(greedy[0]); } catch { return null; } }
return null;
}
@@ -40,18 +40,17 @@ function main() {
}
const r = runHarness(['mcp-scan', ARGS.path]);
if (r.degraded) { emitDegradedJsonAndExit(r.reason); return; }
if (r.exitCode !== 0 && r.exitCode !== 1) {
// exit 1 from harness can be "findings present"; only treat other
// non-zero as a real failure.
if (!r.json && r.exitCode !== 0 && r.exitCode !== 1) {
// Non-zero verdict exits are valid when accompanied by structured JSON.
// Without a payload, exit codes other than the historical findings=1
// remain invocation failures.
console.error(`mcp-scan: harness exited ${r.exitCode}`);
if (r.stderr) console.error(r.stderr.slice(0, 400));
process.exit(2);
}
// The JSON output shape from `harness mcp-scan` historically didn't
// include structured findings — it emits text even with --json. Parse
// the text into findings (iter 50) so audit-trend's introduced/cleared
// diff actually works. If a future upstream version DOES emit
// findings[], the parsed-text findings are overridden.
// Older `harness mcp-scan` releases emitted text even with --json. Keep
// the text parser as a backwards-compatible fallback, but prefer current
// structured findings when the upstream command supplies them.
const parsed = parseMcpScanText(r.stdout);
// iter 124 — normalize finding shape so consumers see a stable contract.
// Upstream metaharness@0.1.x JSON path emits {id, severity, title, detail};
+58 -2
View File
@@ -253,6 +253,47 @@ grep -q "bin.harness" "$F" 2>/dev/null || miss="$miss no-harness-bin"
grep -q "cwd: opts" "$F" || miss="$miss no-cwd-passthrough"
[[ -z "$miss" ]] && ok || bad "$miss"
step "17z80. HIGH security findings survive wrapper + composite boundaries (#2750)"
miss=""
SEC_FIXTURE=$(mktemp -d)
mkdir -p "$SEC_FIXTURE/.harness" "$SEC_FIXTURE/.claude"
printf '%s\n' '{"defaultDeny":false,"allowShell":true,"allowNetwork":true}' \
> "$SEC_FIXTURE/.harness/mcp-policy.json"
printf '%s\n' '{"permissions":{"allow":["mcp__*"],"deny":[]},"mcpServers":{"x":{"command":"bash"}}}' \
> "$SEC_FIXTURE/.claude/settings.json"
THREAT_OUT=$(node "$ROOT/scripts/threat-model.mjs" --path "$SEC_FIXTURE" --fail-on high --format json 2>/dev/null)
THREAT_EXIT=$?
SCAN_OUT=$(node "$ROOT/scripts/mcp-scan.mjs" --path "$SEC_FIXTURE" --fail-on high --format json 2>/dev/null)
SCAN_EXIT=$?
OIA_OUT=$(node "$ROOT/scripts/oia-audit.mjs" --path "$SEC_FIXTURE" --dry-run --format json 2>/dev/null)
OIA_EXIT=$?
[[ "$THREAT_EXIT" -eq 1 ]] || miss="$miss threat-exit-${THREAT_EXIT}"
[[ "$SCAN_EXIT" -eq 1 ]] || miss="$miss scan-exit-${SCAN_EXIT}"
[[ "$OIA_EXIT" -eq 0 ]] || miss="$miss oia-exit-${OIA_EXIT}"
printf '%s' "$THREAT_OUT" | node -e '
let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{
try { const x=JSON.parse(s); if(x.worst!=="high" || !x.alert?.triggered || x.findings?.length<1) process.exitCode=1; }
catch { process.exitCode=1; }
});
' || miss="$miss threat-lost-high"
printf '%s' "$SCAN_OUT" | node -e '
let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{
try { const x=JSON.parse(s); if(x.worst!=="high" || !x.alert?.triggered || x.findings?.length<1) process.exitCode=1; }
catch { process.exitCode=1; }
});
' || miss="$miss scan-lost-high"
printf '%s' "$OIA_OUT" | node -e '
let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{
try { const x=JSON.parse(s); if(x.composite?.worst!=="high" || x.components?.mcpScan?.json?.findings?.length<1) process.exitCode=1; }
catch { process.exitCode=1; }
});
' || miss="$miss oia-lost-high"
rm -f "$SEC_FIXTURE/.harness/mcp-policy.json" "$SEC_FIXTURE/.claude/settings.json"
rmdir "$SEC_FIXTURE/.harness" "$SEC_FIXTURE/.claude" "$SEC_FIXTURE"
[[ -z "$miss" ]] && ok || bad "$miss"
step "17z79. oia-audit-weekly.yml retains iter-108 hard-fail + iter-109 dispatch inputs (iter 116)"
miss=""
# Two load-bearing invariants accumulated in the weekly cron:
@@ -1398,9 +1439,24 @@ done
grep -q "export function rankSeverity\|return SEVERITY_RANK\[.*\] ?? 0" "$HARNESS" 2>/dev/null || miss="$miss no-safe-rank-lookup"
# Rationale documented at the new location
grep -q "NaN-compare hazard" "$HARNESS" 2>/dev/null || miss="$miss no-rationale-comment"
# Runtime: live oia-audit still produces clean (only INFO finding)
# Runtime: the live audit produces a recognized severity. Repository contents
# legitimately change the result (for example, current upstream reports LOW
# for unpinned ranges), so "clean" is not a stable compatibility invariant.
OUT=$(node "$OIA" --dry-run --format json 2>/dev/null)
echo "$OUT" | grep -q '"worst": "clean"' || miss="$miss live-not-clean"
printf '%s' "$OUT" | node -e '
let input = "";
process.stdin.on("data", chunk => { input += chunk; });
process.stdin.on("end", () => {
try {
const payload = JSON.parse(input);
if (payload.degraded === true) return;
const known = new Set(["clean", "info", "low", "medium", "warn", "high", "error", "critical"]);
if (!known.has(payload.composite?.worst)) process.exitCode = 1;
} catch {
process.exitCode = 1;
}
});
' || miss="$miss live-invalid-worst"
[[ -z "$miss" ]] && ok || bad "$miss"
step "17z24. doctor required-files include iter-53 + iter-56 surfaces (iter 61)"
@@ -26,6 +26,7 @@ import {
} from './_similarity.mjs';
// iter 64 — also test the iter-63 shared severity primitives
import { SEVERITY_RANK, rankSeverity, parseMcpScanText } from './_harness.mjs';
import { parseTrailingJson } from './_invoke.mjs';
const ARGS = (() => {
const a = { format: 'table' };
@@ -273,6 +274,22 @@ assert(single.summary?.overallSeverity === 'info',
assert(single.summary?.totalCount === 1,
'summary.totalCount === 1 from "(1 finding,"');
// Newer metaharness releases align severity labels to a fixed-width column.
// Keep parsing the legacy compact form above while accepting `[LOW ]`.
const padded = parseMcpScanText(`harness mcp-scan — /repo
[LOW ] 8 unpinned dependency range(s)
Floating ranges weaken supply-chain reproducibility.
Result: LOW (1 finding, 0 high)
`);
assert(padded.findings.length === 1, 'padded [LOW ] block → 1 finding');
assert(padded.findings[0].severity === 'low',
'padded severity lowercased without trailing whitespace');
assert(padded.findings[0].message.includes('8 unpinned dependency range(s)') &&
padded.findings[0].message.includes('supply-chain reproducibility'),
'padded finding message and continuation extracted');
// Continuation line — indented text appends to previous finding
const cont = parseMcpScanText(`
[HIGH] Exposed credential path
@@ -314,6 +331,27 @@ assert(mixed.findings.length === 1,
assert(mixed.findings[0].severity === 'high',
'strict regex captured the uppercase entry');
console.log('\nPhase 11 — complete trailing JSON extraction');
const nestedJson = parseTrailingJson(`progress: scanning {not-json}
{
"dir": "/repo",
"findings": [
{ "id": "allow-shell", "severity": "high" },
{ "id": "no-audit-log", "severity": "medium" }
],
"worst": "high"
}
`);
assert(nestedJson?.dir === '/repo', 'mixed stdout returns root JSON object');
assert(nestedJson?.findings?.length === 2,
'nested findings remain attached to root payload');
assert(nestedJson?.findings?.[1]?.id === 'no-audit-log',
'extractor does not return the last nested object');
assert(nestedJson?.worst === 'high', 'root-level severity is preserved');
assert(parseTrailingJson('progress only {not-json}') === null,
'non-JSON progress braces return null');
// ──────────────────────────────────────────────────────────────────
const summary = {
passed, failed,
@@ -26,7 +26,10 @@ function main() {
}
const r = runHarness(['threat-model', ARGS.path]);
if (r.degraded) { emitDegradedJsonAndExit(r.reason); return; }
if (r.exitCode !== 0 && r.exitCode !== 1) {
// Upstream uses exit 2 for a valid HIGH verdict. A parsed JSON payload is
// authoritative regardless of that domain exit code; only reject a
// non-zero invocation when it produced no structured result.
if (!r.json && r.exitCode !== 0 && r.exitCode !== 1) {
console.error(`threat-model: harness exited ${r.exitCode}`);
if (r.stderr) console.error(r.stderr.slice(0, 400));
process.exit(2);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ruflo",
"version": "3.32.35",
"version": "3.32.36",
"description": "Ruflo - Enterprise AI agent orchestration platform. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
"main": "bin/ruflo.js",
"type": "module",
@@ -40,7 +40,7 @@
"package:rvf": "bash src/scripts/package-rvf.sh"
},
"dependencies": {
"@claude-flow/cli": "^3.32.35"
"@claude-flow/cli": "^3.32.36"
},
"optionalDependencies": {},
"overrides": {
+3 -1
View File
@@ -1,4 +1,6 @@
FROM node:20-slim
# Node 24 is Active LTS through April 2028. Keep this pinned to a
# supported LTS line; test-security-lock.js fails if it drifts to EOL.
FROM node:24-slim
WORKDIR /app
@@ -11,6 +11,7 @@
* 4. `requireAuth` uses `timingSafeEqual` (constant-time compare)
* 5. `executeTool` denies terminal_execute unless `MCP_ENABLE_TERMINAL === "true"` (Phase 1d + 2a)
* 6. CORS respects `MCP_CORS_ORIGIN` allowlist (Phase 3b)
* 9. Both production images use the supported Node 24 LTS line (#2847)
*
* Runs offline (no server, no deps). CI-safe.
*
@@ -26,6 +27,10 @@ const BRIDGES = [
path.join(HERE, "index.js"), // ruflo/src/mcp-bridge/index.js
path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js"), // ruvocal variant (deployed)
];
const DOCKERFILES = [
path.join(HERE, "Dockerfile"),
path.join(HERE, "..", "ruvocal", "mcp-bridge", "Dockerfile"),
];
const CHECKS = [
{
@@ -91,6 +96,24 @@ for (const bridge of BRIDGES) {
}
}
for (const dockerfile of DOCKERFILES) {
if (!fs.existsSync(dockerfile)) {
console.error(`FAIL: Dockerfile missing: ${dockerfile}`);
hardFail = true;
continue;
}
const source = fs.readFileSync(dockerfile, "utf-8");
const rel = path.relative(path.join(HERE, "..", ".."), dockerfile);
console.log(`\n# ${rel}`);
if (/^FROM\s+node:24(?:[.-][^\s]+)?\s*$/m.test(source)) {
console.log(" ✓ 9. supported-node-lts-runtime (#2847)");
} else {
console.log(" ✗ 9. supported-node-lts-runtime (#2847)");
console.log(" hint: production images must use the supported node:24 LTS line");
hardFail = true;
}
}
if (hardFail) {
console.error("\nADR-166 security lock FAILED — a load-bearing control was removed or drifted.");
process.exit(1);
+3 -1
View File
@@ -1,4 +1,6 @@
FROM node:20-slim
# Node 24 is Active LTS through April 2028. Keep this pinned to a
# supported LTS line; test-security-lock.js fails if it drifts to EOL.
FROM node:24-slim
WORKDIR /app
+7 -6
View File
@@ -6,8 +6,9 @@
// structured findings from its plain-text output. That parser depends
// on TWO format invariants:
//
// 1. Finding lines: ` [SEV] <message>` (2-space indent, uppercase
// severity inside brackets, message on same line)
// 1. Finding lines: ` [SEV] <message>` or ` [SEV ] <message>`
// (uppercase severity inside brackets, optional alignment padding,
// message on same line)
//
// 2. Summary line: `Result: <SEV> (N finding, M high)`
//
@@ -81,14 +82,14 @@ async function main() {
const stdout = r.stdout || '';
const results = [];
// 3. INVARIANT 1: at least one finding line in the expected shape
// ` [SEV] <message>` where SEV is uppercase letters in brackets.
// 3. INVARIANT 1: at least one finding line in a supported shape:
// legacy `[SEV] <message>` or fixed-width `[SEV ] <message>`.
// Pre-iter-50 we never asserted this — upstream changing to
// `* SEV: message` would have been undetectable.
const findingLineRegex = /^\s*\[([A-Z]+)\]\s+\S/m;
const findingLineRegex = /^\s*\[([A-Z]+)\s*\]\s+\S/m;
const findingMatch = findingLineRegex.exec(stdout);
results.push({
check: 'finding line matches `[SEV] message` regex',
check: 'finding line matches `[SEV]` or padded `[SEV ]` message regex',
ok: findingMatch !== null,
detail: findingMatch ? `first match: [${findingMatch[1]}]` : 'no match — parser will return empty findings',
});
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Offline regression guard for helper-signing issues #2674 and #2675.
*/
import { spawnSync } from 'node:child_process';
import { generateKeyPairSync, verify } from 'node:crypto';
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
const root = process.cwd();
const cli = resolve(root, 'v3/@claude-flow/cli');
const signer = join(cli, 'scripts/sign-helpers.mjs');
const verifier = join(cli, 'scripts/verify-helpers.mjs');
const failures = [];
function check(name, condition, detail = '') {
console.log(` ${condition ? 'PASS' : 'FAIL'} ${name}`);
if (!condition) {
if (detail) console.log(` ${detail}`);
failures.push(name);
}
}
const tmp = mkdtempSync(join(tmpdir(), 'helper-signing-security-'));
try {
const fixture = join(tmp, 'cli');
mkdirSync(join(fixture, 'scripts'), { recursive: true });
mkdirSync(join(fixture, '.claude', 'helpers'), { recursive: true });
cpSync(signer, join(fixture, 'scripts', 'sign-helpers.mjs'));
for (const name of ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs']) {
cpSync(join(cli, '.claude', 'helpers', name), join(fixture, '.claude', 'helpers', name));
}
writeFileSync(join(fixture, 'package.json'), JSON.stringify({ version: '0.0.0-security-test' }));
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const privatePem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
const result = spawnSync(
process.execPath,
[join(fixture, 'scripts', 'sign-helpers.mjs'), '--stdin-key'],
{ input: privatePem, encoding: 'utf8', env: { ...process.env } },
);
check('stdin-only signing succeeds', result.status === 0, result.stderr);
check(
'private key never reaches signer output',
!/BEGIN PRIVATE KEY|PRIVATE KEY-----/.test(result.stdout + result.stderr)
&& !(result.stdout + result.stderr).includes(privatePem.slice(30, 70)),
(result.stdout + result.stderr).slice(0, 300),
);
const signed = JSON.parse(readFileSync(
join(fixture, '.claude', 'helpers', 'helpers.manifest.json'),
'utf8',
));
const sortedFiles = {};
for (const key of Object.keys(signed.manifest.files).sort()) sortedFiles[key] = signed.manifest.files[key];
const canonical = Buffer.from(JSON.stringify({
version: signed.manifest.version,
files: sortedFiles,
}));
check(
'stdin key produced a valid Ed25519 signature',
verify(null, canonical, publicKey, Buffer.from(signed.signature, 'base64')),
);
const invalid = '-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----\n';
const rejected = spawnSync(
process.execPath,
[join(fixture, 'scripts', 'sign-helpers.mjs'), '--stdin-key'],
{ input: invalid, encoding: 'utf8' },
);
check(
'invalid key fails with a redacted diagnostic',
rejected.status === 1
&& /not a valid Ed25519 private key/.test(rejected.stderr)
&& !rejected.stderr.includes('not-a-key'),
rejected.stderr,
);
const signerSource = readFileSync(signer, 'utf8');
check(
'Windows uses the executable gcloud.cmd shim',
/process\.platform === 'win32'\s*\?\s*'gcloud\.cmd'\s*:\s*'gcloud'/.test(signerSource),
);
const verifierSource = readFileSync(verifier, 'utf8');
check(
'runtime helper-signing module is the sole public-key source',
!verifierSource.includes('BEGIN PUBLIC KEY')
&& verifierSource.includes("import('../dist/src/init/helper-signing.js')"),
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error(`smoke-helper-signing-security: ${failures.length} failure(s)`);
process.exit(1);
}
console.log('smoke-helper-signing-security: all checks passed');
+97 -136
View File
@@ -1,125 +1,70 @@
#!/usr/bin/env node
/**
* Regression guard for ruvnet/ruflo#1880.
* Regression guard for source-only witness verification (#2729).
*
* The 12-hour scheduled witness verification runs `verify.mjs` in a
* source-only checkout (no `npm ci`, no `npm run build`) and conflates
* two completely different failure modes:
*
* 1. `@noble/ed25519` not installed → precondition (install needed)
* 2. dist files referenced by manifest → precondition (build needed)
* not present on disk
* 3. signature actually doesn't verify → real failure
* 4. specific dist file regressed → real failure
*
* Before this guard, all four exited with code 1, so the scheduled
* runner filed a duplicate "verification failed" issue every 12 hours
* even though nothing was actually wrong with the manifest.
*
* verify.mjs now reserves exit 2 for preconditions (cases 1 and 2) and
* keeps exit 1 strictly for real failures (cases 3 and 4). This smoke
* drives both shapes through the script and asserts the contract holds.
*
* Fails CI if any of:
* - missing @noble/ed25519 exit code is anything other than 2
* - all-files-missing manifest exit code is anything other than 2
* - precondition output missing the operator-facing fix hint
* - precondition output missing the machine-parseable `reason` / `precondition` field
* The standalone verifier must:
* 1. cryptographically verify a signed manifest without node_modules;
* 2. validate every source marker while explicitly skipping generated dist/;
* 3. reject a tampered signature and a regressed source marker; and
* 4. retain exit 2 for legacy full-tree checks when dist has not been built.
*/
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
const REPO_ROOT = process.cwd();
const VERIFY = resolve(REPO_ROOT, 'plugins/ruflo-core/scripts/witness/verify.mjs');
const REAL_MANIFEST = resolve(REPO_ROOT, 'verification/macos/manifest.md.json');
if (!existsSync(VERIFY)) {
console.error(`smoke: not found: ${VERIFY}`);
process.exit(1);
}
if (!existsSync(REAL_MANIFEST)) {
console.error(`smoke: not found: ${REAL_MANIFEST}`);
process.exit(1);
}
const failures = [];
function record(name, condition, detail) {
if (condition) {
console.log(` PASS ${name}`);
} else {
console.log(` FAIL ${name}`);
console.log(` ${condition ? 'PASS' : 'FAIL'} ${name}`);
if (!condition) {
if (detail) console.log(` ${detail}`);
failures.push(name);
}
}
// ── Case 1: @noble/ed25519 missing → exit 2 (precondition) ──────────
// Drop verify.mjs into an empty root with no node_modules anywhere up
// the tree, so the ed25519 probe fails on every candidate.
console.log('\nCase 1: @noble/ed25519 not installed (source-only)');
{
const tmp = mkdtempSync(join(tmpdir(), 'witness-smoke-nodep-'));
// verify.mjs's probes use createRequire(join(root, 'noop.js')) which
// walks up from `root`. Putting `root` deep under tmp ensures Node
// walks /tmp/.../node_modules (absent) then /tmp/node_modules (absent)
// then /node_modules (absent on CI) — no resolution.
const isolatedRoot = join(tmp, 'isolated', 'repo');
mkdirSync(isolatedRoot, { recursive: true });
// Stub node_modules to mask any system-level @noble/ed25519 install.
mkdirSync(join(isolatedRoot, 'node_modules'), { recursive: true });
const out = spawnSync('node', [VERIFY, '--manifest', REAL_MANIFEST, '--root', isolatedRoot, '--json'], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
record(
'exit code is 2 (precondition), not 1',
out.status === 2,
`got exit=${out.status}, stdout=${out.stdout?.slice(0, 200)}, stderr=${out.stderr?.slice(0, 200)}`,
function run(root, manifest = REAL_MANIFEST, ...extra) {
return spawnSync(
process.execPath,
[VERIFY, '--manifest', manifest, '--root', root, '--json', ...extra],
{ encoding: 'utf8', env: { ...process.env, NODE_PATH: '' } },
);
record(
'stderr names @noble/ed25519 and the fix command',
/@noble\/ed25519/.test(out.stderr) && /npm (ci|install)/.test(out.stderr),
`stderr=${out.stderr?.slice(0, 400)}`,
);
record(
'JSON output carries machine-parseable precondition tag',
/noble-ed25519-not-installed/.test(out.stdout),
`stdout=${out.stdout?.slice(0, 200)}`,
);
rmSync(tmp, { recursive: true, force: true });
}
// ── Case 2: dist files missing → exit 2 (precondition) ──────────────
// Point verify.mjs at an isolated root that DOES have @noble/ed25519
// reachable (symlinked from the real install), has source files copied
// from the checkout, but has none of the manifest's dist/ files —
// mimicking a clean clone with dependencies installed and no build run.
console.log('\nCase 2: dist manifest files missing (source-only, deps installed)');
{
const tmp = mkdtempSync(join(tmpdir(), 'witness-smoke-nobuild-'));
// Make @noble/ed25519 reachable by symlinking node_modules.
const realNodeModules = resolve(REPO_ROOT, 'node_modules');
if (existsSync(join(realNodeModules, '@noble', 'ed25519'))) {
mkdirSync(join(tmp, 'node_modules', '@noble'), { recursive: true });
// Use a relative symlink that survives the spawn.
const src = join(realNodeModules, '@noble', 'ed25519');
const dst = join(tmp, 'node_modules', '@noble', 'ed25519');
try {
// fs.symlinkSync via spawnSync to avoid extra import for the rare-path branch.
spawnSync('ln', ['-s', src, dst]);
} catch { /* best-effort */ }
function parseOutput(out) {
try {
return JSON.parse(out.stdout);
} catch {
return null;
}
}
const realManifest = JSON.parse(readFileSync(REAL_MANIFEST, 'utf8'));
for (const fix of realManifest.manifest.fixes) {
if (fix.file.includes('/dist/')) continue;
if (!existsSync(VERIFY) || !existsSync(REAL_MANIFEST)) {
console.error('smoke: verifier or signed manifest is missing');
process.exit(1);
}
const tmp = mkdtempSync(join(tmpdir(), 'witness-source-only-'));
try {
const witness = JSON.parse(readFileSync(REAL_MANIFEST, 'utf8'));
const sourceFixes = witness.manifest.fixes.filter(
(fix) => !fix.file.split(/[\\/]/).includes('dist'),
);
const generatedFixes = witness.manifest.fixes.length - sourceFixes.length;
for (const fix of sourceFixes) {
const src = resolve(REPO_ROOT, fix.file);
const dst = resolve(tmp, fix.file);
if (!existsSync(src)) continue;
@@ -127,59 +72,75 @@ console.log('\nCase 2: dist manifest files missing (source-only, deps installed)
cpSync(src, dst);
}
const out = spawnSync('node', [VERIFY, '--manifest', REAL_MANIFEST, '--root', tmp, '--json'], {
encoding: 'utf8',
});
// We accept either:
// - exit 2 with precondition=dist-not-built (the new behavior)
// - exit 2 with precondition=noble-ed25519-not-installed (if the
// symlink trick didn't satisfy the probe on this runner — also a
// precondition, still not a real failure)
// We do NOT accept exit 1 — that's the bug this guard exists to catch.
console.log('\nCase 1: source-only verification needs no installed dependency');
const sourceOnly = run(tmp, REAL_MANIFEST, '--source-only');
const sourceResult = parseOutput(sourceOnly);
record(
'exit code is 2 (precondition), not 1',
out.status === 2,
`got exit=${out.status}, stdout head=${out.stdout?.slice(0, 200)}, stderr head=${out.stderr?.slice(0, 200)}`,
'signed manifest and all source markers verify',
sourceOnly.status === 0 && sourceResult?.ok === true,
`exit=${sourceOnly.status}, output=${(sourceOnly.stdout + sourceOnly.stderr).slice(0, 400)}`,
);
record(
'output carries a precondition tag',
/dist-not-built/.test(out.stdout + out.stderr),
`combined output=${(out.stdout + out.stderr).slice(0, 400)}`,
'scope and skipped generated entries are explicit',
sourceResult?.scope === 'source-only'
&& sourceResult?.summary?.skippedGenerated === generatedFixes
&& sourceResult?.results?.length === sourceFixes.length,
`result=${JSON.stringify(sourceResult?.summary)}`,
);
record(
'built-in cryptographic verifier was used',
sourceResult?.signature?.verifier === 'node:crypto'
&& sourceResult?.signature?.signatureValid === true
&& sourceResult?.signature?.publicKeyReproducible === true,
`signature=${JSON.stringify(sourceResult?.signature)}`,
);
rmSync(tmp, { recursive: true, force: true });
}
console.log('\nCase 2: signature tampering fails closed');
const tampered = structuredClone(witness);
tampered.integrity.signature =
`${tampered.integrity.signature[0] === '0' ? '1' : '0'}${tampered.integrity.signature.slice(1)}`;
const tamperedPath = join(tmp, 'tampered-manifest.json');
writeFileSync(tamperedPath, JSON.stringify(tampered));
const badSignature = run(tmp, tamperedPath, '--source-only');
record(
'one-byte signature change exits 1',
badSignature.status === 1 && parseOutput(badSignature)?.signature?.signatureValid === false,
`exit=${badSignature.status}, output=${badSignature.stdout.slice(0, 300)}`,
);
// ── Case 3: current checkout classification is explicit ─────────────
// This CI job installs deps but intentionally does not build dist. Local
// dev runs often have dist from a prior build. Accept either shape, but
// require the machine-readable reason to be precise.
console.log('\nCase 3: current checkout classification is explicit');
{
const out = spawnSync('node', [VERIFY, '--manifest', REAL_MANIFEST, '--json'], {
encoding: 'utf8',
cwd: REPO_ROOT,
});
if (out.status === 2) {
record(
'exit 2 is specifically dist-not-built, not missing dependency',
/"precondition":\s*"dist-not-built"/.test(out.stdout),
`got exit=2, stdout=${out.stdout.slice(0, 300)}, stderr=${out.stderr.slice(0, 300)}`,
);
console.log('\nCase 3: source marker regression fails closed');
const markerFix = sourceFixes.find((fix) => existsSync(resolve(tmp, fix.file)));
if (!markerFix) {
record('manifest has a locally present source entry', false);
} else {
const markerPath = resolve(tmp, markerFix.file);
const source = readFileSync(markerPath, 'utf8');
writeFileSync(markerPath, source.replace(markerFix.marker, 'ruflo-regressed-marker'));
const regression = run(tmp, REAL_MANIFEST, '--source-only');
const result = parseOutput(regression);
record(
'built/current tree verification actually ran',
out.status === 0 || out.status === 1,
`got exit=${out.status}, tail=${(out.stdout + out.stderr).slice(-300)}`,
'missing source marker exits 1 and is classified regressed',
regression.status === 1 && result?.summary?.regressed >= 1,
`exit=${regression.status}, summary=${JSON.stringify(result?.summary)}`,
);
writeFileSync(markerPath, source);
}
console.log('\nCase 4: legacy full-tree verification retains precondition exit');
const full = run(tmp);
const fullResult = parseOutput(full);
record(
'unbuilt full-tree verification exits 2',
full.status === 2 && fullResult?.precondition === 'dist-not-built',
`exit=${full.status}, output=${(full.stdout + full.stderr).slice(0, 300)}`,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
console.log('');
if (failures.length > 0) {
console.error(`smoke-witness-verify-precondition: ${failures.length} check(s) failed:`);
for (const f of failures) console.error(` - ${f}`);
console.error(`smoke-witness-verify-precondition: ${failures.length} check(s) failed`);
process.exit(1);
}
console.log('smoke-witness-verify-precondition: all checks passed');
+14 -3
View File
@@ -162,6 +162,11 @@ export class OutputFormatter {
this.errorStream.write(text + '\n');
}
/** Whether transient terminal UI (spinners/progress redraws) is safe. */
supportsInteractiveOutput(): boolean {
return this.outputStream.isTTY === true;
}
// ============================================
// Formatted Output Methods
// ============================================
@@ -596,6 +601,10 @@ export class Spinner {
start(): void {
if (this.interval) return;
// Piped/redirected output has no cursor to redraw. Emitting frames there
// leaves literal carriage-return fragments before the first real result
// (#2814), so keep non-interactive output deterministic and silent.
if (!this.formatter.supportsInteractiveOutput()) return;
this.interval = setInterval(() => {
this.render();
@@ -612,8 +621,10 @@ export class Spinner {
this.interval = null;
}
// Clear the line
process.stdout.write('\r' + ' '.repeat(this.text.length + 10) + '\r');
if (this.formatter.supportsInteractiveOutput()) {
// Erase the entire terminal row, including ANSI-colored frame bytes.
this.formatter.write('\r\x1b[2K');
}
if (message) {
this.formatter.writeln(message);
@@ -630,7 +641,7 @@ export class Spinner {
private render(): void {
const frame = this.formatter.info(this.frames[this.frameIndex]);
process.stdout.write(`\r${frame} ${this.text}`);
this.formatter.write(`\r${frame} ${this.text}`);
}
setText(text: string): void {
@@ -1,13 +1,13 @@
{
"manifest": {
"version": "3.32.35",
"version": "3.32.36",
"files": {
"auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
"hook-handler.cjs": "50ea92a72651bdc95634f7588d56a5870963168eef5226f66ed14af3b47c8d9a",
"intelligence.cjs": "bd1f8e4b034944aee1df0391dc47ac2e8cc4b3aa542c65407a59d49620bbf76b",
"statusline.cjs": "d2a0eac56d1267d8dbed2d70b09feb592299469196ec4b215909f2b052144882"
"hook-handler.cjs": "dae295fb9ae2626b89899c19a20cc911541af82b52d2eeb9b214d618b96e9a86",
"intelligence.cjs": "792c24a12704267e6b45ed0794e128131fb16e4174a43e65a5afe757bc0230af",
"statusline.cjs": "0457fe53f8cd2c56458ff178392536a5868efd1a573665fa43bc01d2d95ca677"
}
},
"signature": "7hAABpKaZablL78ug8UW506P+ppwYlDb2qws0D4BbCeugfGQTdDEOYsndbZMc4+Wzbx10oQbnubqh8jHZnHvBQ==",
"signature": "qGxpst4ul6cizCQw1hjdJ5sBXj7uN7Fm1Dux2sKC7BwcPA66Nr9RzI8c8BJuKEUyPgaDtSvaY/gZTX2hWNqXBg==",
"algorithm": "ed25519"
}
@@ -286,6 +286,31 @@ async function readStdin() {
});
}
function claimSideEffectEvent(family, stdinData, event) {
if (/^(1|true|yes|on)$/i.test(process.env.RUFLO_DISABLE_HOOK_DEDUP || '')) return true;
try {
const crypto = require('crypto');
const eventId = event?.tool_use_id || event?.toolUseId ||
event?.session_id || event?.sessionId || event?.hook_event_id;
const payloadIdentity = eventId
? `event:${eventId}`
: `payload:${(stdinData || '').trim()}|bucket:${Math.floor(Date.now() / 2000)}`;
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const digest = crypto.createHash('sha256')
.update(`ruflo-hook-dedup-v1\0${path.resolve(projectRoot)}\0${family}\0${payloadIdentity}`)
.digest('hex');
const dir = process.env.RUFLO_HOOK_DEDUP_DIR ||
path.join(os.tmpdir(), 'ruflo-hook-dedup-v1');
fs.mkdirSync(dir, { recursive: true });
const fd = fs.openSync(path.join(dir, digest), 'wx', 0o600);
fs.writeFileSync(fd, String(Date.now()));
fs.closeSync(fd);
return true;
} catch (error) {
return error?.code === 'EEXIST' ? false : true;
}
}
async function main() {
// Global safety timeout: hooks must NEVER hang (#1530, #1531)
const safetyTimer = setTimeout(() => {
@@ -302,6 +327,11 @@ async function main() {
try { hookInput = JSON.parse(stdinData); } catch (e) { /* ignore parse errors */ }
}
if ((command === 'post-edit' || command === 'session-end') &&
!claimSideEffectEvent(command, stdinData, hookInput)) {
return;
}
// Normalize snake_case/camelCase: Claude Code sends tool_input/tool_name (snake_case)
const toolInput = hookInput.toolInput || hookInput.tool_input || {};
const toolName = hookInput.toolName || hookInput.tool_name || '';
@@ -17,12 +17,30 @@
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(process.cwd(), '.claude-flow', 'data');
function resolveProjectRoot(startDir) {
if (process.env.CLAUDE_PROJECT_DIR) {
return path.resolve(process.env.CLAUDE_PROJECT_DIR);
}
let dir = path.resolve(startDir || process.cwd());
while (true) {
if (fs.existsSync(path.join(dir, '.git')) ||
fs.existsSync(path.join(dir, '.claude-flow'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) return path.resolve(startDir || process.cwd());
dir = parent;
}
}
const PROJECT_ROOT = resolveProjectRoot(process.cwd());
const DATA_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'data');
const STORE_PATH = path.join(DATA_DIR, 'auto-memory-store.json');
const GRAPH_PATH = path.join(DATA_DIR, 'graph-state.json');
const RANKED_PATH = path.join(DATA_DIR, 'ranked-context.json');
const PENDING_PATH = path.join(DATA_DIR, 'pending-insights.jsonl');
const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions');
const LEGACY_PENDING_PATH = path.join(process.cwd(), '.claude-flow', 'data', 'pending-insights.jsonl');
const SESSION_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'sessions');
const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
// ── Safety limits (fixes #1530, #1531) ─────────────────────────────────────
@@ -47,6 +65,22 @@ const STOP_WORDS = new Set([
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
// Recover signal written by older helpers under a subdirectory cwd. Keep
// the legacy file intact and append only lines not already present.
if (path.resolve(LEGACY_PENDING_PATH) !== path.resolve(PENDING_PATH) &&
fs.existsSync(LEGACY_PENDING_PATH)) {
try {
const existing = fs.existsSync(PENDING_PATH)
? new Set(fs.readFileSync(PENDING_PATH, 'utf-8').split('\n').filter(Boolean))
: new Set();
const recovered = fs.readFileSync(LEGACY_PENDING_PATH, 'utf-8')
.split('\n')
.filter(line => line && !existing.has(line));
if (recovered.length > 0) {
fs.appendFileSync(PENDING_PATH, recovered.join('\n') + '\n', 'utf-8');
}
} catch { /* migration is best-effort; never block hook execution */ }
}
}
function readJSON(filePath) {
@@ -300,7 +334,7 @@ function buildEdges(entries) {
*/
function bootstrapFromMemoryFiles() {
const entries = [];
const cwd = process.cwd();
const cwd = PROJECT_ROOT;
// Search for auto-memory directories
const candidates = [
@@ -1032,7 +1066,16 @@ function stats(outputJson) {
return report;
}
module.exports = { init, getContext, recordEdit, feedback, consolidate, stats };
module.exports = {
init,
getContext,
recordEdit,
feedback,
consolidate,
stats,
resolveProjectRoot,
projectRoot: PROJECT_ROOT,
};
// ── CLI entrypoint ──────────────────────────────────────────────────────────
if (require.main === module) {
@@ -42,7 +42,11 @@ const CONFIG = {
hideCost: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_COST || ''),
};
const CWD = process.cwd();
const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
// Replaced by statusline-generator with the package root of the CLI that
// installed this helper. This survives custom npm prefixes and bundled Node
// runtimes whose process.execPath belongs to a different tree (#2811).
const BAKED_INSTALL_ROOT = "";
// ─── Delegation cache ───────────────────────────────────────────
// Cache the CLI JSON result so rapid prompt re-renders (Claude Code
@@ -404,6 +408,154 @@ function getLocalIntegration() {
return integration;
}
// ─── Security freshness overlay (ruvnet/ruflo#2776) ──────────────
// The shipped CLI producer (dist/src/funnel/local-signals.js getSecurityStatus)
// only ever emits PENDING / CLEAN / ISSUES — it captures `scannedAt` but never
// inspects it, so a year-old scan renders 🛡 ✓ forever and the renderer's
// STALE / IN_PROGRESS branches are unreachable. Worse, when CLI delegation
// fails, the stale-while-revalidate cache (readCache() below) keeps serving
// the pre-scan PENDING pill indefinitely, so a user who runs the advertised
// `ruflo security scan` sees no change — the pill freezes at "scan pending".
//
// This overlay recomputes the security block from disk on EVERY render (same
// pattern as adrs/agentdb/tests/hooks above), which:
// 1) Makes STALE reachable — when the newest scan is older than
// RUFLO_SCAN_STALE_HOURS (default 24h — matches the CVE feed refresh
// cadence), report STALE regardless of what the cached CLI JSON says.
// 2) Makes IN_PROGRESS reachable — when a `scan-in-progress` marker file
// exists and is younger than SECURITY_IN_PROGRESS_MAX_MIN (guards against
// a crashed scan leaving the marker behind).
// 3) Caps the "scan pending" display window — if PENDING has been shown for
// >RUFLO_SCAN_PENDING_CAP_MIN (default 30) without a completion write,
// switch to STALE and stop rendering the yellow indicator. The tracker
// lives in ~/.ruflo/statusline-scan-pending-since.json, keyed by CWD
// hash so multiple project checkouts don't collide.
// 4) Since this runs AFTER readCache() serves stale data, it bypasses the
// "pill freezes at PENDING" freeze in defect 2 — the overlay reads
// fresh disk state even when the CLI delegation is broken.
const SECURITY_STALE_HOURS = Math.max(1, parseInt(process.env.RUFLO_SCAN_STALE_HOURS || '24', 10) || 24);
const SECURITY_PENDING_CAP_MIN = Math.max(1, parseInt(process.env.RUFLO_SCAN_PENDING_CAP_MIN || '30', 10) || 30);
const SECURITY_IN_PROGRESS_MAX_MIN = 30; // marker older than this = crashed scan; treat as absent
const PENDING_TRACK_FILE = path.join(os.homedir(), '.ruflo', 'statusline-scan-pending-since.json');
const CWD_KEY = require('crypto').createHash('md5').update(CWD).digest('hex').slice(0, 12);
function readPendingSince() {
try {
if (!fs.existsSync(PENDING_TRACK_FILE)) return null;
const raw = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw[CWD_KEY] === 'number') return raw[CWD_KEY];
} catch { /* ignore */ }
return null;
}
function writePendingSince(ts) {
try {
let obj = {};
if (fs.existsSync(PENDING_TRACK_FILE)) {
try { obj = JSON.parse(fs.readFileSync(PENDING_TRACK_FILE, 'utf-8')) || {}; } catch { obj = {}; }
}
if (ts === null) { delete obj[CWD_KEY]; } else { obj[CWD_KEY] = ts; }
fs.mkdirSync(path.dirname(PENDING_TRACK_FILE), { recursive: true, mode: 0o700 });
fs.writeFileSync(PENDING_TRACK_FILE, JSON.stringify(obj), { encoding: 'utf-8', mode: 0o600 });
} catch { /* ignore */ }
}
function getLocalSecurity(cliSecurity) {
const base = (cliSecurity && typeof cliSecurity === 'object')
? Object.assign({}, cliSecurity)
: { status: 'NONE', findings: 0, cvesFixed: 0, totalCves: 0 };
base.findings = Math.max(0, base.findings || 0);
const scanDir = path.join(CWD, '.claude', 'security-scans');
// Detect a live in-progress marker (writer opts-in by writing this file).
let inProgress = false;
try {
const marker = path.join(scanDir, 'scan-in-progress');
if (fs.existsSync(marker)) {
const ageMin = (Date.now() - fs.statSync(marker).mtimeMs) / 60000;
if (ageMin < SECURITY_IN_PROGRESS_MAX_MIN) inProgress = true;
}
} catch { /* ignore */ }
// Find newest scan-*.json by mtime and read its findings/timestamp.
let newestPath = null;
let newestMtime = 0;
try {
if (fs.existsSync(scanDir)) {
for (const name of fs.readdirSync(scanDir)) {
if (!name.startsWith('scan-') || !name.endsWith('.json')) continue;
try {
const st = fs.statSync(path.join(scanDir, name));
if (st.mtimeMs > newestMtime) { newestMtime = st.mtimeMs; newestPath = path.join(scanDir, name); }
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
if (newestPath) {
// We have a scan on disk — the never-scanned pending tracker is no longer
// relevant. Clear it so a re-created directory can start a fresh window.
writePendingSince(null);
let scannedAtMs = newestMtime;
let findings = base.findings;
try {
const j = JSON.parse(fs.readFileSync(newestPath, 'utf-8'));
// scannedAt (CLI producer's field name) OR timestamp (writer's field name).
const isoStr = (j && (j.scannedAt || j.timestamp)) || null;
if (isoStr) {
const t = Date.parse(isoStr);
if (!isNaN(t)) scannedAtMs = t;
}
// findings may be a number, an array, or nested in summary.total.
if (j) {
if (typeof j.findings === 'number') findings = j.findings;
else if (Array.isArray(j.findings)) findings = j.findings.length;
else if (j.summary && typeof j.summary.total === 'number') findings = j.summary.total;
}
} catch { /* ignore parse — fall back to mtime + cached findings */ }
base.findings = Math.max(0, findings || 0);
base.scannedAt = new Date(scannedAtMs).toISOString();
const ageHours = (Date.now() - scannedAtMs) / 3600000;
if (ageHours >= SECURITY_STALE_HOURS) {
// Stale but findings still render red (a year-old ISSUES scan is still bad).
base.status = 'STALE';
} else if (inProgress) {
base.status = 'IN_PROGRESS';
} else if (base.findings > 0) {
base.status = 'ISSUES';
} else {
base.status = 'CLEAN';
}
return base;
}
// No scan file. If a live marker exists, we're mid-scan.
if (inProgress) {
base.status = 'IN_PROGRESS';
// Reset the pending tracker so, if the scan crashes mid-flight, the next
// render starts a fresh N-minute pending window instead of an already-expired one.
writePendingSince(null);
return base;
}
// Truly never-scanned: track how long we've shown PENDING. After the cap,
// escalate to STALE with the dim/gray glyph so the pill visibly stops
// shouting for attention — the user has either ignored it for 30 min or
// the scan is silently failing to write.
let pendingSince = readPendingSince();
if (pendingSince === null || typeof pendingSince !== 'number') {
pendingSince = Date.now();
writePendingSince(pendingSince);
}
const pendingAgeMin = (Date.now() - pendingSince) / 60000;
base.status = (pendingAgeMin >= SECURITY_PENDING_CAP_MIN) ? 'STALE' : 'PENDING';
return base;
}
// Overlay every locally-derived block onto the CLI data (mutates in place).
function applyLocalOverlays(data) {
data.adrs = getLocalADRCount();
@@ -411,6 +563,9 @@ function applyLocalOverlays(data) {
data.tests = getLocalTests();
data.hooks = getLocalHooks();
data.integration = getLocalIntegration();
// Security overlay: recompute freshness from disk on every render so cached
// CLI JSON can never freeze the pill at PENDING. See getLocalSecurity() above.
data.security = getLocalSecurity(data.security);
return data;
}
@@ -685,6 +840,7 @@ function getPkgVersion() {
try {
const home = os.homedir();
const pkgPaths = [
...(BAKED_INSTALL_ROOT ? [path.join(BAKED_INSTALL_ROOT, 'package.json')] : []),
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'package.json'),
path.join(CWD, 'node_modules', '@claude-flow', 'cli', 'package.json'),
path.join(CWD, 'node_modules', 'ruflo', 'package.json'),
@@ -712,7 +868,12 @@ function getPkgVersion() {
// #2221 follow-up: a custom npm prefix (e.g. ~/.npm-global) is decoupled from
// the node binary location, so the binDir-derived probes above all miss. Also
// probe the npm prefix from the environment and the common ~/.npm-global default.
for (const prefix of [process.env.npm_config_prefix, process.env.PREFIX, path.join(home, '.npm-global')]) {
for (const prefix of [
process.env.npm_config_prefix,
process.env.PREFIX,
path.join(home, '.local'),
path.join(home, '.npm-global'),
]) {
if (prefix) globalModuleDirs.push(path.join(prefix, 'lib', 'node_modules'));
}
for (const gm of globalModuleDirs) {
@@ -731,14 +892,12 @@ function getPkgVersion() {
// right after a publish). Taking the first EXISTING candidate meant the
// header could show a stale version even when a newer install (e.g.
// node_modules/@claude-flow/cli from a plain npm install) was sitting right there.
let found = false;
for (const p of pkgPaths) {
if (!fs.existsSync(p)) continue;
try {
const pkg = JSON.parse(fs.readFileSync(p, 'utf-8'));
if (pkg && typeof pkg.version === 'string' && pkg.version.length > 0) {
if (!found || compareVersions(pkg.version, ver) > 0) ver = pkg.version;
found = true;
if (compareVersions(pkg.version, ver) > 0) ver = pkg.version;
}
} catch { /* ignore */ }
}
@@ -851,10 +1010,14 @@ function generateStatusline() {
if (healthAllGreen) {
opsParts.push(c.brightGreen + '🛡 ✓' + c.reset);
} else {
// #2776: STALE gets dim/gray (distinct from the actionable yellow of
// PENDING/IN_PROGRESS) so a stale pill visibly stops shouting for
// attention — the user can act on the "run ruflo security scan" prompt or
// ignore it without a permanently-yellow indicator.
if (secStatus === 'PENDING') opsParts.push(c.brightYellow + '🛡 scan pending' + c.reset);
else if (secStatus === 'IN_PROGRESS') opsParts.push(c.brightYellow + '🛡 scanning…' + c.reset);
else if (secStatus === 'ISSUES') opsParts.push(c.brightRed + '🛡 findings' + c.reset);
else if (secStatus === 'STALE') opsParts.push(c.brightYellow + '🛡 scan stale' + c.reset);
else if (secStatus === 'STALE') opsParts.push(c.dim + '🛡 scan stale' + c.reset);
else if (secStatus !== 'NONE' && secStatus !== 'CLEAN') opsParts.push(c.brightRed + '🛡 ' + secStatus.toLowerCase() + c.reset);
if (findings > 0) {
opsParts.push(c.brightRed + '⚠ ' + findings + ' finding' + (findings === 1 ? '' : 's') + c.reset);
+14
View File
@@ -193,6 +193,20 @@ npm install -g ruflo@latest
claude mcp add ruflo -- npx ruflo@latest mcp start
```
Small-context backends can advertise only the tool categories they need:
```bash
CLAUDE_FLOW_MCP_TOOLS=memory,swarm,agent,hooks \
npx ruflo@latest mcp start
# Optional: lets `ruflo doctor -c mcp-overhead` compare the live schema
# estimate with the backend's real context limit.
CLAUDE_FLOW_CONTEXT_WINDOW_TOKENS=32000 ruflo doctor -c mcp-overhead
```
The default remains `all` for backwards compatibility. Selectors accept
categories, namespace prefixes, or exact tool names.
---
## What You Get
@@ -0,0 +1,85 @@
/**
* Regression guard for #2677's unreachable Reflexion substrate.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { doctorCommand } from '../src/commands/doctor.js';
import { _resetMemoryRootCache } from '../src/memory/memory-initializer.js';
type HealthCheck = { name: string; status: 'pass' | 'warn' | 'fail'; message: string };
type DoctorData = { results: HealthCheck[] };
const ORIGINAL_CWD = process.cwd();
let workdir: string;
async function runMemoryDoctor(): Promise<HealthCheck[]> {
const ctx = {
flags: { component: 'memory' },
args: [],
config: {},
} as unknown as Parameters<NonNullable<typeof doctorCommand.action>>[0];
const result = await doctorCommand.action!(ctx);
return (result.data as DoctorData).results;
}
function find(results: HealthCheck[], name: string): HealthCheck | undefined {
return results.find((result) => result.name === name);
}
async function seed(includeEmbedding: boolean, includeCritique: boolean): Promise<void> {
const Database = (await import('better-sqlite3')).default as any;
mkdirSync(join(workdir, '.swarm'), { recursive: true });
const db = new Database(join(workdir, '.swarm', 'memory.db'));
db.exec(`
CREATE TABLE memory_entries (
id TEXT PRIMARY KEY, content TEXT, embedding TEXT
);
INSERT INTO memory_entries VALUES ('m1', 'execution feedback', '[1,0]');
CREATE TABLE episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, task TEXT NOT NULL, critique TEXT
);
CREATE TABLE episode_embeddings (
episode_id INTEGER PRIMARY KEY, embedding BLOB NOT NULL
);
`);
db.prepare('INSERT INTO episodes (session_id, task, critique) VALUES (?,?,?)')
.run('distill:feedback', 'test auth', includeCritique ? 'validation failed before fix' : null);
if (includeEmbedding) {
db.prepare('INSERT INTO episode_embeddings (episode_id, embedding) VALUES (?,?)')
.run(1, Buffer.from(Float32Array.from([1, 0]).buffer));
}
db.close();
}
describe('doctor #2677 — Reflexion and critique coverage', () => {
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), 'doctor-2677-reflexion-'));
process.chdir(workdir);
_resetMemoryRootCache();
});
afterEach(() => {
process.chdir(ORIGINAL_CWD);
_resetMemoryRootCache();
rmSync(workdir, { recursive: true, force: true });
});
it('fails when episodes are structurally unreachable and carry no critique', async () => {
await seed(false, false);
const results = await runMemoryDoctor();
expect(find(results, 'Memory Reflexion Coverage')?.status).toBe('fail');
expect(find(results, 'Memory Reflexion Coverage')?.message).toContain('0/1');
expect(find(results, 'Memory Feedback Critiques')?.status).toBe('fail');
});
it('passes when every feedback episode is embedded and carries a lesson', async () => {
await seed(true, true);
const results = await runMemoryDoctor();
expect(find(results, 'Memory Reflexion Coverage')?.status).toBe('pass');
expect(find(results, 'Memory Feedback Critiques')?.status).toBe('pass');
});
});
@@ -0,0 +1,100 @@
/**
* Regression guard for #2677's stale-settings finding: every
* `npx @claude-flow/cli@latest <subcommand>` in Claude settings has the same
* cold-process cost, not only the historical `hooks` form.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { doctorCommand } from '../src/commands/doctor.js';
import { executeUpgrade } from '../src/init/executor.js';
type HealthCheck = { name: string; status: 'pass' | 'warn' | 'fail'; message: string };
type DoctorData = { results: HealthCheck[] };
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_HOME = process.env.HOME;
let workdir: string;
async function runStaleSettingsCheck(): Promise<HealthCheck> {
const ctx = {
flags: { component: 'stale-settings' },
args: [],
config: {},
} as unknown as Parameters<NonNullable<typeof doctorCommand.action>>[0];
const result = await doctorCommand.action!(ctx);
return (result.data as DoctorData).results[0];
}
function writeSettings(settings: unknown): void {
mkdirSync(join(workdir, '.claude'), { recursive: true });
writeFileSync(join(workdir, '.claude', 'settings.json'), JSON.stringify(settings));
}
describe('doctor #2677 — stale npx detection covers every subcommand', () => {
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), 'doctor-2677-settings-'));
process.chdir(workdir);
// Keep the test isolated from a developer's real ~/.claude/settings.json.
process.env.HOME = join(workdir, 'home');
});
afterEach(() => {
process.chdir(ORIGINAL_CWD);
if (ORIGINAL_HOME === undefined) delete process.env.HOME;
else process.env.HOME = ORIGINAL_HOME;
rmSync(workdir, { recursive: true, force: true });
});
it.each(['memory store --namespace notifications', 'daemon start --quiet', 'swarm status'])(
'fails on npx @latest %s',
async (subcommand) => {
writeSettings({
hooks: {
Notification: [{ hooks: [{ command: `npx @claude-flow/cli@latest ${subcommand}` }] }],
},
});
const check = await runStaleSettingsCheck();
expect(check.status).toBe('fail');
expect(check.message).toContain('CRITICAL');
},
);
it('does not flag the generated local helper command', async () => {
writeSettings({
statusLine: { command: 'node .claude/helpers/statusline.cjs' },
});
expect((await runStaleSettingsCheck()).status).toBe('pass');
});
it('upgrade removes sibling npx hooks and regenerates the statusline', async () => {
writeSettings({
statusLine: { command: 'npx @claude-flow/cli@latest memory retrieve --key status' },
hooks: {
Notification: [{
hooks: [
{ command: 'npx @claude-flow/cli@latest memory store --namespace notifications' },
{ command: 'node custom-notification.cjs' },
],
}],
PreToolUse: [{
hooks: [{ command: 'npx @claude-flow/cli@latest hooks pre-bash' }],
}],
},
});
const upgraded = await executeUpgrade(workdir, true);
expect(upgraded.success).toBe(true);
const settings = JSON.parse(readFileSync(join(workdir, '.claude', 'settings.json'), 'utf8'));
expect(settings.statusLine.command).toContain('.claude/helpers/statusline.cjs');
const commands = Object.values(settings.hooks)
.flatMap((groups: any) => groups.flatMap((group: any) => group.hooks ?? []))
.map((hook: any) => hook.command)
.filter(Boolean);
expect(commands.some((command: string) => command.includes('@claude-flow/cli@latest'))).toBe(false);
expect(commands).toContain('node custom-notification.cjs');
expect(commands.some((command: string) => command.includes('hook-handler.cjs') && command.includes('pre-bash'))).toBe(true);
});
});
+34 -27
View File
@@ -99,9 +99,9 @@ beforeEach(() => {
/**
* Seed the local remote-message cache exactly the way message-transport.ts's
* writeCache() would after a successful GET /v1/messages — ADR-311 makes
* this the ONLY content source (MESSAGES ships empty), so every test that
* exercises rotation/disclosure must seed this cache first.
* writeCache() would after a successful GET /v1/messages. The remote pool is
* authoritative and overrides the in-code seed pool by id, so tests that need
* remote-specific content seed this cache; cold-start tests deliberately do not.
*/
function seedRemoteMessages(messages: unknown[]): void {
fs.writeFileSync(
@@ -191,12 +191,12 @@ describe('message content boundaries (ADR-301)', () => {
expect(isAllowedUrl('not a url')).toBe(false);
});
it('ships ZERO local messages (ADR-311 "zero local promo content" guarantee)', () => {
// The in-code MESSAGES pool is intentionally empty — all rotation
// content (tips, promos, disclosure) is remote-sourced. This test
// pins that guarantee; a future PR that adds a local message back
// in must consciously fail this test to do so.
expect(MESSAGES).toEqual([]);
it('ships a bounded, self-validating local seed pool (cold-start fallback)', () => {
expect(MESSAGES.length).toBeGreaterThan(0);
for (const message of MESSAGES) expect(isValidMessage(message)).toBe(true);
expect(MESSAGES.filter((message) => message.class === 'disclosure')).toHaveLength(1);
expect(MESSAGES.filter((message) => message.class === 'promotional')).toHaveLength(1);
expect(MESSAGES.every((message) => message.id.startsWith('local.'))).toBe(true);
});
it('a disclosure-class message without the manage tail is rejected, never repaired', () => {
@@ -205,10 +205,11 @@ describe('message content boundaries (ADR-301)', () => {
expect(isValidMessage({ ...base, text: '✨ Has it · manage: ruflo settings' })).toBe(true);
});
it('selectDisclosureMessage returns null when no remote disclosure pool is cached (fail-closed)', () => {
// No seedRemoteMessages() call — cache is empty, matching a cold start
// or an unreachable remote feed. Per ADR-311, this means "show nothing".
expect(selectDisclosureMessage(new Date())).toBeNull();
it('selectDisclosureMessage uses the local seed disclosure on cold start', () => {
const message = selectDisclosureMessage(new Date());
expect(message).not.toBeNull();
expect(message!.id).toBe('local.disclosure.v1');
expect(message!.class).toBe('disclosure');
});
it('selectDisclosureMessage is deterministic per 5-minute slot once seeded', () => {
@@ -216,22 +217,24 @@ describe('message content boundaries (ADR-301)', () => {
const t0 = new Date('2026-07-10T12:00:00.000Z');
const t0plus1s = new Date(t0.getTime() + 1000);
expect(selectDisclosureMessage(t0)?.id).toBe(selectDisclosureMessage(t0plus1s)?.id);
const poolSize =
TEST_DISCLOSURE_POOL.length + MESSAGES.filter((message) => message.class === 'disclosure').length;
const seen = new Set<string>();
for (let i = 0; i < TEST_DISCLOSURE_POOL.length * 2; i++) {
for (let i = 0; i < poolSize * 2; i++) {
seen.add(selectDisclosureMessage(new Date(t0.getTime() + i * DISCLOSURE_ROTATION_SLOT_MS))?.id ?? '');
}
// Rotation must cover every variant.
expect(seen.size).toBe(TEST_DISCLOSURE_POOL.length);
expect(seen.size).toBe(poolSize);
});
});
// ─── ADR-301: rotation ratio ────────────────────────────────────────────────
describe('rotation scheduler (ADR-301 content ratio)', () => {
it('returns null when no remote pool is cached (fail-closed, ADR-311)', () => {
// No seedRemoteMessages() call — MESSAGES ships empty, so an unseeded
// cache means nothing to rotate through. This is deliberate, not a bug.
expect(selectMessage(new Date())).toBeNull();
it('uses bounded local seed content when no remote pool is cached', () => {
const message = selectMessage(new Date());
expect(message).not.toBeNull();
expect(message!.id.startsWith('local.')).toBe(true);
expect(['educational', 'promotional']).toContain(message!.class);
});
it('promotional content appears only in 1-of-5 slots and honors the 30-min cap', () => {
@@ -359,15 +362,19 @@ describe('promo orchestrator (getFunnelPromo)', () => {
expect(getFunnelPromo({ interactive: true, env: { ...process.env, RUFLO_FUNNEL: '0' } })).toBeNull();
});
it('renders nothing on first render when no remote pool is cached (fail-closed, ADR-311)', () => {
// No seedRemoteMessages() — this is a cold start or an unreachable API.
// Zero local content means zero row, not a fallback to hardcoded text.
expect(getFunnelPromo({ interactive: true, cwd: stateDir })).toBeNull();
it('first render uses the local seed disclosure when no remote pool is cached', () => {
const row = getFunnelPromo({ interactive: true, cwd: stateDir });
expect(row).not.toBeNull();
expect(row!.kind).toBe('disclosure');
expect(row!.text).toBe(
'Ruflo shows occasional tips and sponsor notes here · manage: ruflo settings',
);
});
it('first interactive render is the disclosure, never a promotion', () => {
seedRemoteMessages(TEST_DISCLOSURE_POOL);
const row = getFunnelPromo({ interactive: true, cwd: stateDir });
const now = new Date(Date.UTC(2026, 6, 10, 12, 0, 0));
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now });
expect(row).not.toBeNull();
expect(row!.kind).toBe('disclosure');
// Row text is one of the seeded disclosure variants.
@@ -391,7 +398,7 @@ describe('promo orchestrator (getFunnelPromo)', () => {
it('keeps showing the disclosure through the grace window, then rotates messages', () => {
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
const t0 = new Date();
const t0 = new Date(Date.UTC(2026, 6, 10, 12, 0, 0));
recordDisclosureShown(t0);
const during = getFunnelPromo({ interactive: true, cwd: stateDir, now: new Date(t0.getTime() + 1000) });
expect(during!.kind).toBe('disclosure');
@@ -1074,7 +1081,7 @@ describe('sponsored-downtime priority override (ADR-313)', () => {
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: after });
expect(row).not.toBeNull();
expect(row!.text).not.toMatch(/sponsor/i);
expect(row!.text).not.toContain('ruflo proxy sponsor');
});
it('shows the enable-CTA when rate-limited without sponsored consent', () => {
@@ -40,9 +40,18 @@ describe('#2797 hooks metrics Pattern Learning counts real writes', () => {
try {
run(['memory', 'init'], wd);
run(['hooks', 'post-task', '-i', 't1', '--success', 'true', '-q', '0.95', '--task', 'some task', '--agent', 'coder', '--store-results'], wd);
run(['hooks', 'post-task', '-i', 't2', '--success', 'false', '-q', '0.2', '--task', 'failed task', '--agent', 'coder', '--store-results'], wd);
const { stdout } = run(['hooks', 'metrics', '--format', 'json'], wd);
const data = JSON.parse(stdout.slice(stdout.indexOf('{')));
expect(data.patterns.total).toBeGreaterThan(0);
expect(data.patterns.total).toBe(2);
expect(data.patterns.successful).toBe(1);
expect(data.patterns.failed).toBe(1);
expect(data.patterns.successful + data.patterns.failed).toBeLessThanOrEqual(data.patterns.total);
expect(data.patterns.described).toBe(2);
expect(data.patterns.descriptionCoverage).toBe(1);
expect(data.agents.routingAccuracy).toBeNull();
expect(data.agents.averageConfidence).toBeGreaterThan(0);
expect(data.agents.outcomeSuccessRate).toBe(0.5);
} finally {
try { rmSync(wd, { recursive: true, force: true }); } catch { /* */ }
}
@@ -84,6 +93,10 @@ describe('#2799 swarm status reflects spawned agents', () => {
// The Agents section's Total must now be 4, not 0. Match the "Total | 4"
// row in the agents table (allow ANSI/box chars around it).
expect(status).toMatch(/Total\D+4/);
expect(status).toMatch(/Active\D+0/);
expect(status).toMatch(/Idle\D+4/);
const list = run(['agent', 'list'], wd).stdout;
expect(list).toMatch(/\| agent-\d+/);
// Sanity: the activity file the fix reads was actually written.
const activityPath = join(wd, '.claude-flow', 'metrics', 'swarm-activity.json');
if (existsSync(activityPath)) {
@@ -83,6 +83,9 @@ describe('#2786 fix-2 — hooks_post-task JSON dual-write for metrics reader', (
expect(entry.metadata?.confidence).toBe(0.9);
// Namespace matches the AgentDB write (patterns) for parity.
expect(entry.namespace).toBe('patterns');
expect(bridgeRecordFeedback).toHaveBeenCalledWith(expect.objectContaining({
task: 'implement auth token refresh',
}));
});
it('does NOT write to store.json when storeDecisions is omitted (backwards compatible)', async () => {
@@ -89,8 +89,8 @@ describe('Docker Compose Configuration', () => {
expect(composeContent).toMatch(/image:\s*mongo:7/);
});
it('mongodb exposes port 27017', () => {
expect(composeContent).toMatch(/"27017:27017"/);
it('mongodb exposes port 27017 on loopback only', () => {
expect(composeContent).toMatch(/"127\.0\.0\.1:27017:27017"/);
});
it('mongodb has a named volume for data persistence', () => {
@@ -102,8 +102,8 @@ describe('Docker Compose Configuration', () => {
expect(composeContent).toMatch(/context:\s*\.\/src\/ruvocal\/mcp-bridge/);
});
it('mcp-bridge publishes port 3001', () => {
expect(composeContent).toMatch(/"3001:3001"/);
it('mcp-bridge publishes port 3001 on loopback only', () => {
expect(composeContent).toMatch(/"127\.0\.0\.1:3001:3001"/);
});
it('mcp-bridge has a healthcheck', () => {
@@ -145,7 +145,9 @@ describe('Docker Compose Configuration', () => {
it('chat-ui injects DOTENV_LOCAL with required env vars', () => {
expect(composeContent).toContain('DOTENV_LOCAL');
expect(composeContent).toContain('MONGODB_URL=mongodb://mongodb:27017');
expect(composeContent).toMatch(
/MONGODB_URL=mongodb:\/\/\$\{MONGO_INITDB_ROOT_USERNAME[^}]*\}:\$\{MONGO_INITDB_ROOT_PASSWORD\}@mongodb:27017/,
);
expect(composeContent).toContain('PUBLIC_APP_NAME');
expect(composeContent).toContain('OPENAI_BASE_URL=http://mcp-bridge:3001');
});
@@ -385,8 +387,8 @@ describe('MCP Bridge Dockerfile', () => {
expect(existsSync(MCP_BRIDGE_DOCKERFILE)).toBe(true);
});
it('uses Node.js 20 slim base image', () => {
expect(dockerContent).toMatch(/FROM\s+node:20-slim/);
it('uses the supported Node.js 24 slim LTS base image', () => {
expect(dockerContent).toMatch(/FROM\s+node:24-slim/);
});
it('installs production dependencies', () => {
@@ -612,7 +614,7 @@ describe('Cross-Service Port Consistency', () => {
const dockerfile = readFile(MCP_BRIDGE_DOCKERFILE);
const compose = readFile(COMPOSE_PATH);
expect(dockerfile).toContain('EXPOSE 3001');
expect(compose).toContain('"3001:3001"');
expect(compose).toContain('"127.0.0.1:3001:3001"');
});
it('nginx Dockerfile EXPOSE matches compose published port', () => {
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
vi.mock('../src/memory/memory-initializer.js', () => ({
generateEmbedding: vi.fn(async (text: string) => ({
embedding: Array.from({ length: 8 }, (_, i) => Math.sin(text.length + i)),
dimensions: 8,
model: 'hash-fallback',
backend: 'mock',
})),
searchEntries: vi.fn(async () => ({ results: [] })),
}));
const { embeddingsTools } = await import('../src/mcp-tools/embeddings-tools.js');
const init = embeddingsTools.find(tool => tool.name === 'embeddings_init')!;
const status = embeddingsTools.find(tool => tool.name === 'embeddings_status')!;
const compare = embeddingsTools.find(tool => tool.name === 'embeddings_compare')!;
let originalCwd: string;
let workdir: string;
beforeEach(async () => {
originalCwd = process.cwd();
workdir = mkdtempSync(join(tmpdir(), 'ruflo-2805-'));
process.chdir(workdir);
await init.handler({ force: true });
});
afterEach(() => {
process.chdir(originalCwd);
rmSync(workdir, { recursive: true, force: true });
});
describe('#2805 embedding backend truthfulness', () => {
it('removes semantic-search claims while the hash fallback is active', async () => {
const result = await status.handler({}) as any;
expect(result.embeddingBackend).toBe('mock');
expect(result.semanticGrounded).toBe(false);
expect(result.capabilities.features).not.toContain('semantic search');
expect(result.warning).toMatch(/not semantically meaningful/i);
});
it('does not interpret hash similarity as semantic similarity', async () => {
const result = await compare.handler({ text1: 'a cat', text2: 'banking regulation' }) as any;
expect(result.embeddingBackend).toBe('mock');
expect(result.semanticGrounded).toBe(false);
expect(result.interpretation).toBeNull();
expect(result.warning).toMatch(/not semantically meaningful/i);
});
});
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OutputFormatter } from '../../cli-core/src/output.js';
describe('#2814 spinner output', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('emits no transient frames or clearing bytes when stdout is not a TTY', () => {
const isTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false });
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
const spinner = new OutputFormatter({ color: false }).createSpinner({ text: 'Checking...' });
spinner.start();
spinner.stop();
expect(write).not.toHaveBeenCalled();
} finally {
if (isTTY) Object.defineProperty(process.stdout, 'isTTY', isTTY);
}
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
const HELPER = resolve(__dirname, '../.claude/helpers/intelligence.cjs');
describe('#2818 intelligence helper project-root storage', () => {
it('writes at CLAUDE_PROJECT_DIR and recovers legacy nested pending insights', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-2818-'));
const nested = join(root, 'src');
const legacyDir = join(nested, '.claude-flow', 'data');
mkdirSync(join(root, '.claude-flow'), { recursive: true });
mkdirSync(legacyDir, { recursive: true });
const legacyLine = JSON.stringify({ type: 'edit', file: 'legacy.ts', success: true });
writeFileSync(join(legacyDir, 'pending-insights.jsonl'), `${legacyLine}\n`, 'utf-8');
try {
execFileSync(process.execPath, ['-e', `require(${JSON.stringify(HELPER)}).recordEdit('current.ts', true)`], {
cwd: nested,
env: { ...process.env, CLAUDE_PROJECT_DIR: root },
});
const rootPending = join(root, '.claude-flow', 'data', 'pending-insights.jsonl');
expect(existsSync(rootPending)).toBe(true);
const contents = readFileSync(rootPending, 'utf-8');
expect(contents).toContain(legacyLine);
expect(contents).toContain('"file":"current.ts"');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
assessMcpSchemaOverhead,
filterAdvertisedMcpTools,
parseMcpToolSelection,
} from '../src/mcp-server.js';
const tools = [
{
name: 'memory_store',
category: 'memory',
description: 'Store persistent memory',
inputSchema: { type: 'object', properties: { value: { type: 'string' } } },
},
{
name: 'memory_search',
category: 'memory',
description: 'Search persistent memory',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
},
{
name: 'swarm_init',
category: 'swarm',
description: 'Initialize a swarm',
inputSchema: { type: 'object', properties: { topology: { type: 'string' } } },
},
{
name: 'aidefence_scan',
category: 'security',
description: 'Scan untrusted content',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
},
];
describe('MCP advertised-tool filtering (#2726)', () => {
it('keeps all schemas by default for backwards compatibility', () => {
expect(parseMcpToolSelection(undefined)).toBe('all');
expect(filterAdvertisedMcpTools(tools, 'all')).toEqual(tools);
});
it('accepts categories, namespace prefixes, and exact tool names', () => {
expect(filterAdvertisedMcpTools(tools, ['memory']).map((tool) => tool.name))
.toEqual(['memory_store', 'memory_search']);
expect(filterAdvertisedMcpTools(tools, ['security']).map((tool) => tool.name))
.toEqual(['aidefence_scan']);
expect(filterAdvertisedMcpTools(tools, ['swarm_init']).map((tool) => tool.name))
.toEqual(['swarm_init']);
});
it('materially reduces the fixed tools/list schema payload', () => {
const full = assessMcpSchemaOverhead(tools, 32_000);
const selected = filterAdvertisedMcpTools(tools, ['memory']);
const reduced = assessMcpSchemaOverhead(selected, 32_000);
expect(selected).toHaveLength(2);
expect(reduced.bytes).toBeLessThan(full.bytes);
expect(reduced.estimatedTokens).toBeLessThan(full.estimatedTokens);
});
it('flags catalogues consuming at least 20% of a configured context window', () => {
const oversized = Array.from({ length: 100 }, (_, index) => ({
name: `tool_${index}`,
description: 'x'.repeat(300),
inputSchema: { type: 'object', properties: { value: { type: 'string' } } },
}));
expect(assessMcpSchemaOverhead(oversized, 32_000)).toMatchObject({
toolCount: 100,
contextWindowTokens: 32_000,
risk: 'high',
});
});
it('parses CLAUDE_FLOW_MCP_TOOLS-compatible comma lists', () => {
expect(parseMcpToolSelection(' memory, swarm_init ,security '))
.toEqual(['memory', 'swarm_init', 'security']);
expect(parseMcpToolSelection(' ')).toBe('all');
});
it('applies filtering in the executable MCP fast path', () => {
const executable = readFileSync(join(process.cwd(), 'bin', 'cli.js'), 'utf8');
expect(executable).toContain('_filterAdvertisedMcpTools(listMCPTools())');
expect(executable).toContain('CLAUDE_FLOW_MCP_TOOLS');
expect(executable).toContain("arg === '--tools'");
});
});
@@ -31,6 +31,11 @@ function seedDb(dbPath: string): void {
success BOOLEAN DEFAULT 0, latency_ms INTEGER, tokens_used INTEGER, tags TEXT,
metadata JSON, created_at INTEGER DEFAULT 0
)`);
db.exec(`CREATE TABLE episode_embeddings (
episode_id INTEGER PRIMARY KEY, embedding BLOB NOT NULL,
embedding_model TEXT DEFAULT 'all-MiniLM-L6-v2',
FOREIGN KEY(episode_id) REFERENCES episodes(id) ON DELETE CASCADE
)`);
db.exec(`CREATE TABLE reasoning_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER DEFAULT 0, task_type TEXT NOT NULL,
approach TEXT NOT NULL, success_rate REAL NOT NULL DEFAULT 0, uses INTEGER DEFAULT 0,
@@ -62,6 +67,7 @@ function counts(dbPath: string): Record<string, number> {
const q = (t: string) => (db.prepare(`SELECT COUNT(*) AS c FROM ${t}`).get() as { c: number }).c;
const r = {
memory_entries: q('memory_entries'), episodes: q('episodes'),
episode_embeddings: q('episode_embeddings'),
reasoning_patterns: q('reasoning_patterns'), pattern_embeddings: q('pattern_embeddings'),
causal_edges: q('causal_edges'),
};
@@ -89,8 +95,18 @@ describe.skipIf(!haveNative)('runDistillation — memory → structured intellig
expect(after.memory_entries).toBe(before.memory_entries); // source untouched
expect(after.reasoning_patterns).toBeGreaterThan(0);
expect(after.episodes).toBeGreaterThan(0);
// ReflexionMemory uses an INNER JOIN, so episode coverage must be 1:1.
expect(after.episode_embeddings).toBe(after.episodes);
expect(r.episodeEmbeddings).toBe(r.episodes);
// Every pattern with a source embedding gets a pattern_embedding.
expect(after.pattern_embeddings).toBe(after.reasoning_patterns);
const check = new Database(dbPath, { readonly: true });
const feedbackWithCritique = (check.prepare(
"SELECT COUNT(*) AS c FROM episodes WHERE session_id='distill:feedback' AND length(trim(coalesce(critique,''))) > 0",
).get() as { c: number }).c;
expect(feedbackWithCritique).toBeGreaterThan(0);
check.close();
});
it('enforces ADR-171 provenance: feedback promotes, proxy never does', async () => {
@@ -134,6 +150,42 @@ describe.skipIf(!haveNative)('runDistillation — memory → structured intellig
expect(r.byProvenance['proxy:structural']).toBeUndefined();
});
it('migrates an older episodes-only store and makes distilled episodes retrievable', async () => {
const dbPath = join(workdir, 'legacy-reflexion.db');
seedDb(dbPath);
const before = new Database(dbPath);
before.prepare(
'INSERT INTO episodes (session_id, task, input, metadata) VALUES (?,?,?,?)',
).run(
'distill:feedback',
'legacy unembedded episode',
JSON.stringify({ taskId: 'legacy', success: false, error: 'old failure' }),
JSON.stringify({ sourceIds: ['f2'], provenance: 'oracle:test-exec' }),
);
before.exec('DROP TABLE episode_embeddings');
before.close();
const r = await runDistillation({ dbPath });
// Five new episodes plus the pre-existing legacy row are embedded.
expect(r.episodeEmbeddings).toBeGreaterThan(r.episodes);
const after = new Database(dbPath, { readonly: true });
const coverage = after.prepare(`
SELECT
(SELECT COUNT(*) FROM episodes) AS episodes,
(SELECT COUNT(*) FROM episode_embeddings) AS embeddings,
(SELECT COUNT(*) FROM episodes e
JOIN episode_embeddings ee ON ee.episode_id = e.id) AS retrievable
`).get() as { episodes: number; embeddings: number; retrievable: number };
expect(coverage.embeddings).toBe(coverage.episodes);
expect(coverage.retrievable).toBe(coverage.episodes);
const missingCritique = (after.prepare(
"SELECT COUNT(*) AS c FROM episodes WHERE session_id LIKE 'distill:feedback%' AND length(trim(coalesce(critique,'')))=0",
).get() as { c: number }).c;
expect(missingCritique).toBe(0);
after.close();
});
it('skips (does not throw) when target tables are absent', async () => {
const dbPath = join(workdir, 'bare.db');
const db = new Database(dbPath);
@@ -108,7 +108,7 @@ describe('statusline cost display — committed artifact drift guard', () => {
it('matches the generator output for default options', () => {
const artifact = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../../.claude/helpers/statusline.cjs',
'../.claude/helpers/statusline.cjs',
);
if (!existsSync(artifact)) return; // package tested in isolation; nothing to guard
// #2679 fix: generator now reads .claude/helpers/statusline.cjs as its
@@ -127,7 +127,9 @@ describe('statusline cost display — committed artifact drift guard', () => {
// environment can still see the two versions differ — that's not
// real drift, just resolution locale. Normalize before compare.
const normalizeVer = (s: string): string =>
s.replace(/let ver = "[^"]+";/, 'let ver = "X.Y.Z";');
s
.replace(/let ver = "[^"]+";/, 'let ver = "X.Y.Z";')
.replace(/const BAKED_INSTALL_ROOT = "[^"]*";/, 'const BAKED_INSTALL_ROOT = "PACKAGE_ROOT";');
expect(normalizeVer(readFileSync(artifact, 'utf-8'))).toBe(normalizeVer(SCRIPT));
});
});
@@ -203,6 +205,11 @@ describe('getPkgVersion() — highest candidate wins, not first-found', () => {
expect(SCRIPT).toContain('compareVersions(pkg.version, ver) > 0');
});
it('includes the baked install root and common ~/.local npm prefix', () => {
expect(SCRIPT).toContain('const BAKED_INSTALL_ROOT = ');
expect(SCRIPT).toContain("path.join(home, '.local')");
});
it('renders the HIGHER of two real candidate package.json versions', () => {
const home = mkdtempSync(path.join(tmpdir(), 'ruflo-statusline-ver-home-'));
const cwd = mkdtempSync(path.join(tmpdir(), 'ruflo-statusline-ver-cwd-'));
+25 -1
View File
@@ -34,6 +34,30 @@ import { dirname, join } from 'path';
const _origWarn = console.warn;
const _origLog = console.log;
const _origError = console.error;
// #2726 — Some MCP clients place every advertised schema in every model
// request. Keep "all" as the backwards-compatible default, but honor the
// same category/namespace/exact-name selector as `mcp start --tools`.
function _filterAdvertisedMcpTools(tools) {
const argv = process.argv.slice(2);
const toolsIndex = argv.findIndex((arg) => arg === '--tools');
const inlineTools = argv.find((arg) => arg.startsWith('--tools='));
const configured = process.env.CLAUDE_FLOW_MCP_TOOLS
|| (toolsIndex >= 0 ? argv[toolsIndex + 1] : undefined)
|| (inlineTools ? inlineTools.slice('--tools='.length) : undefined);
if (!configured || configured.trim().toLowerCase() === 'all') return tools;
const selectors = new Set(
configured.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean),
);
if (selectors.size === 0) return tools;
return tools.filter((tool) => {
const name = tool.name.toLowerCase();
const category = typeof tool.category === 'string' ? tool.category.toLowerCase() : '';
return selectors.has(name)
|| selectors.has(category)
|| Array.from(selectors).some((selector) => name.startsWith(`${selector}_`));
});
}
const _isCosmeticAgentdbPatchNoise = (msg) =>
msg.includes('[AgentDB Patch]') && msg.includes('Controller index not found');
const _STDERR_REDIRECT_PREFIXES = [
@@ -237,7 +261,7 @@ if (isMCPMode) {
};
case 'tools/list': {
const tools = listMCPTools();
const tools = _filterAdvertisedMcpTools(listMCPTools());
return {
jsonrpc: '2.0',
id: message.id,
+2 -2
View File
@@ -1,8 +1,8 @@
{
"schemaVersion": 1,
"generation": 4,
"generatedAt": "2026-07-29T17:53:37.877Z",
"gitSha": "9cf769cc",
"generatedAt": "2026-07-29T18:44:50.000Z",
"gitSha": "f98fc42c",
"catalog": {
"agents": 164,
"tools": 397,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@claude-flow/cli",
"version": "3.32.35",
"version": "3.32.36",
"type": "module",
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
"main": "dist/src/index.js",
+31 -3
View File
@@ -15,10 +15,13 @@
* 2. RUFLO_HELPERS_SIGNING_KEY=<pem-file-path> (local / air-gapped)
* 3. ~/.ruflo/helpers-signing.key (dev default)
*
* Stdin fallback (the PEM never appears in shell output or argv):
* gcloud secrets versions access latest --secret=... | node scripts/sign-helpers.mjs --stdin-key
*
* Usage: RUFLO_HELPERS_SIGNING_SECRET=ruflo-helpers-signing-key node scripts/sign-helpers.mjs
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createHash, sign as edSign } from 'node:crypto';
import { createHash, createPrivateKey, sign as edSign } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -31,19 +34,36 @@ const HELPERS_DIR = join(PKG_ROOT, '.claude', 'helpers');
const CRITICAL = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs'];
function loadPrivateKey() {
if (process.argv.includes('--stdin-key')) {
if (process.stdin.isTTY) {
console.error(
'[sign-helpers] --stdin-key requires piped stdin; refusing interactive key entry. ' +
'Pipe Secret Manager output directly or use RUFLO_HELPERS_SIGNING_KEY=<pem-file-path>.',
);
process.exit(1);
}
const key = readFileSync(0, 'utf-8');
if (!key.trim()) {
console.error('[sign-helpers] --stdin-key received empty input');
process.exit(1);
}
return key;
}
const secret = process.env.RUFLO_HELPERS_SIGNING_SECRET;
if (secret) {
const args = ['secrets', 'versions', 'access', 'latest', '--secret', secret];
const project = process.env.RUFLO_HELPERS_SIGNING_PROJECT;
if (project) args.push('--project', project);
try {
const gcloudBin = process.platform === 'win32' ? 'gcloud.cmd' : 'gcloud';
// stdio: key on stdout (captured), stderr captured too — NOT inherited.
// Inheriting would forward gcloud's stderr verbatim into whatever log
// is capturing this process's output (CI, terminal); this key material
// is sensitive enough that we never want an uncontrolled passthrough,
// even though gcloud's normal error paths don't echo secret payloads.
return execFileSync('gcloud', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
return execFileSync(gcloudBin, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
} catch {
console.error(`[sign-helpers] failed to read GCP secret '${secret}'. Is gcloud authed? (gcloud auth login)`);
process.exit(1);
}
@@ -60,6 +80,14 @@ function loadPrivateKey() {
}
const privateKeyPem = loadPrivateKey();
try {
const key = createPrivateKey(privateKeyPem);
if (key.asymmetricKeyType !== 'ed25519') throw new Error('wrong-key-type');
} catch {
// Never include the PEM or the crypto parser's input in diagnostics.
console.error('[sign-helpers] signing key is not a valid Ed25519 private key');
process.exit(1);
}
const version = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
const files = {};
+10 -7
View File
@@ -24,15 +24,18 @@ const HELPERS_DIR = resolve(process.argv[2] || join(PKG_ROOT, '.claude', 'helper
// Keep in sync with sign-helpers.mjs:CRITICAL and src/init/helper-refresh.ts:CRITICAL_HELPERS.
const CRITICAL = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs'];
// KEEP IN SYNC with src/init/helper-signing.ts:RUFLO_HELPERS_PUBKEY.
// Rotated 2026-07-14 (v3.29.0) after the previous private key was exposed in
// a Claude Code session transcript. Old GCP secret v1 destroyed.
const RUFLO_HELPERS_PUBKEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAyLl9cG+V/C+ffKWaSwvOsHdXSWmB5e3x1z9NUNvq6Ys=
-----END PUBLIC KEY-----`;
function die(msg) { console.error(`[verify-helpers] ${msg}`); process.exit(1); }
// #2675 — helper-signing.ts is the sole key source. prepare-publish.mjs and
// stable-npm-release.yml both build before verification, so importing emitted
// runtime code preserves the exact key users will trust after installation.
let RUFLO_HELPERS_PUBKEY;
try {
({ RUFLO_HELPERS_PUBKEY } = await import('../dist/src/init/helper-signing.js'));
} catch {
die('compiled helper-signing.js is unavailable; run `npm run build` before verification');
}
const manifestPath = join(HELPERS_DIR, 'helpers.manifest.json');
if (!existsSync(manifestPath)) die(`missing manifest: ${manifestPath}`);
+3 -3
View File
@@ -236,9 +236,9 @@ const listCommand: Command = {
// Call MCP tool to list agents
const result = await callMCPTool<{
agents: Array<{
id: string;
agentId: string;
agentType: string;
status: 'active' | 'idle' | 'terminated';
status: 'active' | 'busy' | 'idle' | 'terminated';
createdAt: string;
lastActivityAt?: string;
}>;
@@ -265,7 +265,7 @@ const listCommand: Command = {
// Format for display
const displayAgents = result.agents.map(agent => ({
id: agent.id,
id: agent.agentId,
type: agent.agentType,
status: agent.status,
created: new Date(agent.createdAt).toLocaleTimeString(),
+154 -11
View File
@@ -139,8 +139,8 @@ async function checkConfigFile(): Promise<HealthCheck> {
// Check daemon status
/**
* #2448 — Detect the runaway `npx @claude-flow/cli@latest` statusLine / hook
* commands left over in `.claude/settings.json` from pre-#2337 installs.
* #2448 / #2677 — Detect runaway `npx @claude-flow/cli@latest` commands
* left over in `.claude/settings.json` from pre-#2337 installs.
*
* These fire on every Claude Code event (statusLine refires every few hundred
* ms, hooks fire per tool-use), each spawning a cold Node process + npm
@@ -157,7 +157,9 @@ async function checkStaleSettingsNpx(): Promise<HealthCheck> {
// Same regex pattern the executor migration uses — kept in sync. Flag-list
// repetition bounded at 10 (CodeQL js/redos — unbounded `*` here is
// exponential-backtracking-prone on a crafted settings.json).
const BROKEN_RE = /npx\s+(?:--?\S+\s+){0,10}@?claude-flow\/cli@latest\s+hooks\s+(?:statusline|\S+)/;
// Every subcommand incurs the same cold npm process/registry cost. The
// previous `hooks` literal missed `memory`, `daemon`, `swarm`, etc.
const BROKEN_RE = /npx\s+(?:--?\S+\s+){0,10}@?claude-flow\/cli@latest\s+\S+/;
// Look in both project-local and home-dir settings.
const candidates = [
@@ -290,18 +292,21 @@ async function checkMemoryDatabase(): Promise<HealthCheck> {
// The existing `checkMemoryDatabase` above asserts existence + statability
// only, so it CANNOT distinguish a healthy DB from a 99.97%-empty or
// SQLite-malformed one. Stuinfla reported both cases live (81-store fleet).
// The three checks below layer functional assertions on top, ordered so
// The checks below layer functional assertions on top, ordered so
// the earliest chain-break is always the first red the user sees:
// 1. Integrity — can sql.js open it AND does PRAGMA integrity_check pass?
// 2. Content — do most memory_entries rows carry non-empty content?
// 3. Embedding coverage — do most rows have a vector? (unembedded rows are
// both unrecallable AND undistillable per ADR-174)
// 6. Reflexion coverage — can episodes participate in retrieveRelevant's
// required episode_embeddings INNER JOIN?
// Feedback critiques — do execution-tier episodes carry a real lesson?
// Ordering matters: content ratio is meaningless on a DB that can't open;
// embedding coverage is meaningless on rows with no content. First red wins.
//
// Recall probe (stuinfla check 4) requires actual write+search+delete round
// trips through the CLI's own memory pipeline — deferred to a follow-up PR
// to keep this one purely additive and safe.
// Recall probe (stuinfla check 4) still requires an isolated write+search+
// delete round trip through the CLI's own memory pipeline. It remains separate
// because doctor otherwise stays read-only.
//
// Design rules (also from stuinfla's report):
// - "A check that cannot fail protects nothing" — every check has a
@@ -722,6 +727,92 @@ async function checkMemoryEmbeddingCoverage(): Promise<HealthCheck> {
} finally { try { db.close(); } catch { /* best-effort */ } }
}
// #2677 check 6 — ReflexionMemory.retrieveRelevant() INNER JOINs episodes to
// episode_embeddings. A populated episodes table with an empty/missing partner
// table is therefore not degraded recall; it is structurally zero recall.
async function checkMemoryReflexionCoverage(): Promise<HealthCheck> {
const dbPath = await resolveMemoryDbPath();
if (!dbPath) return { name: 'Memory Reflexion Coverage', status: 'warn', message: 'no memory.db found' };
const db = await tryOpenSqlJs(dbPath);
if (!db) return { name: 'Memory Reflexion Coverage', status: 'warn', message: 'DB unreadable (see Memory Integrity)' };
try {
const tables = new Set<string>(
db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('episodes','episode_embeddings')")
[0]?.values?.map((v: any[]) => String(v[0])) ?? [],
);
if (!tables.has('episodes')) {
return { name: 'Memory Reflexion Coverage', status: 'warn', message: 'episodes table absent — AgentDB Reflexion schema not initialized' };
}
const episodes = Number(db.exec('SELECT count(*) FROM episodes')[0]?.values?.[0]?.[0] ?? 0);
if (episodes === 0) {
return { name: 'Memory Reflexion Coverage', status: 'warn', message: `${dbPath} — 0 episodes (Reflexion has not been exercised)` };
}
if (!tables.has('episode_embeddings')) {
return {
name: 'Memory Reflexion Coverage',
status: 'fail',
message: `${dbPath} — episode embeddings 0/${episodes} (0.00%); retrieveRelevant() cannot return rows`,
fix: 'run `claude-flow memory distill run`; current distillation creates/backfills episode_embeddings',
};
}
const embedded = Number(db.exec(
'SELECT count(*) FROM episodes e JOIN episode_embeddings ee ON ee.episode_id=e.id',
)[0]?.values?.[0]?.[0] ?? 0);
const ratio = embedded / episodes;
const detail = `episode embeddings ${embedded}/${episodes} (${(ratio * 100).toFixed(2)}%)`;
if (ratio < 0.95) {
return {
name: 'Memory Reflexion Coverage',
status: 'fail',
message: `${dbPath} — ${detail} below 95% floor; retrieveRelevant() cannot see uncovered episodes`,
fix: 'run `claude-flow memory distill run` to populate retrievable episodes',
};
}
return { name: 'Memory Reflexion Coverage', status: 'pass', message: `${dbPath} — ${detail}` };
} catch (e) {
return { name: 'Memory Reflexion Coverage', status: 'warn', message: `${dbPath} — probe threw: ${(e as Error).message || String(e)}` };
} finally { try { db.close(); } catch { /* best-effort */ } }
}
// Execution-tier feedback should carry a lesson, not just reward/success bits.
// Proxy episodes intentionally remain critique-free to avoid laundering a
// structural summary into an observed failure explanation.
async function checkMemoryCritiqueCoverage(): Promise<HealthCheck> {
const dbPath = await resolveMemoryDbPath();
if (!dbPath) return { name: 'Memory Feedback Critiques', status: 'warn', message: 'no memory.db found' };
const db = await tryOpenSqlJs(dbPath);
if (!db) return { name: 'Memory Feedback Critiques', status: 'warn', message: 'DB unreadable (see Memory Integrity)' };
try {
const hasEpisodes = (db.exec(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='episodes'",
)[0]?.values?.[0]?.[0] ?? 0) > 0;
if (!hasEpisodes) return { name: 'Memory Feedback Critiques', status: 'warn', message: 'episodes table absent' };
const row = db.exec(`
SELECT count(*),
sum(CASE WHEN length(trim(coalesce(critique,''))) > 0 THEN 1 ELSE 0 END)
FROM episodes WHERE session_id LIKE 'distill:feedback%'
`)[0]?.values?.[0] ?? [0, 0];
const feedback = Number(row[0] ?? 0);
const withCritique = Number(row[1] ?? 0);
if (feedback === 0) {
return { name: 'Memory Feedback Critiques', status: 'warn', message: `${dbPath} — 0 execution-feedback episodes to assess` };
}
const ratio = withCritique / feedback;
const detail = `feedback critiques ${withCritique}/${feedback} (${(ratio * 100).toFixed(2)}%)`;
if (ratio < 0.95) {
return {
name: 'Memory Feedback Critiques',
status: 'fail',
message: `${dbPath} — ${detail} below 95% floor`,
fix: 'run current `claude-flow memory distill run`; execution-tier episodes preserve observed critique/error text',
};
}
return { name: 'Memory Feedback Critiques', status: 'pass', message: `${dbPath} — ${detail}` };
} catch (e) {
return { name: 'Memory Feedback Critiques', status: 'warn', message: `${dbPath} — probe threw: ${(e as Error).message || String(e)}` };
} finally { try { db.close(); } catch { /* best-effort */ } }
}
// #2545: Check that the self-learning bridge can actually load @claude-flow/memory
// the SAME way the SessionStart auto-memory hook does. On the documented `npx ruflo`
// path the package lands in the npx cache — unreachable from the project — so the
@@ -1037,6 +1128,53 @@ async function checkMcpServers(): Promise<HealthCheck> {
};
}
// #2726 — tools/list is fixed prompt overhead on clients/backends that do not
// defer MCP schemas. Measure the actual live registry and warn before a small
// context window becomes unrecoverable even after compaction.
async function checkMcpSchemaOverhead(): Promise<HealthCheck> {
try {
const [{ listMCPTools }, {
assessMcpSchemaOverhead,
filterAdvertisedMcpTools,
parseMcpToolSelection,
}] = await Promise.all([
import('../mcp-client.js'),
import('../mcp-server.js'),
]);
// The `mcp start --tools` CLI flag takes precedence when the server is
// constructed. Doctor intentionally inspects the environment fallback so
// its estimate describes the configuration inherited by MCP clients.
const selection = parseMcpToolSelection(process.env.CLAUDE_FLOW_MCP_TOOLS);
const tools = filterAdvertisedMcpTools(listMCPTools(), selection);
// This is diagnostic metadata about the external client's context window,
// not Ruflo command configuration, so there is no corresponding CLI flag.
const rawWindow = process.env.CLAUDE_FLOW_CONTEXT_WINDOW_TOKENS;
const contextWindow = rawWindow ? Number.parseInt(rawWindow, 10) : undefined;
const assessment = assessMcpSchemaOverhead(tools, contextWindow);
const ratio = assessment.ratio === undefined
? ''
: `, ${(assessment.ratio * 100).toFixed(1)}% of ${assessment.contextWindowTokens}-token window`;
const selectionLabel = selection === 'all' ? 'all tools' : `filtered: ${selection.join(',')}`;
const message = `${assessment.toolCount} advertised tools ≈ ${assessment.estimatedTokens} schema tokens (${selectionLabel}${ratio})`;
if (assessment.risk === 'high') {
return {
name: 'MCP Schema Overhead',
status: 'warn',
message,
fix: 'Set CLAUDE_FLOW_MCP_TOOLS to required categories/tool names (for example: memory,swarm,agent,hooks) and optionally CLAUDE_FLOW_CONTEXT_WINDOW_TOKENS to your backend limit.',
};
}
return { name: 'MCP Schema Overhead', status: 'pass', message };
} catch (error) {
return {
name: 'MCP Schema Overhead',
status: 'warn',
message: `Unable to measure: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
// Check disk space (async with proper env inheritance)
async function checkDiskSpace(): Promise<HealthCheck> {
try {
@@ -1887,7 +2025,7 @@ export const doctorCommand: Command = {
{
name: 'component',
short: 'c',
description: 'Check specific component (version, node, npm, config, daemon, memory, api, git, mcp, claude, disk, typescript, agentic-flow, encryption, federation, funnel, proxy, auth, metaharness)',
description: 'Check specific component (version, node, npm, config, daemon, memory, api, git, mcp, mcp-overhead, claude, disk, typescript, agentic-flow, encryption, federation, funnel, proxy, auth, metaharness)',
type: 'string'
},
{
@@ -2010,13 +2148,14 @@ export const doctorCommand: Command = {
checkGit,
checkGitRepo,
checkConfigFile,
checkStaleSettingsNpx, // #2448 — runaway `npx @latest` in statusLine/hooks
checkStaleSettingsNpx, // #2448/#2677 — runaway `npx @latest` in settings
checkDaemonStatus,
checkMemoryDatabase,
checkMemoryStructuralIntegrity, // #2737 — bounded, native quick_check on every default run
checkLearningBridge, // #2545 — can the auto-memory hook actually load @claude-flow/memory?
checkApiKeys,
checkMcpServers,
checkMcpSchemaOverhead, // #2726 — fixed tools/list prompt cost
checkAIDefence, // #1807
checkDiskSpace,
checkBuildTools,
@@ -2035,8 +2174,9 @@ export const doctorCommand: Command = {
// array — expanded at execution time. Stuinfla's report showed the
// existence-only check reporting PASS on a 99.97%-empty and even a
// SQLite-malformed DB; the array here layers integrity → content →
// embedding coverage over the existing existence probe, ordered so
// the earliest chain-break is always the first red the user sees.
// embedding coverage over the existing existence probe; check 6 then
// verifies that distilled episodes are actually Reflexion-retrievable and
// execution feedback carries a lesson.
const componentMap: Record<string, (() => Promise<HealthCheck>) | Array<() => Promise<HealthCheck>>> = {
'version': checkVersionFreshness,
'freshness': checkVersionFreshness,
@@ -2051,12 +2191,15 @@ export const doctorCommand: Command = {
checkMemoryIntegrity, // #2677 check 1: sql.js open + PRAGMA integrity_check
checkMemoryContent, // #2677 check 2: memory_entries content coverage
checkMemoryEmbeddingCoverage, // #2677 check 3: vector coverage on populated rows
checkMemoryReflexionCoverage, // #2677 check 6: episodes are retrievable
checkMemoryCritiqueCoverage, // #2677 check 6: feedback carries lessons
],
'learning': checkLearningBridge, // #2545
'learning-bridge': checkLearningBridge, // #2545
'api': checkApiKeys,
'git': checkGit,
'mcp': checkMcpServers,
'mcp-overhead': checkMcpSchemaOverhead,
'aidefence': checkAIDefence, // #1807
'disk': checkDiskSpace,
'typescript': checkBuildTools,
+48 -4
View File
@@ -1486,6 +1486,9 @@ const metricsCommand: Command = {
total?: number;
successful?: number;
failed?: number;
unknown?: number;
described?: number;
descriptionCoverage?: number;
avgConfidence?: number;
};
routing?: {
@@ -1495,6 +1498,8 @@ const metricsCommand: Command = {
};
agents?: {
routingAccuracy?: number;
averageConfidence?: number;
outcomeSuccessRate?: number;
totalRoutes?: number;
topAgent?: string;
};
@@ -1524,9 +1529,25 @@ const metricsCommand: Command = {
const totalPatterns = safeNum(rawMetrics.patterns?.total ?? rawMetrics.summary?.patternsLearned);
const successfulPatterns = safeNum(rawMetrics.patterns?.successful ?? Math.round(safeNum(rawMetrics.summary?.successRate) * totalPatterns));
const failedPatterns = Math.max(0, safeNum(rawMetrics.patterns?.failed ?? totalPatterns - successfulPatterns));
const unknownPatterns = Math.max(0, safeNum(rawMetrics.patterns?.unknown));
const describedPatterns = Math.max(0, safeNum(rawMetrics.patterns?.described));
const descriptionCoverage = safeNum(
rawMetrics.patterns?.descriptionCoverage ??
(totalPatterns > 0 ? describedPatterns / totalPatterns : 0),
);
const avgConfidence = safeNum(rawMetrics.patterns?.avgConfidence ?? rawMetrics.summary?.avgQuality);
const routingAccuracy = safeNum(rawMetrics.agents?.routingAccuracy ?? rawMetrics.routing?.avgConfidence);
const routingAccuracy = rawMetrics.agents?.routingAccuracy == null
? null
: safeNum(rawMetrics.agents.routingAccuracy);
const averageRoutingConfidence = safeNum(
rawMetrics.agents?.averageConfidence ??
rawMetrics.routing?.avgConfidence ??
rawMetrics.patterns?.avgConfidence,
);
const routingOutcomeSuccessRate = rawMetrics.agents?.outcomeSuccessRate == null
? null
: safeNum(rawMetrics.agents.outcomeSuccessRate);
const totalRoutes = safeNum(rawMetrics.agents?.totalRoutes ?? rawMetrics.routing?.totalRoutes);
const topAgent = rawMetrics.agents?.topAgent ?? rawMetrics.routing?.topAgents?.[0]?.agent ?? 'n/a';
@@ -1536,8 +1557,22 @@ const metricsCommand: Command = {
const result = {
...rawMetrics,
patterns: { total: totalPatterns, successful: successfulPatterns, failed: failedPatterns, avgConfidence },
agents: { routingAccuracy, totalRoutes, topAgent },
patterns: {
total: totalPatterns,
successful: successfulPatterns,
failed: failedPatterns,
unknown: unknownPatterns,
described: describedPatterns,
descriptionCoverage,
avgConfidence,
},
agents: {
routingAccuracy,
averageConfidence: averageRoutingConfidence,
outcomeSuccessRate: routingOutcomeSuccessRate,
totalRoutes,
topAgent,
},
commands: { totalExecuted: totalCommands, successRate: commandSuccessRate, avgRiskScore },
};
@@ -1557,6 +1592,8 @@ const metricsCommand: Command = {
{ metric: 'Total Patterns', value: totalPatterns },
{ metric: 'Successful', value: output.success(String(successfulPatterns)) },
{ metric: 'Failed', value: output.error(String(failedPatterns)) },
...(unknownPatterns > 0 ? [{ metric: 'Unclassified', value: String(unknownPatterns) }] : []),
{ metric: 'With Task Context', value: `${(descriptionCoverage * 100).toFixed(1)}%` },
{ metric: 'Avg Confidence', value: `${(avgConfidence * 100).toFixed(1)}%` }
]
});
@@ -1571,7 +1608,14 @@ const metricsCommand: Command = {
{ key: 'value', header: 'Value', width: 20, align: 'right' }
],
data: [
{ metric: 'Routing Accuracy', value: `${(routingAccuracy * 100).toFixed(1)}%` },
{
metric: routingAccuracy === null ? 'Avg Confidence' : 'Routing Accuracy',
value: `${((routingAccuracy ?? averageRoutingConfidence) * 100).toFixed(1)}%`,
},
...(routingOutcomeSuccessRate === null ? [] : [{
metric: 'Outcome Success Rate',
value: `${(routingOutcomeSuccessRate * 100).toFixed(1)}%`,
}]),
{ metric: 'Total Routes', value: totalRoutes },
{ metric: 'Top Agent', value: output.highlight(topAgent) }
]
+1 -1
View File
@@ -77,7 +77,7 @@ const startCommand: Command = {
},
{
name: 'tools',
description: 'Tools to enable (comma-separated or "all")',
description: 'Tools to advertise (comma-separated categories, prefixes, exact names, or "all")',
type: 'string',
default: 'all'
},
@@ -217,6 +217,7 @@ const runCommand: Command = {
data: [
{ metric: 'Processed', value: String(report.processed) },
{ metric: 'Episodes', value: String(report.episodes) },
{ metric: 'Episode Embeddings', value: String(report.episodeEmbeddings) },
{ metric: 'Patterns', value: String(report.patterns) },
{ metric: 'Pattern Embeddings', value: String(report.patternEmbeddings) },
{ metric: 'Causal Edges', value: String(report.causalEdges) },
+29
View File
@@ -54,6 +54,35 @@ function getSwarmStatus(swarmId?: string) {
}
}
// The canonical agent registry is the same source used by `agent list`.
// Prefer it over the swarm-level coordination boolean so idle agents are
// not reported as active (#2808). Hive agents are merged additively.
if (totalAgents === 0) {
try {
const canonicalPath = path.join(process.cwd(), '.claude-flow', 'agents', 'store.json');
const hivePath = path.join(process.cwd(), '.claude-flow', 'agents.json');
const merged: Record<string, { status?: string }> = {};
for (const storePath of [hivePath, canonicalPath]) {
if (!fs.existsSync(storePath)) continue;
const parsed = JSON.parse(fs.readFileSync(storePath, 'utf-8'));
if (parsed?.agents && typeof parsed.agents === 'object') {
Object.assign(merged, parsed.agents);
}
}
const agents = Object.values(merged).filter(agent => agent.status !== 'terminated');
if (agents.length > 0) {
totalAgents = agents.length;
activeAgents = agents.filter(agent =>
agent.status === 'active' ||
agent.status === 'running' ||
agent.status === 'busy'
).length;
}
} catch {
// Ignore — the count-only activity file remains the final fallback.
}
}
// #2799 — `agent spawn` never writes `.swarm/agents/*.json`; it records
// the count in `.claude-flow/metrics/swarm-activity.json` (via
// updateSwarmActivityMetrics in commands/agent.ts). So when the agents
+16 -9
View File
@@ -430,10 +430,11 @@ function mergeSettingsForUpgrade(existing: Record<string, unknown>): Record<stri
// js/redos): a crafted settings.json command string with dozens of
// dash-token repetitions can hang this check for minutes.
const BROKEN_STATUSLINE_RE = /(?:npx\s+(?:--?\S+\s+){0,10})?@?claude-flow(?:\/cli)?(?:@\S+)?\s+hooks\s+statusline/;
const BROKEN_NPX_LATEST_RE = /npx\s+(?:--?\S+\s+){0,10}@?claude-flow\/cli@latest\s+\S+/;
const existingStatusLine = existing.statusLine as Record<string, unknown> | undefined;
if (existingStatusLine) {
const existingCmd = typeof existingStatusLine.command === 'string' ? existingStatusLine.command : '';
const isBroken = BROKEN_STATUSLINE_RE.test(existingCmd);
const isBroken = BROKEN_STATUSLINE_RE.test(existingCmd) || BROKEN_NPX_LATEST_RE.test(existingCmd);
merged.statusLine = {
type: 'command',
command: isBroken || !existingCmd ? NEW_STATUSLINE_CMD : existingCmd,
@@ -441,8 +442,8 @@ function mergeSettingsForUpgrade(existing: Record<string, unknown>): Record<stri
};
}
// #2448 — Detect and REGENERATE per-action hook commands that still use
// the runaway `npx @claude-flow/cli@latest hooks <sub>` form. These fire
// #2448 / #2677 — Detect and REGENERATE per-action hook commands that still
// use the runaway `npx @claude-flow/cli@latest hooks <sub>` form. These fire
// on every PreToolUse/PostToolUse/UserPromptSubmit, each spawning ~130 MB
// of cold Node + npm registry resolution; the storm is what kernel-paniced
// the reporter's machine in #2448.
@@ -468,13 +469,19 @@ function mergeSettingsForUpgrade(existing: Record<string, unknown>): Record<stri
if (!Array.isArray(groups)) continue;
for (const group of groups) {
if (!Array.isArray(group.hooks)) continue;
for (const h of group.hooks) {
if (typeof h?.command !== 'string') continue;
group.hooks = group.hooks.filter((h) => {
if (typeof h?.command !== 'string') return true;
const m = BROKEN_HOOK_RE.exec(h.command);
if (!m) continue;
// Subcommand captured (e.g. "pre-bash", "post-edit", "route") — keep it.
h.command = localHookCmd(m[1]);
}
if (m) {
// Subcommand captured (e.g. "pre-bash", "post-edit", "route") — keep it.
h.command = localHookCmd(m[1]);
return true;
}
// Sibling CLI invocations (memory/daemon/swarm/...) have no
// hook-handler equivalent. Drop the stale extra; the merge above
// has already installed current local-helper hooks for this event.
return !BROKEN_NPX_LATEST_RE.test(h.command);
});
}
}
}
@@ -784,11 +784,22 @@ export function generateIntelligenceStub(): string {
"const path = require('path');",
"const os = require('os');",
'',
"const DATA_DIR = path.join(process.cwd(), '.claude-flow', 'data');",
'function resolveProjectRoot(startDir) {',
' if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);',
' var dir = path.resolve(startDir || process.cwd());',
' while (true) {',
' if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, ".claude-flow"))) return dir;',
' var parent = path.dirname(dir);',
' if (parent === dir) return path.resolve(startDir || process.cwd());',
' dir = parent;',
' }',
'}',
"const PROJECT_ROOT = resolveProjectRoot(process.cwd());",
"const DATA_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'data');",
"const STORE_PATH = path.join(DATA_DIR, 'auto-memory-store.json');",
"const RANKED_PATH = path.join(DATA_DIR, 'ranked-context.json');",
"const PENDING_PATH = path.join(DATA_DIR, 'pending-insights.jsonl');",
"const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions');",
"const SESSION_DIR = path.join(PROJECT_ROOT, '.claude-flow', 'sessions');",
"const SESSION_FILE = path.join(SESSION_DIR, 'current.json');",
'',
'function ensureDir(dir) {',
@@ -832,8 +843,8 @@ export function generateIntelligenceStub(): string {
' var entries = [];',
' var candidates = [',
' path.join(os.homedir(), ".claude", "projects"),',
' path.join(process.cwd(), ".claude-flow", "memory"),',
' path.join(process.cwd(), ".claude", "memory"),',
' path.join(PROJECT_ROOT, ".claude-flow", "memory"),',
' path.join(PROJECT_ROOT, ".claude", "memory"),',
' ];',
' for (var i = 0; i < candidates.length; i++) {',
' try {',
@@ -1474,4 +1485,3 @@ function main() {
main();
`;
}
@@ -54,6 +54,16 @@ function getInstalledCliVersionLocal(): string {
}
}
function compareVersionsLocal(a: string, b: string): number {
const pa = a.split(/[.-]/).map(part => Number.parseInt(part, 10) || 0);
const pb = b.split(/[.-]/).map(part => Number.parseInt(part, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const delta = (pa[i] ?? 0) - (pb[i] ?? 0);
if (delta !== 0) return delta;
}
return 0;
}
/**
* Generate optimized statusline script
* Output format:
@@ -93,10 +103,12 @@ export function generateStatuslineScript(options: InitOptions): string {
// getInstalledCliVersionLocal() above — reuse the pattern for the
// same reason it exists there.
let helperContent: string | null = null;
let helperPackageRoot = '';
try {
const esmRequire = createRequire(import.meta.url);
const pkgJsonPath = esmRequire.resolve('@claude-flow/cli/package.json');
const helperPath = path.join(path.dirname(pkgJsonPath), '.claude', 'helpers', 'statusline.cjs');
helperPackageRoot = path.dirname(pkgJsonPath);
const helperPath = path.join(helperPackageRoot, '.claude', 'helpers', 'statusline.cjs');
helperContent = fs.readFileSync(helperPath, 'utf-8');
} catch {
let dir = __dirname_sg;
@@ -105,7 +117,11 @@ export function generateStatuslineScript(options: InitOptions): string {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
if (pkg && pkg.name === '@claude-flow/cli') {
const candidate = path.join(dir, '.claude', 'helpers', 'statusline.cjs');
if (fs.existsSync(candidate)) { helperContent = fs.readFileSync(candidate, 'utf-8'); break; }
if (fs.existsSync(candidate)) {
helperPackageRoot = dir;
helperContent = fs.readFileSync(candidate, 'utf-8');
break;
}
}
} catch { /* keep climbing */ }
const parent = path.dirname(dir);
@@ -127,15 +143,18 @@ export function generateStatuslineScript(options: InitOptions): string {
// whatever the helper hard-codes) ships. Add a paired test in
// statusline-cost-display.test.ts before changing either token.
helperContent = helperContent.replace(/maxAgents: \d+,/, `maxAgents: ${maxAgents},`);
helperContent = helperContent.replace(
/const BAKED_INSTALL_ROOT = "[^"]*";/,
`const BAKED_INSTALL_ROOT = ${JSON.stringify(helperPackageRoot)};`,
);
// Only overwrite the helper's baked version if OURS resolves higher.
// Otherwise the substitution could DOWNGRADE (test environments where
// esmRequire.resolve happens to hit an older node_modules install would
// clobber a fresh committed helper). Naive lexicographic compare — works
// for canonical semver strings with same-width digit parts, which is
// reliable at this stage of the version space.
// clobber a fresh committed helper). Compare numeric components:
// lexicographic comparison freezes 3.32.24 below 3.32.8 (#2811).
const helperVerMatch = helperContent.match(/let ver = "([^"]+)";/);
const helperVer = helperVerMatch ? helperVerMatch[1] : '';
if (!helperVer || bakedVersion > helperVer) {
if (!helperVer || compareVersionsLocal(bakedVersion, helperVer) > 0) {
helperContent = helperContent.replace(/let ver = "[^"]+";/, `let ver = ${JSON.stringify(bakedVersion)};`);
}
+73 -2
View File
@@ -79,6 +79,70 @@ const DEFAULT_OPTIONS: Required<MCPServerOptions> = {
timeout: 30000,
};
export function parseMcpToolSelection(value: string | undefined): string[] | 'all' {
if (!value || value.trim().toLowerCase() === 'all') return 'all';
const selectors = value.split(',').map((item) => item.trim()).filter(Boolean);
return selectors.length > 0 ? selectors : 'all';
}
/**
* Apply the existing `--tools` contract to advertised schemas. A selector can
* be an exact tool name, a category, or a namespace prefix (`memory` matches
* `memory_store`). Execution remains registered internally; only the fixed
* per-request schema catalogue is reduced.
*/
export function filterAdvertisedMcpTools<T extends { name: string; category?: string }>(
tools: T[],
selection: string[] | 'all',
): T[] {
if (selection === 'all') return tools;
const selectors = new Set(selection.map((item) => item.toLowerCase()));
return tools.filter((tool) => {
const name = tool.name.toLowerCase();
const category = tool.category?.toLowerCase();
return selectors.has(name)
|| (category !== undefined && selectors.has(category))
|| Array.from(selectors).some((selector) => name.startsWith(`${selector}_`));
});
}
export interface McpSchemaOverhead {
toolCount: number;
bytes: number;
estimatedTokens: number;
contextWindowTokens?: number;
ratio?: number;
risk: 'normal' | 'high';
}
/** Conservative JSON-size estimate for the fixed tools/list catalogue. */
export function assessMcpSchemaOverhead(
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>,
contextWindowTokens?: number,
): McpSchemaOverhead {
const catalogue = tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
const bytes = Buffer.byteLength(JSON.stringify(catalogue), 'utf8');
const estimatedTokens = Math.ceil(bytes / 4);
const validWindow = Number.isFinite(contextWindowTokens) && Number(contextWindowTokens) > 0
? Number(contextWindowTokens)
: undefined;
const ratio = validWindow ? estimatedTokens / validWindow : undefined;
return {
toolCount: tools.length,
bytes,
estimatedTokens,
...(validWindow === undefined ? {} : { contextWindowTokens: validWindow }),
...(ratio === undefined ? {} : { ratio }),
risk: (ratio !== undefined ? ratio >= 0.2 : estimatedTokens >= 8_000)
? 'high'
: 'normal',
};
}
/**
* MCP Server Manager
*
@@ -93,7 +157,14 @@ export class MCPServerManager extends EventEmitter {
constructor(options: MCPServerOptions = {}) {
super();
this.options = { ...DEFAULT_OPTIONS, ...options };
// `options.tools`, populated by the `mcp start --tools` CLI flag, is
// spread last below and therefore takes precedence over this env fallback.
const environmentTools = parseMcpToolSelection(process.env.CLAUDE_FLOW_MCP_TOOLS);
this.options = {
...DEFAULT_OPTIONS,
...(environmentTools === 'all' ? {} : { tools: environmentTools }),
...options,
};
}
/**
@@ -542,7 +613,7 @@ export class MCPServerManager extends EventEmitter {
};
case 'tools/list':
const tools = listMCPTools();
const tools = filterAdvertisedMcpTools(listMCPTools(), this.options.tools);
return {
jsonrpc: '2.0',
id: message.id,
@@ -91,13 +91,23 @@ async function getRealEmbeddingFunction() {
}
// Generate real ONNX embedding (falls back to deterministic hash if ONNX unavailable)
async function generateRealEmbedding(text: string, dimension: number): Promise<number[]> {
type EmbeddingResult = {
embedding: number[];
backend: 'onnx' | 'mock';
model: string;
};
async function generateRealEmbedding(text: string, dimension: number): Promise<EmbeddingResult> {
const realFn = await getRealEmbeddingFunction();
if (realFn) {
try {
const result = await realFn(text);
return result.embedding;
return {
embedding: result.embedding,
backend: (result as { backend?: 'onnx' | 'mock' }).backend ?? 'onnx',
model: result.model,
};
} catch {
// Fall through to fallback
}
@@ -118,7 +128,11 @@ async function generateRealEmbedding(text: string, dimension: number): Promise<n
// L2 normalize
const norm = Math.sqrt(embedding.reduce((sum, x) => sum + x * x, 0));
return embedding.map(x => x / norm);
return {
embedding: embedding.map(x => x / norm),
backend: 'mock',
model: 'hash-fallback',
};
}
// Convert Euclidean embedding to Poincaré ball
@@ -296,7 +310,8 @@ export const embeddingsTools: MCPTool[] = [
const useHyperbolic = input.hyperbolic === true && config.hyperbolic.enabled;
// Generate real ONNX embedding
const embedding = await generateRealEmbedding(text, config.dimension);
const generated = await generateRealEmbedding(text, config.dimension);
const embedding = generated.embedding;
let result: number[];
let geometry: string;
@@ -314,6 +329,8 @@ export const embeddingsTools: MCPTool[] = [
embedding: result,
metadata: {
model: config.model,
embeddingBackend: generated.backend,
semanticGrounded: generated.backend === 'onnx',
dimension: config.dimension,
geometry,
curvature: useHyperbolic ? config.hyperbolic.curvature : null,
@@ -365,10 +382,14 @@ export const embeddingsTools: MCPTool[] = [
{ const v = validateText(text2, 'text2'); if (!v.valid) return { success: false, error: v.error }; }
// Generate real ONNX embeddings for both texts
const [emb1, emb2] = await Promise.all([
const [generated1, generated2] = await Promise.all([
generateRealEmbedding(text1, config.dimension),
generateRealEmbedding(text2, config.dimension)
]);
const emb1 = generated1.embedding;
const emb2 = generated2.embedding;
const embeddingBackend =
generated1.backend === 'onnx' && generated2.backend === 'onnx' ? 'onnx' : 'mock';
let similarity: number;
let distance: number;
@@ -402,14 +423,21 @@ export const embeddingsTools: MCPTool[] = [
similarity,
distance,
metric,
embeddingBackend,
semanticGrounded: embeddingBackend === 'onnx',
texts: {
text1: { length: text1.length, preview: text1.slice(0, 50) },
text2: { length: text2.length, preview: text2.slice(0, 50) },
},
interpretation: similarity > 0.8 ? 'very similar' :
similarity > 0.6 ? 'similar' :
similarity > 0.4 ? 'somewhat similar' :
similarity > 0.2 ? 'different' : 'very different',
interpretation: embeddingBackend === 'onnx'
? similarity > 0.8 ? 'very similar'
: similarity > 0.6 ? 'similar'
: similarity > 0.4 ? 'somewhat similar'
: similarity > 0.2 ? 'different' : 'very different'
: null,
warning: embeddingBackend === 'mock'
? 'Hash fallback scores are deterministic but not semantically meaningful.'
: undefined,
};
},
},
@@ -487,6 +515,8 @@ export const embeddingsTools: MCPTool[] = [
})),
metadata: {
model: config.model,
embeddingBackend: queryEmbedding.backend,
semanticGrounded: queryEmbedding.backend === 'onnx',
topK,
threshold,
namespace: namespace || 'all',
@@ -494,6 +524,9 @@ export const embeddingsTools: MCPTool[] = [
indexType: config.hyperbolic.enabled ? 'HNSW (hyperbolic)' : 'HNSW (euclidean)',
resultCount: searchResult.results.length
},
warning: queryEmbedding.backend === 'mock'
? 'Results use a hash fallback and are not semantically ranked.'
: undefined,
};
} catch {
// Database not available - return empty but truthful
@@ -504,12 +537,17 @@ export const embeddingsTools: MCPTool[] = [
results: [],
metadata: {
model: config.model,
embeddingBackend: queryEmbedding.backend,
semanticGrounded: queryEmbedding.backend === 'onnx',
topK,
threshold,
namespace: namespace || 'all',
searchTime: `${searchTime}ms`,
indexType: config.hyperbolic.enabled ? 'HNSW (hyperbolic)' : 'HNSW (euclidean)',
},
warning: queryEmbedding.backend === 'mock'
? 'Hash fallback is active; semantic search is unavailable.'
: undefined,
message: 'No embeddings indexed yet. Use memory store to add documents.',
};
}
@@ -866,6 +904,8 @@ export const embeddingsTools: MCPTool[] = [
} catch { /* not installed */ }
const ruvectorEnabled = config.neural.ruvector?.enabled ?? false;
const backendProbe = await generateRealEmbedding('ruflo embedding backend probe', config.dimension);
const semanticGrounded = backendProbe.backend === 'onnx';
return {
success: true,
@@ -894,11 +934,20 @@ export const embeddingsTools: MCPTool[] = [
models: config.modelPath,
},
initializedAt: config.initialized,
embeddingBackend: backendProbe.backend,
semanticGrounded,
warning: semanticGrounded
? undefined
: 'Hash fallback is active. Similarity scores are deterministic but not semantically meaningful.',
capabilities: {
onnxModels: ['Xenova/all-MiniLM-L6-v2', 'Xenova/all-mpnet-base-v2'],
geometries: ['euclidean', 'poincare'],
normalizations: ['L2', 'L1', 'minmax', 'zscore'],
features: ['semantic search', 'hyperbolic projection', 'neural substrate'],
features: [
...(semanticGrounded ? ['semantic search'] : []),
'hyperbolic projection',
'neural substrate',
],
},
};
},
@@ -515,7 +515,14 @@ function loadMemoryStore(): MemoryStore {
*/
function getIntelligenceStatsFromMemory(): {
trajectories: { total: number; successful: number };
patterns: { learned: number; categories: Record<string, number> };
patterns: {
learned: number;
successful: number;
failed: number;
unknown: number;
described: number;
categories: Record<string, number>;
};
memory: { indexSize: number; totalAccessCount: number; memorySizeBytes: number };
routing: { decisions: number; avgConfidence: number };
} {
@@ -556,6 +563,18 @@ function getIntelligenceStatsFromMemory(): {
const category = (e.metadata?.category as string) || 'general';
categories[category] = (categories[category] || 0) + 1;
});
const successfulPatterns = patternEntries.filter(e => e.metadata?.success === true).length;
const failedPatterns = patternEntries.filter(e => e.metadata?.success === false).length;
const describedPatterns = patternEntries.filter(e => {
if (typeof e.metadata?.task === 'string' && e.metadata.task.trim()) return true;
try {
const value = typeof e.value === 'string' ? JSON.parse(e.value) : e.value;
return typeof (value as Record<string, unknown> | null)?.task === 'string' &&
Boolean(String((value as Record<string, unknown>).task).trim());
} catch {
return false;
}
}).length;
// Count routing decisions
const routingEntries = entries.filter(e =>
@@ -595,6 +614,10 @@ function getIntelligenceStatsFromMemory(): {
},
patterns: {
learned: patternEntries.length,
successful: successfulPatterns,
failed: failedPatterns,
unknown: Math.max(0, patternEntries.length - successfulPatterns - failedPatterns),
described: describedPatterns,
categories,
},
memory: {
@@ -1215,22 +1238,28 @@ export const hooksMetrics: MCPTool = {
}
const topAgent = Object.entries(agentCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
const successful = stats.trajectories.successful;
const total = stats.trajectories.total;
const failed = Math.max(0, total - successful);
return {
_real: true,
_dataSource: 'intelligence-stats + routing-outcomes',
period,
patterns: {
total: stats.patterns.learned,
successful,
failed,
successful: stats.patterns.successful,
failed: stats.patterns.failed,
unknown: stats.patterns.unknown,
described: stats.patterns.described,
descriptionCoverage: stats.patterns.learned > 0
? stats.patterns.described / stats.patterns.learned
: null,
avgConfidence: stats.routing.avgConfidence || null,
},
agents: {
routingAccuracy: stats.routing.avgConfidence || null,
// Confidence is a model self-score, not observed routing accuracy
// (#2809). Keep the old key as an explicit null for compatibility
// while exposing the truthful additive field.
routingAccuracy: null,
averageConfidence: stats.routing.avgConfidence || null,
outcomeSuccessRate: successRate,
totalRoutes: stats.routing.decisions,
topAgent,
},
@@ -1239,7 +1268,7 @@ export const hooksMetrics: MCPTool = {
successRate,
avgRiskScore: null,
},
_note: total === 0 && totalCommands === 0
_note: stats.patterns.learned === 0 && totalCommands === 0
? 'No metrics data collected yet. Run hooks_post-task / hooks_intelligence_trajectory-end / hooks_route to populate.'
: undefined,
lastUpdated: new Date().toISOString(),
@@ -1442,6 +1471,7 @@ export const hooksPostTask: MCPTool = {
const bridge = await import('../memory/memory-bridge.js');
feedbackResult = await bridge.bridgeRecordFeedback({
taskId,
task: (params.task as string) || undefined,
success,
quality,
agent,
@@ -3261,7 +3291,7 @@ export const hooksIntelligenceStats: MCPTool = {
} catch {
memoryStats = {
trajectories: { total: 0, successful: 0 },
patterns: { learned: 0, categories: {} },
patterns: { learned: 0, successful: 0, failed: 0, unknown: 0, described: 0, categories: {} },
memory: { indexSize: 0, totalAccessCount: 0, memorySizeBytes: 0 },
routing: { decisions: 0, avgConfidence: 0 },
};
@@ -2084,6 +2084,8 @@ export async function bridgeSearchPatterns(options: {
*/
export async function bridgeRecordFeedback(options: {
taskId: string;
/** Human-readable task text used as the semantic learning signal. */
task?: string;
success: boolean;
quality: number;
agent?: string;
@@ -2118,12 +2120,16 @@ export async function bridgeRecordFeedback(options: {
try {
const intelligence = await import('./intelligence.js');
const verdict = options.success ? 'success' : 'failure';
const taskContext = options.task?.trim();
const recorded = await intelligence.recordTrajectory(
[{
type: 'action',
content: `Task ${options.taskId} completed by ${options.agent || 'unknown'} — success=${options.success}, quality=${options.quality.toFixed(3)}`,
// Put the task description first so the embedding represents the
// work, not just the bookkeeping suffix (#2812).
content: `${taskContext ? `${taskContext} — ` : ''}Task ${options.taskId} completed by ${options.agent || 'unknown'} — success=${options.success}, quality=${options.quality.toFixed(3)}`,
metadata: {
taskId: options.taskId,
task: taskContext,
agent: options.agent,
quality: options.quality,
duration: options.duration,
@@ -3,7 +3,8 @@
*
* Turns raw `memory_entries` (what ruflo has been RECORDING for thousands of
* commits) into the structured intelligence substrate it has never populated:
* `episodes` → `reasoning_patterns` (+ `pattern_embeddings`) → `causal_edges`.
* `episodes` (+ `episode_embeddings`) → `reasoning_patterns`
* (+ `pattern_embeddings`) → `causal_edges`.
* This is the DISTILL/CONSOLIDATE half of the RETRIEVE→JUDGE→DISTILL→CONSOLIDATE
* pipeline; RETRIEVE (embeddings) was already populated, the rest was empty
* because the daemon's `consolidate` worker was a stub (see ADR-174 root cause).
@@ -52,6 +53,7 @@ export interface DistillOptions {
export interface DistillReport {
processed: number;
episodes: number;
episodeEmbeddings: number;
patterns: number;
patternEmbeddings: number;
causalEdges: number;
@@ -81,7 +83,8 @@ interface Cluster {
function emptyReport(dryRun: boolean, extra: Partial<DistillReport> = {}): DistillReport {
return {
processed: 0, episodes: 0, patterns: 0, patternEmbeddings: 0, causalEdges: 0,
processed: 0, episodes: 0, episodeEmbeddings: 0,
patterns: 0, patternEmbeddings: 0, causalEdges: 0,
promoted: 0, byProvenance: {}, namespaces: [], dryRun, spendUsd: 0, ...extra,
};
}
@@ -114,6 +117,20 @@ function judgeFeedback(content: string): { success: boolean; reward: number } {
return { success: true, reward: 0.5 };
}
/** Preserve an execution-observed lesson for Reflexion without presenting
* proxy/structural summaries as genuine self-critique. */
function feedbackCritique(content: string): string {
try {
const value = JSON.parse(content) as Record<string, unknown>;
for (const key of ['critique', 'error', 'message', 'summary', 'stderr']) {
if (typeof value[key] === 'string' && value[key].trim()) {
return value[key].trim().slice(0, 2000);
}
}
} catch { /* retain the original execution feedback below */ }
return `Observed execution feedback: ${content}`.slice(0, 2000);
}
/**
* Distill accumulated memory into the structured intelligence tables.
* Incremental, $0, transactional, provenance-tagged.
@@ -150,7 +167,6 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
for (const t of ['episodes', 'reasoning_patterns', 'pattern_embeddings', 'causal_edges']) {
if (!tableExists(t)) { db.close(); return { ...report, skipped: `target table ${t} missing (agentdb schema not initialised)` }; }
}
// Safety gate: never distill into a structurally-corrupt DB.
const qc = db.prepare('PRAGMA quick_check(1)').get() as Record<string, string> | undefined;
if (qc && String(Object.values(qc)[0] ?? '').toLowerCase() !== 'ok') {
@@ -158,6 +174,18 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
return { ...report, corrupt: true, skipped: 'memory DB reports corruption — run recoverMemoryDatabase first' };
}
// #2677 — older AgentDB stores may have episodes but no embedding table.
// Create the stable AgentDB v1 table only after the corruption gate, and
// never create it during a dry-run.
if (!dryRun && !tableExists('episode_embeddings')) {
db.exec(`CREATE TABLE episode_embeddings (
episode_id INTEGER PRIMARY KEY,
embedding BLOB NOT NULL,
embedding_model TEXT DEFAULT 'all-MiniLM-L6-v2',
FOREIGN KEY(episode_id) REFERENCES episodes(id) ON DELETE CASCADE
)`);
}
// M0: incremental cursor table (per namespace, by monotonic rowid).
db.exec(`CREATE TABLE IF NOT EXISTS distill_state (
namespace TEXT PRIMARY KEY,
@@ -179,7 +207,10 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
);
const insEpisode = db.prepare(
'INSERT INTO episodes (session_id, task, input, output, reward, success, tags, metadata) VALUES (?,?,?,?,?,?,?,?)',
'INSERT INTO episodes (session_id, task, input, output, critique, reward, success, tags, metadata) VALUES (?,?,?,?,?,?,?,?,?)',
);
const insEpisodeEmbedding = dryRun ? null : db.prepare(
'INSERT OR REPLACE INTO episode_embeddings (episode_id, embedding) VALUES (?, ?)',
);
const insPattern = db.prepare(
'INSERT INTO reasoning_patterns (task_type, approach, success_rate, uses, avg_reward, tags, metadata) VALUES (?,?,?,?,?,?,?)',
@@ -189,6 +220,49 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
'INSERT INTO causal_edges (from_memory_id, from_memory_type, to_memory_id, to_memory_type, similarity, confidence, mechanism, metadata) VALUES (?,?,?,?,?,?,?,?)',
);
// Repair episodes produced by older distillation releases. Their metadata
// records sourceIds; if that provenance is absent, exact input matching is
// a conservative fallback. Never invent a vector or call a model.
if (!dryRun) {
const missingEpisodes = db.prepare(`
SELECT e.id, e.input, e.metadata
FROM episodes e
LEFT JOIN episode_embeddings ee ON ee.episode_id = e.id
WHERE ee.episode_id IS NULL AND e.session_id LIKE 'distill:%'
`).all() as Array<{ id: number; input: string | null; metadata: string | null }>;
const embeddingById = db.prepare('SELECT embedding FROM memory_entries WHERE id = ? AND embedding IS NOT NULL');
const embeddingByContent = db.prepare(
'SELECT embedding FROM memory_entries WHERE content = ? AND embedding IS NOT NULL LIMIT 1',
);
const missingCritiques = db.prepare(`
SELECT id, input FROM episodes
WHERE session_id LIKE 'distill:feedback%'
AND length(trim(coalesce(critique,''))) = 0
`).all() as Array<{ id: number; input: string | null }>;
const updateCritique = db.prepare('UPDATE episodes SET critique = ? WHERE id = ?');
const backfill = db.transaction(() => {
for (const episode of missingEpisodes) {
let sourceId: string | undefined;
try {
const metadata = JSON.parse(episode.metadata ?? '{}') as { sourceIds?: unknown };
if (Array.isArray(metadata.sourceIds) && typeof metadata.sourceIds[0] === 'string') {
sourceId = metadata.sourceIds[0];
}
} catch { /* use exact-content fallback */ }
let raw = sourceId ? embeddingById.get(sourceId)?.embedding : undefined;
if (!raw) raw = embeddingByContent.get(episode.input ?? '')?.embedding;
const vector = parseEmbedding(raw);
if (!vector) continue;
insEpisodeEmbedding!.run(episode.id, Buffer.from(Float32Array.from(vector).buffer));
report.episodeEmbeddings++;
}
for (const episode of missingCritiques) {
updateCritique.run(feedbackCritique(episode.input ?? ''), episode.id);
}
});
backfill();
}
let remaining = typeof maxEntries === 'number' ? maxEntries : Infinity;
for (const ns of nsList) {
@@ -265,10 +339,18 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
const epInfo = insEpisode.run(
`distill:${ns}`, approach, cl.rep.content.slice(0, 2000), '',
cl.provenance === 'oracle:test-exec' ? feedbackCritique(cl.rep.content) : null,
avgReward, promoted && cl.successCount > 0 ? 1 : 0,
JSON.stringify(distilled.labels), JSON.stringify(provMeta),
);
report.episodes++;
const epId = Number(epInfo.lastInsertRowid);
// ReflexionMemory.retrieveRelevant() INNER JOINs this table.
// Reuse the source embedding so every distilled episode is
// immediately retrievable without an embedding-model call.
insEpisodeEmbedding!.run(epId, Buffer.from(Float32Array.from(embVec).buffer));
report.episodeEmbeddings++;
const patInfo = insPattern.run(
taskType, approach, successRate, uses, avgReward,
@@ -289,7 +371,6 @@ export async function runDistillation(options: DistillOptions): Promise<DistillR
// rowids). Explicitly typed co-occurrence / proxy-tier / non-
// promoted: may rank retrieval, must NOT justify autonomous action
// (ADR-174 edge contract).
const epId = Number(epInfo.lastInsertRowid);
if (prevEpId !== null) {
insEdge.run(
prevEpId, 'episode', epId, 'episode',
@@ -1744,6 +1744,7 @@ export class WorkerDaemon extends EventEmitter {
// instead of becoming their own distinct pattern.
duplicatesRemoved: Math.max(0, report.processed - report.patterns),
episodes: report.episodes,
episodeEmbeddings: report.episodeEmbeddings,
patternEmbeddings: report.patternEmbeddings,
causalEdges: report.causalEdges,
promoted: report.promoted,
@@ -0,0 +1,32 @@
---
name: github-automation
description: >
GitHub workflow automation for issues, pull requests, CI, and releases.
Use when: repository hosting actions are explicitly requested.
Skip when: local git operations are sufficient or publishing is unauthorized.
---
# GitHub Automation Skill
## Purpose
Automate GitHub workflows while keeping external writes explicit and scoped.
## Commands
### Create Pull Request
```bash
gh pr create --draft
```
### Inspect Checks
```bash
gh pr checks
```
## Best Practices
1. Confirm repository and branch before external writes
2. Inspect CI evidence before changing workflows
3. Never publish, merge, or close without authorization
4. Report the exact resulting URL or identifier
@@ -0,0 +1,32 @@
---
name: performance-analysis
description: >
Performance profiling, benchmarking, and optimization.
Use when: slow operations, regressions, memory pressure, release validation.
Skip when: early prototyping, documentation, configuration-only changes.
---
# Performance Analysis Skill
## Purpose
Measure before optimizing and preserve reproducible benchmark evidence.
## Commands
### Run Benchmark Suite
```bash
npx @claude-flow/cli performance benchmark --suite all
```
### Profile Code
```bash
npx @claude-flow/cli performance profile --target ./src
```
## Best Practices
1. Capture a baseline before changing code
2. Use identical inputs and environments
3. Report variance, not only the fastest result
4. Re-run correctness tests after optimization
+24 -1
View File
@@ -5,6 +5,9 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
DualModeOrchestrator,
DualModeConfig,
@@ -111,7 +114,7 @@ function createRunCommand(): Command {
workers = getTemplateWorkers(templateName, task);
taskContext = `Template: ${templateName}, Task: ${task}`;
} else if (options.config) {
const configData = await import(options.config);
const configData = await loadWorkerConfig(options.config, process.cwd());
workers = configData.workers;
taskContext = configData.taskContext || options.task || 'Collaborative task';
} else {
@@ -177,6 +180,26 @@ function createRunCommand(): Command {
});
}
export async function loadWorkerConfig(
configPath: string,
cwd = process.cwd(),
): Promise<{ workers: WorkerConfig[]; taskContext?: string }> {
const absolute = path.resolve(cwd, configPath);
const loaded = path.extname(absolute).toLowerCase() === '.json'
? JSON.parse(await readFile(absolute, 'utf8'))
: await import(pathToFileURL(absolute).href);
const config = loaded.default && typeof loaded.default === 'object'
? loaded.default
: loaded;
if (!Array.isArray(config.workers)) {
throw new Error(`Dual-mode config must export a workers array: ${absolute}`);
}
return {
workers: config.workers,
...(typeof config.taskContext === 'string' ? { taskContext: config.taskContext } : {}),
};
}
/**
* List available templates
*/
@@ -49,6 +49,8 @@ export interface WorkerResult {
export interface DualModeConfig {
projectPath: string;
/** One database shared by bootstrap, collection, policy, and worker CLIs. */
memoryDbPath?: string;
maxConcurrent?: number;
sharedNamespace?: string;
timeout?: number;
@@ -90,6 +92,11 @@ export class DualModeOrchestrator extends EventEmitter {
}
this.config = {
projectPath: config.projectPath,
memoryDbPath: path.resolve(
config.memoryDbPath
?? process.env.CLAUDE_FLOW_DB_PATH
?? path.join(config.projectPath, '.claude-flow', 'dual-mode-memory.db'),
),
maxConcurrent: config.maxConcurrent ?? 4,
sharedNamespace: config.sharedNamespace ?? 'collaboration',
timeout: config.timeout ?? 300000, // 5 minutes
@@ -107,7 +114,8 @@ export class DualModeOrchestrator extends EventEmitter {
* Initialize shared memory for collaboration
*/
async initializeSharedMemory(taskContext: string): Promise<void> {
const { projectPath, sharedNamespace } = this.config;
const { projectPath, sharedNamespace, memoryDbPath } = this.config;
fs.mkdirSync(path.dirname(memoryDbPath), { recursive: true });
// Initialize memory database
await this.runCommand(
@@ -478,7 +486,11 @@ Remember: Other agents depend on your results in shared memory. Be concise and s
*/
private runCommand(command: string, args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
const proc = spawn(command, args, {
cwd,
env: this.sharedEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
});
let output = '';
let error = '';
@@ -576,6 +588,7 @@ Remember: Other agents depend on your results in shared memory. Be concise and s
env[name] = value;
}
env.FORCE_COLOR = '0';
env.CLAUDE_FLOW_DB_PATH = this.config.memoryDbPath;
env.CLAUDE_FLOW_PRINCIPAL_ID = `agent:${worker.id}`;
env.CLAUDE_FLOW_CAPABILITY_ENVELOPE = JSON.stringify(
this.resolveWorkerEnvelope(worker),
@@ -583,6 +596,14 @@ Remember: Other agents depend on your results in shared memory. Be concise and s
return env;
}
/** Keep every Ruflo subprocess on the same collaboration database. */
private sharedEnvironment(): NodeJS.ProcessEnv {
return {
...process.env,
CLAUDE_FLOW_DB_PATH: this.config.memoryDbPath,
};
}
/**
* Stop all running workers
*/
File diff suppressed because it is too large. Load diff
+13 -14
View File
@@ -14,7 +14,11 @@ import type {
BuiltInSkill,
} from './types.js';
import { generateAgentsMd } from './generators/agents-md.js';
import { generateSkillMd, generateBuiltInSkill } from './generators/skill-md.js';
import {
BUILT_IN_SKILL_NAMES,
generateSkillMd,
generateBuiltInSkill,
} from './generators/skill-md.js';
import { generateConfigToml } from './generators/config-toml.js';
import { DEFAULT_SKILLS_BY_TEMPLATE, AGENTS_OVERRIDE_TEMPLATE, GITIGNORE_ENTRIES, ALL_AVAILABLE_SKILLS } from './templates/index.js';
import { getRufloMcpAddCommand } from './mcp-config.js';
@@ -555,18 +559,9 @@ web_search = "live"
await fs.ensureDir(skillDir);
// Check if it's a built-in skill
const builtInSkills: BuiltInSkill[] = [
'swarm-orchestration',
'memory-management',
'sparc-methodology',
'security-audit',
'performance-analysis',
'github-automation',
];
let skillMd: string;
if (builtInSkills.includes(skillName as BuiltInSkill)) {
if (BUILT_IN_SKILL_NAMES.includes(skillName as BuiltInSkill)) {
const result = await generateBuiltInSkill(skillName);
skillMd = result.skillMd;
@@ -575,15 +570,19 @@ web_search = "live"
const scriptsDir = path.join(skillDir, 'scripts');
await fs.ensureDir(scriptsDir);
for (const [scriptName, scriptContent] of Object.entries(result.scripts)) {
await fs.writeFile(path.join(scriptsDir, scriptName), scriptContent, 'utf-8');
const scriptPath = path.join(scriptsDir, scriptName);
await fs.ensureDir(path.dirname(scriptPath));
await fs.writeFile(scriptPath, scriptContent, 'utf-8');
}
}
if (Object.keys(result.references).length > 0) {
const refsDir = path.join(skillDir, 'docs');
const refsDir = path.join(skillDir, 'references');
await fs.ensureDir(refsDir);
for (const [refName, refContent] of Object.entries(result.references)) {
await fs.writeFile(path.join(refsDir, refName), refContent, 'utf-8');
const referencePath = path.join(refsDir, refName);
await fs.ensureDir(path.dirname(referencePath));
await fs.writeFile(referencePath, refContent, 'utf-8');
}
}
} else {
+64 -1
View File
@@ -9,7 +9,11 @@ import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseWorkerSpecs, createDualModeCommand } from '../src/dual-mode/cli.js';
import {
parseWorkerSpecs,
createDualModeCommand,
loadWorkerConfig,
} from '../src/dual-mode/cli.js';
import {
DualModeOrchestrator,
CollaborationTemplates,
@@ -68,6 +72,26 @@ describe('parseWorkerSpecs', () => {
});
});
describe('loadWorkerConfig', () => {
it('loads a relative JSON config without import assertions (#2766)', async () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-dual-config-'));
writeFileSync(join(root, 'workers.json'), JSON.stringify({
taskContext: 'JSON collaboration',
workers: [{ id: 'reader', platform: 'codex', role: 'reader', prompt: 'inspect' }],
}));
await expect(loadWorkerConfig('workers.json', root)).resolves.toMatchObject({
taskContext: 'JSON collaboration',
workers: [{ id: 'reader', platform: 'codex' }],
});
});
it('rejects configs without a workers array', async () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-dual-config-invalid-'));
writeFileSync(join(root, 'workers.json'), '{}');
await expect(loadWorkerConfig('workers.json', root)).rejects.toThrow('workers array');
});
});
describe('CollaborationTemplates', () => {
it('featureDevelopment: architect -> coder -> tester -> reviewer', () => {
const ws = CollaborationTemplates.featureDevelopment('Add OAuth');
@@ -175,6 +199,45 @@ describe('DualModeOrchestrator', () => {
expect(o.config.claudeCommand).toBe('claude');
expect(o.config.maxConcurrent).toBe(4);
expect(o.config.sharedNamespace).toBe('collaboration');
expect(o.config.memoryDbPath).toBe('/tmp/.claude-flow/dual-mode-memory.db');
});
it('pins bootstrap and workers to one shared memory database (#2766)', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-dual-memory-'));
const memoryDbPath = join(root, 'state', 'shared.db');
const orchestrator = new DualModeOrchestrator({
projectPath: root,
memoryDbPath,
}) as unknown as {
sharedEnvironment(): NodeJS.ProcessEnv;
workerEnvironment(worker: WorkerConfig): NodeJS.ProcessEnv;
};
const worker: WorkerConfig = {
id: 'memory-worker',
platform: 'codex',
role: 'researcher',
prompt: 'search memory',
readOnly: true,
};
expect(orchestrator.sharedEnvironment().CLAUDE_FLOW_DB_PATH).toBe(memoryDbPath);
expect(orchestrator.workerEnvironment(worker).CLAUDE_FLOW_DB_PATH).toBe(memoryDbPath);
});
it('preserves an existing CLAUDE_FLOW_DB_PATH by default', () => {
const previous = process.env.CLAUDE_FLOW_DB_PATH;
const root = mkdtempSync(join(tmpdir(), 'ruflo-dual-memory-env-'));
const configured = join(root, 'existing.db');
process.env.CLAUDE_FLOW_DB_PATH = configured;
try {
const orchestrator = new DualModeOrchestrator({ projectPath: root }) as unknown as {
config: { memoryDbPath: string };
};
expect(orchestrator.config.memoryDbPath).toBe(configured);
} finally {
if (previous === undefined) delete process.env.CLAUDE_FLOW_DB_PATH;
else process.env.CLAUDE_FLOW_DB_PATH = previous;
}
});
it('does not pass policy or provider secrets to workers', () => {
+50 -18
View File
@@ -11,13 +11,18 @@ import {
generateSkillMd,
generateConfigToml,
} from '../src/generators/index.js';
import { generateBuiltInSkill } from '../src/generators/skill-md.js';
import {
BUILT_IN_SKILL_NAMES,
generateBuiltInSkill,
validateBuiltInSkillPayload,
} from '../src/generators/skill-md.js';
import {
generateMinimalConfigToml,
generateCIConfigToml,
} from '../src/generators/config-toml.js';
import { resolveBundledSkillsPath } from '../src/initializer.js';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type {
AgentsMdOptions,
SkillMdOptions,
@@ -567,7 +572,7 @@ describe('generateBuiltInSkill', () => {
expect(result.skillMd).toContain('name: swarm-orchestration');
expect(result.skillMd).toContain('Multi-agent swarm coordination');
expect(result.skillMd).toContain('Initialize Swarm');
expect(result.skillMd).toContain('npx ruflo swarm init');
expect(result.skillMd).toContain('npx @claude-flow/cli swarm init');
});
it('should generate memory-management skill', async () => {
@@ -575,8 +580,8 @@ describe('generateBuiltInSkill', () => {
expect(result.skillMd).toContain('name: memory-management');
expect(result.skillMd).toContain('AgentDB memory system');
expect(result.skillMd).toContain('Store Pattern');
expect(result.skillMd).toContain('Semantic Search');
expect(result.skillMd).toContain('Store Data');
expect(result.skillMd).toContain('Search Data');
});
it('should generate sparc-methodology skill', async () => {
@@ -592,7 +597,7 @@ describe('generateBuiltInSkill', () => {
const result = await generateBuiltInSkill('security-audit');
expect(result.skillMd).toContain('name: security-audit');
expect(result.skillMd).toContain('security scanning');
expect(result.skillMd).toContain('Security scanning');
expect(result.skillMd).toContain('Full Security Scan');
expect(result.skillMd).toContain('--depth full');
});
@@ -621,21 +626,48 @@ describe('generateBuiltInSkill', () => {
);
});
it('should return generated scripts for skills with scripts', async () => {
const result = await generateBuiltInSkill('swarm-orchestration');
// Scripts are now generated for skills that have them
expect(Object.keys(result.scripts).length).toBeGreaterThan(0);
expect(result.references).toEqual({});
it('uses packaged skill trees as the canonical generator source (#2765)', async () => {
const bundledRoot = resolveBundledSkillsPath();
for (const skillName of BUILT_IN_SKILL_NAMES) {
const result = await generateBuiltInSkill(skillName);
const packaged = readFileSync(join(bundledRoot, skillName, 'SKILL.md'), 'utf8');
expect(result.skillMd).toBe(packaged);
}
});
it('should generate valid bash scripts', async () => {
const result = await generateBuiltInSkill('memory-management');
it('ships every local path and prevents paths escaping the skill root', async () => {
for (const skillName of BUILT_IN_SKILL_NAMES) {
const result = await generateBuiltInSkill(skillName);
expect(() => validateBuiltInSkillPayload(
skillName,
result.skillMd,
result.scripts,
result.references,
)).not.toThrow();
}
expect(() => validateBuiltInSkillPayload(
'broken-skill',
'Use `scripts/missing.sh`.',
{},
{},
)).toThrow('broken-skill/scripts/missing.sh');
expect(() => validateBuiltInSkillPayload(
'escaping-skill',
'Use `scripts/../../outside.sh`.',
{},
{},
)).toThrow('escapes escaping-skill');
});
// Check that scripts are generated and contain bash shebang
for (const script of Object.values(result.scripts)) {
expect(script).toContain('#!/bin/bash');
expect(script).toContain('set -e');
it('packages all canonical built-in skill trees in npm artifacts', () => {
const bundledRoot = resolveBundledSkillsPath();
const packageJson = JSON.parse(readFileSync(
join(bundledRoot, '..', '..', 'package.json'),
'utf8',
)) as { files: string[] };
expect(packageJson.files).toContain('.agents/skills');
for (const skillName of BUILT_IN_SKILL_NAMES) {
expect(existsSync(join(bundledRoot, skillName, 'SKILL.md'))).toBe(true);
}
});
});
@@ -3,7 +3,7 @@
**Status**: Accepted
**Date**: 2026-05-08
**Version**: ruflo-core@0.2.1+ / @claude-flow/cli@3.7.0-alpha.18+
**Related**: ADR-102 (CI smoke harness), #1867, #1859, #1862, project memory `project_verification_process.md`
**Related**: ADR-102 (CI smoke harness), ADR-332 (hermetic source verification), #1867, #1859, #1862, project memory `project_verification_process.md`
## Context
@@ -87,8 +87,10 @@ Plus exposure as Claude Code surface area:
| `plugins/ruflo-core/agents/witness-curator.md` | Agent for adding fixes / interpreting regressions |
The scripts are project-agnostic: they take `--manifest`, `--history`,
`--fixes`, `--root` flags. They probe `<root>` and `<root>/v3` for
`@noble/ed25519`, so they work in monorepo and flat layouts.
`--fixes`, `--root` flags. Manifest generation probes `<root>` and
`<root>/v3` for `@noble/ed25519`, so it works in monorepo and flat layouts.
As amended by ADR-332, verification uses `node:crypto` and needs no installed
package; `--source-only` authenticates clean source checkouts explicitly.
The ruflo internal entrypoint at `scripts/regen-witness.mjs` is now a
thin wrapper that hard-codes ruflo's paths and reads the
@@ -124,8 +126,9 @@ to a small set of commits to read.
per-fix lookups O(1) without scanning, which matters once history
reaches 100+ entries.
- `verify.mjs` is parallel to the bundled `ruflo verify` command but
has no ruflo CLI dependency — adopters who only want the witness
toolkit can install one tiny package (`@noble/ed25519`) and run it.
has no ruflo CLI or package dependency. Generation continues to use
`@noble/ed25519`; ADR-332 preserves its witness bytes while moving
standalone verification to Node's built-in Ed25519 implementation.
## Consequences
@@ -145,8 +148,9 @@ to a small set of commits to read.
- One extra file in the repo (`verification-history.jsonl`).
- ~2-3 KB per regen entry (81 fixes × ~30 bytes avg). After 1000
regens, ~3 MB.
- Adopters take a runtime dependency on `@noble/ed25519` (~15 KB
minified, no transitive deps).
- Adopters generating manifests take a development dependency on
`@noble/ed25519` (~15 KB minified, no transitive deps). Verification
itself has no package dependency (ADR-332).
### Not addressed (residual)
@@ -26,7 +26,12 @@ Named `memory distill …` — deliberately **not** `neural distill …`, which
- **RETRIEVE** — reuse the embeddings already on every row (no re-embedding, $0).
- **JUDGE** — `feedback` entries are recorded post-edit outcomes = execution-observed ground truth → `oracle:test-exec` tier. Everything else → `proxy:structural`. (`judge:fable` is reserved for the explicitly opt-in, cost-bounded LLM path per ADR-172 — not enabled in the $0 default.)
- **DISTILL** — reuse the deterministic sub-millisecond extractor `structured-distill.ts` (`distillTrajectoryContent` → `{summary, detail, labels, paths}`); greedily cluster near-duplicate entries by cosine distance so N near-identical logs collapse into one pattern with `uses` = cluster size.
- **CONSOLIDATE** — write `episodes`, `reasoning_patterns`, `pattern_embeddings` (reusing the representative's existing vector as a Float32 BLOB; guaranteed **1:1** — a cluster with no parseable vector is skipped, never producing an embedding-less pattern), and **weak relational edges** (see below).
- **CONSOLIDATE** — write `episodes`, `episode_embeddings`,
`reasoning_patterns`, and `pattern_embeddings` (reusing the representative's
existing vector as a Float32 BLOB; both episode and pattern coverage are
guaranteed **1:1** — a cluster with no parseable vector is skipped), plus
**weak relational edges** (see below). Execution-feedback episodes preserve
critique/error text; proxy episodes do not invent critiques.
- **Promote gate (ADR-171)** — a pattern is `promoted` only if its tier is `oracle:test-exec` (or `judge:fable`). `proxy:structural` patterns are written but **never** promoted — visible for audit, excluded from promoted recall. Enforced in code, not just prose.
### Relational edges are NOT causal proof (high-risk naming)
@@ -45,7 +50,9 @@ promoted = false
### Safety (the DB was just recovered from corruption)
- **Incremental** via a `distill_state` cursor (per namespace, by monotonic `rowid`) — never rescans processed rows.
- **Non-destructive** — never mutates or deletes `memory_entries`; only inserts into the previously-empty target tables.
- **Non-destructive source** — never mutates or deletes `memory_entries`.
Target-table repair may backfill missing `episode_embeddings` and feedback
critiques created by older distillation releases.
- **Transactional** per batch — a failure rolls back the batch and advances no cursor.
- **quick_check gate** before any write — skips (does not throw) on a corrupt DB, deferring to `recoverMemoryDatabase`.
- **better-sqlite3 optional** — silent no-op if the native module is absent (WASM-only hosts).
@@ -152,7 +159,12 @@ Governed memory consolidation with provenance, rollback, and no surprise model s
## Acceptance test
Run distill **twice** on a copy of a production DB and require: `memory_entries` unchanged (count + content hash); the second run processes **0** rows; `proxy_promoted == 0`; `patterns_without_embeddings == 0`; and held-out MRR@10 no worse than baseline by more than 0.002. (Covered by `__tests__/memory-distillation.test.ts` + `distill-tuning.test.ts`.)
Run distill **twice** on a copy of a production DB and require:
`memory_entries` unchanged (count + content hash); the second run processes
**0** rows; `proxy_promoted == 0`; `patterns_without_embeddings == 0`;
`episodes_without_embeddings == 0`; execution-feedback critiques are non-empty;
and held-out MRR@10 is no worse than baseline by more than 0.002. (Covered by
`__tests__/memory-distillation.test.ts` + `distill-tuning.test.ts`.)
## Security — signed provenance for the helper auto-refresh
@@ -168,7 +180,12 @@ those helpers **auto-execute on every tool use**, the refresh is gated by
**GCP Secret Manager** (`RUFLO_HELPERS_SIGNING_SECRET=ruflo-helpers-signing-key`,
fetched via `gcloud secrets versions access`), with a local-PEM fallback
(`RUFLO_HELPERS_SIGNING_KEY`) for air-gapped signing. It is **never committed**.
- Windows selects `gcloud.cmd`; a safe fallback accepts the key only through
piped stdin (`--stdin-key`), refuses interactive key entry, validates the
Ed25519 key type, and never echoes parser input.
- The public key is baked into `src/init/helper-signing.ts` (`RUFLO_HELPERS_PUBKEY`).
The prepublish verifier imports that compiled constant rather than carrying a
second rotation-sensitive copy.
- Before the refresh installs any helper, it verifies the manifest signature
against the baked key AND each source helper's SHA-256 against the manifest. A
tampered helper or manifest — e.g. a sibling package's `postinstall` overwriting
@@ -182,7 +199,10 @@ those helpers **auto-execute on every tool use**, the refresh is gated by
## Rollback
Disable via `-w` omission or `--no-distill`. All writes are additive to the previously-empty target tables and never touch `memory_entries`, so full revert = stop the worker and optionally `DELETE FROM reasoning_patterns/pattern_embeddings/episodes/causal_edges` — zero data loss on the source.
Disable via `-w` omission or `--no-distill`. Distillation never touches
`memory_entries`, so full revert = stop the worker and optionally delete the
derived `reasoning_patterns`, `pattern_embeddings`, `episodes`,
`episode_embeddings`, and `causal_edges` rows — zero data loss on the source.
## Status of milestones
@@ -0,0 +1,128 @@
# ADR-332: Hermetic Release Verification and Supported Runtime Baseline
**Status:** Accepted and implemented
**Date:** 2026-07-29
**Issues:** #2729, #2732, #2744, #2847
**Related:** ADR-102, ADR-103, ADR-104, ADR-166
## Context
Release-security checks must produce useful evidence in both a built workspace
and a clean source checkout. The standalone ADR-103 witness verifier previously
required `@noble/ed25519` to be installed before it could authenticate a
manifest. Its full-tree mode also required generated `dist/` files. A scheduled
source-only check could therefore report an environmental precondition instead
of checking the source markers it actually had.
The two MCP bridge images also used Node 20 after that release line reached end
of life. A green source test did not prevent the deployed image from retaining
an unsupported runtime.
Two related tracker reports describe release markers and transport exports that
have since been implemented on `main`. An open issue is not, by itself,
evidence that the defect is still present; remediation must be based on current
source and executable checks.
## Decision
### 1. Verify witness signatures with the platform runtime
`plugins/ruflo-core/scripts/witness/verify.mjs` uses Node's built-in
`node:crypto` Ed25519 implementation. RFC 8410 PKCS#8 and SPKI wrappers convert
the existing raw seed and public-key bytes without changing the signed witness
format. Existing manifests produced with `@noble/ed25519` remain verifiable
byte-for-byte.
Verification checks three independent statements:
1. the manifest body hashes to `integrity.manifestHash`;
2. the deterministic key derived from the manifest commit reproduces
`integrity.publicKey`; and
3. the Ed25519 signature authenticates the declared manifest hash.
Malformed keys and signatures fail closed as verification errors. Verification
does not download packages, inspect a global install, or silently substitute a
different algorithm.
### 2. Make source-only scope explicit
`--source-only` authenticates the complete signed manifest, then checks every
non-`dist/` marker available in a source checkout. Generated entries are not
treated as verified: the JSON and human-readable results identify the
`source-only` scope and report `skippedGenerated`.
A source marker that is missing or regressed exits 1. An invalid signature
exits 1. A manifest with no source entries exits 2 rather than succeeding
vacuously.
The existing no-flag behavior remains full-tree verification. If only generated
artifacts are unavailable, it retains the `dist-not-built` precondition and
exit 2. This preserves compatibility for release jobs that require evidence
over shipped build output while giving source-only automation a real security
check.
### 3. Pin deployed MCP bridges to a supported LTS line
Both MCP bridge Dockerfiles use `node:24-slim`. The ADR-166 static security
lock checks both files and fails if either leaves the Node 24 LTS line. Updating
the runtime when its support window approaches expiry is an explicit reviewed
change, not an incidental base-image drift.
This decision does not raise the repository-wide `engines.node` minimum.
Libraries may retain their existing compatibility contract; the stricter rule
applies to production images owned by this repository.
### 4. Resolve tracker state from current executable evidence
- #2729 is a confirmed defect and is remediated by hermetic signature
verification plus explicit source-only scope.
- #2847 is a confirmed defect and is remediated by the supported Docker
runtime plus the static lock.
- #2732 is stale on current `main`: the `@claude-flow/cli-core` and
`@ruvector/rvf-wasm` markers are present in the package metadata and signed
manifests, and the marker-drift smoke passes.
- #2744 is stale on current `main`: federation owns a guarded transport loader
with a local WebSocket fallback, and CI forbids an unguarded phantom
`agentic-flow/transport/loader` dependency.
Tracker updates should attach the commit and command output that support these
classifications. This ADR does not authorize automated issue closure.
## Security invariants
1. Source-only mode never claims generated artifacts were checked.
2. A valid source marker cannot compensate for an invalid signature.
3. A signature cannot compensate for a missing or regressed selected marker.
4. Full-tree verification semantics and historical witness bytes remain
backward compatible.
5. Both deployable bridge variants use the same supported runtime baseline.
6. Network access and package installation are not prerequisites for witness
authentication.
## Acceptance tests
1. In an isolated directory with no `node_modules`, a genuine manifest and all
referenced source files pass `verify.mjs --source-only`.
2. A one-byte signature mutation exits 1.
3. Removing a selected source marker exits 1 and reports a regression.
4. Full-tree verification in the same unbuilt checkout exits 2 with
`dist-not-built`.
5. JSON output names the verification scope, built-in verifier, and number of
skipped generated entries.
6. The ADR-166 source lock passes for both Node 24 bridge images and fails for
an unsupported base line.
7. Witness marker-drift and federation transport smoke tests pass on current
`main`.
## Consequences
Source verification becomes useful in clean clones, scheduled audits, and
downstream repositories without weakening release verification. Operators can
distinguish authenticated source evidence from complete built-artifact
evidence. The package dependency remains available to witness generation and
other consumers, but verification no longer depends on its installation
layout.
The Docker runtime assertion is intentionally time-sensitive. Before Node 24
reaches end of life, a reviewed ADR amendment or successor must update both
images and their regression lock.
+1
View File
@@ -49,6 +49,7 @@ All ADRs are located in [`/v3/implementation/adrs/`](../../implementation/adrs/)
| [ADR-329](ADR-329-ruflo-capability-brain-mcp-guidance.md) | Ruflo Capability Brain for MCP Guidance | Accepted |
| [ADR-330](ADR-330-adaptive-pheromone-swarm-consensus.md) | Adaptive Pheromone Swarm Consensus | Accepted |
| [ADR-331](ADR-331-project-local-flywheel-anchors.md) | Project-Local Flywheel Evaluation Anchors | Accepted |
| [ADR-332](ADR-332-hermetic-release-verification-runtime-baseline.md) | Hermetic Release Verification and Supported Runtime Baseline | Accepted |
## Summary Documents
+162
View File
@@ -0,0 +1,162 @@
# Ruflo v3.32.36: Trustworthy Signals, Portable Codex Workflows
Ruflo v3.32.36 makes the information an agent sees line up with what the
runtime is actually doing. Embedding commands now disclose when they are using
the deterministic mock fallback, routing metrics distinguish confidence from
measured outcomes, swarm status reads canonical agent state, and learned
patterns preserve the task description that caused them.
The release also makes mixed Claude Code and Codex installations easier to
operate. Codex receives a native read-only Ruflo status skill, dual-mode memory
workers share one database path, large MCP catalogs can be filtered for
small-context models, and project/plugin hook overlap is claimed exactly once.
Hermetic witness verification and Node 24 MCP images strengthen the release
path without adding a runtime dependency.
## Install or upgrade
```bash
npm install --global ruflo@3.32.36
ruflo doctor
```
Existing projects remain compatible. The default MCP tool set is unchanged,
legacy memory state remains readable, explicit database-path overrides are
preserved, and mock embeddings remain available. The difference is that
degraded or unmeasured states are now reported honestly instead of being
presented as grounded semantic results or routing accuracy.
## Use a smaller MCP catalog
Keep the default full catalog:
```bash
ruflo mcp start
```
Or select only the capabilities a constrained model needs:
```bash
ruflo mcp start --tools memory,swarm,hooks
```
The equivalent environment form is useful in MCP host configuration:
```bash
CLAUDE_FLOW_MCP_TOOLS=memory,swarm,hooks ruflo mcp start
```
Selectors accept categories, tool-name prefixes, and exact tool names. Ruflo
doctor now reports catalog/schema overhead so the selection can be measured.
## Inspect grounded learning state
```bash
ruflo embeddings status
ruflo hooks metrics
ruflo swarm status
ruflo doctor --component memory
```
`embeddings status` identifies the active backend and whether semantic claims
are grounded. Hook metrics expose average confidence and observed outcome
success separately. The memory doctor checks structural integrity, content and
embedding coverage, Reflexion episode coverage, and feedback critiques.
To populate or repair the derived reasoning tables from existing memory:
```bash
ruflo memory distill run
ruflo memory distill status
```
Raw `memory_entries` and the SONA feed remain the operational write path.
`reasoning_patterns`, episodes, embeddings, critiques, and causal edges are
derived intelligence populated by distillation or the consolidate worker.
## Codex and dual-mode projects
After initialization, Codex can invoke the native `ruflo-status` skill without
receiving Cursor-specific permission JSON. Dual-mode workers now use a shared
`CLAUDE_FLOW_DB_PATH`, including worker subprocesses and relative configuration
files, so bootstrap, writes, reads, policy checks, and background work observe
the same database.
The packaged Codex skill set now includes:
- swarm orchestration;
- memory management;
- SPARC methodology;
- security audit;
- GitHub automation;
- performance analysis.
## Security and verification
- Witness signature verification uses Node's built-in Ed25519 support in a
source-only checkout and preserves the legacy exit-2 precondition for an
unbuilt full-tree verification.
- Helper signing has one public-key source, supports a piped stdin key, uses
`gcloud.cmd` on Windows, validates Ed25519 keys, and redacts failures.
- MCP bridge images use the supported Node 24 LTS line.
- MetaHarness wrappers preserve structured HIGH findings when the upstream
scanner exits non-zero.
- Root and packaged hook helpers are byte-identical and covered by a signed
four-helper manifest.
## What changed
- Reports the actual embedding backend and suppresses unsupported semantic
interpretations while the mock fallback is active.
- Replaces misleading “Routing Accuracy” output with average confidence and
measured outcome-success signals.
- Fixes learned-pattern success/failure counts and retains task descriptions in
the learning write.
- Reads canonical per-agent IDs and statuses for `agent list` and
`swarm status`.
- Fixes statusline install discovery, numeric version comparison, project-root
resolution, and legacy nested-intelligence migration.
- Keeps non-TTY spinner output clean and erases TTY spinner lines correctly.
- Deduplicates overlapping project/plugin post-edit and session-end side
effects using an atomic event claim.
- Adds MCP tool filtering and schema-overhead diagnostics.
- Repairs dual-mode Codex memory-path consistency and packaged skill assets.
- Adds memory distillation episode embeddings, historical backfill, critique
preservation, and strict doctor coverage checks.
## Validation
- All 23 V3 workspace packages built successfully.
- 171 changed-surface CLI tests passed.
- Codex passed 235 tests, security passed 559, and federation passed 640.
- Ruflo core hook integration passed 22/22, including duplicate-event
exactly-once behavior.
- MetaHarness passed its complete plugin regression matrix, including
structured finding preservation.
- Helper signing security, hermetic witness, MCP security lock, scan-format,
and 37 release invariants passed.
- The stable release workflow additionally installs and tests immutable tarball
archives for `@claude-flow/cli`, `claude-flow`, and `ruflo` before publishing
those exact bytes.
This release resolves the current runtime and verification defects tracked in
[#2847](https://github.com/ruvnet/ruflo/issues/2847),
[#2821](https://github.com/ruvnet/ruflo/issues/2821),
[#2818](https://github.com/ruvnet/ruflo/issues/2818),
[#2816](https://github.com/ruvnet/ruflo/issues/2816),
[#2814](https://github.com/ruvnet/ruflo/issues/2814),
[#2812](https://github.com/ruvnet/ruflo/issues/2812),
[#2811](https://github.com/ruvnet/ruflo/issues/2811),
[#2810](https://github.com/ruvnet/ruflo/issues/2810),
[#2809](https://github.com/ruvnet/ruflo/issues/2809),
[#2808](https://github.com/ruvnet/ruflo/issues/2808),
[#2807](https://github.com/ruvnet/ruflo/issues/2807),
[#2805](https://github.com/ruvnet/ruflo/issues/2805),
[#2766](https://github.com/ruvnet/ruflo/issues/2766),
[#2765](https://github.com/ruvnet/ruflo/issues/2765),
[#2750](https://github.com/ruvnet/ruflo/issues/2750),
[#2729](https://github.com/ruvnet/ruflo/issues/2729),
[#2726](https://github.com/ruvnet/ruflo/issues/2726),
[#2677](https://github.com/ruvnet/ruflo/issues/2677),
[#2675](https://github.com/ruvnet/ruflo/issues/2675), and
[#2674](https://github.com/ruvnet/ruflo/issues/2674).