@claude-flow/cli 3.12.0 → 3.12.2

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 (53) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +34 -4
  2. package/.claude/helpers/hook-handler.cjs +17 -13
  3. package/.claude/helpers/intelligence.cjs +16 -2
  4. package/README.md +7 -4
  5. package/dist/src/commands/daemon.d.ts.map +1 -1
  6. package/dist/src/commands/daemon.js +76 -7
  7. package/dist/src/commands/daemon.js.map +1 -1
  8. package/dist/src/commands/init.d.ts.map +1 -1
  9. package/dist/src/commands/init.js +24 -3
  10. package/dist/src/commands/init.js.map +1 -1
  11. package/dist/src/commands/security.d.ts.map +1 -1
  12. package/dist/src/commands/security.js +70 -12
  13. package/dist/src/commands/security.js.map +1 -1
  14. package/dist/tsconfig.tsbuildinfo +1 -1
  15. package/package.json +3 -2
  16. package/plugins/ruflo-metaharness/.claude-flow/data/pending-insights.jsonl +1 -0
  17. package/plugins/ruflo-metaharness/.claude-flow/neural/stats.json +6 -0
  18. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +32 -0
  19. package/plugins/ruflo-metaharness/README.md +64 -0
  20. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +58 -0
  21. package/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +46 -0
  22. package/plugins/ruflo-metaharness/scripts/_harness.mjs +261 -0
  23. package/plugins/ruflo-metaharness/scripts/_similarity.mjs +161 -0
  24. package/plugins/ruflo-metaharness/scripts/_spike-similarity.mjs +223 -0
  25. package/plugins/ruflo-metaharness/scripts/audit-list.mjs +158 -0
  26. package/plugins/ruflo-metaharness/scripts/audit-trend.mjs +272 -0
  27. package/plugins/ruflo-metaharness/scripts/bench-parse-mcp-scan.mjs +146 -0
  28. package/plugins/ruflo-metaharness/scripts/bench-recordpair-overhead.mjs +186 -0
  29. package/plugins/ruflo-metaharness/scripts/bench-similarity.mjs +177 -0
  30. package/plugins/ruflo-metaharness/scripts/drift-from-history.mjs +363 -0
  31. package/plugins/ruflo-metaharness/scripts/genome.mjs +80 -0
  32. package/plugins/ruflo-metaharness/scripts/mcp-scan.mjs +111 -0
  33. package/plugins/ruflo-metaharness/scripts/mint.mjs +119 -0
  34. package/plugins/ruflo-metaharness/scripts/oia-audit.mjs +228 -0
  35. package/plugins/ruflo-metaharness/scripts/router-parallel-analyze.mjs +250 -0
  36. package/plugins/ruflo-metaharness/scripts/score.mjs +92 -0
  37. package/plugins/ruflo-metaharness/scripts/similarity.mjs +158 -0
  38. package/plugins/ruflo-metaharness/scripts/smoke.sh +2268 -0
  39. package/plugins/ruflo-metaharness/scripts/test-graceful-degradation.mjs +141 -0
  40. package/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs +437 -0
  41. package/plugins/ruflo-metaharness/scripts/test-parallel-pipeline.mjs +204 -0
  42. package/plugins/ruflo-metaharness/scripts/test-pipeline-roundtrip.mjs +586 -0
  43. package/plugins/ruflo-metaharness/scripts/test-similarity.mjs +334 -0
  44. package/plugins/ruflo-metaharness/scripts/test-with-openrouter.mjs +229 -0
  45. package/plugins/ruflo-metaharness/scripts/threat-model.mjs +59 -0
  46. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +65 -0
  47. package/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md +54 -0
  48. package/plugins/ruflo-metaharness/skills/harness-mcp-scan/SKILL.md +47 -0
  49. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +72 -0
  50. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +79 -0
  51. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +66 -0
  52. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +67 -0
  53. package/plugins/ruflo-metaharness/skills/harness-threat-model/SKILL.md +39 -0
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env node
2
+ // test-pipeline-roundtrip.mjs — end-to-end test of the ADR-152 §3.1
3
+ // cross-pipeline integration shipped iters 36→46.
4
+ //
5
+ // THE GAP THIS CLOSES
6
+ // Every prior iter test exercised ONE surface in isolation:
7
+ // iter 35: spike — synthetic LEGAL/SUPPORT/DEVOPS fixtures
8
+ // iter 37: MCP runtime test — fake mem keys
9
+ // iter 38: audit-trend — hand-written audit JSON
10
+ // iter 39: unit tests — pure-function arithmetic
11
+ // iter 41: bench — synthetic payloads
12
+ // iter 43: Phase 4 — file-path fixtures
13
+ // iter 46: audit_trend file inputs — same fixtures
14
+ //
15
+ // None of these proved the FULL chain works end-to-end with no
16
+ // synthetic data. This test does.
17
+ //
18
+ // THE PIPELINE BEING TESTED
19
+ // 1. oia-audit --dry-run --path . (real run against ruflo)
20
+ // 2. The audit record's fingerprint{score,genome}
21
+ // 3. audit-trend --baseline {same-record} --current {same-record}
22
+ // 4. delta.structuralDistance.verdict === 'near-identical'
23
+ // 5. delta.structuralDistance.overall === 1
24
+ //
25
+ // USAGE
26
+ // node scripts/test-pipeline-roundtrip.mjs
27
+ // node scripts/test-pipeline-roundtrip.mjs --format json
28
+ //
29
+ // EXIT CODES
30
+ // 0 full chain works
31
+ // 1 some assertion failed
32
+ // 2 oia-audit degraded (upstream metaharness absent — test cannot run)
33
+ //
34
+ // ADR-150 ARCHITECTURAL CONSTRAINT BEHAVIOR
35
+ // If oia-audit returns {degraded:true} (no upstream metaharness), the
36
+ // test exits 2 with a clear message — this is the "test cannot run"
37
+ // case, NOT a "test failed" case. CI infrastructure can distinguish.
38
+
39
+ import { spawnSync } from 'node:child_process';
40
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
41
+ import { tmpdir } from 'node:os';
42
+ import { join, dirname } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
46
+ const REPO_ROOT = dirname(dirname(dirname(SCRIPTS_DIR))); // up out of plugins/ruflo-metaharness/scripts
47
+
48
+ const ARGS = (() => {
49
+ const a = { format: 'table' };
50
+ for (let i = 2; i < process.argv.length; i++) {
51
+ if (process.argv[i] === '--format') a.format = process.argv[++i];
52
+ }
53
+ return a;
54
+ })();
55
+
56
+ let passed = 0, failed = 0;
57
+ const failures = [];
58
+ function assert(cond, label) {
59
+ if (cond) { console.log(` ✓ ${label}`); passed++; }
60
+ else { console.log(` ✗ ${label}`); failures.push(label); failed++; }
61
+ }
62
+
63
+ function runNode(scriptName, args, timeoutMs = 60_000) {
64
+ const p = spawnSync('node', [join(SCRIPTS_DIR, scriptName), ...args], {
65
+ encoding: 'utf-8',
66
+ timeout: timeoutMs,
67
+ cwd: REPO_ROOT,
68
+ });
69
+ return { stdout: p.stdout || '', stderr: p.stderr || '', status: p.status ?? -1 };
70
+ }
71
+
72
+ // ──────────────────────────────────────────────────────────────────
73
+ console.log(`# test-pipeline-roundtrip — ADR-152 §3.1 end-to-end (iter 47)\n`);
74
+
75
+ const tmp = mkdtempSync(join(tmpdir(), 'pipeline-roundtrip-'));
76
+
77
+ try {
78
+ // ──────────────────────────────────────────────────────────────
79
+ // STAGE 1: oia-audit --dry-run against ruflo repo itself
80
+ // ──────────────────────────────────────────────────────────────
81
+ console.log('Stage 1 — oia-audit --dry-run against ruflo repo');
82
+ const auditRun = runNode('oia-audit.mjs', ['--path', REPO_ROOT, '--dry-run', '--format', 'json'], 90_000);
83
+
84
+ // Extract the JSON object from stdout (script may emit some prelude)
85
+ const auditMatch = /\{[\s\S]*\}/.exec(auditRun.stdout);
86
+ if (!auditMatch) {
87
+ console.log(` ✗ oia-audit produced no JSON; stderr:\n${auditRun.stderr.slice(0, 500)}`);
88
+ process.exit(1);
89
+ }
90
+ const audit = JSON.parse(auditMatch[0]);
91
+
92
+ // If oia-audit reports degraded (no upstream metaharness installed), skip
93
+ // — this test cannot run without real score/genome output.
94
+ if (audit.degraded === true) {
95
+ console.log(` ⊘ oia-audit reports degraded — upstream metaharness absent`);
96
+ console.log(` This test exercises real metaharness output; cannot run.`);
97
+ console.log(` reason: ${audit.reason}`);
98
+ process.exit(2);
99
+ }
100
+
101
+ assert(typeof audit === 'object' && audit !== null,
102
+ 'oia-audit produced a JSON object');
103
+ assert(typeof audit.composite === 'object',
104
+ 'oia-audit has composite worst-severity');
105
+ assert(typeof audit.components === 'object',
106
+ 'oia-audit has components bundle');
107
+
108
+ // iter 38 — fingerprint must be present
109
+ assert(typeof audit.fingerprint === 'object' && audit.fingerprint !== null,
110
+ 'oia-audit emits fingerprint field (iter 38)');
111
+ if (!audit.fingerprint?.score || !audit.fingerprint?.genome) {
112
+ console.log(` ⊘ fingerprint partial; score+genome may have degraded individually`);
113
+ console.log(` fingerprint: ${JSON.stringify(audit.fingerprint).slice(0, 200)}`);
114
+ process.exit(2);
115
+ }
116
+ assert(typeof audit.fingerprint.score?.harnessFit === 'number',
117
+ 'fingerprint.score has harnessFit');
118
+ assert(Array.isArray(audit.fingerprint.genome?.agent_topology),
119
+ 'fingerprint.genome has agent_topology array');
120
+
121
+ // iter 49 — schema contracts beyond similarity (the OTHER class of
122
+ // silent drift). audit-trend reads specific upstream fields:
123
+ // - composite.worst (severity rollup)
124
+ // - components.threatModel.json.worst (audit-trend.mjs:96-100)
125
+ // - components.mcpScan.json.findings (audit-trend.mjs:124-127)
126
+ // If upstream renames any of these, audit-trend silently breaks.
127
+ // Gating them here forces a CI failure on the next PR after upstream
128
+ // schema drift, instead of months later when someone notices the
129
+ // severity verdict has been wrong all along.
130
+ assert(typeof audit.composite?.worst === 'string',
131
+ 'iter 49 — audit.composite.worst is a string');
132
+ assert(['clean', 'low', 'medium', 'high'].includes(audit.composite.worst),
133
+ `iter 49 — composite.worst in valid severity vocab (got ${audit.composite.worst})`);
134
+ // threatModel.json.worst is the upstream field audit-trend reads
135
+ if (audit.components?.threatModel && !audit.components.threatModel.degraded) {
136
+ assert(typeof audit.components.threatModel.json?.worst === 'string',
137
+ 'iter 49 — components.threatModel.json.worst is a string (audit-trend dep)');
138
+ }
139
+ // mcp-scan.mjs currently emits {rawStdout, durationMs, alert} —
140
+ // no structured `findings` array. audit-trend.mjs reads
141
+ // `json.findings` (guarded with Array.isArray fallback to []) so it
142
+ // doesn't crash, but the introduced/cleared logic is effectively
143
+ // a no-op on real output. iter 49 documents this gap rather than
144
+ // asserting a contract that doesn't yet hold. If a future iter
145
+ // promotes mcp-scan.mjs to emit structured findings, this assertion
146
+ // should become a real type-check.
147
+ if (audit.components?.mcpScan?.json?.findings !== undefined) {
148
+ assert(Array.isArray(audit.components.mcpScan.json.findings),
149
+ 'iter 49 — IF mcpScan.json.findings exists THEN it must be an array');
150
+ } else {
151
+ console.log(` ⊘ mcpScan.json.findings absent (mcp-scan.mjs currently text-only — known iter-49 gap)`);
152
+ }
153
+
154
+ // ──────────────────────────────────────────────────────────────
155
+ // STAGE 2: persist the audit record as both baseline and current
156
+ // ──────────────────────────────────────────────────────────────
157
+ console.log('\nStage 2 — write audit record to baseline + current paths');
158
+ const basePath = join(tmp, 'baseline.json');
159
+ const currPath = join(tmp, 'current.json');
160
+ writeFileSync(basePath, JSON.stringify(audit));
161
+ writeFileSync(currPath, JSON.stringify(audit));
162
+ assert(readFileSync(basePath, 'utf-8').length > 100, 'baseline file written');
163
+ assert(readFileSync(currPath, 'utf-8').length > 100, 'current file written');
164
+
165
+ // ──────────────────────────────────────────────────────────────
166
+ // STAGE 3: audit-trend reading the same audit twice
167
+ // ──────────────────────────────────────────────────────────────
168
+ console.log('\nStage 3 — audit-trend against self (must report near-identical)');
169
+ const trendRun = runNode('audit-trend.mjs', [
170
+ '--baseline', basePath,
171
+ '--current', currPath,
172
+ '--format', 'json',
173
+ ]);
174
+
175
+ const trendMatch = /\{[\s\S]*\}/.exec(trendRun.stdout);
176
+ assert(trendMatch !== null, 'audit-trend produced JSON');
177
+ const trend = JSON.parse(trendMatch[0]);
178
+
179
+ assert(typeof trend.delta?.structuralDistance === 'object',
180
+ 'trend exposes delta.structuralDistance');
181
+ const sd = trend.delta.structuralDistance;
182
+ assert(sd.verdict === 'near-identical',
183
+ `self-roundtrip verdict === near-identical (got ${sd.verdict})`);
184
+ assert(sd.overall === 1,
185
+ `self-roundtrip overall === 1 (got ${sd.overall})`);
186
+ assert(sd.distance === 0,
187
+ `self-roundtrip distance === 0 (got ${sd.distance})`);
188
+
189
+ // ──────────────────────────────────────────────────────────────
190
+ // STAGE 4: alert-on-distance-below should NOT trigger on self-match
191
+ // ──────────────────────────────────────────────────────────────
192
+ console.log('\nStage 4 — distance alert does not trigger on self-match');
193
+ const noAlertRun = runNode('audit-trend.mjs', [
194
+ '--baseline', basePath,
195
+ '--current', currPath,
196
+ '--alert-on-distance-below', '0.5',
197
+ '--format', 'json',
198
+ ]);
199
+ assert(noAlertRun.status === 0,
200
+ `alert at threshold 0.5 does NOT fire on self-match (exit 0, got ${noAlertRun.status})`);
201
+
202
+ // Now flip: any threshold above 1 must trigger
203
+ console.log('\nStage 5 — distance alert triggers when threshold > self-match');
204
+ const alertRun = runNode('audit-trend.mjs', [
205
+ '--baseline', basePath,
206
+ '--current', currPath,
207
+ '--alert-on-distance-below', '1.01',
208
+ '--format', 'json',
209
+ ]);
210
+ assert(alertRun.status === 1,
211
+ `alert at threshold 1.01 fires on self-match (exit 1, got ${alertRun.status})`);
212
+
213
+ // ──────────────────────────────────────────────────────────────
214
+ // STAGE 6 (iter 49) — non-similarity schema contracts in the trend
215
+ //
216
+ // audit-trend reads `composite.worst`, `components.threatModel.json.worst`,
217
+ // and `components.mcpScan.json.findings`. The trend output's
218
+ // delta.worst.verdict on a self-match must be 'unchanged'. If upstream
219
+ // renames any of these fields, the verdict silently becomes 'missing'
220
+ // or undefined — this assertion catches that BEFORE shipping.
221
+ // ──────────────────────────────────────────────────────────────
222
+ // ──────────────────────────────────────────────────────────────
223
+ // STAGE 7 (iter 51) — drift detection actually FIRES on mutated input
224
+ //
225
+ // Every prior stage uses the same audit record twice (self-match).
226
+ // That proves the chain doesn't FALSELY report drift on identical
227
+ // input, but doesn't prove the chain catches REAL drift. This stage
228
+ // takes the audit record and synthetically mutates fields the
229
+ // similarity module reads, then asserts the chain detects the
230
+ // difference.
231
+ //
232
+ // Mutations applied (all measurable by similarity):
233
+ // - harnessFit: unchanged → -20 (cosine signal)
234
+ // - taskCoverage: unchanged → -15 (cosine signal)
235
+ // - agent_topology: add one → jaccard divergence
236
+ //
237
+ // Expected outcome:
238
+ // - structuralDistance.verdict !== 'near-identical'
239
+ // - structuralDistance.overall < 1
240
+ // - --alert-on-distance-below 0.95 should fire
241
+ // ──────────────────────────────────────────────────────────────
242
+ console.log('\nStage 7 — drift detection on mutated audit (iter 51)');
243
+ const mutatedPath = join(tmp, 'mutated.json');
244
+ const mutated = JSON.parse(JSON.stringify(audit));
245
+ // Modify fields the similarity module actually reads. The mutation has
246
+ // to be large enough to cross the verdict-bucket threshold (0.95 →
247
+ // 'near-identical' boundary). The 9-dim cosine vector is dominated by
248
+ // values already clustered around the same range, so single-field
249
+ // changes don't move overall much. Mutate across all 3 components:
250
+ // - cosine: drop 4 scorecard fields by 25-40 points
251
+ // - categorical: swap archetype + template (2 of 4 enums diverge)
252
+ // - jaccard: rebuild agent_topology with a different set
253
+ mutated.fingerprint.score.harnessFit = Math.max(0, audit.fingerprint.score.harnessFit - 40);
254
+ mutated.fingerprint.score.taskCoverage = Math.max(0, audit.fingerprint.score.taskCoverage - 35);
255
+ mutated.fingerprint.score.toolSafety = Math.max(0, audit.fingerprint.score.toolSafety - 30);
256
+ mutated.fingerprint.score.compileConfidence = Math.max(0, audit.fingerprint.score.compileConfidence - 25);
257
+ mutated.fingerprint.score.archetype = 'iter-51-synthetic-archetype';
258
+ mutated.fingerprint.score.template = 'iter-51-synthetic-template';
259
+ mutated.fingerprint.genome.agent_topology = ['iter-51-marker-a', 'iter-51-marker-b'];
260
+ writeFileSync(mutatedPath, JSON.stringify(mutated));
261
+
262
+ const driftRun = runNode('audit-trend.mjs', [
263
+ '--baseline', basePath,
264
+ '--current', mutatedPath,
265
+ '--format', 'json',
266
+ ]);
267
+ const driftMatch = /\{[\s\S]*\}/.exec(driftRun.stdout);
268
+ assert(driftMatch !== null, 'drift run produced JSON');
269
+ const driftTrend = JSON.parse(driftMatch[0]);
270
+ const driftSd = driftTrend.delta?.structuralDistance;
271
+ assert(typeof driftSd === 'object', 'drift trend exposes structuralDistance');
272
+ assert(driftSd.verdict !== 'near-identical',
273
+ `drift detected — verdict !== near-identical (got ${driftSd?.verdict})`);
274
+ assert(driftSd.overall < 1,
275
+ `drift detected — overall < 1 (got ${driftSd?.overall})`);
276
+ assert(driftSd.distance > 0,
277
+ `drift detected — distance > 0 (got ${driftSd?.distance})`);
278
+
279
+ // Alert chain: --alert-on-distance-below 0.95 must fire IF drift > 0.05
280
+ // If the synthetic mutation produced overall ≥ 0.95, lower the threshold.
281
+ const threshold = driftSd.overall < 0.95 ? '0.95' : String(driftSd.overall + 0.01);
282
+ const alertDriftRun = runNode('audit-trend.mjs', [
283
+ '--baseline', basePath,
284
+ '--current', mutatedPath,
285
+ '--alert-on-distance-below', threshold,
286
+ '--format', 'json',
287
+ ]);
288
+ assert(alertDriftRun.status === 1,
289
+ `drift alert at threshold ${threshold} fires on mutated audit (exit 1, got ${alertDriftRun.status})`);
290
+
291
+ // ──────────────────────────────────────────────────────────────
292
+ console.log('\nStage 6 — non-similarity schema contracts (iter 49)');
293
+ assert(typeof trend.delta?.worst === 'object',
294
+ 'trend exposes delta.worst (severity-rollup)');
295
+ assert(trend.delta.worst.verdict === 'unchanged',
296
+ `self-roundtrip severity-verdict === unchanged (got ${trend.delta.worst.verdict})`);
297
+ assert(trend.delta.worst.baseline === trend.delta.worst.current,
298
+ 'self-roundtrip baseline === current severity');
299
+ assert(trend.delta.worst.rankDelta === 0,
300
+ `self-roundtrip rankDelta === 0 (got ${trend.delta.worst.rankDelta})`);
301
+ // Findings arrays: introduced/cleared must both be 0 on self-match
302
+ assert(trend.delta?.findings?.introducedCount === 0,
303
+ `self-roundtrip introducedCount === 0 (got ${trend.delta?.findings?.introducedCount})`);
304
+ assert(trend.delta?.findings?.clearedCount === 0,
305
+ `self-roundtrip clearedCount === 0 (got ${trend.delta?.findings?.clearedCount})`);
306
+
307
+ // ──────────────────────────────────────────────────────────────
308
+ // STAGE 8 (iter 68) — drift-from-history end-to-end via --baseline-file
309
+ //
310
+ // Every prior stage tested audit-trend (the underlying primitive).
311
+ // None tested drift-from-history (the iter-53 user-facing wrapper).
312
+ // This stage runs the iter-67 fastest path: baseline from a file,
313
+ // skipping audit-list AND memory roundtrip entirely.
314
+ // ──────────────────────────────────────────────────────────────
315
+ console.log('\nStage 8 — drift-from-history end-to-end (iter 68)');
316
+ const driftFromHistoryRun = runNode('drift-from-history.mjs', [
317
+ '--baseline-file', basePath,
318
+ '--dry-run',
319
+ '--format', 'json',
320
+ ], 120_000);
321
+ const driftFromHistoryMatch = /\{[\s\S]*\}/.exec(driftFromHistoryRun.stdout);
322
+ assert(driftFromHistoryMatch !== null, 'drift-from-history produced JSON');
323
+ const dfh = JSON.parse(driftFromHistoryMatch[0]);
324
+
325
+ // Iter-66/iter-67 contract: the fastest-path flags are surfaced in timing
326
+ assert(dfh.timing?.skippedAuditList === true,
327
+ 'drift-from-history --baseline-file → skippedAuditList === true (iter 66)');
328
+ assert(dfh.timing?.usedBaselineFile === true,
329
+ 'drift-from-history --baseline-file → usedBaselineFile === true (iter 67)');
330
+ // Wall-clock fastpath proof: must be under 30s with file inputs
331
+ // (vs ~26s minimum for the audit-list-loading slow path)
332
+ assert(typeof dfh.timing?.parallelWallMs === 'number' && dfh.timing.parallelWallMs < 30000,
333
+ `drift-from-history fastpath wall < 30s (got ${dfh.timing?.parallelWallMs}ms)`);
334
+ // Same-file self-match → similarity 1.0
335
+ assert(dfh.drift?.structuralDistance?.overall === 1,
336
+ `drift-from-history self-match overall === 1 (got ${dfh.drift?.structuralDistance?.overall})`);
337
+ assert(dfh.drift?.structuralDistance?.verdict === 'near-identical',
338
+ `drift-from-history self-match verdict === near-identical (got ${dfh.drift?.structuralDistance?.verdict})`);
339
+ // Alert chain end-to-end
340
+ assert(dfh.alert?.triggered === false,
341
+ 'drift-from-history self-match alert NOT triggered at default threshold (0.95)');
342
+
343
+ // ──────────────────────────────────────────────────────────────
344
+ // STAGE 9 (iter 75) — drift-from-history detects drift via fast path
345
+ //
346
+ // Stage 8 proved the fast path correctly identifies self-match.
347
+ // This stage proves it also CATCHES drift — feeds a mutated baseline
348
+ // file and asserts the verdict flips. Without this, a regression
349
+ // that breaks the fast path's similarity computation would still
350
+ // pass Stage 8 (which only checks the identity case).
351
+ //
352
+ // Mutation: re-uses Stage 7's mutated audit shape, written to a
353
+ // separate baseline file. drift-from-history runs fresh oia-audit
354
+ // against current state and diffs against the mutated baseline.
355
+ // ──────────────────────────────────────────────────────────────
356
+ console.log('\nStage 9 — drift-from-history fastpath catches mutation (iter 75)');
357
+ // mutatedPath was already populated in Stage 7. Re-use as baseline-file.
358
+ const dfhDriftRun = runNode('drift-from-history.mjs', [
359
+ '--baseline-file', mutatedPath,
360
+ '--dry-run',
361
+ '--threshold', '0.95',
362
+ '--format', 'json',
363
+ ], 120_000);
364
+ const dfhDriftMatch = /\{[\s\S]*\}/.exec(dfhDriftRun.stdout);
365
+ assert(dfhDriftMatch !== null, 'drift-from-history with mutated baseline produced JSON');
366
+ const dfhDrift = JSON.parse(dfhDriftMatch[0]);
367
+
368
+ // Fast path still fired
369
+ assert(dfhDrift.timing?.usedBaselineFile === true,
370
+ 'Stage 9 fastpath: usedBaselineFile === true');
371
+
372
+ // Drift detected — same invariants as Stage 7 but via the wrapper
373
+ const dfhSd = dfhDrift.drift?.structuralDistance;
374
+ assert(typeof dfhSd === 'object', 'Stage 9: structuralDistance present');
375
+ assert(dfhSd.verdict !== 'near-identical',
376
+ `Stage 9: verdict !== near-identical (got ${dfhSd?.verdict}) — fastpath catches drift`);
377
+ assert(dfhSd.overall < 1,
378
+ `Stage 9: overall < 1 (got ${dfhSd?.overall})`);
379
+ assert(dfhSd.distance > 0,
380
+ `Stage 9: distance > 0 (got ${dfhSd?.distance})`);
381
+
382
+ // Alert end-to-end via the fast path
383
+ // (mutation pushes overall ~0.7, below default 0.95 threshold → triggered)
384
+ assert(dfhDrift.alert?.triggered === true,
385
+ 'Stage 9: --threshold 0.95 fires on mutated baseline via fastpath');
386
+ // Exit code reflects alert
387
+ assert(dfhDriftRun.status === 1,
388
+ `Stage 9: drift-from-history exit=1 when alert triggered (got ${dfhDriftRun.status})`);
389
+
390
+ // ──────────────────────────────────────────────────────────────
391
+ // STAGE 10 (iter 76) — introduced/cleared findings diff actually works
392
+ //
393
+ // Iter 49 documented that mcp-scan emitted text only → audit-trend's
394
+ // findings.introducedCount was dead code. Iter 50 added the parser
395
+ // so findings ARE populated. But no test verified the diff on a
396
+ // REAL finding-set change — Stage 7 mutates only the fingerprint
397
+ // (score/genome), leaving mcpScan.json.findings unchanged.
398
+ //
399
+ // This stage takes the real audit (Stage 1) and mutates ONLY
400
+ // mcpScan.json.findings — adds a synthetic finding to the baseline.
401
+ // audit-trend should report clearedCount === 1 (the synthetic
402
+ // disappeared in the current audit) with severity preserved.
403
+ // ──────────────────────────────────────────────────────────────
404
+ console.log('\nStage 10 — introduced/cleared findings diff (iter 76 — locks iter-50 fix)');
405
+ const findingsBasePath = join(tmp, 'findings-base.json');
406
+ const findingsMutated = JSON.parse(JSON.stringify(audit));
407
+ // Ensure mcpScan.json.findings exists (it should post-iter-50)
408
+ if (!findingsMutated.components?.mcpScan?.json?.findings) {
409
+ findingsMutated.components.mcpScan = findingsMutated.components.mcpScan ?? {};
410
+ findingsMutated.components.mcpScan.json = findingsMutated.components.mcpScan.json ?? {};
411
+ findingsMutated.components.mcpScan.json.findings = [];
412
+ }
413
+ // Append a synthetic finding that wouldn't appear in a fresh audit.
414
+ findingsMutated.components.mcpScan.json.findings.push({
415
+ severity: 'medium',
416
+ id: 'iter-76-synthetic-finding',
417
+ server: 'synthetic-server',
418
+ tool: 'synthetic-tool',
419
+ message: 'Stage 10 synthetic finding for clearedCount verification',
420
+ });
421
+ writeFileSync(findingsBasePath, JSON.stringify(findingsMutated));
422
+
423
+ // Diff: baseline has the synthetic, current (real audit) doesn't →
424
+ // expect clearedCount === 1, introducedCount === 0.
425
+ const findingsTrendRun = runNode('audit-trend.mjs', [
426
+ '--baseline', findingsBasePath,
427
+ '--current', basePath, // basePath = unmodified audit
428
+ '--format', 'json',
429
+ ], 60_000);
430
+ const findingsTrendMatch = /\{[\s\S]*\}/.exec(findingsTrendRun.stdout);
431
+ assert(findingsTrendMatch !== null, 'Stage 10: audit-trend produced JSON');
432
+ const findingsTrend = JSON.parse(findingsTrendMatch[0]);
433
+
434
+ assert(findingsTrend.delta?.findings?.clearedCount === 1,
435
+ `Stage 10: clearedCount === 1 (got ${findingsTrend.delta?.findings?.clearedCount}) — iter-50 fix functional`);
436
+ assert(findingsTrend.delta?.findings?.introducedCount === 0,
437
+ `Stage 10: introducedCount === 0 (got ${findingsTrend.delta?.findings?.introducedCount})`);
438
+ // Cleared finding's severity preserved through the diff
439
+ const cleared = findingsTrend.delta?.findings?.cleared?.[0];
440
+ assert(cleared?.severity === 'medium',
441
+ `Stage 10: cleared finding severity preserved (got ${cleared?.severity})`);
442
+ assert(cleared?.id === 'iter-76-synthetic-finding',
443
+ 'Stage 10: cleared finding id preserved');
444
+
445
+ // ──────────────────────────────────────────────────────────────
446
+ // STAGE 11 (iter 77) — introducedCount symmetry + dedup discrimination
447
+ //
448
+ // Stage 10 proved the CLEARED side. Three more cases to lock the
449
+ // diff logic completely:
450
+ //
451
+ // 11a. introducedCount: baseline clean, current has synthetic
452
+ // 11b. both-different: baseline has finding A, current has finding B
453
+ // → introduced=1, cleared=1
454
+ // 11c. identical: baseline + current have SAME finding
455
+ // → both counters stay at 0 (dedup works)
456
+ // ──────────────────────────────────────────────────────────────
457
+ console.log('\nStage 11 — introduced/cleared symmetric + dedup (iter 77)');
458
+ const findingA = {
459
+ severity: 'medium',
460
+ id: 'iter-77-finding-A',
461
+ server: 'server-A',
462
+ tool: 'tool-A',
463
+ message: 'finding A',
464
+ };
465
+ const findingB = {
466
+ severity: 'high',
467
+ id: 'iter-77-finding-B',
468
+ server: 'server-B',
469
+ tool: 'tool-B',
470
+ message: 'finding B',
471
+ };
472
+
473
+ // 11a — introduced
474
+ const baseClean = JSON.parse(JSON.stringify(audit));
475
+ baseClean.components.mcpScan.json.findings = [];
476
+ const currentWithA = JSON.parse(JSON.stringify(audit));
477
+ currentWithA.components.mcpScan.json.findings = [findingA];
478
+ const baseCleanPath = join(tmp, 'base-clean.json');
479
+ const currentWithAPath = join(tmp, 'current-A.json');
480
+ writeFileSync(baseCleanPath, JSON.stringify(baseClean));
481
+ writeFileSync(currentWithAPath, JSON.stringify(currentWithA));
482
+ const trendRun11a = runNode('audit-trend.mjs', [
483
+ '--baseline', baseCleanPath, '--current', currentWithAPath, '--format', 'json',
484
+ ], 60_000);
485
+ const m11a = /\{[\s\S]*\}/.exec(trendRun11a.stdout);
486
+ const trend11a = JSON.parse(m11a[0]);
487
+ assert(trend11a.delta?.findings?.introducedCount === 1,
488
+ `Stage 11a: introducedCount === 1 (got ${trend11a.delta?.findings?.introducedCount})`);
489
+ assert(trend11a.delta?.findings?.clearedCount === 0,
490
+ `Stage 11a: clearedCount === 0 (got ${trend11a.delta?.findings?.clearedCount})`);
491
+ assert(trend11a.delta?.findings?.introduced?.[0]?.id === 'iter-77-finding-A',
492
+ 'Stage 11a: introduced finding id preserved');
493
+
494
+ // 11b — both different
495
+ const baselineWithB = JSON.parse(JSON.stringify(audit));
496
+ baselineWithB.components.mcpScan.json.findings = [findingB];
497
+ const baselineWithBPath = join(tmp, 'baseline-B.json');
498
+ writeFileSync(baselineWithBPath, JSON.stringify(baselineWithB));
499
+ const trendRun11b = runNode('audit-trend.mjs', [
500
+ '--baseline', baselineWithBPath, '--current', currentWithAPath, '--format', 'json',
501
+ ], 60_000);
502
+ const m11b = /\{[\s\S]*\}/.exec(trendRun11b.stdout);
503
+ const trend11b = JSON.parse(m11b[0]);
504
+ assert(trend11b.delta?.findings?.introducedCount === 1,
505
+ `Stage 11b: introducedCount === 1 (got ${trend11b.delta?.findings?.introducedCount})`);
506
+ assert(trend11b.delta?.findings?.clearedCount === 1,
507
+ `Stage 11b: clearedCount === 1 (got ${trend11b.delta?.findings?.clearedCount})`);
508
+ // Dedup discriminates: B was cleared, A was introduced
509
+ assert(trend11b.delta?.findings?.cleared?.[0]?.id === 'iter-77-finding-B',
510
+ 'Stage 11b: dedup correctly identifies B as cleared');
511
+ assert(trend11b.delta?.findings?.introduced?.[0]?.id === 'iter-77-finding-A',
512
+ 'Stage 11b: dedup correctly identifies A as introduced');
513
+
514
+ // 11c — identical findings on both sides
515
+ const trendRun11c = runNode('audit-trend.mjs', [
516
+ '--baseline', currentWithAPath, '--current', currentWithAPath, '--format', 'json',
517
+ ], 60_000);
518
+ const m11c = /\{[\s\S]*\}/.exec(trendRun11c.stdout);
519
+ const trend11c = JSON.parse(m11c[0]);
520
+ assert(trend11c.delta?.findings?.introducedCount === 0,
521
+ `Stage 11c: identical findings → introducedCount === 0 (dedup works)`);
522
+ assert(trend11c.delta?.findings?.clearedCount === 0,
523
+ `Stage 11c: identical findings → clearedCount === 0`);
524
+
525
+ // ──────────────────────────────────────────────────────────────
526
+ // STAGE 12 (iter 79) — --alert-on-new-severity orthogonal gate
527
+ //
528
+ // Iter 78 added the gate. Iter 79 wires it into the weekly cron.
529
+ // This stage verifies the gate fires correctly through the
530
+ // drift-from-history wrapper:
531
+ // - baseline has no findings + current has 1 medium finding
532
+ // - --threshold 0.5 (similarity stays above)
533
+ // - --alert-on-new-severity medium should trigger
534
+ //
535
+ // Catches the "structural similarity high but new security
536
+ // finding" case that iter-78 closed.
537
+ // ──────────────────────────────────────────────────────────────
538
+ console.log('\nStage 12 — --alert-on-new-severity orthogonal gate (iter 79)');
539
+
540
+ // Re-use baseClean (no findings) from Stage 11
541
+ // Run drift-from-history with --alert-on-new-severity info
542
+ // (real ruflo audit has 1 INFO finding → introduced=1)
543
+ const stage12Run = runNode('drift-from-history.mjs', [
544
+ '--baseline-file', baseCleanPath,
545
+ '--dry-run',
546
+ '--threshold', '0.5',
547
+ '--alert-on-new-severity', 'info',
548
+ '--format', 'json',
549
+ ], 120_000);
550
+ const m12 = /\{[\s\S]*\}/.exec(stage12Run.stdout);
551
+ assert(m12 !== null, 'Stage 12: drift-from-history produced JSON');
552
+ const stage12 = JSON.parse(m12[0]);
553
+
554
+ // The orthogonal gate fires
555
+ assert(stage12.alert?.triggered === true,
556
+ 'Stage 12: --alert-on-new-severity info triggers on ruflo current INFO finding');
557
+ // Reason mentions severity, not similarity (structural is fine at threshold 0.5)
558
+ const reasons = stage12.alert?.reasons ?? [];
559
+ const hasSeverityReason = reasons.some((r) => /finding\(s\) at or above/.test(r));
560
+ assert(hasSeverityReason,
561
+ `Stage 12: reasons mention new-finding severity (got ${JSON.stringify(reasons)})`);
562
+ // elevatedFindings populated
563
+ assert(Array.isArray(stage12.alert?.elevatedFindings) && stage12.alert.elevatedFindings.length >= 1,
564
+ `Stage 12: elevatedFindings non-empty (got ${stage12.alert?.elevatedFindings?.length})`);
565
+ // CLI exit reflects the alert
566
+ assert(stage12Run.status === 1,
567
+ `Stage 12: drift-from-history exit=1 on severity alert (got ${stage12Run.status})`);
568
+ // newSeverityThreshold echoed in payload
569
+ assert(stage12.alert?.newSeverityThreshold === 'info',
570
+ 'Stage 12: alert.newSeverityThreshold echoed');
571
+
572
+ } finally {
573
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
574
+ }
575
+
576
+ // ──────────────────────────────────────────────────────────────────
577
+ const summary = { passed, failed, total: passed + failed, failures };
578
+
579
+ console.log(`\n${passed} passed, ${failed} failed`);
580
+ if (ARGS.format === 'json') console.log(JSON.stringify(summary, null, 2));
581
+ if (failed > 0) {
582
+ console.log('\nFailures:');
583
+ for (const f of failures) console.log(` - ${f}`);
584
+ process.exit(1);
585
+ }
586
+ console.log('\n✓ Full ADR-152 §3.1 pipeline works end-to-end with real metaharness output.');