From 401e02d511b1cf4f2f4d9771feef50a0203e5bf6 Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 29 Jul 2026 15:40:17 -0400 Subject: [PATCH] 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 --- .claude/helpers/intelligence.cjs | 37 +++++- .github/workflows/metaharness-ci.yml | 19 ++- package-lock.json | 4 +- package.json | 2 +- plugin/scripts/ruflo-hook.sh | 4 +- plugins/ruflo-adr/README.md | 2 +- plugins/ruflo-adr/REFERENCE.md | 11 +- plugins/ruflo-adr/commands/adr.md | 2 +- .../docs/adrs/0002-reconcile-deleted-adrs.md | 4 +- .../__tests__/adr-create-schema-2651.test.mjs | 24 ++++ .../__tests__/index-idempotency-2660.test.mjs | 65 +++++++++ .../__tests__/parser-bullets-2659.test.mjs | 46 +++++++ plugins/ruflo-adr/scripts/import.mjs | 41 +++--- .../ruflo-adr/scripts/lib/index-records.mjs | 59 +++++++++ plugins/ruflo-adr/scripts/lib/parse-adrs.mjs | 8 +- plugins/ruflo-adr/scripts/reindex.mjs | 42 +++--- plugins/ruflo-adr/scripts/smoke.sh | 7 + plugins/ruflo-adr/scripts/verify.mjs | 8 +- plugins/ruflo-adr/skills/adr-create/SKILL.md | 10 +- plugins/ruflo-adr/skills/adr-index/SKILL.md | 6 +- .../commands/ruflo-metaharness.md | 6 +- plugins/ruflo-metaharness/scripts/genome.mjs | 33 ++++- .../scripts/test-mcp-tools.mjs | 26 +++- .../skills/harness-genome/SKILL.md | 7 +- ruflo/package.json | 4 +- scripts/smoke-ruflo-hook-cjs.mjs | 30 +++-- .../cli/.claude/helpers/helpers.manifest.json | 6 +- .../cli/.claude/helpers/intelligence.cjs | 37 +++++- .../init-dual-native-2636-2637.test.ts | 102 ++++++++++++++ ...ssue-2628-intelligence-consolidate.test.ts | 125 ++++++++++++++++++ .../memory-active-row-visibility-2652.test.ts | 107 +++++++++++++++ .../memory-init-db-path-2629.test.ts | 72 ++++++++++ v3/@claude-flow/cli/catalog-manifest.json | 4 +- v3/@claude-flow/cli/package.json | 2 +- v3/@claude-flow/cli/src/commands/init.ts | 65 +++++++-- v3/@claude-flow/cli/src/init/executor.ts | 31 +++++ .../cli/src/mcp-tools/metaharness-tools.ts | 2 +- .../cli/src/memory/memory-bridge.ts | 30 ++++- .../cli/src/memory/memory-initializer.ts | 20 +-- v3/@claude-flow/codex/src/initializer.ts | 57 ++++++-- .../codex/tests/initializer.test.ts | 89 +++++++++++++ v3/docs/releases/v3.32.37.md | 95 +++++++++++++ 42 files changed, 1227 insertions(+), 124 deletions(-) create mode 100644 plugins/ruflo-adr/scripts/__tests__/adr-create-schema-2651.test.mjs create mode 100644 plugins/ruflo-adr/scripts/__tests__/index-idempotency-2660.test.mjs create mode 100644 plugins/ruflo-adr/scripts/__tests__/parser-bullets-2659.test.mjs create mode 100644 plugins/ruflo-adr/scripts/lib/index-records.mjs create mode 100644 v3/@claude-flow/cli/__tests__/init-dual-native-2636-2637.test.ts create mode 100644 v3/@claude-flow/cli/__tests__/issue-2628-intelligence-consolidate.test.ts create mode 100644 v3/@claude-flow/cli/__tests__/memory-active-row-visibility-2652.test.ts create mode 100644 v3/@claude-flow/cli/__tests__/memory-init-db-path-2629.test.ts create mode 100644 v3/@claude-flow/codex/tests/initializer.test.ts create mode 100644 v3/docs/releases/v3.32.37.md diff --git a/.claude/helpers/intelligence.cjs b/.claude/helpers/intelligence.cjs index cd6e720c8..cef9b77b9 100755 --- a/.claude/helpers/intelligence.cjs +++ b/.claude/helpers/intelligence.cjs @@ -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; diff --git a/.github/workflows/metaharness-ci.yml b/.github/workflows/metaharness-ci.yml index bcfdacf72..1853fa1f7 100644 --- a/.github/workflows/metaharness-ci.yml +++ b/.github/workflows/metaharness-ci.yml @@ -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)" - diff --git a/package-lock.json b/package-lock.json index 4c25e08f5..704b92f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 548dad296..bb4ff805d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugin/scripts/ruflo-hook.sh b/plugin/scripts/ruflo-hook.sh index fc198d2e2..2cce46100 100755 --- a/plugin/scripts/ruflo-hook.sh +++ b/plugin/scripts/ruflo-hook.sh @@ -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 @alpha hooks …` re-resolves the @alpha dist-tag and re-installs +# `npx @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 diff --git a/plugins/ruflo-adr/README.md b/plugins/ruflo-adr/README.md index e96c1aff9..040e30028 100644 --- a/plugins/ruflo-adr/README.md +++ b/plugins/ruflo-adr/README.md @@ -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 diff --git a/plugins/ruflo-adr/REFERENCE.md b/plugins/ruflo-adr/REFERENCE.md index b3262e4b9..a72fd8e6f 100644 --- a/plugins/ruflo-adr/REFERENCE.md +++ b/plugins/ruflo-adr/REFERENCE.md @@ -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/ +# 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: diff --git a/plugins/ruflo-adr/commands/adr.md b/plugins/ruflo-adr/commands/adr.md index b9cf55eeb..c8ed27688 100644 --- a/plugins/ruflo-adr/commands/adr.md +++ b/plugins/ruflo-adr/commands/adr.md @@ -11,7 +11,7 @@ Manage Architecture Decision Records. Parse $ARGUMENTS to determine the subcomma **`adr create `** -- 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. diff --git a/plugins/ruflo-adr/docs/adrs/0002-reconcile-deleted-adrs.md b/plugins/ruflo-adr/docs/adrs/0002-reconcile-deleted-adrs.md index 86ac473ed..3359c74eb 100644 --- a/plugins/ruflo-adr/docs/adrs/0002-reconcile-deleted-adrs.md +++ b/plugins/ruflo-adr/docs/adrs/0002-reconcile-deleted-adrs.md @@ -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 diff --git a/plugins/ruflo-adr/scripts/__tests__/adr-create-schema-2651.test.mjs b/plugins/ruflo-adr/scripts/__tests__/adr-create-schema-2651.test.mjs new file mode 100644 index 000000000..cc7d18090 --- /dev/null +++ b/plugins/ruflo-adr/scripts/__tests__/adr-create-schema-2651.test.mjs @@ -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); + } +}); diff --git a/plugins/ruflo-adr/scripts/__tests__/index-idempotency-2660.test.mjs b/plugins/ruflo-adr/scripts/__tests__/index-idempotency-2660.test.mjs new file mode 100644 index 000000000..8850c3e67 --- /dev/null +++ b/plugins/ruflo-adr/scripts/__tests__/index-idempotency-2660.test.mjs @@ -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', + }); +}); diff --git a/plugins/ruflo-adr/scripts/__tests__/parser-bullets-2659.test.mjs b/plugins/ruflo-adr/scripts/__tests__/parser-bullets-2659.test.mjs new file mode 100644 index 000000000..7bd30b491 --- /dev/null +++ b/plugins/ruflo-adr/scripts/__tests__/parser-bullets-2659.test.mjs @@ -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 }); + } +}); diff --git a/plugins/ruflo-adr/scripts/import.mjs b/plugins/ruflo-adr/scripts/import.mjs index 0f0eaa703..2971380b5 100755 --- a/plugins/ruflo-adr/scripts/import.mjs +++ b/plugins/ruflo-adr/scripts/import.mjs @@ -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}`); } } diff --git a/plugins/ruflo-adr/scripts/lib/index-records.mjs b/plugins/ruflo-adr/scripts/lib/index-records.mjs new file mode 100644 index 000000000..0b5dbe919 --- /dev/null +++ b/plugins/ruflo-adr/scripts/lib/index-records.mjs @@ -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}`, + ]; +} diff --git a/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs b/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs index c6fbd46d6..a75c92a5b 100644 --- a/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs +++ b/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs @@ -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); diff --git a/plugins/ruflo-adr/scripts/reindex.mjs b/plugins/ruflo-adr/scripts/reindex.mjs index 0cb453ae2..8812f512b 100755 --- a/plugins/ruflo-adr/scripts/reindex.mjs +++ b/plugins/ruflo-adr/scripts/reindex.mjs @@ -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 diff --git a/plugins/ruflo-adr/scripts/smoke.sh b/plugins/ruflo-adr/scripts/smoke.sh index ecfdf0421..ef9706fc5 100755 --- a/plugins/ruflo-adr/scripts/smoke.sh +++ b/plugins/ruflo-adr/scripts/smoke.sh @@ -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 diff --git a/plugins/ruflo-adr/scripts/verify.mjs b/plugins/ruflo-adr/scripts/verify.mjs index 3a8fbb5fd..bf173ac4c 100755 --- a/plugins/ruflo-adr/scripts/verify.mjs +++ b/plugins/ruflo-adr/scripts/verify.mjs @@ -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)); diff --git a/plugins/ruflo-adr/skills/adr-create/SKILL.md b/plugins/ruflo-adr/skills/adr-create/SKILL.md index ee3beb6ca..ba1bfca6c 100644 --- a/plugins/ruflo-adr/skills/adr-create/SKILL.md +++ b/plugins/ruflo-adr/skills/adr-create/SKILL.md @@ -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. diff --git a/plugins/ruflo-adr/skills/adr-index/SKILL.md b/plugins/ruflo-adr/skills/adr-index/SKILL.md index 5ee775e91..615758565 100644 --- a/plugins/ruflo-adr/skills/adr-index/SKILL.md +++ b/plugins/ruflo-adr/skills/adr-index/SKILL.md @@ -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`. diff --git a/plugins/ruflo-metaharness/commands/ruflo-metaharness.md b/plugins/ruflo-metaharness/commands/ruflo-metaharness.md index 0904e5b6c..67d21da4b 100644 --- a/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +++ b/plugins/ruflo-metaharness/commands/ruflo-metaharness.md @@ -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>` diff --git a/plugins/ruflo-metaharness/scripts/genome.mjs b/plugins/ruflo-metaharness/scripts/genome.mjs index 50f558764..2f3cc0945 100755 --- a/plugins/ruflo-metaharness/scripts/genome.mjs +++ b/plugins/ruflo-metaharness/scripts/genome.mjs @@ -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(); diff --git a/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs b/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs index b864de38e..5be6abf01 100755 --- a/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs +++ b/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs @@ -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) { diff --git a/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md b/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md index cc7eb0c00..db27cc57a 100644 --- a/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md +++ b/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md @@ -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) diff --git a/ruflo/package.json b/ruflo/package.json index 33767dcf2..ed5f3bfd3 100644 --- a/ruflo/package.json +++ b/ruflo/package.json @@ -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": { diff --git a/scripts/smoke-ruflo-hook-cjs.mjs b/scripts/smoke-ruflo-hook-cjs.mjs index 67f3d5888..99f092af4 100644 --- a/scripts/smoke-ruflo-hook-cjs.mjs +++ b/scripts/smoke-ruflo-hook-cjs.mjs @@ -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`); diff --git a/v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json b/v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json index 7e10f4975..3eeb543f6 100644 --- a/v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json +++ b/v3/@claude-flow/cli/.claude/helpers/helpers.manifest.json @@ -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" } diff --git a/v3/@claude-flow/cli/.claude/helpers/intelligence.cjs b/v3/@claude-flow/cli/.claude/helpers/intelligence.cjs index cd6e720c8..cef9b77b9 100755 --- a/v3/@claude-flow/cli/.claude/helpers/intelligence.cjs +++ b/v3/@claude-flow/cli/.claude/helpers/intelligence.cjs @@ -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; diff --git a/v3/@claude-flow/cli/__tests__/init-dual-native-2636-2637.test.ts b/v3/@claude-flow/cli/__tests__/init-dual-native-2636-2637.test.ts new file mode 100644 index 000000000..64255ca7b --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/init-dual-native-2636-2637.test.ts @@ -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)'); + }); +}); diff --git a/v3/@claude-flow/cli/__tests__/issue-2628-intelligence-consolidate.test.ts b/v3/@claude-flow/cli/__tests__/issue-2628-intelligence-consolidate.test.ts new file mode 100644 index 000000000..d1d65fe75 --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/issue-2628-intelligence-consolidate.test.ts @@ -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 }); + } + }); +}); diff --git a/v3/@claude-flow/cli/__tests__/memory-active-row-visibility-2652.test.ts b/v3/@claude-flow/cli/__tests__/memory-active-row-visibility-2652.test.ts new file mode 100644 index 000000000..3f373a663 --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/memory-active-row-visibility-2652.test.ts @@ -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'); + }); +}); diff --git a/v3/@claude-flow/cli/__tests__/memory-init-db-path-2629.test.ts b/v3/@claude-flow/cli/__tests__/memory-init-db-path-2629.test.ts new file mode 100644 index 000000000..1c27faf46 --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/memory-init-db-path-2629.test.ts @@ -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); + }); +}); diff --git a/v3/@claude-flow/cli/catalog-manifest.json b/v3/@claude-flow/cli/catalog-manifest.json index 7f367b825..f587e12e6 100644 --- a/v3/@claude-flow/cli/catalog-manifest.json +++ b/v3/@claude-flow/cli/catalog-manifest.json @@ -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, diff --git a/v3/@claude-flow/cli/package.json b/v3/@claude-flow/cli/package.json index 7477b8254..96c673a68 100644 --- a/v3/@claude-flow/cli/package.json +++ b/v3/@claude-flow/cli/package.json @@ -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", diff --git a/v3/@claude-flow/cli/src/commands/init.ts b/v3/@claude-flow/cli/src/commands/init.ts index 9660e98b0..6c736c8ef 100644 --- a/v3/@claude-flow/cli/src/commands/init.ts +++ b/v3/@claude-flow/cli/src/commands/init.ts @@ -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' }, diff --git a/v3/@claude-flow/cli/src/init/executor.ts b/v3/@claude-flow/cli/src/init/executor.ts index 4f58ca43e..4123c9196 100644 --- a/v3/@claude-flow/cli/src/init/executor.ts +++ b/v3/@claude-flow/cli/src/init/executor.ts @@ -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 diff --git a/v3/@claude-flow/cli/src/mcp-tools/metaharness-tools.ts b/v3/@claude-flow/cli/src/mcp-tools/metaharness-tools.ts index 98faf3c5d..1516af678 100644 --- a/v3/@claude-flow/cli/src/mcp-tools/metaharness-tools.ts +++ b/v3/@claude-flow/cli/src/mcp-tools/metaharness-tools.ts @@ -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', diff --git a/v3/@claude-flow/cli/src/memory/memory-bridge.ts b/v3/@claude-flow/cli/src/memory/memory-bridge.ts index ce8d450cb..b4dc2d94c 100644 --- a/v3/@claude-flow/cli/src/memory/memory-bridge.ts +++ b/v3/@claude-flow/cli/src/memory/memory-bridge.ts @@ -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. * diff --git a/v3/@claude-flow/cli/src/memory/memory-initializer.ts b/v3/@claude-flow/cli/src/memory/memory-initializer.ts index 15a26b2ca..b94d5d576 100644 --- a/v3/@claude-flow/cli/src/memory/memory-initializer.ts +++ b/v3/@claude-flow/cli/src/memory/memory-initializer.ts @@ -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 diff --git a/v3/@claude-flow/codex/src/initializer.ts b/v3/@claude-flow/codex/src/initializer.ts index c9c13be86..edcffba31 100644 --- a/v3/@claude-flow/codex/src/initializer.ts +++ b/v3/@claude-flow/codex/src/initializer.ts @@ -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; } diff --git a/v3/@claude-flow/codex/tests/initializer.test.ts b/v3/@claude-flow/codex/tests/initializer.test.ts new file mode 100644 index 000000000..5619508a0 --- /dev/null +++ b/v3/@claude-flow/codex/tests/initializer.test.ts @@ -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/'); + }); +}); diff --git a/v3/docs/releases/v3.32.37.md b/v3/docs/releases/v3.32.37.md new file mode 100644 index 000000000..4d418050b --- /dev/null +++ b/v3/docs/releases/v3.32.37.md @@ -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).