@claude-flow/cli 3.16.3 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/src/commands/metaharness.d.ts.map +1 -1
  2. package/dist/src/commands/metaharness.js +9 -3
  3. package/dist/src/commands/metaharness.js.map +1 -1
  4. package/dist/src/mcp-tools/agentdb-tools.d.ts.map +1 -1
  5. package/dist/src/mcp-tools/agentdb-tools.js +52 -1
  6. package/dist/src/mcp-tools/agentdb-tools.js.map +1 -1
  7. package/dist/src/mcp-tools/browser-session-tools.d.ts +5 -1
  8. package/dist/src/mcp-tools/browser-session-tools.d.ts.map +1 -1
  9. package/dist/src/mcp-tools/browser-session-tools.js +92 -18
  10. package/dist/src/mcp-tools/browser-session-tools.js.map +1 -1
  11. package/dist/src/mcp-tools/hooks-tools.d.ts +1 -0
  12. package/dist/src/mcp-tools/hooks-tools.d.ts.map +1 -1
  13. package/dist/src/mcp-tools/hooks-tools.js +131 -0
  14. package/dist/src/mcp-tools/hooks-tools.js.map +1 -1
  15. package/dist/src/mcp-tools/metaharness-tools.d.ts +7 -0
  16. package/dist/src/mcp-tools/metaharness-tools.d.ts.map +1 -1
  17. package/dist/src/mcp-tools/metaharness-tools.js +89 -0
  18. package/dist/src/mcp-tools/metaharness-tools.js.map +1 -1
  19. package/dist/src/mcp-tools/neural-tools.d.ts.map +1 -1
  20. package/dist/src/mcp-tools/neural-tools.js +119 -84
  21. package/dist/src/mcp-tools/neural-tools.js.map +1 -1
  22. package/dist/src/memory/memory-bridge.d.ts +15 -2
  23. package/dist/src/memory/memory-bridge.d.ts.map +1 -1
  24. package/dist/src/memory/memory-bridge.js +64 -10
  25. package/dist/src/memory/memory-bridge.js.map +1 -1
  26. package/dist/src/ruvector/output-verifier.d.ts +83 -0
  27. package/dist/src/ruvector/output-verifier.d.ts.map +1 -0
  28. package/dist/src/ruvector/output-verifier.js +277 -0
  29. package/dist/src/ruvector/output-verifier.js.map +1 -0
  30. package/dist/src/ruvector/trajectory-tree.d.ts +113 -0
  31. package/dist/src/ruvector/trajectory-tree.d.ts.map +1 -0
  32. package/dist/src/ruvector/trajectory-tree.js +237 -0
  33. package/dist/src/ruvector/trajectory-tree.js.map +1 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +5 -5
  36. package/plugins/ruflo-metaharness/README.md +2 -0
  37. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +1 -1
  38. package/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +5 -3
  39. package/plugins/ruflo-metaharness/scripts/_darwin.mjs +51 -41
  40. package/plugins/ruflo-metaharness/scripts/_harness.mjs +173 -104
  41. package/plugins/ruflo-metaharness/scripts/_invoke.mjs +231 -0
  42. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +37 -70
  43. package/plugins/ruflo-metaharness/scripts/evolve.mjs +163 -3
  44. package/plugins/ruflo-metaharness/scripts/gepa.mjs +153 -0
  45. package/plugins/ruflo-metaharness/scripts/learn.mjs +127 -0
  46. package/plugins/ruflo-metaharness/scripts/smoke.sh +107 -51
  47. package/plugins/ruflo-metaharness/scripts/test-graceful-degradation.mjs +16 -0
  48. package/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs +24 -5
  49. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +42 -3
  50. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +65 -0
  51. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +65 -0
  52. package/plugins/ruflo-metaharness/skills/harness-mcp-scan/SKILL.md +3 -1
  53. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  54. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +1 -1
  55. package/plugins/ruflo-metaharness/skills/harness-threat-model/SKILL.md +3 -1
