ruvnet-brain 4.5.6 → 4.5.8

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 (47) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +50 -1
  3. package/kb/forge-update.mjs +50 -9
  4. package/package.json +5 -3
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/configure.md +13 -1
  8. package/plugin/hooks/codex-hooks.json +31 -1
  9. package/plugin/hooks/hook-contracts.json +82 -2
  10. package/plugin/hooks/hooks.json +31 -1
  11. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  12. package/plugin/mcp/managed-cli-interface.mjs +36 -12
  13. package/plugin/mcp/server.mjs +3 -2
  14. package/plugin/scripts/agentdb-recall.mjs +38 -10
  15. package/plugin/scripts/continuation-gate.mjs +3 -1
  16. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  17. package/plugin/scripts/continuity-journal.mjs +11 -11
  18. package/plugin/scripts/hook-shim.mjs +3 -2
  19. package/plugin/scripts/kb-copy-proof.mjs +84 -22
  20. package/plugin/scripts/learn-capture.mjs +44 -0
  21. package/plugin/scripts/learn-capture.sh +2 -241
  22. package/plugin/scripts/learn-flush.mjs +86 -191
  23. package/plugin/scripts/learning-observation.mjs +51 -0
  24. package/plugin/scripts/learning-queue.mjs +135 -0
  25. package/plugin/scripts/learning-store.mjs +105 -0
  26. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  27. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  28. package/plugin/scripts/project-capture-queue.mjs +16 -7
  29. package/plugin/scripts/project-progression-contract.mjs +58 -4
  30. package/plugin/scripts/project-progression-outbox.mjs +22 -6
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  33. package/plugin/scripts/project-transition-hook.mjs +25 -8
  34. package/plugin/scripts/runtime-preferences.mjs +45 -4
  35. package/plugin/scripts/session-snapshot-hook.mjs +14 -7
  36. package/plugin/scripts/session-start-core.mjs +8 -0
  37. package/plugin/scripts/session-start-proof.mjs +49 -0
  38. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  39. package/scripts/codex-fresh-host-proof.mjs +179 -0
  40. package/scripts/codex-host-execution-proof.mjs +246 -0
  41. package/scripts/codex-host-proof-runtime.mjs +104 -0
  42. package/scripts/console-engine.mjs +29 -16
  43. package/scripts/health-repair.mjs +47 -68
  44. package/scripts/onboarding-console.mjs +25 -54
  45. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  46. package/scripts/release-qualification-contract.mjs +76 -7
  47. package/scripts/remedy-registry.mjs +9 -2
@@ -24,7 +24,11 @@ import path from 'node:path';
24
24
  import os from 'node:os';
25
25
  import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { findStores, diagnose } from './memory-doctor.mjs';
27
- import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
27
+ import { distillLearning } from '../plugin/scripts/learning-store.mjs';
28
+ import { readSafe } from '../plugin/scripts/learning-queue.mjs';
29
+ import { learningContext } from '../plugin/scripts/runtime-preferences.mjs';
30
+ import { learningQueueDepth, learningQueueFiles, observeLearning } from '../plugin/scripts/learning-observation.mjs';
31
+ import { resolveRuflo, rufloInvocation } from '../plugin/scripts/ruflo-bin.mjs';
28
32
  import { projectDirectory } from '../plugin/scripts/project-identity.mjs';
29
33
  import { newestSnapshot, snapshotInventory, MTIME_GRACE_MS } from './snapshot-freshness.mjs';
30
34
 
@@ -34,9 +38,10 @@ const HOME = os.homedir();
34
38
  // RESIDUAL of #134: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either host, so
35
39
  // consult `projectDirectory()` (project-identity.mjs) — the CLAUDE_PROJECT_DIR-with-containment
36
40
  // rule #85/#107 already established, reused rather than trusting the variable unconditionally.
37
- const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory();
38
41
  const argv = process.argv.slice(2);
39
42
  const has = (f) => argv.includes(f);
43
+ const requestedProject = has('--project') ? argv[argv.indexOf('--project') + 1] : undefined;
44
+ const PROJECT = learningContext({ cwd: requestedProject || process.env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory() }).projectDir;
40
45
 
41
46
  /**
42
47
  * Find ruflo HONESTLY.
@@ -50,13 +55,6 @@ const has = (f) => argv.includes(f);
50
55
  * Rule 21 still holds — ONE ruflo, the global one, never `npx ruflo@latest`. This resolves WHERE
51
56
  * that one global binary is rather than assuming a path.
52
57
  */
