fix: complete reports and consistent initialization for v3.32.37 (#2851)

* fix(metaharness): preserve readiness verdict payloads

* test(metaharness): cover blocked genome verdicts

* fix(adr): parse bullet metadata and relationships (#2659)

* fix(adr): align adr-create with AgentDB schema (#2651)

* fix(adr): make index updates idempotent (#2660)

* fix(memory): bound session-end graph consolidation (#2628)

* fix(memory): align active row visibility (#2652)

* fix(memory): honor database path during init

* fix(hooks): keep all shim fallback tags aligned

* fix(codex): omit unbacked full-template skills

* fix(init): generate complete native dual projects

* test(memory): isolate path and legacy-row regressions

* chore(release): prepare v3.32.37
This commit is contained in:
rUv
2026-07-29 15:40:17 -04:00
committed by GitHub
parent 67ff9898c0
commit 401e02d511
42 changed files with 1227 additions and 124 deletions
+36 -1
View File
@@ -46,6 +46,11 @@ const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
// ── Safety limits (fixes #1530, #1531) ─────────────────────────────────────
const MAX_DATA_FILE_SIZE = 10 * 1024 * 1024; // 10 MB — skip files larger than this
const MAX_GRAPH_NODES = 5000; // skip PageRank if graph exceeds this
// #2628: similarity edges used to compare every pair in every category.
// Keep exact graph behavior for normal stores, but never let a session-end
// hook enter an unbounded O(n²) pass. Temporal edges remain linear and are
// always retained when the similarity pass is skipped.
const MAX_SIMILARITY_COMPARISONS = 100000;
// ── Stop words for trigram matching ──────────────────────────────────────────
@@ -169,7 +174,14 @@ function deduplicateByContent(entries) {
const seen = new Map();
for (const entry of entries) {
const content = entry.content || entry.summary || entry.value || '';
const fp = fingerprintContent(typeof content === 'string' ? content : JSON.stringify(content));
const normalizedContent = typeof content === 'string' ? content : JSON.stringify(content);
// Content-less records can still represent distinct graph nodes. There is
// no content identity to prove they are duplicates, so preserve them.
if (!normalizedContent || !normalizedContent.trim()) {
seen.set(`__no_content_${seen.size}`, entry);
continue;
}
const fp = fingerprintContent(normalizedContent);
if (!seen.has(fp)) {
seen.set(fp, entry);
} else {
@@ -292,10 +304,28 @@ function buildEdges(entries) {
}
}
let similarityComparisons = 0;
for (const group of Object.values(byCategory)) {
similarityComparisons += (group.length * (group.length - 1)) / 2;
if (similarityComparisons > MAX_SIMILARITY_COMPARISONS) break;
}
// Similarity edges within categories (Jaccard > 0.3).
// ADR-095 G6 perf: hoist the trigram computation outside the inner
// loop. Previously we re-tokenized + re-trigrammed group[j] for every
// i — O(n²) extra work for nothing. Now compute once per entry.
// #2628: the old unconditional nested loop blocked session exit for tens
// of seconds on accumulated stores. Skip only the quadratic similarity
// layer when its deterministic pair count exceeds the budget; the linear
// temporal graph above is still complete.
if (similarityComparisons > MAX_SIMILARITY_COMPARISONS) {
process.stderr.write(
`[INTELLIGENCE] WARN: Similarity graph needs >${MAX_SIMILARITY_COMPARISONS} comparisons; ` +
'skipping similarity edges (temporal edges retained)\n'
);
return edges;
}
for (const cat of Object.keys(byCategory)) {
const group = byCategory[cat];
if (group.length < 2) continue;
@@ -688,6 +718,11 @@ function consolidate() {
// Deduplicate store entries by ID before processing (fixes #1518)
const preDedupCount = store.length;
store = deduplicateById(store);
// #2628: imports assign fresh IDs to repeated MEMORY.md content, so ID
// dedup alone never shrinks the store. Consolidate is the session-end path:
// content-dedup here before edge construction and persist the compacted
// store so subsequent sessions stay bounded.
store = deduplicateByContent(store);
// 1. Process pending insights
let newEntries = 0;
+18 -1
View File
@@ -46,6 +46,24 @@ jobs:
- name: Plugin structural smoke
run: bash plugins/ruflo-metaharness/scripts/smoke.sh
- name: Genome preserves blocked verdict payloads (#2626)
shell: bash
run: |
fixture="$(mktemp -d)"
output="$RUNNER_TEMP/metaharness-genome-blocked.json"
node plugins/ruflo-metaharness/scripts/genome.mjs \
--path "$fixture" \
--format json > "$output"
GENOME_OUTPUT="$output" node - <<'NODE'
const payload = JSON.parse(require('node:fs').readFileSync(process.env.GENOME_OUTPUT, 'utf8'));
if (payload.verdict !== 'blocked' || payload.verdictExitCode !== 2) {
throw new Error(`expected blocked/2 verdict, got ${payload.verdict}/${payload.verdictExitCode}`);
}
if (typeof payload.risk_score !== 'number' || payload.risk_score < 0.7) {
throw new Error(`expected blocked risk score, got ${payload.risk_score}`);
}
NODE
- name: harness-score against ruflo (alert on harnessFit < 70)
run: |
node plugins/ruflo-metaharness/scripts/score.mjs \
@@ -637,4 +655,3 @@ jobs:
exit 1
fi
echo "✓ drift-from-history dispatcher round-trip green (fast-path via CLI; wall ${WALL}ms)"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-flow",
"version": "3.32.36",
"version": "3.32.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-flow",
"version": "3.32.36",
"version": "3.32.37",
"bundleDependencies": [
"@claude-flow/codex",
"@claude-flow/plugin-agent-federation",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-flow",
"version": "3.32.36",
"version": "3.32.37",
"workspaces": [
"v3/@claude-flow/codex",
"v3/@claude-flow/plugin-agent-federation",
+2 -2
View File
@@ -2,7 +2,7 @@
# ruflo-hook.sh — resilient invoker for ruflo CLI hook subcommands (#1921).
#
# Hooks fire on EVERY PreToolUse / PostToolUse / Stop. A bare
# `npx <pkg>@alpha hooks …` re-resolves the @alpha dist-tag and re-installs
# `npx <pkg>@latest hooks …` re-resolves the @latest dist-tag and re-installs
# from cold cache on every fire, and when the install crashes (e.g. an
# arborist `Invalid Version` on npm 10.8.x) the user sees a hook error in
# Claude Code after every turn. This shim:
@@ -31,7 +31,7 @@ if command -v ruflo >/dev/null 2>&1; then
elif command -v claude-flow >/dev/null 2>&1; then
run claude-flow hooks "$@"
else
run npx --prefer-offline --yes ruflo@alpha hooks "$@"
run npx --prefer-offline --yes ruflo@latest hooks "$@"
fi
exit 0
+1 -1
View File
@@ -67,7 +67,7 @@ This plugin owns the `adr-patterns` AgentDB namespace. It defers to [ruflo-agent
```bash
bash plugins/ruflo-adr/scripts/smoke.sh
# Expected: "21 passed, 0 failed"
# Expected: "22 passed, 0 failed"
```
## Architecture Decisions
+6 -5
View File
@@ -64,18 +64,19 @@ Every ADR follows this structure:
Persist the ADR tree + relationships so traversal queries (e.g. "all ADRs depended on by ADR-097") work without reparsing markdown:
```bash
# Hierarchical tree — store each ADR under adr/<id>
# Hierarchical tree — store each ADR under its typed memory key
mcp__plugin_ruflo-core_ruflo__agentdb_hierarchical-store \
--path "adr/ADR-097" \
--key "mem:ADR-097" \
--tier "semantic" \
--value '{"status":"accepted","title":"Federation Budget Circuit Breaker","date":"2026-05-04"}'
# Causal edges for relationships
mcp__plugin_ruflo-core_ruflo__agentdb_causal-edge \
--from "ADR-097" --to "ADR-086" --relation "depends-on"
--sourceId "mem:ADR-097" --targetId "mem:ADR-086" --relation "depends-on"
mcp__plugin_ruflo-core_ruflo__agentdb_causal-edge \
--from "ADR-098" --to "ADR-095" --relation "depends-on"
--sourceId "mem:ADR-098" --targetId "mem:ADR-095" --relation "depends-on"
mcp__plugin_ruflo-core_ruflo__agentdb_causal-edge \
--from "ADR-094" --to "ADR-093" --relation "amends"
--sourceId "mem:ADR-094" --targetId "mem:ADR-093" --relation "amends"
```
Standard relationship vocabulary:
+1 -1
View File
@@ -11,7 +11,7 @@ Manage Architecture Decision Records. Parse $ARGUMENTS to determine the subcomma
**`adr create <title>`** -- Create a new ADR with the next sequential number.
1. Scan `docs/adr/` for existing ADRs to determine the next number
2. Create `docs/adr/ADR-NNN-<slug>.md` from the standard template
3. Store in AgentDB: `mcp__plugin_ruflo-core_ruflo__agentdb_hierarchical-store` at path `adr/ADR-NNN`
3. Store in AgentDB with `mcp__plugin_ruflo-core_ruflo__agentdb_hierarchical-store`: key `mem:ADR-NNN`, tier `semantic`, and a JSON-encoded string value
4. Report the created file path and ADR number
**`adr list`** -- List all ADRs with their status.
@@ -10,7 +10,7 @@ tags: [plugin, adr, agentdb, reconcile, memory, hard-delete]
## Context
Issue #2666: `adr-index` can add and (once #2660 is fixed) update an ADR in the `adr-patterns`/`adr-edges` namespaces, but has no way to **remove** one. Delete an ADR file, or delete a single relation line from a surviving one, and the row `adr-index` wrote for it survives every future import. `adr-verify` then certifies the resulting graph as healthy — an orphan row has no dangling ref and forms no cycle, so it's invisible to both of `adr-verify`'s checks.
Issue #2666: `adr-index` can add and update an ADR in the `adr-patterns`/`adr-edges` namespaces, but has no way to **remove** one. Delete an ADR file, or delete a single relation line from a surviving one, and the row `adr-index` wrote for it survives every future import. `adr-verify` then certifies the resulting graph as healthy — an orphan row has no dangling ref and forms no cycle, so it's invisible to both of `adr-verify`'s checks.
Root cause traced to the underlying `@claude-flow/cli` `memory` command surface, not this plugin's own logic:
@@ -41,7 +41,7 @@ New script, paired with the new `/adr-reindex` skill:
2. Re-scan every ADR currently on disk (same dual-format parser `import.mjs` uses) and store fresh.
3. Re-list `adr-patterns` and assert the count equals the number of files just scanned — a `storedRecords != 0` tally cannot see the failure this exists to prevent (issue's point 3): if the purge got clobbered by a concurrent writer (#2621) between step 1 and step 2, every store in step 2 still reports "ok", landing on top of resurrected rows. Only a fresh recount catches it. Exits non-zero on failure.
Full-namespace wipe rather than a selective orphan diff, deliberately: `adr-edges` keys always carry a fresh `timestamp-rand` suffix (`import.mjs`), so edges are never deduplicated across repeated imports — a selective "only remove orphans" pass would still leave duplicate edge rows accumulating for ADRs that *do* still exist. A full rebuild is simpler, avoids that accumulation as a side effect, and — as a bonus, not the goal — incidentally fixes the separate staleness problem (#2660) where a changed-but-still-present ADR's stored content never refreshes, since a fresh insert after a full purge has nothing to conflict with.
Full-namespace wipe rather than a selective orphan diff, deliberately. ADR records and edges now use explicit upserts with deterministic semantic identities (#2660), which makes repeated imports converge for sources that still exist. Upsert cannot express absence, however: only a rebuild from the on-disk source of truth can remove ADRs or relation lines that no longer exist.
### 3. `import.mjs`/`verify.mjs` cwd fix
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('adr-create documents the live AgentDB key/value/tier and causal-edge schema', () => {
const skill = readFileSync(
new URL('../../skills/adr-create/SKILL.md', import.meta.url),
'utf8',
);
assert.doesNotMatch(skill, /^\s*-\s+path:/m);
assert.match(skill, /^\s*-\s+key:\s+`mem:ADR-NNN`/m);
assert.match(skill, /^\s*-\s+tier:\s+`semantic`/m);
assert.match(skill, /value: a JSON-encoded string:/);
assert.match(skill, /^\s*-\s+sourceId:\s+`mem:ADR-NNN`/m);
assert.match(skill, /^\s*-\s+targetId:\s+`mem:ADR-RELATED`/m);
for (const relative of [
'../../REFERENCE.md',
'../../commands/adr.md',
]) {
const companion = readFileSync(new URL(relative, import.meta.url), 'utf8');
assert.doesNotMatch(companion, /(?:--path|\bat path)[^\n]*adr\/ADR-/i);
}
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
adrRecordKey,
adrRecordValue,
edgeKey,
memoryStoreArgs,
parseEdgeKey,
uniqueEdges,
} from '../lib/index-records.mjs';
test('stable ADR keys are explicitly upserted when mutable metadata changes', () => {
const proposed = {
id: 'ADR-007',
file: 'docs/adr/ADR-007-bullet-contract.md',
title: 'Bullet contract',
context: 'Context',
status: 'proposed',
date: '2026-07-29',
tags: ['agentdb'],
};
const accepted = { ...proposed, status: 'accepted', tags: ['agentdb', 'accepted'] };
assert.equal(adrRecordKey(proposed), adrRecordKey(accepted));
assert.notEqual(adrRecordValue(proposed), adrRecordValue(accepted));
const args = memoryStoreArgs('adr-patterns', adrRecordKey(accepted), adrRecordValue(accepted));
assert.equal(args.filter((arg) => arg === '--upsert').length, 1);
assert.ok(args.includes('--key=ADR-007::ADR-007-bullet-contract'));
assert.ok(args.some((arg) => arg.includes('status: accepted')));
});
test('edge identity is deterministic and duplicate semantic triples collapse', () => {
const edge = { relation: 'depends-on', from: 'ADR-007', to: 'ADR-003' };
assert.equal(edgeKey(edge), 'depends-on:ADR-007->ADR-003');
assert.equal(edgeKey({ ...edge }), edgeKey(edge));
const unique = uniqueEdges([
edge,
{ ...edge },
{ relation: 'related', from: 'ADR-007', to: 'ADR-003' },
]);
assert.deepEqual(unique.map(edgeKey), [
'depends-on:ADR-007->ADR-003',
'related:ADR-007->ADR-003',
]);
const args = memoryStoreArgs('adr-edges', edgeKey(edge), edge);
assert.ok(args.includes('--upsert'));
assert.ok(args.includes('--key=depends-on:ADR-007->ADR-003'));
assert.deepEqual(parseEdgeKey('depends-on:ADR-007->ADR-003'), {
relation: 'depends-on',
from: 'ADR-007',
to: 'ADR-003',
key: 'depends-on:ADR-007->ADR-003',
});
assert.deepEqual(parseEdgeKey('depends-on:ADR-007->ADR-003:1721061000000-a1b2c3'), {
relation: 'depends-on',
from: 'ADR-007',
to: 'ADR-003',
key: 'depends-on:ADR-007->ADR-003:1721061000000-a1b2c3',
});
});
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { parseAdr } from '../lib/parse-adrs.mjs';
test('adr-create bullet metadata and relationship lines round-trip through adr-index', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-adr-contract-'));
try {
const adrDir = join(root, 'docs', 'adr');
mkdirSync(adrDir, { recursive: true });
const file = join(adrDir, 'ADR-007-bullet-contract.md');
writeFileSync(file, `# ADR-007: Bullet contract
- **Status**: accepted
- **Date**: 2026-07-29
- **Tags**: agentdb, lifecycle, graph
- **Supersedes**: ADR-006
- **Amends**: ADR-005
- **Related**: ADR-004
- **Depends-on**: ADR-003
## Context
The adr-create template and adr-index parser must agree.
`);
const parsed = parseAdr(file, root);
assert.equal(parsed.status, 'accepted');
assert.equal(parsed.date, '2026-07-29');
assert.deepEqual(parsed.tags, ['agentdb', 'lifecycle', 'graph']);
assert.deepEqual(
new Set(parsed.links.map(({ from, to, relation }) => `${relation}:${from}->${to}`)),
new Set([
'supersedes:ADR-006->ADR-007',
'amends:ADR-007->ADR-005',
'related:ADR-007->ADR-004',
'depends-on:ADR-007->ADR-003',
]),
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+22 -19
View File
@@ -20,9 +20,16 @@
// is hundreds of MCP round-trips. spawnSync over the CLI is materially faster
// and avoids shell-quoting pitfalls in the ADR titles.
import { basename } from 'node:path';
import { spawnSync } from 'node:child_process';
import { findAdrs, parseAdr } from './lib/parse-adrs.mjs';
import {
adrRecordKey,
adrRecordValue,
edgeKey,
edgeValue,
memoryStoreArgs,
uniqueEdges,
} from './lib/index-records.mjs';
// #2781 (Jordi-Izquierdo-DDS): CLI_CORE=1 previously routed writes through
// `@claude-flow/cli-core@alpha`, whose JsonMemoryBackend lives in a different
@@ -33,7 +40,6 @@ import { findAdrs, parseAdr } from './lib/parse-adrs.mjs';
// in later searches). Unified on the default CLI so writer and reader
// always agree — the CLI_CORE env var is now honored as read-only/logged
// but no longer routes to a different package.
const CLI_PKG = '@claude-flow/cli@latest';
if (process.env.CLI_CORE === '1') {
console.warn(
'[ruflo-adr] warning: CLI_CORE=1 is ignored — writing to the default ' +
@@ -44,7 +50,6 @@ if (process.env.CLI_CORE === '1') {
const ROOT = process.env.ADR_ROOT || process.cwd();
function memoryStore(namespace, key, value) {
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
// #2474 Bug 1 (fatal): ADR titles like "ADR-005 — Repository …" contain
// a U+2014 em-dash. \`npm exec\` runs argv validation BEFORE handing args
// to the underlying bin, and \`commander\`-style argv with a non-ASCII
@@ -62,15 +67,13 @@ function memoryStore(namespace, key, value) {
// `.swarm/memory.db` (the CLI resolves the db path relative to the
// subprocess's cwd, not ADR_ROOT). Every memory subprocess call in this
// plugin must pass `cwd: ROOT` so the scan root and the db root agree.
const r = spawnSync('npx', [
CLI_PKG, 'memory', 'store',
`--namespace=${namespace}`,
`--key=${key}`,
`--value=${valueStr}`,
], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', cwd: ROOT });
// #2660: pass --upsert explicitly. The importer owns stable logical keys,
// so re-running it must refresh changed ADRs and relationships in place.
// Do not depend on a CLI parser default for this data-integrity contract.
const r = spawnSync('npx', memoryStoreArgs(namespace, key, value),
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', cwd: ROOT });
if (r.status !== 0) {
if (/UNIQUE constraint/i.test(r.stderr || r.stdout || '')) return 'exists';
return 'error: ' + (r.stderr || '').slice(0, 100);
return 'error: ' + (r.stderr || r.stdout || '').slice(0, 100);
}
return 'ok';
}
@@ -81,25 +84,25 @@ const fmt = process.env.IMPORT_FORMAT || 'markdown';
const files = findAdrs(ROOT);
const adrs = files.map((f) => parseAdr(f, ROOT));
const byId = new Map();
const allEdges = [];
const parsedEdges = [];
for (const a of adrs) {
byId.set(a.id, a);
allEdges.push(...a.links);
parsedEdges.push(...a.links);
}
const allEdges = uniqueEdges(parsedEdges);
let storedRecords = 0, storedEdges = 0;
const errors = [];
if (!dryRun) {
for (const a of adrs) {
const r = memoryStore('adr-patterns', `${a.id}::${basename(a.file, '.md')}`,
`${a.title}${a.context || '(no context)'}\n\nfile: ${a.file}\nstatus: ${a.status}\ndate: ${a.date}\ntags: ${a.tags.join(',')}`);
if (r === 'ok' || r === 'exists') storedRecords++;
const r = memoryStore('adr-patterns', adrRecordKey(a), adrRecordValue(a));
if (r === 'ok') storedRecords++;
else errors.push(`${a.id} ${a.file}: ${r}`);
}
for (const e of allEdges) {
const key = `${e.relation}:${e.from}->${e.to}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const r = memoryStore('adr-edges', key, JSON.stringify({ ...e, capturedAt: new Date().toISOString() }));
if (r === 'ok' || r === 'exists') storedEdges++;
const r = memoryStore('adr-edges', edgeKey(e), edgeValue(e));
if (r === 'ok') storedEdges++;
else errors.push(`${edgeKey(e)}: ${r}`);
}
}
@@ -0,0 +1,59 @@
// Pure record construction helpers shared by adr-index and adr-reindex.
//
// Keeping identity and CLI argv construction here makes the persistence
// contract directly testable without spawning `npx` or touching memory.db.
import { basename } from 'node:path';
export const CLI_PKG = '@claude-flow/cli@latest';
export function adrRecordKey(adr) {
return `${adr.id}::${basename(adr.file, '.md')}`;
}
export function adrRecordValue(adr) {
return `${adr.title}${adr.context || '(no context)'}\n\n` +
`file: ${adr.file}\n` +
`status: ${adr.status}\n` +
`date: ${adr.date}\n` +
`tags: ${adr.tags.join(',')}`;
}
// An ADR relationship's identity is its semantic triple. Timestamps describe
// an observation; they must not be part of identity or every index run creates
// another logically-identical edge (#2660).
export function edgeKey(edge) {
return `${edge.relation}:${edge.from}->${edge.to}`;
}
// Accept the deterministic #2660 key and the legacy timestamp-random suffix
// so existing installations remain verifiable after upgrading.
export function parseEdgeKey(key) {
const match = /^([\w-]+):([^:]+)->([^:]+?)(?::\d+-[a-z0-9]+)?$/i.exec(key);
if (!match) return null;
return { relation: match[1], from: match[2], to: match[3], key };
}
export function edgeValue(edge, capturedAt = new Date().toISOString()) {
return JSON.stringify({ ...edge, capturedAt });
}
export function uniqueEdges(edges) {
const byIdentity = new Map();
for (const edge of edges) {
const key = edgeKey(edge);
if (!byIdentity.has(key)) byIdentity.set(key, edge);
}
return [...byIdentity.values()];
}
export function memoryStoreArgs(namespace, key, value) {
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
return [
CLI_PKG, 'memory', 'store',
`--namespace=${namespace}`,
`--key=${key}`,
'--upsert',
`--value=${valueStr}`,
];
}
+5 -3
View File
@@ -108,7 +108,9 @@ function parseDate(text) {
const m = /^date:\s*(\S+)/m.exec(fm[1]);
if (m) return m[1];
}
const m = /^\*\*Date\*\*:\s*(\S+)/m.exec(text);
// #2659: adr-create emits metadata as Markdown list items. Accept the
// optional list marker and both common colon placements, matching Status.
const m = /^[-*+]?\s*\*\*Date:?\*\*:?\s*(\S+)/m.exec(text);
return m ? m[1] : '';
}
@@ -118,7 +120,7 @@ function parseTags(text) {
const m = /^tags:\s*\[([^\]]+)\]/m.exec(fm[1]);
if (m) return m[1].split(',').map((s) => s.trim()).filter(Boolean);
}
const m = /^\*\*Tags\*\*:\s*(.+)$/m.exec(text);
const m = /^[-*+]?\s*\*\*Tags:?\*\*:?\s*(.+)$/m.exec(text);
return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : [];
}
@@ -168,7 +170,7 @@ function parseLinks(text, selfId) {
// Safe because extractAdrRefs strips anything that isn't an ADR-NNN
// token, so over-capture into a plain-text continuation is harmless.
const REL = (label) => new RegExp(
`^\\*\\*${label}(?:\\s*\\([^)]*\\))?:?\\*\\*:?\\s*(.+(?:\\n(?!\\s*(?:\\*\\*[A-Za-z]|##|---|[-*+]\\s|\\d+\\.\\s))[^\\n]+)*)`,
`^[-*+]?\\s*\\*\\*${label}(?:\\s*\\([^)]*\\))?:?\\*\\*:?\\s*(.+(?:\\n(?!\\s*(?:\\*\\*[A-Za-z]|##|---|[-*+]\\s|\\d+\\.\\s))[^\\n]+)*)`,
'mi',
);
const supersedes = REL('Supersedes').exec(text);
+23 -19
View File
@@ -21,10 +21,9 @@
// concurrent writer resurrecting old rows (#2621), step 2's upserts
// still report "ok", and only a fresh re-count catches the drift.
//
// This also incidentally fixes the separate staleness problem where
// `memory store` (no --upsert) leaves a changed-but-still-present ADR's
// content stale forever (#2660) — after a full purge there's nothing to
// conflict with, so every store is a clean insert of current content.
// Normal adr-index runs explicitly upsert changed records and deterministic
// relationship keys (#2660). Reindex remains necessary for deletion/reaping:
// an upsert cannot remove a record whose source file or relationship vanished.
//
// Usage:
// node scripts/reindex.mjs # purge + rebuild, markdown summary
@@ -41,14 +40,21 @@
// primitive. Re-run this script if that ever happens; the post-condition
// check below will tell you.
import { basename } from 'node:path';
import { spawnSync } from 'node:child_process';
import { findAdrs, parseAdr } from './lib/parse-adrs.mjs';
import {
CLI_PKG,
adrRecordKey,
adrRecordValue,
edgeKey,
edgeValue,
memoryStoreArgs,
uniqueEdges,
} from './lib/index-records.mjs';
// #2781: unify on the default CLI so the reindex writer and the default
// `ruflo memory search` reader hit the same store. See import.mjs for the
// full rationale.
const CLI_PKG = '@claude-flow/cli@latest';
if (process.env.CLI_CORE === '1') {
console.warn(
'[ruflo-adr] warning: CLI_CORE=1 is ignored — writing to the default ' +
@@ -75,15 +81,13 @@ function purgeNamespace(namespace) {
}
function memoryStore(namespace, key, value) {
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
// Same argv-encoding note as import.mjs: `--flag=value` avoids npm's
// non-ASCII-leading-dash argv rejection on em-dash titles (#2474 Bug 1).
const r = spawnSync('npx', [
CLI_PKG, 'memory', 'store',
`--namespace=${namespace}`,
`--key=${key}`,
`--value=${valueStr}`,
], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', cwd: ROOT });
const r = spawnSync(
'npx',
memoryStoreArgs(namespace, key, value),
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', cwd: ROOT },
);
if (r.status !== 0) return 'error: ' + (r.stderr || r.stdout || '').slice(0, 100);
return 'ok';
}
@@ -102,11 +106,12 @@ function memoryListCount(namespace) {
const files = findAdrs(ROOT);
const adrs = files.map((f) => parseAdr(f, ROOT));
const byId = new Map();
const allEdges = [];
const parsedEdges = [];
for (const a of adrs) {
byId.set(a.id, a);
allEdges.push(...a.links);
parsedEdges.push(...a.links);
}
const allEdges = uniqueEdges(parsedEdges);
const result = {
scannedRoot: ROOT,
@@ -141,15 +146,14 @@ for (const ns of NAMESPACES) {
// Step 2: rebuild from the current on-disk scan.
for (const a of adrs) {
const r = memoryStore('adr-patterns', `${a.id}::${basename(a.file, '.md')}`,
`${a.title}${a.context || '(no context)'}\n\nfile: ${a.file}\nstatus: ${a.status}\ndate: ${a.date}\ntags: ${a.tags.join(',')}`);
const r = memoryStore('adr-patterns', adrRecordKey(a), adrRecordValue(a));
if (r === 'ok') result.storedRecords++;
else result.errors.push(`${a.id} ${a.file}: ${r}`);
}
for (const e of allEdges) {
const key = `${e.relation}:${e.from}->${e.to}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const r = memoryStore('adr-edges', key, JSON.stringify({ ...e, capturedAt: new Date().toISOString() }));
const r = memoryStore('adr-edges', edgeKey(e), edgeValue(e));
if (r === 'ok') result.storedEdges++;
else result.errors.push(`${edgeKey(e)}: ${r}`);
}
// Step 3: post-condition — re-count from a fresh `memory list`, not the
+7
View File
@@ -177,5 +177,12 @@ ADR="$ROOT/docs/adrs/0002-reconcile-deleted-adrs.md"
[[ -f "$ADR" ]] && grep -qE "^status:[[:space:]]*Accepted" "$ADR" \
&& ok || bad "ADR-0002 missing or status != Accepted"
# 22. Behavioral contracts for #2660/#2659/#2651
step "22. ADR indexing + adr-create regression contracts"
TEST_DIR="$ROOT/scripts/__tests__"
test_count=$(find "$TEST_DIR" -maxdepth 1 -name '*.test.mjs' 2>/dev/null | wc -l | tr -d ' ')
[[ "$test_count" -eq 3 ]] && node --test "$TEST_DIR"/*.test.mjs >/dev/null 2>&1 \
&& ok || bad "contract tests failed (run: node --test $TEST_DIR/*.test.mjs)"
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]] || exit 1
+5 -3
View File
@@ -13,6 +13,7 @@
// ADR_ROOT=/path/to/repo node scripts/verify.mjs # same root import.mjs was run with
import { spawnSync } from 'node:child_process';
import { parseEdgeKey } from './lib/index-records.mjs';
// ADR-100 / #1748 Issue 3 — CLI_CORE=1 routes to lite cli-core (~2s cold-cache).
// verify only does list+retrieve across adr-patterns and adr-edges namespaces;
@@ -58,9 +59,10 @@ const adrIds = new Set(
const edges = [];
for (const e of edgeEntries) {
const k = e.key || '';
// key format: relation:FROM->TO:timestamp-rand
const m = /^(\w[\w-]*?):(\S+?)->(\S+?):/.exec(k);
if (m) edges.push({ relation: m[1], from: m[2], to: m[3], key: k });
// Current deterministic key: relation:FROM->TO. Keep reading the legacy
// relation:FROM->TO:timestamp-rand shape for seamless upgrades (#2660).
const parsed = parseEdgeKey(k);
if (parsed) edges.push(parsed);
}
const danglingRefs = edges.filter((e) => !adrIds.has(e.to));
+7 -3
View File
@@ -51,10 +51,14 @@ When a significant architectural decision needs to be recorded -- new technology
```
4. **Store in AgentDB** -- Call `mcp__plugin_ruflo-core_ruflo__agentdb_hierarchical-store` with:
- path: `adr/ADR-NNN`
- value: `{ "id": "ADR-NNN", "title": "<title>", "status": "proposed", "date": "<today>", "file": "docs/adr/ADR-NNN-<slug>.md" }`
- key: `mem:ADR-NNN`
- tier: `semantic`
- value: a JSON-encoded string: `{ "id": "ADR-NNN", "title": "<title>", "status": "proposed", "date": "<today>", "file": "docs/adr/ADR-NNN-<slug>.md" }`
5. **Find related ADRs** -- Call `mcp__plugin_ruflo-core_ruflo__memory_search` with the title as query in namespace `adr-patterns` to find related decisions. If matches found, add them to the Links section and create causal edges with relation `depends-on`.
5. **Find related ADRs** -- Call `mcp__plugin_ruflo-core_ruflo__memory_search` with the title as query in namespace `adr-patterns` to find related decisions. If matches are found, add them to the Links section and call `mcp__plugin_ruflo-core_ruflo__agentdb_causal-edge` for each relationship with:
- sourceId: `mem:ADR-NNN`
- targetId: `mem:ADR-RELATED`
- relation: `depends-on`
6. **Store pattern** -- Call `mcp__plugin_ruflo-core_ruflo__memory_store` in namespace `adr-patterns` with key `ADR-NNN` and the title + context as value for future semantic search.
+5 -1
View File
@@ -56,12 +56,16 @@ date: <ISO date>
tags: <comma-separated>
```
`adr-edges` namespace, key `<relation>:<FROM>-><TO>:<timestamp-rand>`, value:
`adr-edges` namespace, deterministic key `<relation>:<FROM>-><TO>`, value:
```json
{ "from": "ADR-097", "to": "ADR-086", "relation": "related", "capturedAt": "<ISO>" }
```
Both ADR records and relationship edges are stored with explicit upsert
semantics. Re-running `adr-index` refreshes changed metadata in place and does
not create duplicate copies of an unchanged semantic edge.
## False-positive guard
`#1697` / `commit abc123` / `PR 1234` references inside ADR bodies are stripped before regex extraction so they don't get misread as `ADR-1697` etc. See `extractAdrRefs()` in `scripts/import.mjs`.
@@ -18,8 +18,10 @@ ruflo's boot path.
**`harness genome [--path .] [--alert-on-risk-above 0.5] [--format table|json]`** -- 7-section repo readiness report (repo_type / agent_topology / risk_score / mcp_surface / test_confidence / publish_readiness).
1. Run `node plugins/ruflo-metaharness/scripts/genome.mjs --path <dir>`
2. Pairs with harness-score for full readiness view — score is numeric, genome is categorical
3. `--alert-on-risk-above N` exits 1 when risk_score > N
4. Useful for drift detection: snapshot genome over time, diff to spot agent_topology drift
3. `needs-work` and `blocked` are valid reports. JSON includes the upstream
`verdict` and `verdictExitCode`; only an invalid/missing report is fatal.
4. `--alert-on-risk-above N` exits 1 when risk_score > N
5. Useful for drift detection: snapshot genome over time, diff to spot agent_topology drift
**`harness mcp-scan [--path .] [--fail-on low|medium|high] [--format table|json]`** -- Static security scan of `.mcp/servers.json` + `.harness/claims.json`. Reads only; no dispatch.
1. Run `node plugins/ruflo-metaharness/scripts/mcp-scan.mjs --path <dir>`
+29 -4
View File
@@ -10,9 +10,9 @@
// node scripts/genome.mjs --path <dir> --alert-on-risk-above 0.5 --format json
//
// EXIT CODES
// 0 OK
// 0 Valid readiness report (including needs-work / blocked verdicts)
// 1 --alert-on-risk-above threshold breached
// 2 config error or genome failure
// 2 config error or genome failure (no valid readiness report)
import { runMetaharness, emitDegradedJsonAndExit } from './_harness.mjs';
@@ -30,14 +30,21 @@ const ARGS = (() => {
function main() {
const r = runMetaharness(['genome', ARGS.path]);
if (r.degraded) { emitDegradedJsonAndExit(r.reason); return; }
if (r.exitCode !== 0 || !r.json) {
// Upstream uses 0/1/2 as a verdict channel:
// ready / needs-work / blocked. A non-zero status with a complete genome
// is therefore data, not a subprocess failure. Normalize valid reports to
// wrapper exit 0 so CLI and MCP callers can consume them; preserve the
// upstream verdict explicitly in the payload.
if (![0, 1, 2].includes(r.exitCode) || !isGenomePayload(r.json)) {
console.error(`genome: metaharness exited ${r.exitCode}`);
if (r.stderr) console.error(r.stderr.slice(0, 400));
process.exit(2);
}
// iter 112 — generatedAt for consistency with other --format json outputs
const payload = { ...r.json, path: ARGS.path, durationMs: r.durationMs,
generatedAt: new Date().toISOString() };
generatedAt: new Date().toISOString(),
verdict: verdictFromExitCode(r.exitCode),
verdictExitCode: r.exitCode };
if (ARGS.alertRiskAbove !== null) {
if (!isFinite(ARGS.alertRiskAbove)) {
@@ -66,6 +73,7 @@ function main() {
console.log(`| mcp_surface | ${payload.mcp_surface ?? '—'} |`);
console.log(`| test_confidence | ${payload.test_confidence ?? '—'} |`);
console.log(`| publish_readiness | ${payload.publish_readiness ?? '—'} |`);
console.log(`| verdict | ${payload.verdict} (upstream exit ${payload.verdictExitCode}) |`);
console.log(`| **duration** | ${payload.durationMs}ms |`);
console.log('');
if (payload.alert) {
@@ -77,4 +85,21 @@ function main() {
if (payload.alert?.triggered) process.exit(1);
}
function isGenomePayload(value) {
return !!value
&& typeof value === 'object'
&& typeof value.repo_type === 'string'
&& Array.isArray(value.agent_topology)
&& typeof value.risk_score === 'number'
&& typeof value.mcp_surface === 'string'
&& typeof value.test_confidence === 'number'
&& typeof value.publish_readiness === 'number';
}
function verdictFromExitCode(exitCode) {
if (exitCode === 0) return 'ready';
if (exitCode === 1) return 'needs-work';
return 'blocked';
}
main();
@@ -233,11 +233,35 @@ async function main() {
// ──────────────────────────────────────────────────────────────────
console.log('\nPhase 4 — positive-case data shape (iter 43)');
const { writeFileSync, mkdtempSync } = await import('node:fs');
const { writeFileSync, mkdtempSync, mkdirSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join: pjoin } = await import('node:path');
const tmp = mkdtempSync(pjoin(tmpdir(), 'mcp-positive-'));
// #2626 — upstream deliberately exits 2 for a valid `blocked` genome.
// The wrapper must preserve that report as data instead of converting it
// into an input/system error at the MCP boundary.
const genomeTool = tools.find((t) => t.name === 'metaharness_genome');
if (genomeTool) {
const blockedRepo = pjoin(tmp, 'blocked-repo');
mkdirSync(blockedRepo);
const r = await genomeTool.handler({ path: blockedRepo });
if (!r.degraded) {
assert(r.success === true,
'genome blocked verdict: wrapper succeeds with a valid report (#2626)');
assert(r.exitCode === 0,
'genome blocked verdict: wrapper exitCode === 0 (#2626)');
assert(r.data?.verdict === 'blocked',
`genome blocked verdict: data.verdict === blocked (got ${r.data?.verdict})`);
assert(r.data?.verdictExitCode === 2,
`genome blocked verdict: preserves upstream exit 2 (got ${r.data?.verdictExitCode})`);
assert(typeof r.data?.risk_score === 'number' && r.data.risk_score >= 0.7,
`genome blocked verdict: risk_score >= 0.7 (got ${r.data?.risk_score})`);
} else {
console.log(` ⊘ genome: metaharness absent — graceful skip`);
}
}
// metaharness_similarity — full positive case (no @metaharness/* needed)
const simTool = tools.find((t) => t.name === 'metaharness_similarity');
if (simTool) {
@@ -17,8 +17,11 @@ Implementation: [`scripts/genome.mjs`](../../scripts/genome.mjs).
1. Shell out to `npx metaharness genome <path> --json` (60s hard timeout).
2. Parse the shape: `{ repo_type, agent_topology[], risk_score,
mcp_surface, test_confidence, publish_readiness }`.
3. If `--alert-on-risk-above N`: exit 1 when `risk_score > N`.
4. Output JSON (default) or markdown.
3. Preserve upstream's readiness verdict as `verdict` and
`verdictExitCode`. Upstream exits 1 for `needs-work` and 2 for `blocked`;
these are valid reports, so the wrapper returns them successfully.
4. If `--alert-on-risk-above N`: exit 1 when `risk_score > N`.
5. Output JSON (default) or markdown.
## Phase-0 baseline (ruflo, measured 2026-06-16)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ruflo",
"version": "3.32.36",
"version": "3.32.37",
"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.36"
"@claude-flow/cli": "^3.32.37"
},
"optionalDependencies": {},
"overrides": {
+19 -11
View File
@@ -19,12 +19,15 @@ import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const SHIM_PATH = resolve(REPO_ROOT, 'plugins', 'ruflo-core', 'scripts', 'ruflo-hook.cjs');
const SH_SHIM_PATH = resolve(REPO_ROOT, 'plugins', 'ruflo-core', 'scripts', 'ruflo-hook.sh');
// Sibling copies must stay in sync — #2132 shipped three .cjs mirrors
const SIBLING_CJS_PATHS = [
resolve(REPO_ROOT, 'plugin', 'scripts', 'ruflo-hook.cjs'),
resolve(REPO_ROOT, '.claude-plugin', 'scripts', 'ruflo-hook.cjs'),
];
// Every distributable mirror must keep its platform pair on one dist-tag.
const SHIM_PAIRS = [
resolve(REPO_ROOT, 'plugins', 'ruflo-core', 'scripts'),
resolve(REPO_ROOT, 'plugin', 'scripts'),
resolve(REPO_ROOT, '.claude-plugin', 'scripts'),
].map((directory) => [
resolve(directory, 'ruflo-hook.sh'),
resolve(directory, 'ruflo-hook.cjs'),
]);
let passed = 0;
let failed = 0;
@@ -131,11 +134,16 @@ console.log(`Shim path: ${SHIM_PATH}\n`);
const m = readFileSync(p, 'utf8').match(tagRe);
return m ? m[1] : null;
};
const shTag = extractTag(SH_SHIM_PATH);
const cjsPaths = [SHIM_PATH, ...SIBLING_CJS_PATHS];
const cjsTags = cjsPaths.map(extractTag);
const allMatch = shTag && cjsTags.every((t) => t === shTag);
assert(allMatch, `dist-tag parity: .sh=${shTag}, .cjs=${JSON.stringify(cjsTags)}`);
const tags = SHIM_PAIRS.map(([shellPath, nodePath]) => ({
directory: dirname(shellPath),
shell: extractTag(shellPath),
node: extractTag(nodePath),
}));
const expectedTag = tags[0]?.shell;
const allMatch = Boolean(expectedTag) && tags.every(
({ shell, node }) => shell === expectedTag && node === expectedTag,
);
assert(allMatch, `dist-tag parity across all shim pairs: ${JSON.stringify(tags)}`);
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
@@ -1,13 +1,13 @@
{
"manifest": {
"version": "3.32.36",
"version": "3.32.37",
"files": {
"auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
"hook-handler.cjs": "dae295fb9ae2626b89899c19a20cc911541af82b52d2eeb9b214d618b96e9a86",
"intelligence.cjs": "792c24a12704267e6b45ed0794e128131fb16e4174a43e65a5afe757bc0230af",
"intelligence.cjs": "4e637e065b8f712ce6cf28cccab4244fe222dff2b984690d830bfc40d21fd050",
"statusline.cjs": "0457fe53f8cd2c56458ff178392536a5868efd1a573665fa43bc01d2d95ca677"
}
},
"signature": "qGxpst4ul6cizCQw1hjdJ5sBXj7uN7Fm1Dux2sKC7BwcPA66Nr9RzI8c8BJuKEUyPgaDtSvaY/gZTX2hWNqXBg==",
"signature": "4aXDfffQBBhj8kE+NJo5s/G4exfj/xdhqCwWIJxyGijINFL2GleKUam1oIYH9iyDeAZ1FKWrFAUzZ2WNEwMCDg==",
"algorithm": "ed25519"
}
@@ -46,6 +46,11 @@ const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
// ── Safety limits (fixes #1530, #1531) ─────────────────────────────────────
const MAX_DATA_FILE_SIZE = 10 * 1024 * 1024; // 10 MB — skip files larger than this
const MAX_GRAPH_NODES = 5000; // skip PageRank if graph exceeds this
// #2628: similarity edges used to compare every pair in every category.
// Keep exact graph behavior for normal stores, but never let a session-end
// hook enter an unbounded O(n²) pass. Temporal edges remain linear and are
// always retained when the similarity pass is skipped.
const MAX_SIMILARITY_COMPARISONS = 100000;
// ── Stop words for trigram matching ──────────────────────────────────────────
@@ -169,7 +174,14 @@ function deduplicateByContent(entries) {
const seen = new Map();
for (const entry of entries) {
const content = entry.content || entry.summary || entry.value || '';
const fp = fingerprintContent(typeof content === 'string' ? content : JSON.stringify(content));
const normalizedContent = typeof content === 'string' ? content : JSON.stringify(content);
// Content-less records can still represent distinct graph nodes. There is
// no content identity to prove they are duplicates, so preserve them.
if (!normalizedContent || !normalizedContent.trim()) {
seen.set(`__no_content_${seen.size}`, entry);
continue;
}
const fp = fingerprintContent(normalizedContent);
if (!seen.has(fp)) {
seen.set(fp, entry);
} else {
@@ -292,10 +304,28 @@ function buildEdges(entries) {
}
}
let similarityComparisons = 0;
for (const group of Object.values(byCategory)) {
similarityComparisons += (group.length * (group.length - 1)) / 2;
if (similarityComparisons > MAX_SIMILARITY_COMPARISONS) break;
}
// Similarity edges within categories (Jaccard > 0.3).
// ADR-095 G6 perf: hoist the trigram computation outside the inner
// loop. Previously we re-tokenized + re-trigrammed group[j] for every
// i — O(n²) extra work for nothing. Now compute once per entry.
// #2628: the old unconditional nested loop blocked session exit for tens
// of seconds on accumulated stores. Skip only the quadratic similarity
// layer when its deterministic pair count exceeds the budget; the linear
// temporal graph above is still complete.
if (similarityComparisons > MAX_SIMILARITY_COMPARISONS) {
process.stderr.write(
`[INTELLIGENCE] WARN: Similarity graph needs >${MAX_SIMILARITY_COMPARISONS} comparisons; ` +
'skipping similarity edges (temporal edges retained)\n'
);
return edges;
}
for (const cat of Object.keys(byCategory)) {
const group = byCategory[cat];
if (group.length < 2) continue;
@@ -688,6 +718,11 @@ function consolidate() {
// Deduplicate store entries by ID before processing (fixes #1518)
const preDedupCount = store.length;
store = deduplicateById(store);
// #2628: imports assign fresh IDs to repeated MEMORY.md content, so ID
// dedup alone never shrinks the store. Consolidate is the session-end path:
// content-dedup here before edge construction and persist the compacted
// store so subsequent sessions stay bounded.
store = deduplicateByContent(store);
// 1. Process pending insights
let newEntries = 0;
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { CommandContext } from '../src/types.js';
const codexMock = vi.hoisted(() => ({
initialize: vi.fn(),
}));
vi.mock('@claude-flow/codex', () => ({
CodexInitializer: class {
initialize = codexMock.initialize;
},
}));
import { initCommand } from '../src/commands/init.js';
import { executeInit, MINIMAL_INIT_OPTIONS } from '../src/init/index.js';
let projectPath: string;
beforeEach(() => {
projectPath = mkdtempSync(join(tmpdir(), 'dual-native-2636-'));
codexMock.initialize.mockImplementation(async (options: Record<string, unknown>) => {
const target = String(options.projectPath);
mkdirSync(join(target, '.agents'), { recursive: true });
writeFileSync(join(target, 'AGENTS.md'), '# Native Codex Instructions\n', 'utf8');
writeFileSync(join(target, '.agents', 'config.toml'), '# native codex config\n', 'utf8');
return {
success: true,
filesCreated: ['AGENTS.md', '.agents/config.toml'],
skillsGenerated: [],
};
});
});
afterEach(() => {
vi.clearAllMocks();
rmSync(projectPath, { recursive: true, force: true });
});
describe('native dual initialization (#2636)', () => {
it('runs both native initializers and preserves the full Claude scaffold', async () => {
const ctx: CommandContext = {
args: [],
flags: {
_: [],
dual: true,
minimal: true,
global: false,
'no-signup': true,
'no-skills-sh': true,
},
cwd: projectPath,
interactive: false,
};
const result = await initCommand.action!(ctx);
expect(result?.success).toBe(true);
expect(existsSync(join(projectPath, '.claude', 'settings.json'))).toBe(true);
expect(existsSync(join(projectPath, '.mcp.json'))).toBe(true);
expect(existsSync(join(projectPath, '.claude-flow', 'config.yaml'))).toBe(true);
expect(existsSync(join(projectPath, 'CLAUDE.md'))).toBe(true);
expect(existsSync(join(projectPath, 'AGENTS.md'))).toBe(true);
expect(existsSync(join(projectPath, '.agents', 'config.toml'))).toBe(true);
expect(readFileSync(join(projectPath, 'CLAUDE.md'), 'utf8')).not.toContain(
'Primary instructions are in `AGENTS.md`',
);
expect(codexMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
projectPath,
template: 'minimal',
dual: false,
}));
});
});
describe('root secret ignore (#2637)', () => {
it('appends secret/runtime entries without replacing existing project rules', async () => {
writeFileSync(join(projectPath, '.gitignore'), 'dist/\n', 'utf8');
const options = structuredClone(MINIMAL_INIT_OPTIONS);
options.targetDir = projectPath;
options.skipGlobalClaudeMd = true;
const result = await executeInit(options);
expect(result.success).toBe(true);
const gitignore = readFileSync(join(projectPath, '.gitignore'), 'utf8');
expect(gitignore).toContain('dist/\n');
expect(gitignore).toContain('\n.env\n');
expect(gitignore).toContain('.env.local');
expect(gitignore).toContain('.claude-flow/data/');
expect(result.created.files).toContain('.gitignore (updated)');
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { 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');
type Entry = {
id: string;
content: string;
namespace: string;
metadata?: { sourceFile?: string };
};
function consolidate(root: string): { entries: number; edges: number } {
const script = [
`const intelligence = require(${JSON.stringify(HELPER)});`,
'process.stdout.write(JSON.stringify(intelligence.consolidate()));',
].join('');
const stdout = execFileSync(process.execPath, ['-e', script], {
cwd: root,
env: { ...process.env, CLAUDE_PROJECT_DIR: root },
encoding: 'utf-8',
timeout: 2_000,
stdio: ['ignore', 'pipe', 'pipe'],
});
return JSON.parse(stdout);
}
function writeStore(root: string, entries: Entry[]): string {
const dataDir = join(root, '.claude-flow', 'data');
mkdirSync(dataDir, { recursive: true });
writeFileSync(join(dataDir, 'auto-memory-store.json'), JSON.stringify(entries), 'utf-8');
return dataDir;
}
describe('#2628 session-end intelligence consolidation', () => {
it('deduplicates identical content with fresh IDs and persists the compacted store', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-2628-dedup-'));
const unique = Array.from({ length: 10 }, (_, i) => ({
content: `unique memory section ${i} with stable semantic content`,
namespace: 'auto-memory',
}));
const entries = Array.from({ length: 12 }, (_, batch) =>
unique.map((entry, i) => ({ ...entry, id: `fresh-${batch}-${i}` })),
).flat();
try {
const dataDir = writeStore(root, entries);
const result = consolidate(root);
const persisted = JSON.parse(
readFileSync(join(dataDir, 'auto-memory-store.json'), 'utf-8'),
) as Entry[];
const graph = JSON.parse(
readFileSync(join(dataDir, 'graph-state.json'), 'utf-8'),
) as { nodeCount: number };
expect(result.entries).toBe(unique.length);
expect(persisted).toHaveLength(unique.length);
expect(graph.nodeCount).toBe(unique.length);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('preserves temporal and similarity graph behavior for stores within the pair budget', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-2628-small-'));
const entries: Entry[] = [
{
id: 'a',
content: 'authentication token refresh security pattern',
namespace: 'patterns',
metadata: { sourceFile: 'auth.ts' },
},
{
id: 'b',
content: 'authentication token refresh security patterns',
namespace: 'patterns',
metadata: { sourceFile: 'auth.ts' },
},
];
try {
const dataDir = writeStore(root, entries);
consolidate(root);
const graph = JSON.parse(
readFileSync(join(dataDir, 'graph-state.json'), 'utf-8'),
) as { edges: Array<{ sourceId: string; targetId: string; type: string }> };
expect(graph.edges).toEqual(
expect.arrayContaining([
{ sourceId: 'a', targetId: 'b', type: 'temporal', weight: 0.5 },
expect.objectContaining({ sourceId: 'a', targetId: 'b', type: 'similar' }),
]),
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('bounds large unique stores while retaining their linear temporal graph', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-2628-bound-'));
const entries: Entry[] = Array.from({ length: 600 }, (_, i) => ({
id: `entry-${i}`,
content: `distinct project fact number ${i} token-${i.toString(36)}`,
namespace: 'one-large-category',
metadata: { sourceFile: 'MEMORY.md' },
}));
try {
const dataDir = writeStore(root, entries);
const result = consolidate(root);
const graph = JSON.parse(
readFileSync(join(dataDir, 'graph-state.json'), 'utf-8'),
) as { edges: Array<{ type: string }> };
expect(result.entries).toBe(entries.length);
expect(graph.edges).toHaveLength(entries.length - 1);
expect(graph.edges.every((edge) => edge.type === 'temporal')).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,107 @@
/**
* Regression for #2652's native/fallback visibility disagreement.
*
* Databases created before the status column existed contain NULL after an
* additive migration. list() has long treated those rows as legacy-active,
* but native retrieve/delete required the literal string "active". Mock the
* ControllerRegistry only all SQL executes against a real better-sqlite3
* fixture, so this pins the production bridge path deterministically.
*/
import { afterAll, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const root = mkdtempSync(join(tmpdir(), 'ruflo-2652-native-'));
const dbPath = join(root, 'memory.db');
let db: Database.Database | null = null;
function seedLegacyActiveRow(): void {
const db = new Database(dbPath);
db.exec(`
CREATE TABLE memory_entries (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
namespace TEXT DEFAULT 'default',
content TEXT NOT NULL,
type TEXT DEFAULT 'semantic',
embedding TEXT,
embedding_model TEXT DEFAULT 'local',
embedding_dimensions INTEGER,
tags TEXT,
metadata TEXT,
owner_id TEXT,
created_at INTEGER,
updated_at INTEGER,
expires_at INTEGER,
last_accessed_at INTEGER,
access_count INTEGER DEFAULT 0,
status TEXT,
provenance_type TEXT DEFAULT 'unknown',
UNIQUE(namespace, key)
);
INSERT INTO memory_entries (
id, key, namespace, content, created_at, updated_at, status
) VALUES (
'legacy-live-id', 'project-state-current', 'fixture', 'visible value',
1, 1, NULL
);
`);
db.close();
}
seedLegacyActiveRow();
afterAll(() => {
db?.close();
rmSync(root, { recursive: true, force: true });
});
describe('#2652 active-row visibility', () => {
it('retrieves and deletes the same legacy-active row through the native bridge', async () => {
const {
__setMemoryBridgeRegistryForTests,
bridgeGetEntry,
bridgeDeleteEntry,
} = await import('../src/memory/memory-bridge.js');
db = new Database(dbPath);
__setMemoryBridgeRegistryForTests({
getAgentDB: () => ({ database: db, embedder: null }),
get: () => null,
});
const retrieved = await bridgeGetEntry({
key: 'project-state-current',
namespace: 'fixture',
dbPath,
});
expect(retrieved).toMatchObject({
success: true,
found: true,
entry: {
id: 'legacy-live-id',
key: 'project-state-current',
namespace: 'fixture',
content: 'visible value',
},
});
const deleted = await bridgeDeleteEntry({
key: 'project-state-current',
namespace: 'fixture',
dbPath,
});
expect(deleted).toMatchObject({
success: true,
deleted: true,
remainingEntries: 0,
});
const row = db
.prepare('SELECT status FROM memory_entries WHERE id = ?')
.get('legacy-live-id') as { status: string };
expect(row.status).toBe('deleted');
});
});
@@ -0,0 +1,72 @@
/**
* Regression coverage for #2629: `memory init` must use the same path
* precedence as every other memory command.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
_resetMemoryRootCache,
initializeMemoryDatabase,
} from '../src/memory/memory-initializer.js';
let testDir: string;
let originalDbPath: string | undefined;
let originalMemoryPath: string | undefined;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'memory-init-path-2629-'));
originalDbPath = process.env.CLAUDE_FLOW_DB_PATH;
originalMemoryPath = process.env.CLAUDE_FLOW_MEMORY_PATH;
delete process.env.CLAUDE_FLOW_DB_PATH;
delete process.env.CLAUDE_FLOW_MEMORY_PATH;
_resetMemoryRootCache();
});
afterEach(() => {
if (originalDbPath === undefined) delete process.env.CLAUDE_FLOW_DB_PATH;
else process.env.CLAUDE_FLOW_DB_PATH = originalDbPath;
if (originalMemoryPath === undefined) delete process.env.CLAUDE_FLOW_MEMORY_PATH;
else process.env.CLAUDE_FLOW_MEMORY_PATH = originalMemoryPath;
_resetMemoryRootCache();
rmSync(testDir, { recursive: true, force: true });
});
describe('memory init database path precedence (#2629)', () => {
it('initializes at CLAUDE_FLOW_DB_PATH when --path is absent', async () => {
const customPath = join(testDir, 'isolated', 'custom.db');
const unusedDefaultRoot = join(testDir, 'default-root');
process.env.CLAUDE_FLOW_DB_PATH = customPath;
process.env.CLAUDE_FLOW_MEMORY_PATH = unusedDefaultRoot;
const result = await initializeMemoryDatabase({
backend: 'sqlite',
force: true,
migrate: false,
});
expect(result.success).toBe(true);
expect(result.dbPath).toBe(resolve(customPath));
expect(existsSync(customPath)).toBe(true);
expect(existsSync(join(unusedDefaultRoot, 'memory.db'))).toBe(false);
});
it('keeps the explicit --path equivalent above CLAUDE_FLOW_DB_PATH', async () => {
const envPath = join(testDir, 'env.db');
const explicitPath = join(testDir, 'explicit.db');
process.env.CLAUDE_FLOW_DB_PATH = envPath;
const result = await initializeMemoryDatabase({
backend: 'sqlite',
dbPath: explicitPath,
force: true,
migrate: false,
});
expect(result.success).toBe(true);
expect(result.dbPath).toBe(resolve(explicitPath));
expect(existsSync(explicitPath)).toBe(true);
expect(existsSync(envPath)).toBe(false);
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
{
"schemaVersion": 1,
"generation": 4,
"generatedAt": "2026-07-29T18:44:50.000Z",
"gitSha": "f98fc42c",
"generatedAt": "2026-07-29T19:34:39.000Z",
"gitSha": "d45a371d",
"catalog": {
"agents": 164,
"tools": 397,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@claude-flow/cli",
"version": "3.32.36",
"version": "3.32.37",
"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",
+56 -9
View File
@@ -548,7 +548,7 @@ function isInitialized(cwd: string): { claude: boolean; claudeFlow: boolean } {
}
// Init subcommand (default)
const initAction = async (ctx: CommandContext): Promise<CommandResult> => {
const initClaudeAction = async (ctx: CommandContext): Promise<CommandResult> => {
const force = ctx.flags.force as boolean;
const minimal = ctx.flags.minimal as boolean;
const full = ctx.flags.full as boolean;
@@ -563,15 +563,8 @@ const initAction = async (ctx: CommandContext): Promise<CommandResult> => {
const noGlobal = ctx.flags['no-global'] === true || ctx.flags['global'] === false;
const allAgents = ctx.flags['all-agents'] as boolean;
const cloudMcp = ctx.flags['cloud-mcp'] as boolean;
const codexMode = ctx.flags.codex as boolean;
const dualMode = ctx.flags.dual as boolean;
const cwd = ctx.cwd;
// If codex mode, use the Codex initializer
if (codexMode || dualMode) {
return initCodexAction(ctx, { codexMode, dualMode, force, minimal, full });
}
// Check if already initialized
const initialized = isInitialized(cwd);
const hasExisting = initialized.claude || initialized.claudeFlow;
@@ -880,6 +873,60 @@ const initAction = async (ctx: CommandContext): Promise<CommandResult> => {
}
};
/**
* Route platform initialization. Dual mode deliberately runs both native
* initializers: Claude Code first, then Codex without its compatibility
* CLAUDE.md stub so the full native Claude scaffold remains authoritative.
*/
const initAction = async (ctx: CommandContext): Promise<CommandResult> => {
const force = ctx.flags.force as boolean;
const minimal = ctx.flags.minimal as boolean;
const full = ctx.flags.full as boolean;
const codexMode = ctx.flags.codex as boolean;
const dualMode = ctx.flags.dual as boolean;
if (codexMode && !dualMode) {
return initCodexAction(ctx, { codexMode, dualMode: false, force, minimal, full });
}
if (!dualMode) {
return initClaudeAction(ctx);
}
const claudeContext: CommandContext = {
...ctx,
flags: {
...ctx.flags,
codex: false,
dual: false,
// The explicit Codex pass below owns Codex setup.
'no-codex-detect': true,
},
};
const claudeResult = await initClaudeAction(claudeContext);
if (!claudeResult.success) return claudeResult;
const codexResult = await initCodexAction(ctx, {
codexMode: true,
// Preserve the full CLAUDE.md and .claude/ scaffold just generated.
dualMode: false,
force,
minimal,
full,
});
if (!codexResult.success) {
return {
...codexResult,
message: 'Claude Code initialized, but Codex initialization failed',
data: { claude: claudeResult.data, codex: codexResult.data },
};
}
return {
success: true,
data: { claude: claudeResult.data, codex: codexResult.data },
};
};
// Wizard subcommand for interactive setup
const wizardCommand: Command = {
name: 'wizard',
@@ -1616,7 +1663,7 @@ export const initCommand: Command = {
{ command: 'claude-flow init upgrade --settings', description: 'Update helpers and merge new settings (Agent Teams)' },
{ command: 'claude-flow init upgrade --verbose', description: 'Show detailed upgrade info' },
{ command: 'claude-flow init --codex', description: 'Initialize for OpenAI Codex (AGENTS.md)' },
{ command: 'claude-flow init --codex --full', description: 'Codex init with all 137+ skills' },
{ command: 'claude-flow init --codex --full', description: 'Codex init with all canonical packaged skills' },
{ command: 'claude-flow init --dual', description: 'Initialize for both Claude Code and Codex' },
{ command: 'claude-flow init --no-codex-detect', description: 'Skip auto-configuring OpenAI Codex even if it is installed' },
{ command: 'claude-flow init --no-skills-sh', description: 'Skip the post-init skills.sh registration' },
+31
View File
@@ -1508,6 +1508,8 @@ async function writeRuntimeConfig(
options: InitOptions,
result: InitResult
): Promise<void> {
updateRootGitignore(targetDir, result);
const configPath = path.join(targetDir, '.claude-flow', 'config.yaml');
if (fs.existsSync(configPath) && !options.force) {
@@ -1583,6 +1585,35 @@ neural/
await writeCapabilitiesDoc(targetDir, options, result);
}
/**
* Protect project-local secrets and runtime state for Claude-only installs.
* Append missing entries without replacing the project's existing rules.
*/
function updateRootGitignore(targetDir: string, result: InitResult): void {
const gitignorePath = path.join(targetDir, '.gitignore');
const entries = [
'# Ruflo local secrets and runtime data',
'.env',
'.env.local',
'.env.*.local',
'.claude-flow/data/',
'.claude-flow/logs/',
'.claude-flow/sessions/',
];
const existing = fs.existsSync(gitignorePath)
? fs.readFileSync(gitignorePath, 'utf8')
: '';
const existingLines = new Set(existing.split(/\r?\n/));
const missing = entries.filter((entry) => !existingLines.has(entry));
if (missing.length === 0) return;
const separator = existing.length === 0
? ''
: existing.endsWith('\n') ? '\n' : '\n\n';
fs.writeFileSync(gitignorePath, `${existing}${separator}${missing.join('\n')}\n`, 'utf8');
result.created.files.push('.gitignore (updated)');
}
/**
* Write initial metrics files for statusline
* Creates baseline data so statusline shows meaningful state instead of all zeros
@@ -206,7 +206,7 @@ export const metaharnessTools: MCPTool[] = [
},
{
name: 'metaharness_genome',
description: 'ADR-150 — 7-section categorical readiness report from `metaharness genome <path>` (repo_type / agent_topology / risk_score / mcp_surface / test_confidence / publish_readiness). Use when you need the categorical view (vs numeric score). Pair with metaharness_score for the full readiness picture — score-alone is wrong because two harnesses with the same harnessFit can have very different agent_topology and mcp_surface. ' + MCP_SUCCESS_SEMANTIC,
description: 'ADR-150 — 7-section categorical readiness report from `metaharness genome <path>` (repo_type / agent_topology / risk_score / mcp_surface / test_confidence / publish_readiness). Upstream needs-work/blocked exit statuses are valid verdicts and return successfully as data.verdict + data.verdictExitCode; only a missing or malformed report is an error. Use when you need the categorical view (vs numeric score). Pair with metaharness_score for the full readiness picture — score-alone is wrong because two harnesses with the same harnessFit can have very different agent_topology and mcp_surface. ' + MCP_SUCCESS_SEMANTIC,
category: 'metaharness',
inputSchema: {
type: 'object',
@@ -26,6 +26,10 @@ import { createRequire } from 'node:module';
let registryPromise: Promise<any> | null = null;
let registryInstance: any = null;
let bridgeAvailable: boolean | null = null;
// #2652/#2120: rows created before the status column existed receive NULL
// during migration. They are live rows, not tombstones. Every user-facing
// read/delete path must agree with list() about their visibility.
const ACTIVE_MEMORY_ROW_SQL = `(status = 'active' OR status IS NULL)`;
/**
* Why the bridge is unavailable, when it is.
*
@@ -1088,7 +1092,7 @@ export async function bridgeSearchEntries(options: {
const stmt = ctx.db.prepare(`
SELECT id, key, namespace, content, embedding, provenance_type
FROM memory_entries
WHERE status = 'active' ${whereExtra}
WHERE ${ACTIVE_MEMORY_ROW_SQL} ${whereExtra}
LIMIT 1000
`);
rows = filterParams.length > 0 ? stmt.all(...filterParams) : stmt.all();
@@ -1244,7 +1248,7 @@ export async function bridgeListEntries(options: {
// the `status = 'active'` filter matched zero. Treat NULL as
// "legacy-active" — the safe default for any entry that predates the
// status column.
const statusFilter = `(status = 'active' OR status IS NULL)`;
const statusFilter = ACTIVE_MEMORY_ROW_SQL;
// Count
let total = 0;
@@ -1361,7 +1365,7 @@ export async function bridgeGetEntry(options: {
const stmt = ctx.db.prepare(`
SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags
FROM memory_entries
WHERE status = 'active' AND key = ? AND namespace = ?
WHERE ${ACTIVE_MEMORY_ROW_SQL} AND key = ? AND namespace = ?
LIMIT 1
`);
row = stmt.get(key, namespace);
@@ -1446,7 +1450,7 @@ export async function bridgeDeleteEntry(options: {
const result = ctx.db.prepare(`
UPDATE memory_entries
SET status = 'deleted', updated_at = ?
WHERE key = ? AND namespace = ? AND status = 'active'
WHERE key = ? AND namespace = ? AND ${ACTIVE_MEMORY_ROW_SQL}
`).run(Date.now(), key, namespace);
changes = result?.changes ?? 0;
} catch {
@@ -1480,7 +1484,7 @@ export async function bridgeDeleteEntry(options: {
let remaining = 0;
try {
const row = ctx.db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE status = 'active'`).get();
const row = ctx.db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`).get();
remaining = row?.cnt ?? 0;
} catch {
// Non-fatal
@@ -1902,6 +1906,22 @@ export function getBridgeFailureReason(): string | null {
return bridgeFailureReason;
}
/**
* Install a pre-initialized registry for deterministic bridge tests.
*
* The CLI test runner intentionally externalizes the optional
* `@claude-flow/memory` package so an unbuilt workspace can still exercise
* fallback paths. That also makes module-level mocking of ControllerRegistry
* environment-dependent. This narrow seam keeps native SQL regression tests
* independent of package build order without changing production startup.
*/
export function __setMemoryBridgeRegistryForTests(registry: any | null): void {
registryInstance = registry;
registryPromise = registry ? Promise.resolve(registry) : null;
bridgeAvailable = registry ? true : null;
bridgeFailureReason = null;
}
/**
* Shutdown the bridge and release resources.
*
@@ -160,6 +160,11 @@ export function resolveDbPath(cliFlag?: string): string {
return path.join(getMemoryRoot(), 'memory.db');
}
// #2652/#2120: legacy rows with NULL status predate soft-delete semantics and
// are active. Keep fallback retrieve/delete aligned with list and the native
// bridge instead of making a row visible to one command but not another.
const ACTIVE_MEMORY_ROW_SQL = `(status = 'active' OR status IS NULL)`;
// ADR-053: Lazy import of AgentDB v3 bridge
let _bridge: typeof import('./memory-bridge.js') | null | undefined;
async function getBridge(): Promise<typeof import('./memory-bridge.js') | null> {
@@ -1801,8 +1806,7 @@ export async function initializeMemoryDatabase(options: {
migrate = true
} = options;
const swarmDir = getMemoryRoot();
const dbPath = customPath || path.join(swarmDir, 'memory.db');
const dbPath = resolveDbPath(customPath);
const dbDir = path.dirname(dbPath);
try {
@@ -3339,7 +3343,7 @@ export async function listEntries(options: {
// that predate the status column may have NULL after migration.
// See memory-bridge.ts:bridgeListEntries for full context.
// Get total count
const whereClauses = [`(status = 'active' OR status IS NULL)`];
const whereClauses = [ACTIVE_MEMORY_ROW_SQL];
const whereParams: string[] = [];
if (namespace) {
whereClauses.push('namespace = ?');
@@ -3508,7 +3512,7 @@ export async function getEntry(options: {
const getStmt = db.prepare(`
SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags
FROM memory_entries
WHERE status = 'active'
WHERE ${ACTIVE_MEMORY_ROW_SQL}
AND key = ?
AND namespace = ?
LIMIT 1
@@ -3653,7 +3657,7 @@ export async function deleteEntry(options: {
// Check if entry exists first
const checkStmt = db.prepare(`
SELECT id FROM memory_entries
WHERE status = 'active'
WHERE ${ACTIVE_MEMORY_ROW_SQL}
AND key = ?
AND namespace = ?
LIMIT 1
@@ -3668,7 +3672,7 @@ export async function deleteEntry(options: {
if (!checkResult[0]?.values?.[0]) {
// Get remaining count before closing
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`);
const remainingEntries = countResult[0]?.values?.[0]?.[0] as number || 0;
db.close();
return {
@@ -3690,11 +3694,11 @@ export async function deleteEntry(options: {
updated_at = strftime('%s', 'now') * 1000
WHERE key = ?
AND namespace = ?
AND status = 'active'
AND ${ACTIVE_MEMORY_ROW_SQL}
`, [key, namespace]);
// Get remaining count
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`);
const remainingEntries = countResult[0]?.values?.[0]?.[0] as number || 0;
// Save updated database
+49 -8
View File
@@ -20,7 +20,7 @@ import {
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 { DEFAULT_SKILLS_BY_TEMPLATE, AGENTS_OVERRIDE_TEMPLATE, GITIGNORE_ENTRIES } from './templates/index.js';
import { getRufloMcpAddCommand } from './mcp-config.js';
/**
@@ -81,6 +81,21 @@ export class CodexInitializer {
warnings.push('Overwriting existing configuration files');
}
// Template catalog entries are capability names, not proof that a
// complete SKILL.md payload ships in this package. For default template
// selections, install only canonical packaged assets. Explicit
// `options.skills` remain an intentional custom-skill request and keep
// the existing scaffold behavior.
if (options.skills === undefined) {
const omitted = await this.retainCanonicalPackagedSkills();
if (omitted.length > 0) {
warnings.push(
`Omitted ${omitted.length} catalog skills without canonical packaged assets. ` +
'Install additional capabilities from the Ruflo plugin catalog.',
);
}
}
// Create directory structure
await this.createDirectoryStructure();
@@ -278,6 +293,28 @@ export class CodexInitializer {
}
}
/**
* Keep generated configuration truthful: a template-selected skill is
* enabled only when its canonical SKILL.md is present in the package.
*/
private async retainCanonicalPackagedSkills(): Promise<string[]> {
const canonical = new Set<string>();
try {
const entries = await fs.readdir(this.bundledSkillsPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(this.bundledSkillsPath, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) canonical.add(entry.name);
}
} catch {
// Missing/unreadable package assets must fail safe to omission.
}
const omitted = this.skills.filter((skillName) => !canonical.has(skillName));
this.skills = this.skills.filter((skillName) => canonical.has(skillName));
return omitted;
}
/**
* Copy bundled skills from the package or source directory
* Returns the list of skills copied
@@ -611,14 +648,18 @@ web_search = "live"
content = await fs.readFile(gitignorePath, 'utf-8');
}
// Check if Codex entries already exist
if (content.includes('.codex/')) {
return false; // Already has entries
}
const existingLines = new Set(content.split(/\r?\n/));
const missing = GITIGNORE_ENTRIES.filter(
(entry) => entry.length > 0 && !existingLines.has(entry),
);
if (missing.length === 0) return false;
// Add entries with proper spacing
const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : '\n';
const newContent = content + separator + GITIGNORE_ENTRIES.join('\n') + '\n';
// Add only missing entries so a preceding Claude-native init does not
// duplicate shared .env and runtime rules.
const separator = content.length === 0
? ''
: content.endsWith('\n') ? '\n' : '\n\n';
const newContent = content + separator + missing.join('\n') + '\n';
await fs.writeFile(gitignorePath, newContent, 'utf-8');
return true;
}
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CodexInitializer } from '../src/initializer.js';
import { BUILT_IN_SKILL_NAMES } from '../src/generators/skill-md.js';
let projectPath: string;
let originalPath: string | undefined;
beforeEach(() => {
projectPath = mkdtempSync(join(tmpdir(), 'codex-full-init-2634-'));
originalPath = process.env.PATH;
// Prevent tests from registering MCP servers or plugins in user config.
process.env.PATH = '';
});
afterEach(() => {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
rmSync(projectPath, { recursive: true, force: true });
});
describe('Codex full template canonical skills (#2634)', () => {
it('omits catalog-only capabilities instead of generating placeholder skills', async () => {
const result = await new CodexInitializer().initialize({
projectPath,
template: 'full',
});
expect(result.success).toBe(true);
expect(result.skillsGenerated.sort()).toEqual([...BUILT_IN_SKILL_NAMES].sort());
expect(result.warnings).toContain(
'Omitted 103 catalog skills without canonical packaged assets. ' +
'Install additional capabilities from the Ruflo plugin catalog.',
);
const installed = readdirSync(join(projectPath, '.agents', 'skills')).sort();
expect(installed).toEqual([...BUILT_IN_SKILL_NAMES].sort());
for (const skillName of installed) {
const content = readFileSync(
join(projectPath, '.agents', 'skills', skillName, 'SKILL.md'),
'utf8',
);
expect(content).not.toContain('Custom skill:');
expect(content).not.toContain('Define when to trigger this skill');
}
const config = readFileSync(join(projectPath, '.agents', 'config.toml'), 'utf8');
expect(config).not.toContain('.agents/skills/agentdb-advanced');
});
it('preserves explicitly requested custom-skill scaffolding', async () => {
const result = await new CodexInitializer().initialize({
projectPath,
template: 'full',
skills: ['my-project-skill'],
});
expect(result.success).toBe(true);
expect(result.skillsGenerated).toContain('my-project-skill');
const content = readFileSync(
join(projectPath, '.agents', 'skills', 'my-project-skill', 'SKILL.md'),
'utf8',
);
expect(content).toContain('Custom skill: my-project-skill');
});
it('adds Codex ignores without duplicating shared Claude init entries', async () => {
const gitignorePath = join(projectPath, '.gitignore');
writeFileSync(
gitignorePath,
'# Ruflo local secrets and runtime data\n.env\n.claude-flow/data/\n',
'utf8',
);
const result = await new CodexInitializer().initialize({
projectPath,
template: 'minimal',
});
expect(result.success).toBe(true);
const gitignore = readFileSync(gitignorePath, 'utf8');
expect(gitignore.match(/^\.env$/gm)).toHaveLength(1);
expect(gitignore.match(/^\.claude-flow\/data\/$/gm)).toHaveLength(1);
expect(gitignore).toContain('.codex/');
});
});
+95
View File
@@ -0,0 +1,95 @@
# Ruflo v3.32.37: Complete Reports, Consistent Initialization
Ruflo v3.32.37 closes the follow-up defects found while verifying v3.32.36
against older open bug reports. MetaHarness readiness verdicts remain
machine-readable even when a repository is blocked, memory initialization
uses the same configured database path as every later operation, and dual
Claude Code/Codex initialization now installs both native surfaces.
The release also removes unbacked Codex skill placeholders, makes ADR indexing
idempotent, repairs legacy live-memory row visibility, and bounds the
quadratic work in session-end intelligence consolidation.
## Install or upgrade
```bash
npm install --global ruflo@3.32.37
ruflo doctor
```
Existing projects remain compatible. The stable train still contains exactly
the three supported packages: `@claude-flow/cli`, `claude-flow`, and `ruflo`.
## MetaHarness readiness is data
```bash
ruflo metaharness genome --path . --format json
```
Upstream MetaHarness intentionally uses exit 1 for `needs-work` and exit 2 for
`blocked`. Ruflo now preserves those valid reports instead of replacing them
with a generic subprocess error:
```json
{
"risk_score": 0.72,
"verdict": "blocked",
"verdictExitCode": 2
}
```
The Ruflo wrapper exits successfully for a valid report so CLI and MCP callers
can consume it. `--alert-on-risk-above` still exits 1 when its policy threshold
is crossed, and malformed or missing reports still fail with exit 2.
## Initialization and portability
- `memory init` honors `CLAUDE_FLOW_DB_PATH`, completing the shared-path
contract used by dual-mode bootstrap and background workers.
- `init --dual` runs both native initializers, preserving the full Claude Code
scaffold while adding Codex assets.
- Root `.gitignore` protection covers `.env` secrets in initialized projects.
- Codex full init installs canonical skill implementations only; it no longer
generates more than one hundred placeholder skills that appear usable but
have no implementation.
- Every tracked shell and Windows hook shim now resolves the same stable Ruflo
dist-tag.
## ADR and memory correctness
- ADR metadata parsing accepts the bullet-prefixed Date, Tags, and relationship
format emitted by `adr-create`.
- ADR records use explicit upsert semantics and stable keys.
- ADR relationships use deterministic semantic keys and are deduplicated
within an import, while verification remains compatible with legacy keys.
- ADR creation guidance uses the real AgentDB `key`/`value` contract.
- Native memory CRUD treats legacy `NULL` status rows consistently as live.
- Intelligence consolidation deduplicates by content before graph creation and
caps similarity comparisons while preserving temporal edges.
## Validation
- Blocked-genome CLI and MCP fixtures preserve the complete verdict.
- ADR smoke: 22/22; hook-shim smoke: 12/12.
- Release gates: 12 CLI contract, 238 Codex, 559 security, and 640
federation tests pass.
- All 23 buildable V3 workspace packages compile successfully.
- Focused MetaHarness, memory-path, dual-init, canonical-skill, ADR parser,
ADR idempotency, memory visibility, and consolidation regressions pass.
- The stable release workflow builds immutable archives, tests the bundled
policy/Codex/federation runtimes, installs all three archives, publishes the
same bytes, and installs them again from the npm registry.
This patch resolves
[#2626](https://github.com/ruvnet/ruflo/issues/2626),
[#2629](https://github.com/ruvnet/ruflo/issues/2629),
[#2600](https://github.com/ruvnet/ruflo/issues/2600),
[#2634](https://github.com/ruvnet/ruflo/issues/2634),
[#2636](https://github.com/ruvnet/ruflo/issues/2636),
[#2637](https://github.com/ruvnet/ruflo/issues/2637),
[#2660](https://github.com/ruvnet/ruflo/issues/2660),
[#2659](https://github.com/ruvnet/ruflo/issues/2659),
[#2651](https://github.com/ruvnet/ruflo/issues/2651),
[#2628](https://github.com/ruvnet/ruflo/issues/2628), and
the remaining active-row visibility defect in
[#2652](https://github.com/ruvnet/ruflo/issues/2652).