mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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}`,
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user