53
- function resolveRuflo() {
54
- const preferred = path.join(HOME, '.npm-global/bin/ruflo');
55
- if (fs.existsSync(preferred)) return preferred;
56
- const which = spawnSync('sh', ['-lc', 'command -v ruflo'], { encoding: 'utf8', timeout: 10_000 });
57
- const found = String(which.stdout || '').trim().split('\n')[0];
58
- return found && fs.existsSync(found) ? found : null;
59
- }
60
58
  const RUFLO = resolveRuflo();
61
59
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
62
60
 
@@ -121,61 +119,45 @@ function repairMemory() {
121
119
  * queue — the reporter needed 15 rounds for 293 entries. A single call would leave a queue that
122
120
  * "flushes" every time and never empties, which is the same lie in slow motion.
123
121
  */
124
- function flushLearning() {
122
+ function flushLearning(legacyUser = false) {
123
+ const original = learningContext({ cwd: PROJECT });
124
+ const context = legacyUser && original.enabled
125
+ ? learningContext({ cwd: PROJECT, env: { ...process.env, RUVNET_LEARNING_SCOPE: 'user' } }) : original;
126
+ if (!context.enabled) return { ok: true, noop: true, log: 'learning is switched off for this project — queued evidence is untouched' };
127
+ const { scope, queueDir } = context;
128
+ const bundled = path.resolve(import.meta.dirname, '../plugin/scripts/learn-flush.mjs');
125
129
  const flusher = path.join(HOME, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'scripts', 'learn-flush.mjs');
126
130
  const local = path.join(PROJECT, 'plugin', 'scripts', 'learn-flush.mjs');
127
- const script = fs.existsSync(flusher) ? flusher : (fs.existsSync(local) ? local : null);
131
+ const script = [bundled, local, flusher].find((file) => fs.existsSync(file));
128
132
  if (!script) return { ok: false, log: 'learn-flush.mjs not found — cannot drain the queue' };
129
133
 
130
- const configured = process.env.RUVNET_LEARNING_SCOPE
131
- || loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
132
- const scope = ['off', 'project', 'user'].includes(configured) ? configured : 'project';
133
- if (scope === 'off') {
134
- return { ok: true, noop: true, log: 'learning is switched off for this project — nothing is being captured, so there is nothing to feed' };
135
- }
136
-
137
- // Derived from the scope, never assumed: this is the directory the child will actually read.
138
- const queueDir = scope === 'user'
139
- ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
140
- : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
141
134
  // Displayed to a human and matched by tests, so it is normalised to forward slashes on every
142
135
  // platform. Without this, Windows reports `~\.cache\ruvnet-brain\learn` while macOS and Linux
143
136
  // report `~/.cache/ruvnet-brain/learn` — the same location under two spellings, which is the
144
137
  // exact defect class this branch has been closing (one fact, two representations).
145
138
  const where = queueDir.replace(HOME, '~').split(path.sep).join('/');
146
139
 
147
- const queueFiles = () => {
148
- try { return fs.readdirSync(queueDir).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(queueDir, f)); }
149
- catch { return []; }
150
- };
151
- const depthOf = (f) => {
152
- try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).length; } catch { return 0; }
153
- };
154
- const depth = () => queueFiles().reduce((n, f) => n + depthOf(f), 0);
155
-
156
- const before = depth();
140
+ const depth = () => learningQueueDepth(queueDir);
141
+ try {
142
+ const lock = JSON.parse(readSafe(path.join(queueDir, '.worker-lock'), 4096));
143
+ if (lock.retirementUnconfirmed || (lock.retirementRequired && lock.expires < Date.now())) return { ok: false, log: 'learning recovery is paused: owned worker retirement is unconfirmed; queue fence and original observations retained' };
144
+ } catch { /* Regular safety checks below still decide whether a queue is readable. */ }
145
+ let before;
146
+ try { before = depth(); }
147
+ catch { return { ok: false, log: `cannot read learning queue ${where} safely — evidence is preserved` }; }
157
148
  if (!before) return { ok: true, noop: true, log: `nothing queued in ${where} — the learner is already caught up` };
158
-
159
- const env = { ...process.env, RUVNET_LEARNING_SCOPE: scope, RUVNET_BRAIN_PROJECT_DIR: PROJECT };
160
- const deadline = Date.now() + 540_000; // inside the 600s this action is allowed overall
149
+ const env = { ...process.env, RUVNET_BRAIN_PROJECT_DIR: PROJECT, ...(legacyUser ? { RUVNET_LEARNING_SCOPE: 'user', RUVNET_LEGACY_USER_APPLY: '1' } : {}) };
150
+ const deadline = Date.now() + 540_000;
161
151
  const stalled = [];