@@ -0,0 +1,231 @@
1
+ // _invoke.mjs — shared subprocess/plumbing layer for the metaharness plugin family.
2
+ //
3
+ // Extracted from the copy-pasted plumbing that had accreted across
4
+ // _harness.mjs / _darwin.mjs / _redblue.mjs / gepa.mjs (converged
5
+ // security/perf/arch review, 2026-07). The four invocation helpers are now
6
+ // thin adapters on top of this module; their exported function signatures
7
+ // are unchanged (~15 scripts import them).
8
+ //
9
+ // WHAT LIVES HERE (the genuinely-shared parts):
10
+ // - DEGRADED_RX one superset regex (incl. `npm ERR` — previously
11
+ // only _redblue had it) for "upstream unavailable"
12
+ // - classifyDegraded() timeout vs not-available distinction, per-package
13
+ // reason prefix
14
+ // - injectJson() append --json unless caller opted out / present
15
+ // - parseTrailingJson() LAST-{...}-block extraction (the _darwin variant;
16
+ // the old _harness FIRST-block variant was a live
17
+ // bug — a progress line containing `{...}` would
18
+ // shadow the final structured result)
19
+ // - ensureCachedInstall() one-time `npm install --prefix ~/.ruflo/<name>-cache-<pin>`
20
+ // of a PINNED range — generalizes _redblue.mjs /
21
+ // gepa.mjs; the versioned dir means pin bumps
22
+ // invalidate stale caches automatically
23
+ // - findLocalPackageDir() walk-up node_modules resolution so an already
24
+ // installed optionalDependency is used for free
25
+ // - importOptionalLibrary() bare-import → cached-install fallback for
26
+ // library entries (gepa) — never throws on absence
27
+ // - makeDegradedEmitter() the ADR-150 rule-#3 exit-0 degraded payload
28
+ //
29
+ // WHAT DOES NOT LIVE HERE (the per-consumer parts):
30
+ // - _redblue's node-direct isMain workaround rationale
31
+ // - _darwin's async streaming (`onProgress`) for long evolve runs
32
+ // - _harness's dual-binary (metaharness + harness) resolution
33
+ //
34
+ // TEST SEAMS (used by test-graceful-degradation.mjs so the ADR-150 drill
35
+ // stays meaningful on machines with a warm cache / local install):
36
+ // - RUFLO_METAHARNESS_CACHE_BASE overrides ~/.ruflo as the cache root
37
+ // - RUFLO_METAHARNESS_SKIP_LOCAL=1 disables local node_modules resolution
38
+
39
+ import { spawnSync } from 'node:child_process';
40
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
41
+ import { homedir } from 'node:os';
42
+ import { dirname, join } from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ const INSTALL_TIMEOUT_MS = 180_000; // npm install can be slow on cold cache
46
+
47
+ // Superset of the three per-file regexes. `npm ERR` (from _redblue) is
48
+ // included for everyone: an npm-level failure during a cached install or an
49
+ // npx shim means "upstream unavailable", never a ruflo bug.
50
+ export const DEGRADED_RX = /could not determine executable|404|not installed|MODULE_NOT_FOUND|ENOTFOUND|getaddrinfo|ECONNREFUSED|ETIMEDOUT|npm ERR/i;
51
+
52
+ /**
53
+ * Classify a finished subprocess as degraded or healthy.
54
+ * `exitCode === null` means the harness killed it (timeout) — that is a
55
+ * DIFFERENT operational signal from "package not installable", so the two
56
+ * reasons stay distinct: `<prefix>-timeout` vs `<prefix>-not-available`.
57
+ */
58
+ export function classifyDegraded(stderr, exitCode, reasonPrefix) {
59
+ if (exitCode === null) return { degraded: true, reason: `${reasonPrefix}-timeout` };
60
+ if (DEGRADED_RX.test(stderr || '')) return { degraded: true, reason: `${reasonPrefix}-not-available` };
61
+ return { degraded: false };
62
+ }
63
+
64
+ /** Append --json unless the caller opted out or already passed it. */
65
+ export function injectJson(args, wantJson) {
66
+ if (!wantJson || args.includes('--json')) return [...args];
67
+ return [...args, '--json'];
68
+ }
69
+
70
+ /**
71
+ * Parse the trailing JSON object from mixed stdout. CLIs in this family emit
72
+ * human-readable progress first and a structured JSON object LAST — so grab
73
+ * the last parseable {...} block, falling back to a greedy whole-span match
74
+ * for nested objects the lazy regex fragments.
75
+ */
76
+ export function parseTrailingJson(stdout) {
77
+ const s = stdout || '';
78
+ const matches = [...s.matchAll(/\{[\s\S]*?\}/g)];
79
+ for (let i = matches.length - 1; i >= 0; i--) {
80
+ try { return JSON.parse(matches[i][0]); } catch { /* try previous */ }
81
+ }
82
+ const greedy = /\{[\s\S]*\}/.exec(s);
83
+ if (greedy) { try { return JSON.parse(greedy[0]); } catch { return null; } }
84
+ return null;
85
+ }
86
+
87
+ /** Cache root — ~/.ruflo unless the test seam overrides it. */
88
+ export function cacheBaseDir() {
89
+ return process.env.RUFLO_METAHARNESS_CACHE_BASE || join(homedir(), '.ruflo');
90
+ }
91
+
92
+ /** Minimal `~X.Y.Z` satisfaction check (no semver dep): same major.minor, patch >= Z. */
93
+ export function satisfiesTildeRange(version, pinVersion) {
94
+ const pin = /^~(\d+)\.(\d+)\.(\d+)/.exec(String(pinVersion));
95
+ const ver = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
96
+ if (!pin || !ver) return false;
97
+ return ver[1] === pin[1] && ver[2] === pin[2] && parseInt(ver[3], 10) >= parseInt(pin[3], 10);
98
+ }
99
+
100
+ /**
101
+ * Walk up node_modules from this plugin's directory AND from $CWD looking
102
+ * for an already-installed copy of `pkg` (e.g. the optionalDependency the
103
+ * user's ruflo install shipped with). Returns the package dir or null.
104
+ * When `pinVersion` is given, only a copy satisfying the pin is accepted —
105
+ * a stale major/minor in an ancestor node_modules is skipped, not used.
106
+ */
107
+ export function findLocalPackageDir(pkg, pinVersion) {
108
+ if (process.env.RUFLO_METAHARNESS_SKIP_LOCAL === '1') return null;
109
+ const segments = pkg.split('/');
110
+ const starts = [dirname(fileURLToPath(import.meta.url)), process.cwd()];
111
+ const seen = new Set();
112
+ for (const start of starts) {
113
+ let dir = start;
114
+ for (;;) {
115
+ if (!seen.has(dir)) {
116
+ seen.add(dir);
117
+ const candidate = join(dir, 'node_modules', ...segments);
118
+ const pj = join(candidate, 'package.json');
119
+ if (existsSync(pj)) {
120
+ if (!pinVersion) return candidate;
121
+ try {
122
+ const version = JSON.parse(readFileSync(pj, 'utf-8')).version;
123
+ if (satisfiesTildeRange(version, pinVersion)) return candidate;
124
+ } catch { /* unreadable — keep walking */ }
125
+ }
126
+ }
127
+ const parent = dirname(dir);
128
+ if (parent === dir) break;
129
+ dir = parent;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * One-time versioned cache install of a PINNED range. Generalizes
137
+ * gepa.mjs:98-121 / _redblue.mjs:72-102. The cache dir is
138
+ * `<base>/<shortname>-cache-<pin-digits>` (e.g. redblue-cache-0.1.4,
139
+ * darwin-cache-0.8.0, metaharness-cache-0.3.0) so existing caches created
140
+ * by the pre-consolidation helpers remain valid, and bumping the pin
141
+ * invalidates stale installs automatically.
142
+ *
143
+ * @param {object} spec
144
+ * @param {string} spec.pkg npm package name (may be scoped)
145
+ * @param {string} spec.pinVersion tilde range, e.g. '~0.3.0'
146
+ * @param {string} [spec.cliRelPath] path inside the package that must exist
147
+ * post-install (also returned as cliPath)
148
+ * @param {number} [spec.timeoutMs] install timeout (default 180s)
149
+ * @returns {{ok:true, cacheDir:string, pkgDir:string, cliPath:string|null} |
150
+ * {ok:false, reason:string, stderr?:string, stdout?:string, error?:string}}
151
+ */
152
+ export function ensureCachedInstall({ pkg, pinVersion, cliRelPath, timeoutMs = INSTALL_TIMEOUT_MS }) {
153
+ const short = pkg.split('/').pop();
154
+ const cacheDir = join(cacheBaseDir(), `${short}-cache-${pinVersion.replace(/[~^]/g, '')}`);
155
+ const pkgDir = join(cacheDir, 'node_modules', ...pkg.split('/'));
156
+ const cliPath = cliRelPath ? join(pkgDir, cliRelPath) : null;
157
+ const probe = cliPath ?? join(pkgDir, 'package.json');
158
+ if (existsSync(probe)) return { ok: true, cacheDir, pkgDir, cliPath };
159
+ try {
160
+ mkdirSync(cacheDir, { recursive: true });
161
+ } catch (e) {
162
+ return { ok: false, reason: 'cache-dir-create-failed', error: String(e) };
163
+ }
164
+ // `npm install --prefix` puts the package in a known location; this both
165
+ // avoids npx's symlinked bin shim (the _redblue isMain upstream bug) and
166
+ // removes the per-call registry check that `npx -y pkg@latest` forced.
167
+ // shell:false; argv only.
168
+ const r = spawnSync('npm', [
169
+ 'install',
170
+ '--no-audit', '--no-fund', '--no-package-lock',
171
+ '--prefix', cacheDir,
172
+ `${pkg}@${pinVersion}`,
173
+ ], {
174
+ stdio: ['ignore', 'pipe', 'pipe'],
175
+ encoding: 'utf-8',
176
+ timeout: timeoutMs,
177
+ shell: process.platform === 'win32',
178
+ });
179
+ if (r.status !== 0 || !existsSync(probe)) {
180
+ return {
181
+ ok: false,
182
+ reason: 'install-failed',
183
+ stderr: (r.stderr || '').slice(0, 600),
184
+ stdout: (r.stdout || '').slice(0, 600),
185
+ };
186
+ }
187
+ return { ok: true, cacheDir, pkgDir, cliPath };
188
+ }
189
+
190
+ /**
191
+ * Import an optional LIBRARY entry (as opposed to spawning a CLI):
192
+ * 1. bare `import(specifier)` — free when the optional dep is installed in
193
+ * an ancestor node_modules;
194
+ * 2. versioned cache install + file-URL import of `entryRelPath`.
195
+ * Returns the module namespace or null. Never throws on absence / stale
196
+ * installs (ERR_PACKAGE_PATH_NOT_EXPORTED covers pre-subpath versions).
197
+ */
198
+ export async function importOptionalLibrary({ specifier, pkg, pinVersion, entryRelPath }) {
199
+ try {
200
+ return await import(specifier);
201
+ } catch (e) {
202
+ const msg = String(e?.message ?? e);
203
+ const recoverable = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|ERR_PACKAGE_PATH_NOT_EXPORTED|is not defined by "exports"/i;
204
+ if (!recoverable.test(msg)) throw e;
205
+ }
206
+ const r = ensureCachedInstall({ pkg, pinVersion, cliRelPath: entryRelPath });
207
+ if (!r.ok || !r.cliPath) return null;
208
+ try {
209
+ return await import(pathToFileURL(r.cliPath).href);
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Build the per-package `emit*DegradedJsonAndExit(reason)` helper. Emits the
217
+ * structured degraded payload and exits 0 — ADR-150 architectural constraint
218
+ * rule #3: ruflo continues to function when MetaHarness is absent.
219
+ */
220
+ export function makeDegradedEmitter(pkg, pinVersion) {
221
+ return function emitDegradedAndExit(reason) {
222
+ const payload = {
223
+ degraded: true,
224
+ reason,
225
+ hint: `Install with \`npm i -D ${pkg}@${pinVersion}\` (pinned range — this plugin never fetches @latest) or verify network access for the one-time cache install.`,
226
+ generatedAt: new Date().toISOString(),
227
+ };
228
+ console.log(JSON.stringify(payload, null, 2));
229
+ process.exit(0);
230
+ };
231
+ }
@@ -1,7 +1,10 @@
1
1
  // _redblue.mjs — invocation helper for `@metaharness/redblue`.
2
2
  //
3
3
  // Sibling of `_darwin.mjs` / `_harness.mjs`. Targets the `redblue` binary
4
- // from the standalone `@metaharness/redblue@~0.1.1` package.
4
+ // from the standalone `@metaharness/redblue@~0.1.4` package. Shared plumbing
5
+ // (degraded classification, versioned cache install, degraded emitter) lives
6
+ // in `_invoke.mjs`; what stays here is the genuinely-redblue part: the
7
+ // node-direct invocation rationale below.
5
8
  //
6
9
  // Subcommands surfaced (matches redblue 0.1.x):
7
10
  // - `redblue init [--out redblue.yaml]`
@@ -21,12 +24,13 @@
21
24
  // no error. Darwin's CLI doesn't suffer this (calls main() unconditionally).
22
25
  //
23
26
  // Workaround: install the package once into a ruflo-owned cache dir
24
- // (~/.ruflo/redblue-cache) and invoke `node <abs_path>/dist/cli/index.js`
25
- // directly. argv[1] then equals the real path and isMain becomes true.
27
+ // (~/.ruflo/redblue-cache-<pin>, via _invoke.ensureCachedInstall) and invoke
28
+ // `node <abs_path>/dist/cli/index.js` directly. argv[1] then equals the real
29
+ // path and isMain becomes true.
26
30
  //
27
31
  // Track upstream fix at: github.com/ruvnet/agent-harness-generator/issues
28
- // (file separately — when fixed, we can drop the cached-install path and
29
- // go back to the `npx -y -p ...` pattern used by _darwin.mjs).
32
+ // (file separately — when fixed, we could go back to a bin-shim pattern,
33
+ // though the node-direct cached path is now the family-wide standard anyway).
30
34
  //
31
35
  // CONTRACT (matches runMetaharness/runDarwin):
32
36
  // - returns `{ stdout, stderr, exitCode, durationMs, degraded, reason? }`
@@ -43,63 +47,29 @@
43
47
  // $OPENROUTER_API_KEY which we never inject.
44
48
 
45
49
  import { spawnSync } from 'node:child_process';
46
- import { existsSync, mkdirSync } from 'node:fs';
47
50
  import { homedir } from 'node:os';
48
51
  import { join } from 'node:path';
52
+ import {
53
+ classifyDegraded,
54
+ ensureCachedInstall,
55
+ makeDegradedEmitter,
56
+ } from './_invoke.mjs';
49
57
 
50
58
  const DEFAULT_TIMEOUT_MS = 120_000;
51
- const INSTALL_TIMEOUT_MS = 180_000; // npm install can be slow on cold cache
52
59
 
53
60
  // Pinned semver range. Bump in lock-step with optionalDependencies in
54
61
  // @claude-flow/cli/package.json + ruflo/package.json.
55
- const REDBLUE_PIN = '@metaharness/redblue@~0.1.1';
56
62
  const REDBLUE_PKG = '@metaharness/redblue';
57
- const REDBLUE_PIN_VERSION = '~0.1.1';
63
+ const REDBLUE_PIN_VERSION = '~0.1.4';
64
+ const REDBLUE_PIN = `${REDBLUE_PKG}@${REDBLUE_PIN_VERSION}`;
58
65
 
59
- const CACHE_DIR = join(homedir(), '.ruflo', 'redblue-cache');
60
- const RESOLVED_CLI = join(
61
- CACHE_DIR, 'node_modules', '@metaharness', 'redblue', 'dist', 'cli', 'index.js',
62
- );
66
+ const REASON_PREFIX = 'metaharness-redblue';
63
67
 
64
- const DEGRADED_RX = /could not determine executable|404|not installed|MODULE_NOT_FOUND|ENOTFOUND|getaddrinfo|ECONNREFUSED|ETIMEDOUT|npm ERR/i;
65
-
66
- function ensureInstalled() {
67
- if (existsSync(RESOLVED_CLI)) return { ok: true };
68
- try {
69
- mkdirSync(CACHE_DIR, { recursive: true });
70
- } catch (e) {
71
- return { ok: false, reason: 'cache-dir-create-failed', error: String(e) };
72
- }
73
- // Use `npm install --prefix` so the package lands in a known location;
74
- // this avoids npx's symlinked shim entirely (the upstream bin-bootstrap bug).
75
- // shell:false; argv only.
76
- const r = spawnSync('npm', [
77
- 'install',
78
- '--no-audit', '--no-fund', '--no-package-lock',
79
- '--prefix', CACHE_DIR,
80
- `${REDBLUE_PKG}@${REDBLUE_PIN_VERSION}`,
81
- ], {
82
- stdio: ['ignore', 'pipe', 'pipe'],
83
- encoding: 'utf-8',
84
- timeout: INSTALL_TIMEOUT_MS,
85
- shell: process.platform === 'win32',
86
- });
87
- if (r.status !== 0 || !existsSync(RESOLVED_CLI)) {
88
- return {
89
- ok: false,
90
- reason: 'install-failed',
91
- stderr: (r.stderr || '').slice(0, 600),
92
- stdout: (r.stdout || '').slice(0, 600),
93
- };
94
- }
95
- return { ok: true };
96
- }
97
-
98
- function classifyDegraded(stderr, exitCode) {
99
- if (exitCode === null) return { degraded: true, reason: 'metaharness-redblue-timeout' };
100
- if (DEGRADED_RX.test(stderr)) return { degraded: true, reason: 'metaharness-redblue-not-available' };
101
- return { degraded: false };
102
- }
68
+ // Cache dir is versioned by the pin so bumping REDBLUE_PIN_VERSION
69
+ // invalidates stale installs (handled inside ensureCachedInstall). The
70
+ // resolved layout matches the pre-consolidation helper so existing caches
71
+ // keep serving.
72
+ const CLI_REL_PATH = join('dist', 'cli', 'index.js');
103
73
 
104
74
  /**
105
75
  * Sync invocation. `redblue` runs are bounded by `max_cost_usd` and
@@ -111,7 +81,11 @@ export function runRedblue(args, opts = {}) {
111
81
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
112
82
 
113
83
  // 1. Ensure redblue is installed in our cache dir (one-time install).
114
- const install = ensureInstalled();
84
+ const install = ensureCachedInstall({
85
+ pkg: REDBLUE_PKG,
86
+ pinVersion: REDBLUE_PIN_VERSION,
87
+ cliRelPath: CLI_REL_PATH,
88
+ });
115
89
  if (!install.ok) {
116
90
  return {
117
91
  stdout: install.stdout ?? '',
@@ -120,14 +94,14 @@ export function runRedblue(args, opts = {}) {
120
94
  durationMs: Date.now() - start,
121
95
  degraded: true,
122
96
  reason: install.reason === 'install-failed'
123
- ? 'metaharness-redblue-not-available'
124
- : `metaharness-redblue-${install.reason}`,
97
+ ? `${REASON_PREFIX}-not-available`
98
+ : `${REASON_PREFIX}-${install.reason}`,
125
99
  };
126
100
  }
127
101
 
128
102
  // 2. Invoke `node <real-path-to-cli> <args>` so upstream's isMain check
129
103
  // succeeds (argv[1] matches import.meta.url).
130
- const r = spawnSync('node', [RESOLVED_CLI, ...args], {
104
+ const r = spawnSync('node', [install.cliPath, ...args], {
131
105
  stdio: ['ignore', 'pipe', 'pipe'],
132
106
  encoding: 'utf-8',
133
107
  timeout: timeoutMs,
@@ -138,7 +112,7 @@ export function runRedblue(args, opts = {}) {
138
112
  const durationMs = Date.now() - start;
139
113
  const stdout = r.stdout || '';
140
114
  const stderr = r.stderr || '';
141
- const classified = classifyDegraded(stderr, r.status);
115
+ const classified = classifyDegraded(stderr, r.status, REASON_PREFIX);
142
116
  if (classified.degraded) {
143
117
  return {
144
118
  stdout, stderr,
@@ -158,19 +132,12 @@ export function runRedblue(args, opts = {}) {
158
132
 
159
133
  /**
160
134
  * Match the existing `emit*DegradedJsonAndExit` shape used by sibling
161
- * scripts so MCP tool consumers see one contract.
135
+ * scripts so MCP tool consumers see one contract. Exit 0 — ADR-150
136
+ * architectural constraint #3.
162
137
  */
163
- export function emitRedblueDegradedJsonAndExit(reason) {
164
- const payload = {
165
- degraded: true,
166
- reason,
167
- hint: 'Install with `npm i -D ' + REDBLUE_PIN + '` or run `npx -y ' + REDBLUE_PIN + ' --help` to verify network access.',
168
- generatedAt: new Date().toISOString(),
169
- };
170
- console.log(JSON.stringify(payload, null, 2));
171
- // Exit 0 — ADR-150 architectural constraint #3.
172
- process.exit(0);
173
- }
138
+ export const emitRedblueDegradedJsonAndExit = makeDegradedEmitter(REDBLUE_PKG, REDBLUE_PIN_VERSION);
174
139
 
175
140
  export const REDBLUE_VERSION_PIN = REDBLUE_PIN;
176
- export const REDBLUE_CACHE_DIR = CACHE_DIR;
141
+ export const REDBLUE_CACHE_DIR = join(
142
+ homedir(), '.ruflo', `redblue-cache-${REDBLUE_PIN_VERSION.replace(/[~^]/g, '')}`,
143
+ );
@@ -24,6 +24,23 @@
24
24
  // node scripts/evolve.mjs --repo . --confirm --generations 5 --children 3
25
25
  // node scripts/evolve.mjs --repo . --confirm --sandbox mock # no real tests
26
26
  // node scripts/evolve.mjs --repo . --confirm --selection pareto
27
+ // node scripts/evolve.mjs --repo . --confirm --diagnose # + GEPA failure diagnosis
28
+ //
29
+ // --diagnose (GEPA failure diagnosis — the natural-language-diagnosis trick
30
+ // from GEPA, scoped modestly): after the evolve run completes, the losing /
31
+ // failed variants' transcripts are run through darwin's gepa
32
+ // `analyzeTranscript` + `classifyFailure` ops and a `diagnosis` section
33
+ // (failure classes + counts + dominant class per variant) is appended to the
34
+ // emitted JSON report. UPSTREAM SHAPE NOTE (verified against darwin 0.8.0):
35
+ // `metaharness-darwin evolve --json` emits a TEXT leaderboard on stdout — no
36
+ // JSON, no transcripts. The per-variant run records live at
37
+ // `<repo>/.metaharness/runs/<id>.json` as sandbox exec traces
38
+ // ({taskId, exitCode, stdout, stderr}), which are NOT gepa {actionRaw, obs}
39
+ // transcripts. So the diagnosis layer (a) uses gepa-shaped transcripts when
40
+ // a run record embeds them (agent-sandbox / future upstream), (b) falls back
41
+ // to the champion's transcript, and (c) otherwise emits
42
+ // `diagnosis: {available: false, reason, traceSummary}` with a mechanical
43
+ // per-variant trace summary — it NEVER fails the run.
27
44
  //
28
45
  // EXIT CODES
29
46
  // 0 evolved OK (or dry-run, or degraded — MetaHarness Darwin not available)
@@ -31,9 +48,9 @@
31
48
  // 2 config error or evolution failure
32
49
  // 99 reserved — upstream "safety-disqualified" (propagated)
33
50
 
34
- import { runDarwinAsync, emitDarwinDegradedJsonAndExit } from './_darwin.mjs';
35
- import { existsSync } from 'node:fs';
36
- import { resolve } from 'node:path';
51
+ import { runDarwinAsync, emitDarwinDegradedJsonAndExit, importGepa } from './_darwin.mjs';
52
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
53
+ import { join, resolve } from 'node:path';
37
54
 
38
55
  const ARGS = (() => {
39
56
  const a = {
@@ -56,6 +73,7 @@ const ARGS = (() => {
56
73
  ruvllmModel: null,
57
74
  confirm: false,
58
75
  alertOnNoImprovement: false,
76
+ diagnose: false,
59
77
  format: 'json',
60
78
  timeoutMs: null, // computed below if unset
61
79
  };
@@ -80,6 +98,7 @@ const ARGS = (() => {
80
98
  else if (v === '--ruvllm-model') a.ruvllmModel = process.argv[++i];
81
99
  else if (v === '--confirm') a.confirm = true;
82
100
  else if (v === '--alert-on-no-improvement') a.alertOnNoImprovement = true;
101
+ else if (v === '--diagnose') a.diagnose = true;
83
102
  else if (v === '--format') a.format = process.argv[++i];
84
103
  else if (v === '--timeout-ms') a.timeoutMs = parseInt(process.argv[++i], 10);
85
104
  }
@@ -119,6 +138,141 @@ function safetyChecks() {
119
138
  return repoPath;
120
139
  }
121
140
 
141
+ // ---------------------------------------------------------------------------
142
+ // --diagnose support (GEPA failure diagnosis)
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** True when `arr` looks like a gepa transcript: [{actionRaw?, obs?}, ...]. */
146
+ function looksLikeGepaTranscript(arr) {
147
+ return Array.isArray(arr) && arr.length > 0 &&
148
+ arr.every((e) => e && typeof e === 'object' && ('actionRaw' in e || 'obs' in e));
149
+ }
150
+
151
+ /**
152
+ * Pull every gepa-shaped transcript out of one run record. Upstream darwin
153
+ * 0.8.0 run records carry sandbox exec traces (NOT gepa transcripts), but a
154
+ * record MAY embed `transcript` arrays (agent sandbox / future upstream) —
155
+ * accept `record.transcript`, `record.traces[i].transcript`, or `traces`
156
+ * itself when its entries are {actionRaw, obs}-shaped.
157
+ */
158
+ function extractGepaTranscripts(record) {
159
+ const out = [];
160
+ if (looksLikeGepaTranscript(record?.transcript)) {
161
+ out.push({ taskId: null, exitCode: null, entries: record.transcript });
162
+ }
163
+ const traces = Array.isArray(record?.traces) ? record.traces : [];
164
+ if (looksLikeGepaTranscript(traces)) {
165
+ out.push({ taskId: null, exitCode: null, entries: traces });
166
+ } else {
167
+ for (const t of traces) {
168
+ if (looksLikeGepaTranscript(t?.transcript)) {
169
+ out.push({ taskId: t.taskId ?? null, exitCode: t.exitCode ?? null, entries: t.transcript });
170
+ }
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** Mechanical per-variant summary of sandbox exec traces (the always-available fallback signal). */
177
+ function summarizeTraces(record) {
178
+ const traces = Array.isArray(record?.traces) ? record.traces : [];
179
+ return {
180
+ tasks: traces.length,
181
+ failed: traces.filter((t) => t?.exitCode !== 0).length,
182
+ timedOut: traces.filter((t) => t?.timedOut === true).length,
183
+ blockedActions: traces.reduce((n, t) => n + (Array.isArray(t?.blockedActions) ? t.blockedActions.length : 0), 0),
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Build the `diagnosis` section for the emitted report. Never throws and
189
+ * never affects the run's exit code — any internal failure degrades to
190
+ * `{available: false, reason}`.
191
+ */
192
+ async function buildDiagnosis(repoPath) {
193
+ try {
194
+ const metaDir = join(repoPath, '.metaharness');
195
+ const runsDir = join(metaDir, 'runs');
196
+ if (!existsSync(runsDir)) {
197
+ return { available: false, reason: 'no-run-records: <repo>/.metaharness/runs does not exist' };
198
+ }
199
+ let winnerId = null;
200
+ try {
201
+ winnerId = JSON.parse(readFileSync(join(metaDir, 'reports', 'winner.json'), 'utf-8'))?.variant?.id ?? null;
202
+ } catch { /* winner unknown — treat all variants as candidates */ }
203
+
204
+ const records = [];
205
+ for (const f of readdirSync(runsDir).filter((f) => f.endsWith('.json')).slice(0, 100)) {
206
+ try {
207
+ records.push({ id: f.replace(/\.json$/, ''), rec: JSON.parse(readFileSync(join(runsDir, f), 'utf-8')) });
208
+ } catch { /* skip unreadable record */ }
209
+ }
210
+ if (records.length === 0) {
211
+ return { available: false, reason: 'no-run-records: <repo>/.metaharness/runs contains no parseable records' };
212
+ }
213
+
214
+ // Losing/failed variants are the primary diagnosis targets; the champion
215
+ // is the fallback when no loser exposes a transcript.
216
+ const losers = records.filter((r) =>
217
+ r.id !== winnerId &&
218
+ (r.rec?.score?.promoted === false ||
219
+ (Array.isArray(r.rec?.traces) && r.rec.traces.some((t) => t?.exitCode !== 0 || t?.timedOut))));
220
+ const champion = records.filter((r) => r.id === winnerId);
221
+
222
+ let scope = 'losing-variants';
223
+ let pool = losers.map((r) => ({ ...r, transcripts: extractGepaTranscripts(r.rec) }))
224
+ .filter((r) => r.transcripts.length > 0);
225
+ if (pool.length === 0) {
226
+ scope = 'champion-fallback';
227
+ pool = champion.map((r) => ({ ...r, transcripts: extractGepaTranscripts(r.rec) }))
228
+ .filter((r) => r.transcripts.length > 0);
229
+ }
230
+
231
+ if (pool.length === 0) {
232
+ // Verified upstream shape (darwin 0.8.0): run records are sandbox exec
233
+ // traces, not gepa transcripts. Emit the mechanical trace summary so
234
+ // --diagnose still yields signal.
235
+ const target = losers.length ? losers : records;
236
+ return {
237
+ available: false,
238
+ reason: 'no-gepa-transcripts: run records contain sandbox exec traces ({taskId, exitCode, stdout, stderr}), not gepa {actionRaw, obs} transcripts',
239
+ traceSummary: Object.fromEntries(target.map((r) => [r.id, summarizeTraces(r.rec)])),
240
+ };
241
+ }
242
+
243
+ const gepa = await importGepa();
244
+ if (!gepa || typeof gepa.analyzeTranscript !== 'function') {
245
+ return { available: false, reason: 'metaharness-darwin-gepa-not-available' };
246
+ }
247
+
248
+ const classLabel = (n) => {
249
+ const raw = gepa.FAILURE_CLASSES?.[n];
250
+ return raw ? String(raw).split(' (')[0] : `class-${n}`;
251
+ };
252
+
253
+ const totals = {};
254
+ const variants = [];
255
+ for (const r of pool) {
256
+ const classes = {};
257
+ for (const t of r.transcripts) {
258
+ const analysis = gepa.analyzeTranscript(t.entries);
259
+ const cls = typeof gepa.classifyFailure === 'function'
260
+ ? gepa.classifyFailure({ goldResolved: t.exitCode === 0, analysis })
261
+ : -1;
262
+ const label = classLabel(cls);
263
+ classes[label] = (classes[label] || 0) + 1;
264
+ totals[label] = (totals[label] || 0) + 1;
265
+ }
266
+ const dominantClass = Object.entries(classes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
267
+ variants.push({ id: r.id, transcripts: r.transcripts.length, failureClasses: classes, dominantClass });
268
+ }
269
+
270
+ return { available: true, scope, variants, totals };
271
+ } catch (e) {
272
+ return { available: false, reason: `diagnosis-failed: ${e?.message ?? e}` };
273
+ }
274
+ }
275
+
122
276
  // Compute a sensible timeout from the search shape if caller didn't specify.
123
277
  // Rough budget: each variant ~= 30s (sandbox test command + safety inspect),
124
278
  // total ~= generations × children × per-variant / concurrency.
@@ -146,6 +300,7 @@ async function main() {
146
300
  epistasis: ARGS.epistasis,
147
301
  curriculum: ARGS.curriculum,
148
302
  mutator: ARGS.mutator,
303
+ diagnose: ARGS.diagnose,
149
304
  estVariants: ARGS.generations * ARGS.children,
150
305
  timeoutMs: ARGS.timeoutMs ?? defaultTimeoutMs(),
151
306
  output: `${repoPath}/.metaharness/{archive.json, lineage.json, variants/, runs/, reports/winner.json}`,
@@ -233,6 +388,11 @@ async function main() {
233
388
  generatedAt: new Date().toISOString(),
234
389
  };
235
390
 
391
+ // GEPA failure diagnosis — opt-in, additive, never fails the run.
392
+ if (ARGS.diagnose) {
393
+ payload.data.diagnosis = await buildDiagnosis(repoPath);
394
+ }
395
+
236
396
  console.log(JSON.stringify(payload, null, 2));
237
397
  if (ARGS.alertOnNoImprovement && noImprovement) process.exit(1);
238
398
  process.exit(0);