mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
81 files changed
+2998
-1380
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,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}`);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
]
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)};`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user