Files
rUv ae9bba523e feat(metaharness): learn + gepa integration — metaharness@0.3.0 / darwin@0.8.0 (15 MCP tools) (#2540)
* fix(mcp-bridge): streamable-HTTP session cleanup — DELETE + Mcp-Session-Id + 202 (#2425)

Closes #2425. Cherry-picked from djimit's commit b905c09f4 with
extensions: applied symmetrically to BOTH bridges (per ADR-166's
"both bridges move together" rule), and added regression coverage
in the ADR-166 security lock so this can't silently regress.

Streamable-HTTP clients (Codex/RMCP) send `DELETE /mcp` with an
`Mcp-Session-Id` header at shutdown. Previously the bridge returned
`Cannot DELETE /mcp` which surfaced as noisy client-side cleanup
failures after otherwise-successful MCP calls.

Changes (both bridges — ruflo/src/mcp-bridge/index.js AND
ruflo/src/ruvocal/mcp-bridge/index.js):

- Stable per-process `MCP_SESSION_ID = randomUUID()` echoed as the
  `Mcp-Session-Id` header on every `/mcp*` response
- `DELETE /mcp` and `DELETE /mcp/:group` → 204 No Content (idempotent)
- `DELETE` added to `Access-Control-Allow-Methods`; `Mcp-Session-Id`
  added to `Access-Control-Allow-Headers` (both compatible with the
  ADR-166 CORS allowlist wiring — allowlist controls Origin, not
  Method/Header)
- `notifications/initialized` responses now `202 Accepted` with empty
  body per the MCP streamable-HTTP spec (was returning a jsonrpc
  envelope which some clients rejected as unexpected)

Regression locks (extends ADR-166 §6 acceptance):
- test-security-lock.js — 2 new static checks (#7 delete handler,
  #8 Mcp-Session-Id header) × 2 bridges = 16/16
- test-runtime-security.mjs — 2 new runtime checks per bridge (R6
  DELETE→204 + header echoed; R7 notifications/initialized→202 empty)
  = 20/20 total green

Interaction with ADR-166 auth: DELETE flows through `app.use(requireAuth)`
same as the rest of `/mcp*`. When `MCP_AUTH_TOKEN` is set, DELETE without
Bearer returns 401 (verified in R2 by contrast; DELETE with Bearer
returns 204 verified in R6). Local-only deployments with no token remain
zero-friction (the middleware is a no-op).

Credit: @djimit for the original patch (branch
fix/mcp-session-cleanup-forkbase on djimit/claude-code-flow, commit
b905c09f42).

Co-Authored-By: djimit <djimit@users.noreply.github.com>
Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(metaharness): learn + gepa integration — metaharness@0.3.0 / darwin@0.8.0

Version pins (optionalDependencies, both @claude-flow/cli + ruflo wrapper):
- @metaharness/darwin  ~0.3.1 → ~0.8.0  (GEPA engine + cand-6 genome)
- metaharness          ~0.2.6 → ~0.2.8
- @metaharness/redblue ~0.1.1 → ~0.1.4  (+ versioned cache dir — pin bumps
  now invalidate stale installs instead of serving 0.1.1 forever)
- @metaharness/kernel  ~0.1.0 → ~0.1.2

New surfaces (13 → 15 MCP tools, 11 → 13 CLI subcommands):
- metaharness_learn / `ruflo metaharness learn` — upstream ADR-235 GEPA
  learning run; $0 dry-run default, --run to spend, structured
  checkout-required payload when the metaharness clone is absent
- metaharness_gepa / `ruflo metaharness gepa` — darwin GEPA library
  surface (genome|validate|render|analyze); gepaOptimize stays
  library-only (in-process evaluator can't cross subprocess boundary)
- skills: harness-learn, harness-gepa

Fixes found during validation:
- gepa.mjs treats ERR_PACKAGE_PATH_NOT_EXPORTED (stale pre-0.8.0 darwin
  in ancestor node_modules) as recoverable → falls back to versioned cache
- smoke.sh contract counts advanced (scripts 13→15, tools 13→15,
  subcommands 11→13, CLAUDE.md doc counts)

Validated: tsc clean; test-mcp-tools 171/171 across all 15 tools; darwin
0.8.0 evolve/bench/security-bench verbs proven through wrappers; redblue
0.1.4 attack preview proven via new cache path.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: djimit <djimit@users.noreply.github.com>
2026-07-03 14:17:09 -04:00

128 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// learn.mjs — wrapper around `metaharness learn` (upstream ADR-235, metaharness@0.3.0).
//
// GEPA learning run: optimizes a harness genome against a SWE-bench-style
// slice manifest. $0 DRY-RUN BY DEFAULT — upstream only spends (model calls,
// Docker sandboxes) when --run is passed, and we forward that flag verbatim
// so the spend opt-in stays explicit at every layer.
//
// CHECKOUT PRECONDITION (upstream design, not a bug)
// ==================================================
// The learning harness (GEPA + SWE-bench + Docker) is too heavy to ship in
// the npm package, so `metaharness learn` requires a local clone of the
// metaharness repo, located via $METAHARNESS_REPO or by running inside the
// clone. When the checkout is absent we emit a structured
// `{status: "checkout-required"}` payload and exit 0 — the script ran as
// designed and told the agent what to do next. This is distinct from
// `degraded: true` (npm package absent). The managed-service path (gateway-
// side learn jobs, no checkout) is upstream's ADR-235 follow-up.
//
// USAGE
// node scripts/learn.mjs --host claude-code --model haiku --slice slices/lite.json
// node scripts/learn.mjs --repo ~/src/metaharness --host codex --model gpt-5-mini --slice s.json --run
//
// EXIT CODES
// 0 learn completed (or dry-run report, or degraded, or checkout-required)
// 1 --alert-on-fail and the learn run reported failure
// 2 config error (bad arg)
import { existsSync } from 'node:fs';
import { runMetaharnessAsync, emitDegradedJsonAndExit } from './_harness.mjs';
const ARGS = (() => {
const a = {
host: null,
model: null,
slice: null,
repo: null,
run: false,
alertOnFail: false,
format: 'json',
timeoutMs: null,
};
for (let i = 2; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === '--host') a.host = process.argv[++i];
else if (v === '--model') a.model = process.argv[++i];
else if (v === '--slice') a.slice = process.argv[++i];
else if (v === '--repo') a.repo = process.argv[++i];
else if (v === '--run') a.run = true;
else if (v === '--alert-on-fail') a.alertOnFail = true;
else if (v === '--format') a.format = process.argv[++i];
else if (v === '--timeout-ms') a.timeoutMs = parseInt(process.argv[++i], 10);
}
return a;
})();
const CHECKOUT_RX = /requires a metaharness repo checkout/i;
function defaultTimeoutMs() {
// Dry-run resolves the slice manifest + prices the run without spending —
// bounded by repo scan, not model calls. Real runs (--run) are dominated
// by model calls × slice size; callers should pass --timeout-ms matched
// to their slice. 10 min is a floor for small slices, not a budget.
return ARGS.run ? 600_000 : 120_000;
}
async function main() {
if (ARGS.repo && !existsSync(ARGS.repo)) {
console.error(`learn: --repo path does not exist: ${ARGS.repo}`);
process.exit(2);
}
const cliArgs = ['learn'];
if (ARGS.host) cliArgs.push('--host', ARGS.host);
if (ARGS.model) cliArgs.push('--model', ARGS.model);
if (ARGS.slice) cliArgs.push('--slice', ARGS.slice);
if (ARGS.run) cliArgs.push('--run');
const r = await runMetaharnessAsync(cliArgs, {
// learn emits a human-readable report; --json support is not guaranteed
// across 0.3.x, so we don't inject it and parse leniently instead.
json: false,
timeoutMs: ARGS.timeoutMs ?? defaultTimeoutMs(),
env: ARGS.repo ? { METAHARNESS_REPO: ARGS.repo } : {},
});
if (r.degraded) emitDegradedJsonAndExit(r.reason);
const combined = `${r.stdout}\n${r.stderr}`;
if (CHECKOUT_RX.test(combined)) {
console.log(JSON.stringify({
status: 'checkout-required',
hint: 'git clone https://github.com/ruvnet/metaharness.git, then re-run with '
+ '--repo /path/to/metaharness (or set METAHARNESS_REPO). The learning '
+ 'harness (GEPA + SWE-bench + Docker) is not shipped in the npm package.',
dryRun: !ARGS.run,
durationMs: r.durationMs,
}, null, 2));
process.exit(0);
}
// Lenient JSON extraction — grab the last {...} block if one exists.
let json = null;
const matches = [...r.stdout.matchAll(/\{[\s\S]*?\}/g)];
for (let i = matches.length - 1; i >= 0; i--) {
try { json = JSON.parse(matches[i][0]); break; } catch { /* try previous */ }
}
const out = {
status: r.exitCode === 0 ? 'ok' : 'failed',
dryRun: !ARGS.run,
exitCode: r.exitCode,
report: json,
// When upstream emits no JSON, the raw report is still the deliverable.
rawReport: json ? undefined : r.stdout.slice(0, 20_000),
durationMs: r.durationMs,
};
console.log(JSON.stringify(out, null, 2));
if (ARGS.alertOnFail && r.exitCode !== 0) process.exit(1);
process.exit(0);
}
main().catch((e) => {
console.error(`learn: ${e?.message ?? e}`);
process.exit(2);
});