mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
d012042800
* chore(arch): untrack the prepublish plugin mirror — build artifact, not source
v3/@claude-flow/cli/plugins/ is regenerated by prepublishOnly from
plugins/ruflo-metaharness/ at publish time. Keeping it committed caused
silent drift between publishes (4 files diverged this week alone) and
every source edit required a manual mirror sync. npm pack still bundles
it — the files array + prepublish copy are unaffected by gitignore.
Co-Authored-By: RuFlo <ruv@ruv.net>
* feat(gaia): submission integrity checklist — fail-closed pre-submit audit (Berkeley RDI hardening)
Adds a 4-part integrity section to the gaia-validate pre-submit gate,
motivated by UC Berkeley RDI's 'Agents' Last Exam' (April 2026), which
gamed all 8 major agent benchmarks to near-100% without solving tasks.
- scripts/gaia-integrity.mjs: (A) answer-key-shaped path reads outside
the sanctioned dataset dir — FAIL-CLOSED exit 2; (B) eval()/new
Function()/exec-of-non-literal strings in gaia-bench runner code
paths — FAIL-CLOSED exit 2; (C) judge-prompt-injection markers in
produced answers/trajectories — WARN; (D) provenance stamp (results
+ git SHA + dataset content hash) written for gaia-submit to embed.
- --allow-integrity-override suppresses the exit code but records
'overridden: true' in the stamp — never silent.
- --self-test plants a fake answer-key read, an eval-of-task runner,
and injection markers in temp fixtures and proves fail-closed/pass/warn.
- gaia-validate.md: new check 7 documenting the checklist + flags.
- gaia-submit.md: package now embeds integrity.json under the Ed25519
witness manifest (tamper-evident attestation); stale/failed stamps block
packaging.
- smoke-gaia.sh: 3 new steps (script wired, self-test green, repo scan
clean); plugin bumped to 0.5.0.
Co-Authored-By: RuFlo <ruv@ruv.net>
* perf(cli): lazy ONNX embedder init + local-first ruvector resolution
Fix 1 — lazy ONNX init (measured, biggest win):
neural-tools.ts had a module-level try/catch with TOP-LEVEL AWAIT that ran
`await import('ruvector')` + `initOnnxEmbedder()` + a probe embed at import
time. Because mcp-client statically imports neural-tools, this added the
full ONNX startup to every CLI command. The block is now a memoized
`ensureEmbeddings()` (module-level promise guard) invoked from
generateEmbedding() and from the three handlers that report the provider
without embedding (neural_compress / neural_status / neural_optimize), so
degraded/fallback semantics and provider reporting are byte-identical.
The probe embed moved inside ensureEmbeddings() — it still guards the
ADR-086 type-load-success-but-runtime-fails trap, but only on first use.
Measured (warm, darwin-arm64):
node bin/cli.js --help 0.50s -> 0.09s
node bin/cli.js status 0.64s -> 0.24s
import(mcp-client.js) 438ms -> 30ms
"Loading ONNX model" no longer appears on non-embedding commands;
appears exactly once on first embed (neural_predict / memory search).
Fix 2 — ruvector pin + local-first resolution:
browser-session-tools.ts pinned `npx -y ruvector@0.2.25` while 0.2.27 is
what the package installs, forcing cold npx downloads of a second version
(4 per browser session). The pin is now 0.2.27 and a memoized
resolveLocalRuvectorCli() (createRequire + package.json bin walk) spawns
`node <local-cli>` directly, falling back to the npx pin only when no
local install exists.
While verifying the 0.2.27 subcommand surface (required before bumping),
found the trajectory-* flags passed here were never valid on 0.2.25 OR
0.2.27 (`--session-id/--task/--args/--verdict` vs the real
`--context/--agent`, `--action/--result`, `--success/--quality`) — every
trajectory call exited with commander "unknown option", same bug class as
the #2015 `rvf create --kind` fix documented in this file. Flags are now
mapped correctly; browser_session_record and browser_session_end verified
end-to-end (rvf create + trajectory-begin/step/end + rvf compact all pass).
Fix 3 — lazy heavy MCP tool groups: profiled and NOT converted.
Per-module timed dynamic imports of all 36 tool modules show neural-tools
was 469.6ms and every other group is 0.3-5.5ms (hooks-tools 3.5ms,
agentdb-tools 2.5ms, embeddings 0.9ms, wasm-agent 0.6ms). After fix 1 the
whole registry builds in ~30ms; converting handler dispatch to lazy
import() would add risk for <6ms of residual win, so mcp-client.ts is
intentionally untouched.
Validation:
- tsc build clean
- plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs: 171/171 passed
- memory search / neural predict still work; ONNX loads lazily then
Co-Authored-By: RuFlo <ruv@ruv.net>
* security(deps): lift OTel floor-became-ceiling pins — core >=2.8.0, otlp family >=0.220.0
The exact pins @opentelemetry/{core,resources,sdk-trace-base}@1.25.1 and
the otlp exporter/transformer family@0.52.1 (root + ruflo wrapper
overrides) were added in #1997/#2112 as a 'fewer-knobs-for-arborist'
defense against the npm arborist Invalid Version crash (empty-version
placeholder from optionalDependencies/peerDependencies overlap). They
were never API-compat pins — but they became a ceiling that forces the
tree onto @opentelemetry/core <2.8.0, which npm audit flags for
GHSA-8988-4f7v-96qf (unbounded memory allocation in W3C Baggage
parsing), and they blocked every upstream fix.
Convert to floors while keeping the lockstep families coherent:
- stable line: core / resources / sdk-trace-base -> >=2.8.0
- otlp line: sdk-node, exporter-prometheus, the three
exporter-trace-otlp-* packages, otlp-exporter-base,
otlp-grpc-exporter-base, otlp-transformer -> >=0.220.0
(sdk-node@0.220.0 declares exactly core@2.9.0 + otlp@0.220.0, so the
floors resolve to one mutually consistent set; verified in both the
root lockfile and a fresh ruflo/ resolution: core 2.9.0 everywhere,
otlp 0.220.0 everywhere.)
The arborist defense is preserved — every package in both families is
still forced to a single resolution via overrides; the override set is
now broader (whole otlp family at root too), which gives arborist fewer
ambiguous nodes than before, not more.
Also adds the exporter family overrides to the root manifest so root
and the published ruflo wrapper stay aligned (#2112 lesson: root
overrides do not propagate to the published wrapper).
Validation:
- root: npm audit --omit=dev 30 moderate -> 0 vulnerabilities
- ruflo: npm install --package-lock-only succeeds, 0 vulnerabilities
- runtime probe: new NodeSDK({}) via @opentelemetry/sdk-node@0.220.0
with core@2.9.0 loads and exposes start(); OTLPTraceExporter loads
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* security(deps): npm audit fix on v3 workspace lock — grpc-js, hono/node-server, brace-expansion
Plain `npm audit fix --omit=dev --package-lock-only` (no --force) on the
v3 npm workspace lockfile. Clears the three targeted advisories:
- @grpc/grpc-js 1.14.3 -> 1.14.4 GHSA-5375-pq7m-f5r2 (+GHSA-99f4-grh7-6pcq)
- @hono/node-server 1.19.9 -> 1.19.14 GHSA-wc8c-qw6v-h7f6 (+GHSA-92pp-h63x-v22m)
- @isaacs/brace-expansion 5.0.0 removed/deduped GHSA-7h2j-956f-4vf2
Audit summary: 82 vulnerabilities (2 low, 56 moderate, 24 high)
-> 55 vulnerabilities (50 moderate, 5 high).
The remainder needs --force / dependency swaps and is out of scope for
this pass.
Note: the reify was run with the two pnpm-style `workspace:*` devDep
specs (plugin-agent-federation, plugin-iot-cognitum) temporarily
substituted with the registry pins the lockfile already records
(@claude-flow/shared@3.0.0-alpha.8, @claude-flow/security@3.0.0-alpha.10),
because npm's arborist does not support the workspace: protocol; the
manifests themselves are unchanged. The v3 workspace is otherwise
pnpm-driven (`pnpm -r build`).
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* security(deps): align agentdb floor in @claude-flow/memory — ^3.0.0-alpha.17
Root overrides force agentdb >=3.0.0-alpha.17, but @claude-flow/memory
declared ^3.0.0-alpha.16, so its nested/published tree could still
resolve alpha.16 (the #2112 lesson: overrides do not propagate to
nested or published trees). The v3 pnpm lock indeed had memory pinned
to agentdb 3.0.0-alpha.16 — bumping the package's own floor makes both
branches resolve the same version regardless of the consumer's
override set.
pnpm-lock.yaml is regenerated by `pnpm install`; besides the
agentdb 3.0.0-alpha.16 -> 3.0.0-alpha.17 move under memory, it also
reconciles previously committed manifest bumps (metaharness family)
that had drifted from the stale lock.
Verified: `npm ls agentdb` from root shows a single deduped
agentdb@3.0.0-alpha.17 across direct, @claude-flow/memory,
@claude-flow/neural, and agentic-flow branches.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* security(deps): correct FEDERATION_BIND_HOST doc default — 127.0.0.1 (ADR-166 Phase 3d)
The bin.ts header JSDoc still advertised `default: 0.0.0.0`, but the
code (makeContext) has defaulted to 127.0.0.1 since the ADR-166
Phase 3d loopback-by-default hardening. Doc-only change.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* fix(memory): drop vitest-4 style vi.fn generic that breaks tsc build
The hoisted vitest 1.x types Mock as Mock<Args extends any[], Returns>,
so the single-generic vi.fn<SearchFn> form fails the package build
(pre-existing baseline failure). Annotate the variable instead — the
inferred mock signature is identical.
Co-Authored-By: RuFlo <ruv@ruv.net>
* perf(memory): batch embeddings in the migration import path
MemoryMigrator.processBatch batched entries (batchSize=100) but then
awaited this.embeddingGenerator(content) ONE ENTRY AT A TIME inside the
loop — N sequential ONNX inferences per batch on the
memory_import_claude hot path.
Now:
- New BatchEmbeddingGenerator type ((contents: string[]) =>
Promise<Float32Array[]>) accepted as an optional 4th constructor /
factory param — one true batch call per migration batch when the
embedder supports it (e.g. bge-embedder's single padded forward pass).
- Fallback: bounded concurrency over the single-text generator
(MigrationConfig.embeddingConcurrency, default 8 — never unbounded;
set 1 to restore sequential behavior).
- If the batch call fails, falls back to per-entry embedding so one
backend hiccup doesn't strand the batch.
Per-entry error semantics preserved: validation failures skip, store
failures fail, embedding failures warn and store the entry without a
vector — one bad entry never fails the batch.
Measured (200 synthetic entries, 5ms simulated single-embed latency):
sequential 1220ms -> concurrency-8 156ms (7.8x) -> true batch 115ms
(10.6x).
Co-Authored-By: RuFlo <ruv@ruv.net>
* feat(memory): temporal validity on hierarchical memory (Zep/Graphiti-style)
Facts in hierarchical memory can now carry validity windows and are
INVALIDATED on conflict instead of overwritten:
- New TieredMemoryStore module (@claude-flow/memory) promotes the inline
controller-registry stub to a first-class, tested store with optional
validFrom / validUntil (ISO) + supersededBy fields.
- store accepts supersedes=<entryId|key>: the old entry is stamped
validUntil=now + supersededBy=<newId> and archived — never deleted.
- recall filters temporally-invalid entries (expired, superseded, or
not-yet-valid) by default; includeExpired=true is the audit escape
hatch.
- Backward compatible: entries without the fields behave exactly as
before (always valid); legacy store(key,value,tier) / recall(query,
topK) call shapes unchanged; same-key stores still overwrite; the
bridge's real-vs-stub detection signal (getStats+promote) is
preserved.
- CLI bridge passes the new optional fields through
bridgeHierarchicalStore/-Recall. On the native agentdb
HierarchicalMemory backend, validFrom/validUntil ride in metadata and
are filtered at recall; supersedes is reported as unsupported there
(no public update API) rather than silently dropped.
- MCP inputSchemas for agentdb_hierarchical-store (validFrom,
validUntil, supersedes) and agentdb_hierarchical-recall
(includeExpired) expose the fields to agents, with ISO-8601
validation.
Tests: supersede flow (including same-key supersede preserving
history), expired + not-yet-valid filtering, includeExpired escape
hatch, legacy entries untouched, unparseable timestamps fail open.
Co-Authored-By: RuFlo <ruv@ruv.net>
* feat(routing): confidence-gated tier escalation — hooks_model-verify ($0 post-generation verifier)
ADR-026/143 classify BEFORE generation only. This adds the 2026-SOTA
post-generation gate: attempt the cheap tier, verify the output with
CHEAP structural signals (no LLM call), escalate only on failure.
- src/ruvector/output-verifier.ts — pure verifier: empty/truncated
output, refusal patterns, degenerate repetition, unbalanced
delimiters, and a real syntax parse for code/JSON tasks (TypeScript
transpileModule diagnostics / JSON.parse, graceful degradation when
typescript is absent). Returns {confident, reasons[], suggestedTier,
suggestedModel, escalate}; ladder haiku→sonnet→opus, tier 2→3.
- hooks_model-verify MCP tool (hooks-tools.ts, surgical) — wraps the
verifier and feeds the verdict into the SAME learning stream as
hooks_model-outcome (ModelRouter.recordOutcome: success when
confident, escalated when not) so the Thompson priors learn which
task shapes the cheap tier fails on. A dedicated tool (not a verify
mode on hooks_model-outcome) because verify is a mid-loop decision
point returning a verdict the agent acts on, while outcome is a
terminal write — the sequence is route → generate → verify →
(escalate) → outcome.
- __tests__/output-verifier.test.ts — refusal/truncation/parse/
escalation-ladder coverage + MCP handler tests (18 tests).
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* feat(federation): A2A Agent Cards — spec-1.0 discovery adapter over the bespoke federation schema
Implements the Agent2Agent (A2A, Linux Foundation) Agent Card layer as an
ADAPTER over the existing federation registration/discovery shapes. Cards
only — A2A Tasks/messaging are deliberately out of scope this iteration.
Spec: A2A Protocol 1.0.0 (a2a-protocol.org; schema source of truth
specification/a2a.proto in a2aproject/A2A). Required AgentCard fields per
spec 4.4.1: name, description, supportedInterfaces, version, capabilities,
defaultInputModes, defaultOutputModes, skills. Well-known discovery path
per 8.2 + IANA registration: /.well-known/agent-card.json.
- src/a2a/agent-card.ts: toAgentCard() maps FederationManifest -> spec-
compliant card (agent types -> AgentSkills; federation endpoint -> open-
form RUFLO-FEDERATION AgentInterface; bespoke identity rides in a spec
4.6 extension urn:ruflo:federation:manifest:v1 for lossless round-trip);
validateAgentCard() enforces the 1.0 required-field shape;
fromAgentCard() maps a remote card into a FederationNode — ALWAYS
TrustLevel.UNTRUSTED (cards are self-asserted metadata; trust is earned
via the normal handshake + TrustEvaluator path).
- src/a2a/well-known.ts: minimal node:http endpoint serving GET/HEAD on
the exact well-known path with spec 8.6 Cache-Control + ETag/304.
Binds 127.0.0.1 by default and REFUSES non-loopback binds without an
explicit allowNonLoopback opt-in (ADR-166 posture preserved — no
loosening of federation bind or auth).
- src/a2a/consume.ts: fetchAgentCard (injectable fetch, timeout, size cap,
structural validation) + consumeAgentCard registering the peer into
DiscoveryService so A2A peers appear in federation discovery.
- DiscoveryService: new 'a2a-card' discovery mechanism +
registerExternalPeer() (refresh-not-replace preserves trust state).
- plugin.ts: opt-in wiring via config.a2aCard/a2aCardPort/a2aCardHost;
bind failure degrades gracefully; server closed on shutdown.
- 40 new unit tests incl. golden-file card fixture
(__tests__/fixtures/a2a-agent-card.golden.json), foreign-card mapping,
round-trip, loopback guard, and a live serve->consume e2e over HTTP.
- plugin.test.ts: fixed pre-existing stale assertion (10 -> 11 CLI
commands; 'federation trust elevate' was added without updating the
count) — baseline suite failed before this change.
Build clean; 600/600 tests green.
Co-Authored-By: RuFlo <ruv@ruv.net>
* feat(intelligence): execution-state-tree retrieval prototype (MAGE-style positional recall)
Research basis: MAGE (arXiv 2606.06090) — semantic-similarity retrieval
fragments decision trajectories on long-horizon tasks; retrieving by
POSITION in a hierarchical execution-state tree (root→current path)
preserves coherence.
- src/ruvector/trajectory-tree.ts — TrajectoryTree prototype:
trajectory-start opens a node under the session root (nesting under
the deepest open trajectory), steps append children, end closes and
pops the current position. recallPath({sessionId, depth}) returns
the exact root→current path + recent siblings — no embedding search.
Best-effort JSON snapshot at .claude-flow/intelligence/
trajectory-tree.json, ALONGSIDE existing trajectory storage (no
migration). Limitations documented in the module header.
- hooks-tools.ts (surgical): non-fatal tree mirrors in
hooks_intelligence_trajectory-start/step/end (+ optional sessionId
param on start), and an opt-in strategy:"state-tree" on
hooks_intelligence_pattern-search (+ sessionId/depth params).
Default strategy stays "semantic" — zero behavior change unless
opted in.
- __tests__/trajectory-tree.test.ts — synthetic 3-level trajectory
path assertions, nesting/pop, sibling window, persistence
round-trip, and default-strategy-untouched checks (12 tests).
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* fix(metaharness): kill third-party @latest, extract shared _invoke.mjs, derive smoke counts
Three of the four converged security/perf/arch review fixes:
1. SECURITY (HIGH) + PERF — _harness.mjs no longer shells to
`npx -y metaharness@latest` (a compromised upstream publish would have
executed arbitrary code on user machines, and @latest forced a registry
check per call). Resolution is now: (a) locally installed metaharness
satisfying the PINNED range ~0.3.0 (walk-up node_modules), then (b) a
one-time `npm install --prefix ~/.ruflo/metaharness-cache-0.3.0`, then
plain `node <abs-path-to-cli>` spawns — zero network per call. Both bins
(metaharness + harness) are read from the resolved package.json bin map.
The {stdout, stderr, exitCode, json, degraded, reason, durationMs}
contract and ADR-150 graceful degradation are preserved; timeouts now
report 'metaharness-timeout' (matching _darwin/_redblue) instead of being
conflated with 'metaharness-not-available'.
Measured: `score.mjs --path .` completes in ~0.35s via the cache.
2. ARCH — new scripts/_invoke.mjs consolidates the copy-pasted plumbing:
DEGRADED_RX (superset incl. `npm ERR`), classifyDegraded (keeps the
-timeout vs -not-available distinction), injectJson, parseTrailingJson
(the LAST-{...}-block variant from _darwin — the old _harness first-block
parse was a live bug), ensureCachedInstall (generalizes gepa/_redblue,
cache-dir naming kept compatible so existing caches keep serving),
importOptionalLibrary, findLocalPackageDir, makeDegradedEmitter.
_harness/_darwin/_redblue/gepa are now thin adapters with IDENTICAL
exported signatures; their genuinely-different parts stay (redblue's
node-direct isMain workaround, darwin's async streaming for evolve,
harness's dual binaries). test-graceful-degradation.mjs gains
RUFLO_METAHARNESS_CACHE_BASE / RUFLO_METAHARNESS_SKIP_LOCAL seams so the
ADR-150 drill still exercises the degraded path on warm-cache machines.
3. ARCH — smoke.sh derives EXPECTED_TOOLS / EXPECTED_SUBS once from
metaharness-tools.ts / metaharness.ts and replaces the 7 independently
hardcoded literals (15 at :750/:829/:2046, 13 at :725/:851, 16 footnote,
15 success-pattern). Cross-surface value preserved: independent surfaces
(CLAUDE.md catalog, test-mcp-tools' own `tools.length === 15` literal,
footnote appends, runScript refs) are compared against the derived count,
never a surface against itself; a >=15/>=13 floor step catches
extraction-regex rot.
Validation: smoke.sh 122/122, test-graceful-degradation 16/16,
test-mcp-tools 171/171.
Co-Authored-By: RuFlo <ruv@ruv.net>
* fix(metaharness): add --diagnose GEPA failure diagnosis to evolve
GEPA's key trick — natural-language failure diagnosis from execution traces
feeding the next mutation, not just scalar fitness — scoped modestly into
the evolve wrapper as an opt-in flag.
`evolve.mjs --diagnose` now appends a `diagnosis` section to the emitted
JSON after the run completes: losing/failed variants' transcripts are run
through darwin's gepa `analyzeTranscript` + `classifyFailure` ops (via the
shared importGepa resolver, moved to _darwin.mjs because gepa.mjs runs
main() on import) and aggregated into failure classes + counts + dominant
class per variant.
Upstream shape findings (verified against @metaharness/darwin@0.8.0 with a
tiny mock-sandbox evolve):
- `metaharness-darwin evolve --json` emits a TEXT leaderboard — stdout
carries no JSON and no transcripts (r.json has always been null here).
- Per-variant run records live at <repo>/.metaharness/runs/<id>.json as
sandbox exec traces ({taskId, exitCode, stdout, stderr}) — NOT gepa
{actionRaw, obs} transcripts.
Diagnosis therefore layers: gepa-shaped transcripts when a run record
embeds them (agent sandbox / future upstream) → champion-transcript
fallback → `diagnosis: {available: false, reason, traceSummary}` with a
mechanical per-variant tally. Never fails the run — internal errors
degrade to {available: false, reason: 'diagnosis-failed: ...'}.
Both paths exercised end-to-end: mock evolve yields the traceSummary
fallback; a seeded gepa-shaped run record yields
{available: true, scope: 'losing-variants', failureClasses:
{exploration-loop: 1, edit-mechanics: 1}, dominantClass: 'exploration-loop'}.
Documented in skills/harness-evolve/SKILL.md.
Co-Authored-By: RuFlo <ruv@ruv.net>
* Revert "feat(gaia): submission integrity checklist — fail-closed pre-submit audit (Berkeley RDI hardening)"
This reverts commit af185fcccd.
* fix(hooks): ADR-112 guidance clause on hooks_model-verify description
Co-Authored-By: RuFlo <ruv@ruv.net>
* fix(ci): retune #1921 fix-invariant to the >=2.8.0 OTel override floor
The PR deliberately raised the @opentelemetry/core override from the exact
1.25.1 pin to a >=2.8.0 floor (OTel 2.x audit fix), but the #1921 invariant
in audit-fix-invariants.mjs still asserted the old pin substring, failing
the 'Hook-command install safety (#1921)' job. Per the audit's own guidance,
the invariant is updated to point at the new location of the fix — the
override entry itself — preserving the regression guard (arborist Invalid
Version placeholder) rather than deleting it.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* fix(ci): register RUFLO_METAHARNESS_CACHE_BASE as env-var escape hatch
The metaharness _invoke.mjs consolidation introduced cacheBaseDir(), whose
RUFLO_METAHARNESS_CACHE_BASE read is a CI/test seam for relocating the
~/.ruflo pinned-cache root in smoke tests — there is no CLI-flag surface in
plugin scripts to take precedence. Register it in KNOWN_ESCAPE_HATCHES per
ADR-125/ADR-130 remediation Option B, unblocking the 'Plugin package
install-safety (#1902/#1903/#1904)' job.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* fix(ci): linear-time dash trim in A2A slugify — resolves js/polynomial-redos
CodeQL flagged /^-+|-+$/g in slugify() (agent-card.ts:281) as polynomial
ReDoS: the anchored quantifier backtracks O(n^2) on adversarial all-dash
card names, and card names are remote library input. Replace the trim regex
with an index-scan (charCodeAt) that is strictly linear. Behavior verified
equivalent against the old implementation over edge cases plus 5000-input
fuzz; package typechecks and all 600 tests pass.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01S7GYqnVUVxBfZ5W8znqry3
* fix(ci): no-metaharness drill uses the _invoke.mjs test seams
The pinned-cache resolution defeats the drill's registry-blackhole absence
simulation (metaharness resolves locally / from the warm ~/.ruflo cache).
RUFLO_METAHARNESS_SKIP_LOCAL=1 + an empty RUFLO_METAHARNESS_CACHE_BASE make
'absent' mean absent again. Reproduced locally: exit 0 + degraded:true.
Co-Authored-By: RuFlo <ruv@ruv.net>
405 lines
17 KiB
JavaScript
405 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
// evolve.mjs — wrapper around `metaharness-darwin evolve <repo>`.
|
||
//
|
||
// ADR-153: Darwin Mode is the WRITE layer that closes the loop ADR-150's
|
||
// READ layer (score / genome / mcp-scan / threat-model / oia-audit) opens.
|
||
// score+genome tell you where the harness IS; darwin evolve tells you which
|
||
// mutation makes it provably better, without retraining the foundation model.
|
||
//
|
||
// SAFETY (ADR-153 §"Safety model"):
|
||
// - Variant generation + sandbox happen under <repo>/.metaharness/variants/,
|
||
// never in the repo root. Upstream `inspectVariant()` rejects nested dirs,
|
||
// symlinks, secret-shaped strings, shell-out / network / dynamic-eval
|
||
// before any variant runs. Exit code 99 is reserved for "safety-disqualified".
|
||
// - --confirm is REQUIRED. Without it the script prints a plan and exits 0
|
||
// (mirrors mint.mjs convention). This is in addition to upstream's safety
|
||
// layer — defense in depth at the ruflo boundary.
|
||
// - Default --generations 3 --children 3 (small) — anything larger is opt-in.
|
||
// Real evolutions are minutes-to-hours; ruflo's default sandbox config
|
||
// errs toward "show me the mechanism works" over "find a winner today".
|
||
//
|
||
// USAGE
|
||
// node scripts/evolve.mjs --repo . # dry-run plan
|
||
// node scripts/evolve.mjs --repo . --confirm # actually evolve
|
||
// node scripts/evolve.mjs --repo . --confirm --generations 5 --children 3
|
||
// node scripts/evolve.mjs --repo . --confirm --sandbox mock # no real tests
|
||
// node scripts/evolve.mjs --repo . --confirm --selection pareto
|
||
// node scripts/evolve.mjs --repo . --confirm --diagnose # + GEPA failure diagnosis
|
||
//
|
||
// --diagnose (GEPA failure diagnosis — the natural-language-diagnosis trick
|
||
// from GEPA, scoped modestly): after the evolve run completes, the losing /
|
||
// failed variants' transcripts are run through darwin's gepa
|
||
// `analyzeTranscript` + `classifyFailure` ops and a `diagnosis` section
|
||
// (failure classes + counts + dominant class per variant) is appended to the
|
||
// emitted JSON report. UPSTREAM SHAPE NOTE (verified against darwin 0.8.0):
|
||
// `metaharness-darwin evolve --json` emits a TEXT leaderboard on stdout — no
|
||
// JSON, no transcripts. The per-variant run records live at
|
||
// `<repo>/.metaharness/runs/<id>.json` as sandbox exec traces
|
||
// ({taskId, exitCode, stdout, stderr}), which are NOT gepa {actionRaw, obs}
|
||
// transcripts. So the diagnosis layer (a) uses gepa-shaped transcripts when
|
||
// a run record embeds them (agent-sandbox / future upstream), (b) falls back
|
||
// to the champion's transcript, and (c) otherwise emits
|
||
// `diagnosis: {available: false, reason, traceSummary}` with a mechanical
|
||
// per-variant trace summary — it NEVER fails the run.
|
||
//
|
||
// EXIT CODES
|
||
// 0 evolved OK (or dry-run, or degraded — MetaHarness Darwin not available)
|
||
// 1 --alert-on-no-improvement and champion did not beat parent
|
||
// 2 config error or evolution failure
|
||
// 99 reserved — upstream "safety-disqualified" (propagated)
|
||
|
||
import { runDarwinAsync, emitDarwinDegradedJsonAndExit, importGepa } from './_darwin.mjs';
|
||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const ARGS = (() => {
|
||
const a = {
|
||
repo: '.',
|
||
generations: 3,
|
||
children: 3,
|
||
concurrency: 2,
|
||
seed: null,
|
||
sandbox: 'real',
|
||
selection: null,
|
||
crossover: false,
|
||
epistasis: false,
|
||
curriculum: false,
|
||
riskBudget: null,
|
||
fdr: null,
|
||
tie: null,
|
||
bench: null,
|
||
mutator: 'deterministic',
|
||
ruvllmUrl: null,
|
||
ruvllmModel: null,
|
||
confirm: false,
|
||
alertOnNoImprovement: false,
|
||
diagnose: false,
|
||
format: 'json',
|
||
timeoutMs: null, // computed below if unset
|
||
};
|
||
for (let i = 2; i < process.argv.length; i++) {
|
||
const v = process.argv[i];
|
||
if (v === '--repo') a.repo = process.argv[++i];
|
||
else if (v === '--generations') a.generations = parseInt(process.argv[++i], 10);
|
||
else if (v === '--children') a.children = parseInt(process.argv[++i], 10);
|
||
else if (v === '--concurrency') a.concurrency = parseInt(process.argv[++i], 10);
|
||
else if (v === '--seed') a.seed = parseInt(process.argv[++i], 10);
|
||
else if (v === '--sandbox') a.sandbox = process.argv[++i];
|
||
else if (v === '--selection') a.selection = process.argv[++i];
|
||
else if (v === '--crossover') a.crossover = true;
|
||
else if (v === '--epistasis') a.epistasis = true;
|
||
else if (v === '--curriculum') a.curriculum = true;
|
||
else if (v === '--risk-budget') a.riskBudget = parseInt(process.argv[++i], 10);
|
||
else if (v === '--fdr') a.fdr = parseFloat(process.argv[++i]);
|
||
else if (v === '--tie') a.tie = process.argv[++i];
|
||
else if (v === '--bench') a.bench = process.argv[++i];
|
||
else if (v === '--mutator') a.mutator = process.argv[++i];
|
||
else if (v === '--ruvllm-url') a.ruvllmUrl = process.argv[++i];
|
||
else if (v === '--ruvllm-model') a.ruvllmModel = process.argv[++i];
|
||
else if (v === '--confirm') a.confirm = true;
|
||
else if (v === '--alert-on-no-improvement') a.alertOnNoImprovement = true;
|
||
else if (v === '--diagnose') a.diagnose = true;
|
||
else if (v === '--format') a.format = process.argv[++i];
|
||
else if (v === '--timeout-ms') a.timeoutMs = parseInt(process.argv[++i], 10);
|
||
}
|
||
return a;
|
||
})();
|
||
|
||
function safetyChecks() {
|
||
const repoPath = resolve(ARGS.repo);
|
||
if (!existsSync(repoPath)) {
|
||
console.error(`evolve: repo path does not exist: ${repoPath}`);
|
||
process.exit(2);
|
||
}
|
||
if (ARGS.generations < 1 || ARGS.generations > 50) {
|
||
console.error('evolve: --generations must be 1..50 (ruflo cap; upstream supports more)');
|
||
process.exit(2);
|
||
}
|
||
if (ARGS.children < 1 || ARGS.children > 20) {
|
||
console.error('evolve: --children must be 1..20 (ruflo cap)');
|
||
process.exit(2);
|
||
}
|
||
if (ARGS.concurrency < 1 || ARGS.concurrency > 8) {
|
||
console.error('evolve: --concurrency must be 1..8 (ruflo cap)');
|
||
process.exit(2);
|
||
}
|
||
if (!['real', 'mock', 'agent'].includes(ARGS.sandbox)) {
|
||
console.error(`evolve: --sandbox must be real|mock|agent (got: ${ARGS.sandbox})`);
|
||
process.exit(2);
|
||
}
|
||
if (ARGS.selection && !['quality-diversity', 'behavioral-diversity', 'niche-steering', 'clade', 'pareto'].includes(ARGS.selection)) {
|
||
console.error(`evolve: --selection must be one of quality-diversity|behavioral-diversity|niche-steering|clade|pareto`);
|
||
process.exit(2);
|
||
}
|
||
if (!['deterministic', 'ruvllm'].includes(ARGS.mutator)) {
|
||
console.error(`evolve: --mutator must be deterministic|ruvllm`);
|
||
process.exit(2);
|
||
}
|
||
return repoPath;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// --diagnose support (GEPA failure diagnosis)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** True when `arr` looks like a gepa transcript: [{actionRaw?, obs?}, ...]. */
|
||
function looksLikeGepaTranscript(arr) {
|
||
return Array.isArray(arr) && arr.length > 0 &&
|
||
arr.every((e) => e && typeof e === 'object' && ('actionRaw' in e || 'obs' in e));
|
||
}
|
||
|
||
/**
|
||
* Pull every gepa-shaped transcript out of one run record. Upstream darwin
|
||
* 0.8.0 run records carry sandbox exec traces (NOT gepa transcripts), but a
|
||
* record MAY embed `transcript` arrays (agent sandbox / future upstream) —
|
||
* accept `record.transcript`, `record.traces[i].transcript`, or `traces`
|
||
* itself when its entries are {actionRaw, obs}-shaped.
|
||
*/
|
||
function extractGepaTranscripts(record) {
|
||
const out = [];
|
||
if (looksLikeGepaTranscript(record?.transcript)) {
|
||
out.push({ taskId: null, exitCode: null, entries: record.transcript });
|
||
}
|
||
const traces = Array.isArray(record?.traces) ? record.traces : [];
|
||
if (looksLikeGepaTranscript(traces)) {
|
||
out.push({ taskId: null, exitCode: null, entries: traces });
|
||
} else {
|
||
for (const t of traces) {
|
||
if (looksLikeGepaTranscript(t?.transcript)) {
|
||
out.push({ taskId: t.taskId ?? null, exitCode: t.exitCode ?? null, entries: t.transcript });
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Mechanical per-variant summary of sandbox exec traces (the always-available fallback signal). */
|
||
function summarizeTraces(record) {
|
||
const traces = Array.isArray(record?.traces) ? record.traces : [];
|
||
return {
|
||
tasks: traces.length,
|
||
failed: traces.filter((t) => t?.exitCode !== 0).length,
|
||
timedOut: traces.filter((t) => t?.timedOut === true).length,
|
||
blockedActions: traces.reduce((n, t) => n + (Array.isArray(t?.blockedActions) ? t.blockedActions.length : 0), 0),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build the `diagnosis` section for the emitted report. Never throws and
|
||
* never affects the run's exit code — any internal failure degrades to
|
||
* `{available: false, reason}`.
|
||
*/
|
||
async function buildDiagnosis(repoPath) {
|
||
try {
|
||
const metaDir = join(repoPath, '.metaharness');
|
||
const runsDir = join(metaDir, 'runs');
|
||
if (!existsSync(runsDir)) {
|
||
return { available: false, reason: 'no-run-records: <repo>/.metaharness/runs does not exist' };
|
||
}
|
||
let winnerId = null;
|
||
try {
|
||
winnerId = JSON.parse(readFileSync(join(metaDir, 'reports', 'winner.json'), 'utf-8'))?.variant?.id ?? null;
|
||
} catch { /* winner unknown — treat all variants as candidates */ }
|
||
|
||
const records = [];
|
||
for (const f of readdirSync(runsDir).filter((f) => f.endsWith('.json')).slice(0, 100)) {
|
||
try {
|
||
records.push({ id: f.replace(/\.json$/, ''), rec: JSON.parse(readFileSync(join(runsDir, f), 'utf-8')) });
|
||
} catch { /* skip unreadable record */ }
|
||
}
|
||
if (records.length === 0) {
|
||
return { available: false, reason: 'no-run-records: <repo>/.metaharness/runs contains no parseable records' };
|
||
}
|
||
|
||
// Losing/failed variants are the primary diagnosis targets; the champion
|
||
// is the fallback when no loser exposes a transcript.
|
||
const losers = records.filter((r) =>
|
||
r.id !== winnerId &&
|
||
(r.rec?.score?.promoted === false ||
|
||
(Array.isArray(r.rec?.traces) && r.rec.traces.some((t) => t?.exitCode !== 0 || t?.timedOut))));
|
||
const champion = records.filter((r) => r.id === winnerId);
|
||
|
||
let scope = 'losing-variants';
|
||
let pool = losers.map((r) => ({ ...r, transcripts: extractGepaTranscripts(r.rec) }))
|
||
.filter((r) => r.transcripts.length > 0);
|
||
if (pool.length === 0) {
|
||
scope = 'champion-fallback';
|
||
pool = champion.map((r) => ({ ...r, transcripts: extractGepaTranscripts(r.rec) }))
|
||
.filter((r) => r.transcripts.length > 0);
|
||
}
|
||
|
||
if (pool.length === 0) {
|
||
// Verified upstream shape (darwin 0.8.0): run records are sandbox exec
|
||
// traces, not gepa transcripts. Emit the mechanical trace summary so
|
||
// --diagnose still yields signal.
|
||
const target = losers.length ? losers : records;
|
||
return {
|
||
available: false,
|
||
reason: 'no-gepa-transcripts: run records contain sandbox exec traces ({taskId, exitCode, stdout, stderr}), not gepa {actionRaw, obs} transcripts',
|
||
traceSummary: Object.fromEntries(target.map((r) => [r.id, summarizeTraces(r.rec)])),
|
||
};
|
||
}
|
||
|
||
const gepa = await importGepa();
|
||
if (!gepa || typeof gepa.analyzeTranscript !== 'function') {
|
||
return { available: false, reason: 'metaharness-darwin-gepa-not-available' };
|
||
}
|
||
|
||
const classLabel = (n) => {
|
||
const raw = gepa.FAILURE_CLASSES?.[n];
|
||
return raw ? String(raw).split(' (')[0] : `class-${n}`;
|
||
};
|
||
|
||
const totals = {};
|
||
const variants = [];
|
||
for (const r of pool) {
|
||
const classes = {};
|
||
for (const t of r.transcripts) {
|
||
const analysis = gepa.analyzeTranscript(t.entries);
|
||
const cls = typeof gepa.classifyFailure === 'function'
|
||
? gepa.classifyFailure({ goldResolved: t.exitCode === 0, analysis })
|
||
: -1;
|
||
const label = classLabel(cls);
|
||
classes[label] = (classes[label] || 0) + 1;
|
||
totals[label] = (totals[label] || 0) + 1;
|
||
}
|
||
const dominantClass = Object.entries(classes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
||
variants.push({ id: r.id, transcripts: r.transcripts.length, failureClasses: classes, dominantClass });
|
||
}
|
||
|
||
return { available: true, scope, variants, totals };
|
||
} catch (e) {
|
||
return { available: false, reason: `diagnosis-failed: ${e?.message ?? e}` };
|
||
}
|
||
}
|
||
|
||
// Compute a sensible timeout from the search shape if caller didn't specify.
|
||
// Rough budget: each variant ~= 30s (sandbox test command + safety inspect),
|
||
// total ~= generations × children × per-variant / concurrency.
|
||
function defaultTimeoutMs() {
|
||
const perVariantMs = ARGS.sandbox === 'mock' ? 2_000 : 60_000;
|
||
const variants = ARGS.generations * ARGS.children;
|
||
const parallelism = Math.min(ARGS.concurrency, variants);
|
||
const wall = Math.ceil(variants / parallelism) * perVariantMs;
|
||
// Add 30s overhead for npm install + initial profile + final report.
|
||
return Math.max(60_000, wall + 30_000);
|
||
}
|
||
|
||
async function main() {
|
||
const repoPath = safetyChecks();
|
||
|
||
const plan = {
|
||
binary: 'metaharness-darwin evolve',
|
||
repo: repoPath,
|
||
generations: ARGS.generations,
|
||
children: ARGS.children,
|
||
concurrency: ARGS.concurrency,
|
||
sandbox: ARGS.sandbox,
|
||
selection: ARGS.selection,
|
||
crossover: ARGS.crossover,
|
||
epistasis: ARGS.epistasis,
|
||
curriculum: ARGS.curriculum,
|
||
mutator: ARGS.mutator,
|
||
diagnose: ARGS.diagnose,
|
||
estVariants: ARGS.generations * ARGS.children,
|
||
timeoutMs: ARGS.timeoutMs ?? defaultTimeoutMs(),
|
||
output: `${repoPath}/.metaharness/{archive.json, lineage.json, variants/, runs/, reports/winner.json}`,
|
||
};
|
||
|
||
if (!ARGS.confirm) {
|
||
const payload = {
|
||
success: true,
|
||
data: { plan, dryRun: true, message: 'Pass --confirm to run the evolution.' },
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
process.exit(0);
|
||
}
|
||
|
||
const cliArgs = ['evolve', repoPath,
|
||
'--generations', String(ARGS.generations),
|
||
'--children', String(ARGS.children),
|
||
'--concurrency', String(ARGS.concurrency),
|
||
'--sandbox', ARGS.sandbox,
|
||
'--mutator', ARGS.mutator,
|
||
];
|
||
if (ARGS.seed != null) cliArgs.push('--seed', String(ARGS.seed));
|
||
if (ARGS.selection) cliArgs.push('--selection', ARGS.selection);
|
||
if (ARGS.crossover) cliArgs.push('--crossover');
|
||
if (ARGS.epistasis) cliArgs.push('--epistasis');
|
||
if (ARGS.curriculum) cliArgs.push('--curriculum');
|
||
if (ARGS.riskBudget != null) cliArgs.push('--risk-budget', String(ARGS.riskBudget));
|
||
if (ARGS.fdr != null) cliArgs.push('--fdr', String(ARGS.fdr));
|
||
if (ARGS.tie) cliArgs.push('--tie', ARGS.tie);
|
||
if (ARGS.bench) cliArgs.push('--bench', ARGS.bench);
|
||
if (ARGS.ruvllmUrl) cliArgs.push('--ruvllm-url', ARGS.ruvllmUrl);
|
||
if (ARGS.ruvllmModel) cliArgs.push('--ruvllm-model', ARGS.ruvllmModel);
|
||
|
||
// Forward progress lines to stderr so the user sees per-generation activity
|
||
// (subprocess-of-an-MCP-tool case: this still surfaces in the agent log).
|
||
const r = await runDarwinAsync(cliArgs, {
|
||
timeoutMs: plan.timeoutMs,
|
||
onProgress: (line) => { if (line.trim()) process.stderr.write(`[evolve] ${line}\n`); },
|
||
});
|
||
|
||
if (r.degraded) {
|
||
emitDarwinDegradedJsonAndExit(r.reason);
|
||
return;
|
||
}
|
||
|
||
// Upstream exit code 99 = safety-disqualified — propagate verbatim so
|
||
// CI gates can distinguish "evolution failed" from "evolution surfaced
|
||
// a safety-tripping mutation". This is a designed-in tripwire, not an
|
||
// error the ruflo layer should remap.
|
||
if (r.exitCode === 99) {
|
||
const payload = {
|
||
success: false,
|
||
data: { safetyDisqualified: true, hint: 'A variant tripped the safety inspection layer. See <repo>/.metaharness/runs/ for which surface and pattern.' },
|
||
stderrTail: r.stderr.slice(-400),
|
||
durationMs: r.durationMs,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
process.exit(99);
|
||
}
|
||
|
||
if (r.exitCode !== 0) {
|
||
const payload = {
|
||
success: false,
|
||
data: { exitCode: r.exitCode, stderrTail: r.stderr.slice(-400) },
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
process.exit(2);
|
||
}
|
||
|
||
const champion = r.json || {};
|
||
const noImprovement = champion.parentScore != null && champion.championScore != null &&
|
||
champion.championScore <= champion.parentScore;
|
||
|
||
const payload = {
|
||
success: true,
|
||
data: {
|
||
...champion,
|
||
plan,
|
||
durationMs: r.durationMs,
|
||
improved: !noImprovement,
|
||
},
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
// GEPA failure diagnosis — opt-in, additive, never fails the run.
|
||
if (ARGS.diagnose) {
|
||
payload.data.diagnosis = await buildDiagnosis(repoPath);
|
||
}
|
||
|
||
console.log(JSON.stringify(payload, null, 2));
|
||
if (ARGS.alertOnNoImprovement && noImprovement) process.exit(1);
|
||
process.exit(0);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(`evolve: unexpected failure: ${e?.message ?? e}`);
|
||
process.exit(2);
|
||
});
|