162
- for (const file of queueFiles()) {
163
- let d = depthOf(file);
164
- while (d > 0 && Date.now() < deadline) {
165
- try {
166
- execFileSync(process.execPath, [script], {
167
- env: { ...env, LEARN_QUEUE: file },
168
- stdio: 'ignore',
169
- timeout: Math.min(120_000, Math.max(1_000, deadline - Date.now())),
170
- });
171
- } catch (e) { stalled.push(`${path.basename(file)}: ${String(e.message).split('\n')[0].slice(0, 80)}`); break; }
172
- const next = depthOf(file);
173
- // STRICT progress, or stop. learn-flush KEEPS a queue it could not feed (by design — the queue
174
- // is evidence), so a round that shrinks nothing means the learner is not accepting the work.
175
- // Spinning on it would burn the whole budget and still report zero.
176
- if (next >= d) { stalled.push(`${path.basename(file)}: ${d} entr${d === 1 ? 'y' : 'ies'} would not feed`); break; }
177
- d = next;
178
- }
152
+ let previous = before;
153
+ while (previous > 0 && Date.now() < deadline) {
154
+ try {
155
+ execFileSync(process.execPath, [script, '--sync'], { env, cwd: PROJECT, stdio: 'ignore',
156
+ timeout: Math.min(25_000, Math.max(1000, deadline - Date.now())) });
157
+ } catch { stalled.push('bounded worker failed'); break; }
158
+ const next = depth();
159
+ if (next >= previous) { stalled.push(`${next} entries would not feed`); break; }
160
+ previous = next;
179
161
  }
180
162
 
181
163
  const after = depth();
@@ -183,10 +165,8 @@ function flushLearning() {
183
165
  if (fed <= 0) {
184
166
  // Name the most likely cause instead of shrugging: learn-flush invokes ruflo at a FIXED path,
185
167
  // so on a machine with a different npm prefix it feeds nothing and honestly keeps the queue.
186
- const rufloAtFixedPath = fs.existsSync(path.join(HOME, '.npm-global/bin/ruflo'));
187
168
  const why = stalled.length ? ` (${stalled.slice(0, 3).join('; ')})` : '';
188
- const hint = rufloAtFixedPath ? '' : ' ruflo is not at ~/.npm-global/bin/ruflo, which is where the flusher looks for it —'
189
- + ' `npm i -g ruflo@latest` installs it there.';
169
+ const hint = RUFLO ? '' : ' No global ruflo was resolved from the managed prefix or PATH.';
190
170
  return { ok: false, log: `fed 0 of ${before} queued events from ${where}${why} — the queue is preserved for retry.${hint}` };
191
171
  }
192
172
  return {
@@ -196,22 +176,20 @@ function flushLearning() {
196
176
  };
197
177
  }
198
178
 
199
- /** One training cycle, via rUv's own CLI, in the GLOBAL (cross-project) learner. */
179
+ /** One explicit training cycle in the same learner the Console measures. */
200
180
  function trainLearning() {
181
+ const context = learningContext({ cwd: PROJECT });
182
+ if (!context.enabled) return { ok: true, noop: true, log: 'learning is switched off — queued evidence and learner are untouched' };
201
183
  if (!RUFLO) return { ok: false, log: 'ruflo is not on this machine — install it with `npm i -g ruflo@latest` to enable learning' };
202
184
  try {
203
- // ISSUE #136: train the SAME store the console card reads. With `cwd: HOME` the card read the
204
- // project's learner and this trained the home one, so the button could never clear the card it
205
- // was offered for — a remedy that cannot resolve its own finding is worse than no button.
206
- // PROJECT is the same root learn-flush.mjs and learn-capture.sh resolve (#134).
207
- // ISSUE #139 — now that ruflo v3.38.9 made `--train` REAL (ruvnet/ruflo#2940 was a no-op
208
- // before), training the wrong store is no longer harmless: it moves that store's
209
- // lastAdaptation to 0s and the card SILENTLY SELF-CLEARS while the learner the operator
210
- // actually uses is untouched. #136 already moved this off `cwd: HOME`; it now shares the
211
- // console's resolver so the remedy provably trains the store the card measured.
212
- execFileSync(RUFLO, ['hooks', 'intelligence', '--train'], { cwd: learnerCwd({ cwd: PROJECT }), env: RUFLO_ENV, stdio: 'ignore', timeout: 600_000 });
213
- } catch (e) { return { ok: false, log: `training cycle failed: ${e.message}` }; }
214
- return { ok: true, log: 'ran one training cycle in the cross-project learner' };
185
+ const evidence = distillLearning(RUFLO, context, { deadline: Date.now() + 18_000, allowed: () => {
186
+ const current = learningContext({ cwd: PROJECT }); return current.enabled && current.scope === context.scope && current.queueDir === context.queueDir;
187
+ } });
188
+ const verified = evidence.completed && evidence.patternDelta > 0;
189
+ return { ok: verified, log: verified
190
+ ? `verified ${evidence.patternDelta} new structural patterns in ${evidence.db}; no ratified lessons claimed; snapshot ${evidence.snapshot}`
191
+ : `distillation returned without measurable pattern progress in ${evidence.db}; recorded observations remain separate; snapshot retained` };
192
+ } catch { return { ok: false, log: 'canonical distillation unavailable; observations and pending queue retained' }; }
215
193
  }
216
194
 
217
195
  /**
@@ -326,6 +304,7 @@ function distillFleet() {
326
304
  }
327
305
 
328
306
  const action = has('--repair-memory') ? repairMemory
307
+ : has('--flush-legacy-user-learning') ? () => flushLearning(true)
329
308
  : has('--flush-learning') ? flushLearning
330
309
  : has('--train-learning') ? trainLearning
331
310
  : has('--distill-fleet') ? distillFleet
@@ -67,9 +67,9 @@ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf,
67
67
  import {
68
68
  openRouterCredentialStatus,
69
69
  saveOpenRouterCredential,
70
- learnerCwd,
71
70
  loadRuntimePreferences,
72
71
  } from '../plugin/scripts/runtime-preferences.mjs';
72
+ import { observeLearning as observeScopedLearning } from '../plugin/scripts/learning-observation.mjs';
73
73
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
74
74
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
75
75
  // the PreCompact snapshot producer (#85) and with memory-doctor's root scan (#107).
@@ -2461,8 +2461,8 @@ function journalUndo(entry) {
2461
2461
  fs.appendFileSync(UNDO_JOURNAL, JSON.stringify({ token, at: new Date().toISOString(), ...entry }) + '\n');
2462
2462
  return token;
2463
2463
  }
2464
- function runNode(scriptRelPath, args) {
2465
- const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO });
2464
+ function runNode(scriptRelPath, args, options = {}) {
2465
+ const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO, ...options });
2466
2466
  return { ok: r.status === 0, code: r.status, log: `${r.stdout || ''}${r.stderr || ''}`.trim().slice(-4000) };
2467
2467
  }
2468
2468
  function elapsedMs(startedAt) {
@@ -2483,57 +2483,11 @@ function resolveProjectDir(project) {
2483
2483
  /**
2484
2484
  * Observe the learner's REAL state, for the health recommendations.
2485
2485
  *
2486
- * Deliberately reads the global learner, because that is the store the capture flush
2487
- * actually writes to. Reading the project-local `.claude-flow/neural` instead is exactly the mistake
2488
- * that made the console display a dead learner (5 trajectories, last trained 6 days earlier) while
2489
- * the live one held 412 — rUv documents this fragmentation as issue #2245, "four contradictory
2490
- * sources". Until it is unified upstream we read the store that learning writes, never the corpse.
2486
+ * The queue and learner are selected together for the served project. Apply uses this same
2487
+ * snapshot and remeasures it, so a successful child exit alone cannot clear a learning finding.
2491
2488
  */
2492
2489
  function observeLearning() {
2493
- const queueDir = path.join(SYSTEM_HOME, '.cache', 'ruvnet-brain', 'learn');
2494
- let queueDepth = 0;
2495
- try {
2496
- for (const f of fs.readdirSync(queueDir)) {
2497
- if (!f.endsWith('.jsonl')) continue;
2498
- queueDepth += fs.readFileSync(path.join(queueDir, f), 'utf8').split('\n').filter(Boolean).length;
2499
- }
2500
- } catch { /* no queue dir yet — depth stays 0, which is honest */ }
2501
-
2502
- let lastTrainSeconds = null; let trajectories = 0;
2503
- try {
2504
- // ISSUE #136 — THE LEARNER IS PROJECT-SCOPED, so this must ask about the SERVED project.
2505
- //
2506
- // `ruflo hooks intelligence --status` reports `Data Dir: <cwd>/.claude-flow/neural`. With
2507
- // `cwd: SYSTEM_HOME` this measured `~/.claude-flow/neural` — a store nothing writes to on a
2508
- // machine whose work happens inside project directories. Measured on one machine, one minute:
2509
- // the home store held 1,216 trajectories last trained 6.9 DAYS ago while the served project held
2510
- // 9,940 last trained 22 SECONDS ago. The card said "Your learner has gone quiet" about a learner
2511
- // training every few seconds.
2512
- //
2513
- // This file already carries the verdict on this exact mistake at the refresh-child spawn: "cwd =
2514
- // the SERVED project, NOT REPO … it was a real console-honesty bug". Same rule, same file,
2515
- // different call site — #104's and #134's residual arriving a third time.
2516
- // ISSUE #139 (@ObiWanKenobi) — `process.cwd()` was a HARDCODE in the opposite direction from
2517
- // #136's `SYSTEM_HOME`. Neither asked which scope was in effect; the first was wrong by default
2518
- // and the second is right only BECAUSE `project` is the default. Under
2519
- // `RUVNET_LEARNING_SCOPE=user` the flush feeds ~/.claude-flow/neural while this read
2520
- // <project>/.claude-flow/neural — the same false-positive card, inverted. The writer and this
2521
- // reader now call ONE resolver (runtime-preferences.learnerCwd), so they agree by construction
2522
- // instead of by coincidence.
2523
- const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
2524
- ['hooks', 'intelligence', '--status'],
2525
- {
2526
- cwd: learnerCwd(),
2527
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
2528
- encoding: 'utf8',
2529
- timeout: 20_000,
2530
- });
2531
- const out = `${r.stdout || ''}`;
2532
- const t = out.match(/Last Training:\s*(\d+)s ago/);
2533
- const j = out.match(/Trajectories\s*\|\s*(\d+)/);
2534
- if (t) lastTrainSeconds = Number(t[1]);
2535
- if (j) trajectories = Number(j[1]);
2536
- } catch { /* ruflo absent or slow — leave null, and null NEVER produces a recommendation */ }
2490
+ const learning = observeScopedLearning({ cwd: projectDirectory() });
2537
2491
 
2538
2492
  // The fleet is what makes ADR-027's North Star recommendation constructible at all — without it,
2539
2493
  // `learning:distill-fleet` can never be built, so it can never be offered, so clicking it would be
@@ -2545,7 +2499,7 @@ function observeLearning() {
2545
2499
  // produces no recommendation, which is the correct answer when we have not looked.
2546
2500
  const fleet = readJSON(MEMORY_CACHE)?.data?.fleet ?? [];
2547
2501
 
2548
- return { queueDepth, lastTrainSeconds, trajectories, fleet };
2502
+ return { ...learning, fleet };
2549
2503
  }
2550
2504
 
2551
2505
  function currentValidIds(onlyId = null) {
@@ -2649,7 +2603,23 @@ function apply(ids) {
2649
2603
  const undoToken = journalUndo(undoSpec);
2650
2604
  phaseMs.undoJournalMs += elapsedMs(journalStartedAt);
2651
2605
  const remedyStartedAt = performance.now();
2652
- const res = runNode(plan.exec.script, args);
2606
+ const learningBefore = ['learning:flush', 'learning:train', 'learning:flush-legacy-user'].includes(id) ? observeLearning() : null;
2607
+ const res = runNode(plan.exec.script, args, learningBefore ? {
2608
+ cwd: learningBefore.projectDir,
2609
+ env: { ...process.env, RUVNET_BRAIN_PROJECT_DIR: learningBefore.projectDir },
2610
+ } : {});
2611
+ if (res.ok && learningBefore) {
2612
+ const after = observeLearning();
2613
+ const progressed = id === 'learning:flush-legacy-user'
2614
+ ? after.legacyUserKnown && after.legacyUserDepth < learningBefore.legacyUserDepth
2615
+ : id === 'learning:flush'
2616
+ ? after.queueKnown && after.queueDepth < learningBefore.queueDepth
2617
+ : learningBefore.statusKnown && after.statusKnown && after.patterns > learningBefore.patterns;
2618
+ if (!progressed) {
2619
+ res.ok = false;
2620
+ res.log += `\nNo measured learning progress: queue ${after.queueDir}; learner ${after.learnerCwd}. Reload to inspect the remaining evidence.`;
2621
+ }
2622
+ }
2653
2623
  phaseMs.childRemedyMs += elapsedMs(remedyStartedAt);
2654
2624
  results.push({ id, ...res, undoToken });
2655
2625
  }
@@ -3531,6 +3501,7 @@ export {
3531
3501
  autoEligibleIds,
3532
3502
  gatherConfig,
3533
3503
  gatherSavings,
3504
+ observeLearning,
3534
3505
  };
3535
3506
  // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
3536
3507
  // scopeKey is the guard that stops one project's cached state being served for another.
@@ -0,0 +1,66 @@
1
+ /** Source-bound synthetic validation comparison; deliberately excludes database/host I/O. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { createProgressionSnapshot, restoreProjectProgression, digestCanonical } from '../../plugin/scripts/project-progression-contract.mjs';
8
+
9
+ const flag = (name) => process.argv[process.argv.indexOf(name) + 1];
10
+ if (!process.argv.includes('--baseline-module') || !process.argv.includes('--output')) {
11
+ throw new Error('Require --baseline-module <unchanged contract module> --output <receipt.json>');
12
+ }
13
+ const baselinePath = path.resolve(flag('--baseline-module'));
14
+ const baseline = await import(pathToFileURL(baselinePath).href);
15
+ const currentPath = fileURLToPath(new URL('../../plugin/scripts/project-progression-contract.mjs', import.meta.url));
16
+ const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
17
+ const sizes = (process.argv.includes('--sizes') ? flag('--sizes') : '100,400,1000').split(',').map(Number);
18
+ if (!sizes.every(n => Number.isSafeInteger(n) && n > 0 && n <= 10_000)) throw new Error('sizes must be positive integers <= 10000');
19
+ const projectIdentity = { id: 'synthetic-validation', canonicalAgentDbPath: '/synthetic/project/.swarm/memory.db' };
20
+ const sourceIdentity = { checkoutPath: '/synthetic/project', worktreeId: 'primary', branch: 'main', head: 'a'.repeat(40),
21
+ trackedDigest: 'b'.repeat(64), untrackedDigest: 'c'.repeat(64), dirtyTreeDigest: 'd'.repeat(64) };
22
+ const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions',
23
+ 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map(k => [k, []]));
24
+ const receipt = { scope: 'Synthetic CPU-only complete-history validation; no native capture/restore deadline claim',
25
+ node: process.version, platform: process.platform, arch: process.arch,
26
+ sources: { baseline: { path: baselinePath, sha256: hash(baselinePath) }, candidate: { path: currentPath, sha256: hash(currentPath) } },
27
+ workloads: [] };
28
+ for (const size of sizes) {
29
+ const snapshots = [], observations = [];
30
+ let parents = [];
31
+ for (let sequence = 1; sequence <= size; sequence++) {
32
+ observations.push({ id: `event-${sequence}`, occurredAt: '2026-10-05T00:00:00.000Z', trigger: 'PostToolUse',
33
+ kind: 'tool-observation', source: 'host-observation', authoritative: false, outcome: 'success', exitCode: 0,
34
+ intent: { action: 'verify', subjects: ['project memory'] }, tool: 'exec_command' });
35
+ const snapshot = createProgressionSnapshot({ projectIdentity, sourceIdentity,
36
+ hostIdentity: { host: 'codex', adapterVersion: 'synthetic-benchmark' }, sessionIdentity: 'synthetic',
37
+ sequence, parentEventKeys: parents, occurredAt: '2026-10-05T00:00:00.000Z', trigger: 'PostToolUse', dedupId: `event-${sequence}`,
38
+ completeProjectState: { ...empty, currentGoal: 'Preserve complete history', nextAction: 'Validate all evidence',
39
+ acceptanceContract: { required: ['exact equality'] }, activeProcess: 'verification', activeStep: 'PostToolUse',
40
+ observations: [...observations], commands: [...observations] } });
41
+ snapshots.push(snapshot); parents = [snapshot.eventKey];
42
+ }
43
+ const run = (restore) => {
44
+ const started = performance.now();
45
+ const value = restore(snapshots, { expectedProjectIdentity: projectIdentity });
46
+ return { ms: performance.now() - started, value };
47
+ };
48
+ const original = run(baseline.restoreProjectProgression);
49
+ const candidate = run(restoreProjectProgression);
50
+ if (JSON.stringify(original.value) !== JSON.stringify(candidate.value)) throw new Error('full restored result differs from baseline');
51
+ // Check a historical mutation whose digest was not recomputed, not just the current head.
52
+ const oldGoal = snapshots[0].completeProjectState.currentGoal;
53
+ snapshots[0].completeProjectState.currentGoal = 'tampered historical state';
54
+ const mutatedOriginal = baseline.restoreProjectProgression(snapshots, { expectedProjectIdentity: projectIdentity });
55
+ const mutatedCandidate = restoreProjectProgression(snapshots, { expectedProjectIdentity: projectIdentity });
56
+ if (JSON.stringify(mutatedOriginal) !== JSON.stringify(mutatedCandidate) || mutatedCandidate.ok) {
57
+ throw new Error('historical mutation negative semantics differ');
58
+ }
59
+ snapshots[0].completeProjectState.currentGoal = oldGoal;
60
+ receipt.workloads.push({ snapshots: size, bytes: snapshots.reduce((n, s) => n + Buffer.byteLength(JSON.stringify(s)), 0),
61
+ baselineMs: original.ms, candidateMs: candidate.ms, resultDigest: digestCanonical(candidate.value),
62
+ fullResultEqual: true, historicalMutationRejected: true, observations: candidate.value.state.observations.length,
63
+ commands: candidate.value.state.commands.length, peakRssKiB: process.resourceUsage().maxRSS });
64
+ fs.writeFileSync(path.resolve(flag('--output')), JSON.stringify(receipt, null, 2));
65
+ process.stdout.write(`${JSON.stringify(receipt.workloads.at(-1))}\n`);
66
+ }
@@ -1,6 +1,33 @@
1
1
  // Reviewed stabilization release boundaries. Legacy tests remain developer diagnostics.
2
2
  export const RELEASE_REQUIREMENTS = Object.freeze({
3
3
  "source": [
4
+ {
5
+ "id": "owned-startup-execution-evidence",
6
+ "reason": "Opt-in native execution and private stage diagnostics bind released source and preserve unknown convergence and incomplete cleanup boundaries",
7
+ "files": ["tests/unit/codex-host-execution-proof.test.mjs", "tests/unit/codex-host-proof-runtime.test.mjs", "tests/unit/session-start-proof.test.mjs"]
8
+ },
9
+ {
10
+ "id": "canonical-learning-capture",
11
+ "reason": "Fixed metadata capture, canonical scope, consent, acknowledgement and bounded owned recovery retain privacy and originals",
12
+ "files": [
13
+ "tests/unit/learn-flush-partial-failure.test.mjs",
14
+ "tests/unit/learn-capture-project-root.test.mjs",
15
+ "tests/unit/learn-capture-redaction.test.mjs",
16
+ "tests/unit/learner-scope-agreement.test.mjs",
17
+ "tests/unit/health-repair-flush-learning.test.mjs",
18
+ "tests/unit/learning-worker-supervisor.test.mjs"
19
+ ]
20
+ },
21
+ {
22
+ "id": "fresh-owned-host-proof",
23
+ "reason": "Source-bound native registry declarations reject warnings, trust changes and unretired owned processes",
24
+ "files": ["tests/unit/codex-fresh-host-proof.test.mjs"]
25
+ },
26
+ {
27
+ "id": "complete-progression-validation",
28
+ "reason": "Canonical digest validation retains full-history and serialization semantics",
29
+ "files": ["tests/unit/project-progression-contract.test.mjs"]
30
+ },
4
31
  {
5
32
  "id": "signed-artifacts",
6
33
  "reason": "Signature verification and exact assembled coverage reject changed bytes",
@@ -44,7 +71,10 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
44
71
  "files": [
45
72
  "tests/unit/automatic-hook-retirement.test.mjs",
46
73
  "tests/unit/install-activation-rollback.test.mjs",
47
- "tests/unit/forge-update-apply-rollback.test.mjs"
74
+ "tests/unit/forge-update-apply-rollback.test.mjs",
75
+ "tests/unit/forge-update-archive-digest.test.mjs",
76
+ "tests/unit/kb-copy-proof-legacy-sidecars.test.mjs",
77
+ "tests/unit/kb-copy-proof-unknown-content.test.mjs"
48
78
  ]
49
79
  },
50
80
  {
@@ -93,7 +123,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
93
123
  "files": [
94
124
  "tests/unit/nightly-scheduler.test.mjs",
95
125
  "tests/unit/nightly-refresh-launcher.test.mjs",
96
- "tests/unit/nightly-two-run-proof.test.mjs"
126
+ "tests/unit/nightly-two-run-proof.test.mjs",
127
+ "tests/unit/nightly-refresh-run-health.test.mjs"
97
128
  ]
98
129
  },
99
130
  {
@@ -151,22 +182,55 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
151
182
  "tests/mutation/citation-binding-mutation.test.mjs"
152
183
  ]
153
184
  },
185
+ {
186
+ "id": "owner-capture-and-off-controls",
187
+ "reason": "Brain OFF suppresses capacity execution; promise opt-out suppresses only new capture and preserves existing closure and capability truth",
188
+ "files": [
189
+ "tests/unit/capacity-aware-parallel-work.test.mjs",
190
+ "tests/unit/continuation-gate-capability-truth.test.mjs",
191
+ "tests/unit/continuation-gate-completion-claims.test.mjs"
192
+ ]
193
+ },
194
+ {
195
+ "id": "active-managed-generation",
196
+ "reason": "Active code selection binds help authorization and execution to one immutable leased generation across promotion",
197
+ "files": [
198
+ "tests/unit/managed-cli-generation.test.mjs"
199
+ ]
200
+ },
154
201
  {
155
202
  "id": "pending-memory-durability",
156
203
  "reason": "Complete outbox tails replay and accepted pending journal events survive capacity pressure without silent deletion",
157
204
  "files": [
158
205
  "tests/unit/project-progression-outbox.test.mjs",
159
206
  "tests/unit/project-progression-durability.test.mjs",
160
- "tests/unit/continuity-customer-regressions.test.mjs"
207
+ "tests/unit/continuity-customer-regressions.test.mjs",
208
+ "tests/unit/progression-outbox-containment.test.mjs",
209
+ "tests/unit/transition-pending-notices.test.mjs"
161
210
  ]
162
211
  }
163
212
  ],
164
213
  "integration": [
214
+ {
215
+ "id": "canonical-learning-recovery",
216
+ "reason": "Cross-session recovery and Console evidence agree on the same canonical scope without ratifying tool metadata as instructions",
217
+ "files": ["tests/integration/learning-recovery-377.test.mjs", "tests/integration/learning-console-scope.test.mjs"]
218
+ },
219
+ {
220
+ "id": "canonical-progression-store",
221
+ "reason": "Actual adopted canonical storage, exact readback, and concurrent session restoration retain consent and complete history",
222
+ "files": ["tests/integration/project-progression-concurrent-sessions.test.mjs", "tests/integration/project-progression-reader-identity.test.mjs"],
223
+ "platformFiles": {
224
+ "linux": ["tests/integration/project-progression-store.test.mjs"],
225
+ "macos": ["tests/integration/project-progression-store.test.mjs"]
226
+ }
227
+ },
165
228
  {
166
229
  "id": "native-explicit-interface",
167
230
  "reason": "Real MCP subprocess readiness, command policy and literal argv safety",
168
231
  "files": [
169
- "tests/integration/managed-cli-mcp.test.mjs"
232
+ "tests/integration/managed-cli-mcp.test.mjs",
233
+ "tests/integration/managed-cli-server-boundary.test.mjs"
170
234
  ]
171
235
  },
172
236
  {
@@ -193,10 +257,14 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
193
257
  },
194
258
  {
195
259
  "id": "owned-uninstall",
196
- "reason": "Actual offline uninstall preserves unrelated files and user guidance",
260
+ "reason": "Actual offline uninstall and POSIX copy-cleanup callers preserve unrelated files, changed unknown bytes and private state",
197
261
  "files": [
198
262
  "tests/integration/uninstall-footprint.test.mjs"
199
- ]
263
+ ],
264
+ "platformFiles": {
265
+ "linux": ["tests/unit/brain-footprint.test.mjs"],
266
+ "macos": ["tests/unit/brain-footprint.test.mjs"]
267
+ }
200
268
  },
201
269
  {
202
270
  "id": "canonical-memory-native-boundary",
@@ -219,7 +287,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
219
287
  "tests/unit/turn-durable-transport.test.mjs",
220
288
  "tests/integration/automatic-progression-continuation.test.mjs",
221
289
  "tests/integration/capture-consent-boundary.test.mjs",
222
- "tests/integration/session-start-turn-replay.test.mjs"
290
+ "tests/integration/session-start-turn-replay.test.mjs",
291
+ "tests/integration/progression-suspension.test.mjs"
223
292
  ]
224
293
  },
225
294
  {
@@ -77,12 +77,19 @@ export const REMEDIES = [
77
77
  // purpose, and the human string says what a user would actually do instead.
78
78
  inverse: () => ({ kind: K.NONE, human: 'nothing to reverse — this only adds observations the learner already had queued; learned state can be reset separately' }),
79
79
  },
80
+ {
81
+ key: 'learning-legacy-user-flush',
82
+ summary: 'drain retained legacy user history into its original home learner',
83
+ match: (id) => (id === 'learning:flush-legacy-user' ? {} : null),
84
+ plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--flush-legacy-user-learning'] }),
85
+ inverse: () => ({ kind: K.NONE, human: 'original queue bytes stay retained; learned observations can be reset separately' }),
86
+ },
80
87
  {
81
88
  key: 'learning-train',
82
- summary: 'run one training cycle',
89
+ summary: 'distill canonical observations into structural patterns',
83
90
  match: (id) => (id === 'learning:train' ? {} : null),
84
91
  plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--train-learning'] }),
85
- inverse: () => ({ kind: K.NONE, human: 'nothing to reverse here — learned state is reset with `ruflo hooks intelligence --reset`, which is a separate, deliberate action' }),
92
+ inverse: () => ({ kind: K.NONE, human: 'a verified snapshot is retained; automatic exact restore is unavailable and no ratified lessons are claimed' }),
86
93
  },
87
94
  {
88
95
  // THE ONE THAT HAD NO EXECUTOR. See ADR-027's North Star case: stores full of memories that