ruvnet-brain 4.6.4 → 4.7.1

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 (82) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +65 -54
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/.grok-plugin/plugin.json +12 -0
  10. package/plugin/commands/checkpoint.md +22 -40
  11. package/plugin/commands/rnb-brief.md +18 -15
  12. package/plugin/hooks/codex-hooks.json +1 -28
  13. package/plugin/hooks/grok-hooks.json +95 -0
  14. package/plugin/hooks/hook-contracts.json +6 -127
  15. package/plugin/hooks/hooks.json +1 -40
  16. package/plugin/host-adapters/grok.json +11 -0
  17. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  18. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  19. package/plugin/scripts/agentdb-recall.mjs +35 -27
  20. package/plugin/scripts/automated-run.mjs +42 -0
  21. package/plugin/scripts/brain-footprint.mjs +1 -1
  22. package/plugin/scripts/checkpoint-keys.mjs +46 -0
  23. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  24. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  25. package/plugin/scripts/continuation-gate.mjs +6 -0
  26. package/plugin/scripts/continuity-events.mjs +90 -2
  27. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  28. package/plugin/scripts/decision-gate.mjs +2 -2
  29. package/plugin/scripts/decision-record.mjs +11 -40
  30. package/plugin/scripts/grok-hook-adapter.mjs +155 -0
  31. package/plugin/scripts/hook-failures.mjs +16 -0
  32. package/plugin/scripts/hook-shim.mjs +1 -2
  33. package/plugin/scripts/learn-capture.mjs +60 -42
  34. package/plugin/scripts/learning-store.mjs +70 -60
  35. package/plugin/scripts/node-sqlite.mjs +1 -1
  36. package/plugin/scripts/ruflo-bin.mjs +71 -1
  37. package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
  38. package/plugin/scripts/runtime-preferences.mjs +1 -1
  39. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  40. package/plugin/scripts/session-start-core.mjs +2 -12
  41. package/plugin/scripts/signal-watch.mjs +1 -1
  42. package/plugin/scripts/substitution-wall.mjs +222 -0
  43. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  44. package/scripts/codex-host-execution-proof.mjs +1 -1
  45. package/scripts/console-engine.mjs +0 -32
  46. package/scripts/health-repair.mjs +1 -81
  47. package/scripts/hook-qualify-core.mjs +2 -2
  48. package/scripts/model-managed-workflow-service.mjs +9 -10
  49. package/scripts/model-native-qualification.mjs +17 -5
  50. package/scripts/model-routing-controller.mjs +9 -8
  51. package/scripts/model-routing-execution-adapters.mjs +24 -2
  52. package/scripts/model-weekly-cycle.mjs +13 -12
  53. package/scripts/no-silent-substitution.mjs +65 -1
  54. package/scripts/onboarding-console.mjs +8 -23
  55. package/scripts/product-integrity-contract.mjs +1 -1
  56. package/scripts/release-qualification-contract.mjs +21 -30
  57. package/scripts/remedy-registry.mjs +0 -18
  58. package/scripts/user-model-prompt-hook.mjs +11 -10
  59. package/scripts/wired-check.mjs +1 -5
  60. package/plugin/scripts/continuity-brief.mjs +0 -351
  61. package/plugin/scripts/continuity-journal.mjs +0 -553
  62. package/plugin/scripts/learn-flush.mjs +0 -96
  63. package/plugin/scripts/learning-observation.mjs +0 -51
  64. package/plugin/scripts/learning-queue.mjs +0 -135
  65. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  66. package/plugin/scripts/project-capture-queue.mjs +0 -357
  67. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  68. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  69. package/plugin/scripts/project-progression-contract.mjs +0 -496
  70. package/plugin/scripts/project-progression-hook.mjs +0 -233
  71. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  72. package/plugin/scripts/project-progression-producer.mjs +0 -309
  73. package/plugin/scripts/project-progression-reader.mjs +0 -304
  74. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  75. package/plugin/scripts/project-progression-sources.mjs +0 -246
  76. package/plugin/scripts/project-progression-store.mjs +0 -723
  77. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  78. package/plugin/scripts/project-transition-hook.mjs +0 -267
  79. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  80. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  81. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  82. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -25,15 +25,13 @@ import os from 'node:os';
25
25
  import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { findStores, diagnose } from './memory-doctor.mjs';
27
27
  import { distillLearning } from '../plugin/scripts/learning-store.mjs';
28
- import { readSafe } from '../plugin/scripts/learning-queue.mjs';
29
28
  import { learningContext } from '../plugin/scripts/runtime-preferences.mjs';
30
- import { learningQueueDepth, learningQueueFiles, observeLearning } from '../plugin/scripts/learning-observation.mjs';
31
29
  import { resolveRuflo, rufloInvocation } from '../plugin/scripts/ruflo-bin.mjs';
32
30
  import { projectDirectory } from '../plugin/scripts/project-identity.mjs';
33
31
  import { newestSnapshot, snapshotInventory, MTIME_GRACE_MS } from './snapshot-freshness.mjs';
34
32
 
35
33
  const HOME = os.homedir();
36
- // The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
34
+ // The SAME project root the learning hook computes, by the same rule — the two halves of the flush
37
35
  // have to agree about which project they mean or they address different queues (issue #104).
38
36
  // RESIDUAL of #134: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either host, so
39
37
  // consult `projectDirectory()` (project-identity.mjs) — the CLAUDE_PROJECT_DIR-with-containment
@@ -100,82 +98,6 @@ function repairMemory() {
100
98
  return { ok: true, log: `repaired — integrity ok, ${rowsAfter} entries intact (was ${rowsBefore}). Backup: ${backup.replace(HOME, '~')}`, backup };
101
99
  }
102
100
 
103
- /**
104
- * Drain the capture queue into rUv's learner — his tool, not ours.
105
- *
106
- * ISSUE #104: this measured one queue and drained another, so it could only ever report "fed 0".
107
- * The flusher was spawned with NO environment, so inside learn-flush.mjs RUVNET_LEARNING_SCOPE was
108
- * unset and defaulted to 'project' — it drained `<project>/.swarm/ruvnet-brain-learn/` while THIS
109
- * function counted `<user>/.cache/ruvnet-brain/learn/`. `before - after` was structurally 0: it
110
- * could not report truthfully in either direction even on a flush that worked, and the real queue
111
- * grew forever. Two independent defaults are not an agreement.
112
- *
113
- * So: resolve the scope ONCE, from the same policy module learn-flush reads, derive the queue root
114
- * FROM that scope, pass the scope (and the exact queue file) to the child explicitly, and measure
115
- * the root that was actually drained.
116
- *
117
- * And LOOP. learn-flush feeds at most MAX_ACTIONS distinct actions per invocation and writes the
118
- * remainder back on purpose (a SessionEnd hook must stay fast), so one call cannot drain a deep
119
- * queue — the reporter needed 15 rounds for 293 entries. A single call would leave a queue that
120
- * "flushes" every time and never empties, which is the same lie in slow motion.
121
- */
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');
129
- const flusher = path.join(HOME, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'scripts', 'learn-flush.mjs');
130
- const local = path.join(PROJECT, 'plugin', 'scripts', 'learn-flush.mjs');
131
- const script = [bundled, local, flusher].find((file) => fs.existsSync(file));
132
- if (!script) return { ok: false, log: 'learn-flush.mjs not found — cannot drain the queue' };
133
-
134
- // Displayed to a human and matched by tests, so it is normalised to forward slashes on every
135
- // platform. Without this, Windows reports `~\.cache\ruvnet-brain\learn` while macOS and Linux
136
- // report `~/.cache/ruvnet-brain/learn` — the same location under two spellings, which is the
137
- // exact defect class this branch has been closing (one fact, two representations).
138
- const where = queueDir.replace(HOME, '~').split(path.sep).join('/');
139
-
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` }; }
148
- if (!before) return { ok: true, noop: true, log: `nothing queued in ${where} — the learner is already caught up` };
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;
151
- const stalled = [];
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;
161
- }
162
-
163
- const after = depth();
164
- const fed = before - after;
165
- if (fed <= 0) {
166
- // Name the most likely cause instead of shrugging: learn-flush invokes ruflo at a FIXED path,
167
- // so on a machine with a different npm prefix it feeds nothing and honestly keeps the queue.
168
- const why = stalled.length ? ` (${stalled.slice(0, 3).join('; ')})` : '';
169
- const hint = RUFLO ? '' : ' No global ruflo was resolved from the managed prefix or PATH.';
170
- return { ok: false, log: `fed 0 of ${before} queued events from ${where}${why} — the queue is preserved for retry.${hint}` };
171
- }
172
- return {
173
- ok: true,
174
- log: `fed ${fed} captured events into the ${scope} learner (queue ${before} → ${after} in ${where})`
175
- + (after ? ` — ${after} remain and will drain on the next flush` : ''),
176
- };
177
- }
178
-
179
101
  /** One explicit training cycle in the same learner the Console measures. */
180
102
  function trainLearning() {
181
103
  const context = learningContext({ cwd: PROJECT });
@@ -304,8 +226,6 @@ function distillFleet() {
304
226
  }
305
227
 
306
228
  const action = has('--repair-memory') ? repairMemory
307
- : has('--flush-legacy-user-learning') ? () => flushLearning(true)
308
- : has('--flush-learning') ? flushLearning
309
229
  : has('--train-learning') ? trainLearning
310
230
  : has('--distill-fleet') ? distillFleet
311
231
  : null;
@@ -89,11 +89,11 @@ export function fixturesFor(reg, root = REPO) {
89
89
  const base = all.find((f) => eventOf(reg.host, f.payload) === baseEvent);
90
90
  const event = reg.host === 'grok' ? reg.event.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase() : reg.event;
91
91
  // Consumer-only Layer 1 coverage: hook-input.mjs accepts the common host envelope;
92
- // project-transition-hook.mjs dispatches child/failure observations by event name.
92
+ // Child/failure observations are dispatched by event name.
93
93
  // No unobserved producer-specific child/error fields are invented. Layer 2/public
94
94
  // host verification must establish native schema and delivery independently.
95
95
  return base ? [{ name: `${reg.event}-adapter-contract-derived`, nativeSchemaObserved: false, nativeDeliveryObserved: false,
96
- _provenance: `DERIVED adapter-contract envelope from captured ${reg.host} ${baseEvent}; consumer-only mapping in plugin/scripts/hook-input.mjs and project-transition-hook.mjs; native schema and delivery NOT observed; producer completeness NOT verified`,
96
+ _provenance: `DERIVED adapter-contract envelope from captured ${reg.host} ${baseEvent}; consumer-only mapping in plugin/scripts/hook-input.mjs; native schema and delivery NOT observed; producer completeness NOT verified`,
97
97
  payload: { ...base.payload, hook_event_name: event, ...(reg.host === 'grok' ? { hookEventName: event } : {}) } }] : [];
98
98
  }
99
99
  if (!/ToolUse$/.test(reg.event)) return [];
@@ -9,9 +9,8 @@ import { runRoutingWorkflow, validateWorkflowRequest, artifactDigest, verifyCont
9
9
  import { createGuardedWorkflowAdapters, runObservedWorkflowWorker, nativeWorkflowBinaries } from './model-routing-execution-adapters.mjs';
10
10
  import { extractFeatures, selectDecision, loadPolicy, loadProfile, applyProfile, loadCatalog } from './model-router-engine.mjs';
11
11
  import { validateDispatchDecision, subscriptionEnvironment } from './model-router-dispatch.mjs';
12
- import { ContinuityJournal, drain } from '../plugin/scripts/continuity-journal.mjs';
12
+ import { storeExact } from '../plugin/scripts/ruflo-memory-hooks.mjs';
13
13
  import { CONTINUITY_NAMESPACE, EVENT_SCHEMA, redactText } from '../plugin/scripts/continuity-events.mjs';
14
- import { withProgressionReader } from '../plugin/scripts/project-progression-reader.mjs';
15
14
  import { recall } from '../plugin/scripts/agentdb-recall.mjs';
16
15
  import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
17
16
  import { selectPracticalRules, practicalSelectionReceipt } from './practical-rule-selector.mjs';
@@ -262,18 +261,18 @@ export async function runRegisteredChecker(check, { deadline, signal, env = proc
262
261
  return result;
263
262
  }
264
263
 
265
- /** Completion requires the exact current receipt row in the adopted canonical store; queued debt fails closed. */
266
- export function commitManagedReceipt(request, receipt, { Journal = ContinuityJournal, drainJournal = drain, read = withProgressionReader } = {}) {
264
+ /** Completion requires the receipt written through `ruflo memory store` and read back by exact key. */
265
+ export function commitManagedReceipt(request, receipt, { store = storeExact } = {}) {
267
266
  const resolved = resolveProjectStore({ projectDir: request.projectRoot, gitTimeoutMs: 1000 });
268
- const journal = new Journal({ projectRoot: resolved.projectRoot, projectDir: request.projectRoot });
269
267
  const event = { schema: EVENT_SCHEMA, kind: 'decision', id: sha(JSON.stringify(receipt)).slice(0, 16),
270
268
  at: receipt.at, source: 'model-managed-workflow-service', authoritative: true,
271
269
  summary: `Managed workflow ${request.id}: ${receipt.status}`, detail: receipt };
272
- const [row] = journal.record([event]); assert(row, 'Current workflow receipt was not durably enqueued');
273
- drainJournal(journal, { budgetMs: Math.max(0, Math.min(1000, request.deadline - Date.now())), backoff: [] });
274
- const readback = read(journal.db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, row.key));
275
- assert(readback.ok && readback.value === JSON.stringify(row.event), 'Canonical AgentDB exact receipt readback failed; queued is not complete');
276
- return { durable: true, agentDbCommitted: true, key: row.key, digest: row.digest };
270
+ const key = `cevt-workflow-${event.id}`;
271
+ const value = JSON.stringify(event);
272
+ const result = store({ db: resolved.canonicalAgentDbPath, namespace: CONTINUITY_NAMESPACE, key, value,
273
+ timeoutMs: Math.max(1, Math.min(6000, request.deadline - Date.now())) });
274
+ assert(result.verified, `Canonical AgentDB exact receipt readback failed: ${result.error || 'value differs'}`);
275
+ return { durable: true, agentDbCommitted: true, key, digest: sha(value) };
277
276
  }
278
277
 
279
278
  function validateStoredRegistry(request) {
@@ -26,8 +26,19 @@ function binaryFor(host) {
26
26
  return path.join(entry.location.path, 'resources/native-binary', process.platform === 'win32' ? 'claude.exe' : 'claude');
27
27
  }
28
28
 
29
- export function verifyClaudeAllowance(usage, model) {
30
- // Native 2.1.289 get_usage is explicitly experimental. Unknown shapes fail closed.
29
+ // The get_usage contract was first verified on native 2.1.289. An exact pin broke every hourly weekly
30
+ // cycle once 2.1.296 was installed (4.7.1), so any 2.x at or above that floor is accepted; the
31
+ // experimental response SHAPE is still checked and fails closed below (verifyClaudeAllowance).
32
+ export function supportedClaudeVersion(harnessVersion) {
33
+ const m = /^(\d+)\.(\d+)\.(\d+) \(Claude Code\)$/.exec(String(harnessVersion || ''));
34
+ if (!m) return null;
35
+ const [major, minor, patch] = m.slice(1).map(Number);
36
+ const atLeastFloor = minor > 1 || (minor === 1 && patch >= 289);
37
+ return major === 2 && atLeastFloor ? `${major}.${minor}.${patch}` : null;
38
+ }
39
+
40
+ export function verifyClaudeAllowance(usage, model, version = '2.1.289') {
41
+ // Native get_usage is explicitly experimental. Unknown shapes fail closed.
31
42
  if (usage?.rate_limits_available !== true || usage.rate_limits?.extra_usage?.is_enabled !== false) throw new Error('Native Claude allowance unknown or extra usage enabled');
32
43
  const limits = usage.rate_limits;
33
44
  for (const key of ['five_hour', 'seven_day']) {
@@ -40,7 +51,7 @@ export function verifyClaudeAllowance(usage, model) {
40
51
  for (const limit of limits.limits || []) {
41
52
  if (!Number.isFinite(limit.percent) || limit.percent < 0 || limit.percent >= 100 || !['normal', 'warning'].includes(limit.severity)) throw new Error('Native Claude allowance unavailable or unknown');
42
53
  }
43
- return { verified: true, basis: 'native-get_usage-2.1.289', checkedAt: new Date().toISOString(), reservation: false, raceSafe: false, model };
54
+ return { verified: true, basis: `native-get_usage-${version}`, checkedAt: new Date().toISOString(), reservation: false, raceSafe: false, model };
44
55
  }
45
56
 
46
57
  class Transport {
@@ -189,10 +200,11 @@ async function run(options, inspectOnly) {
189
200
  } else result.sourceReceipt.nativeSettings.after.serviceTierBasis = 'pre-turn-native-settings-and-fixed-host-configuration';
190
201
  }
191
202
  } else {
192
- if (!/^2\.1\.289 \(Claude Code\)$/.test(result.harnessVersion)) throw new Error('Experimental Claude allowance contract version unsupported');
203
+ const claudeVersion = supportedClaudeVersion(result.harnessVersion);
204
+ if (!claudeVersion) throw new Error('Experimental Claude allowance contract version unsupported');
193
205
  const initialized = await request('initialize');
194
206
  if (initialized?.session_state !== 'idle') throw new Error('Native Claude session not idle');
195
- result.sourceReceipt.allowance = verifyClaudeAllowance(await request('get_usage', { skip_behaviors: true }), model);
207
+ result.sourceReceipt.allowance = verifyClaudeAllowance(await request('get_usage', { skip_behaviors: true }), model, claudeVersion);
196
208
  // Native initialize.models is the extension's supportedModels() source.
197
209
  result.catalogStatus = Array.isArray(initialized.models) ? 'verified' : 'unknown';
198
210
  for (const m of initialized.models || []) {
@@ -7,8 +7,9 @@ import { createRequire } from 'node:module';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { performance } from 'node:perf_hooks';
9
9
  import { validateDispatchDecision } from './model-router-dispatch.mjs';
10
- import { ContinuityJournal, drain } from '../plugin/scripts/continuity-journal.mjs';
11
- import { EVENT_SCHEMA } from '../plugin/scripts/continuity-events.mjs';
10
+ import { CONTINUITY_NAMESPACE, EVENT_SCHEMA } from '../plugin/scripts/continuity-events.mjs';
11
+ import { storeExact } from '../plugin/scripts/ruflo-memory-hooks.mjs';
12
+ import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
12
13
  import { collectMacPressure, effectiveAgentRecommendation } from '../plugin/scripts/capacity-aware-parallel-work.mjs';
13
14
 
14
15
  const HASH = /^[a-f0-9]{64}$/;
@@ -271,15 +272,15 @@ function guardAdapters(adapters, request, budget, now, admission) {
271
272
  }));
272
273
  }
273
274
 
274
- export function durableWorkflowReceipt(request, receipt) {
275
- const journal = new ContinuityJournal({ projectRoot: request.projectRoot });
275
+ export function durableWorkflowReceipt(request, receipt, { store = storeExact } = {}) {
276
276
  const event = { schema: EVENT_SCHEMA, kind: 'decision', id: sha(JSON.stringify(receipt)),
277
277
  at: receipt.at, source: 'model-routing-controller', authoritative: true,
278
278
  summary: `Routing workflow ${request.id}: ${receipt.status}`, detail: receipt };
279
- journal.record([event]);
280
- // Existing outbox is durable before the existing exact-readback AgentDB drainer runs.
281
- const status = drain(journal, { budgetMs: Math.max(0, Math.min(1000, request.deadline - Date.now())), backoff: [] });
282
- return { durable: true, agentDbCommitted: status.remaining === 0, ...status };
279
+ // Written directly through `ruflo memory store` and proven by exact-key readback; no local outbox.
280
+ const db = resolveProjectStore({ projectDir: request.projectRoot, gitTimeoutMs: 1000 }).canonicalAgentDbPath;
281
+ const result = store({ db, namespace: CONTINUITY_NAMESPACE, key: `cevt-routing-${event.id.slice(0, 24)}`, value: JSON.stringify(event),
282
+ timeoutMs: Math.max(1, Math.min(6000, request.deadline - Date.now())) });
283
+ return { durable: result.verified, agentDbCommitted: result.verified, ...(result.error ? { error: result.error } : {}) };
283
284
  }
284
285
 
285
286
  function validateAcceptance(request, acceptance) {
@@ -13,8 +13,30 @@ import { readCodexAllowance } from './native-subscription-usage.mjs';
13
13
  const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
14
14
  const blocked = reason => Object.assign(new Error(reason), { safetyBlocked: true });
15
15
 
16
- export function nativeWorkflowBinaries(home = os.homedir()) {
17
- const config = JSON.parse(fs.readFileSync(path.join(home, '.cache/ruvnet-brain/model-routing/terminal-launcher-config.json'), 'utf8'));
16
+ // The launcher config is retired: an owner who removed it gets the standard install path, else a PATH
17
+ // lookup. A binary that exists nowhere throws ENOENT (as before), never a path that cannot run. Never
18
+ // recreate the config here (install.mjs refreshInstalledModelLaunchers would reinstall the terminal wrappers).
19
+ function findNativeBinary(name, standard, env) {
20
+ const isFile = file => { try { return fs.statSync(file).isFile(); } catch { return false; } };
21
+ if (isFile(standard)) return standard;
22
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
23
+ for (const dir of String(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean)) {
24
+ for (const ext of exts) { const candidate = path.join(dir, name + ext); if (isFile(candidate)) return candidate; }
25
+ }
26
+ throw Object.assign(new Error(`native ${name} binary not found at ${standard} or on PATH`), { code: 'ENOENT' });
27
+ }
28
+
29
+ export function nativeWorkflowBinaries(home = os.homedir(), env = process.env) {
30
+ let text;
31
+ try { text = fs.readFileSync(path.join(home, '.cache/ruvnet-brain/model-routing/terminal-launcher-config.json'), 'utf8'); }
32
+ catch (error) {
33
+ if (error.code !== 'ENOENT') throw error;
34
+ return {
35
+ claude: findNativeBinary('claude', path.join(home, '.npm-global/bin/claude'), env),
36
+ codex: findNativeBinary('codex', path.join(home, '.local/bin/codex'), env)
37
+ };
38
+ }
39
+ const config = JSON.parse(text);
18
40
  return { claude: config.realClaude, codex: config.realCodex };
19
41
  }
20
42
 
@@ -100,20 +100,21 @@ export function canonicalTextReleases(bytes) {
100
100
  function discoveryDue(state, now) { const checked = Date.parse(state?.checkedAt);
101
101
  return !Number.isFinite(checked) || checked > now || now - checked >= WEEK_MS;
102
102
  }
103
- export function maybeLaunchWeeklyCycle({ routerDir = path.join(os.homedir(), '.claude', 'model-router'), now = Date.now(), launch = spawn, env = process.env } = {}) {
103
+ // READ-ONLY status for the per-prompt hook (4.7.1). It never creates, claims or launches anything:
104
+ // the prompt hook used to launch a detached cycle on every prompt, which spawned native claude/codex.
105
+ // The weekly cycle runs only from the scheduled LaunchAgent (com.ruvnet.goldie-weekly, goldie-weekly.sh).
106
+ export function weeklyCycleStatus({ routerDir = path.join(os.homedir(), '.claude', 'model-router'), now = Date.now() } = {}) {
104
107
  try {
105
- if (env.MODEL_ROUTER_WEEKLY_ANALYST === '1') return { status: 'recursive-worker', launched: false, reviewRequired: false };
106
- fs.mkdirSync(routerDir, { recursive: true, mode: 0o700 }); const state = json(routerDir, STATE_FILE, 1048576);
108
+ const state = json(routerDir, STATE_FILE, 1048576);
107
109
  const reviewRequired = !!state?.pendingReleases?.length;
108
- if (!reviewRequired && !discoveryDue(state, now) && state?.removedPublicReleases?.length) return { status: 'blocked', launched: false, checkedAt: state.checkedAt, reviewRequired: false, reason: 'Public release removal requires native access verification; policy retained' };
109
- if (!reviewRequired && !discoveryDue(state, now)) return { status: 'current', launched: false, checkedAt: state.checkedAt, reviewRequired: false };
110
- const last = json(routerDir, 'weekly-cycle-last-attempt.json'); const tried = Date.parse(last?.checkedAt);
111
- if (['failed', 'qualification-pending'].includes(last?.status) && Number.isFinite(tried) && tried <= now && now - tried < RETRY_MS) return { status: 'blocked', launched: false, reason: 'weekly retry cooldown', checkedAt: state?.checkedAt, reviewRequired };
112
- const token = claim(routerDir, now); if (!token) return { status: 'busy', launched: false, reviewRequired };
113
- try { const child = launch(process.execPath, [fileURLToPath(import.meta.url), '--router-dir', routerDir, '--claim-token', token], { detached: true, stdio: 'ignore' });
114
- child.once?.('error', () => release(routerDir, token)); child.unref(); return { status: 'launched', launched: true, checkedAt: state?.checkedAt, reviewRequired };
115
- } catch (e) { release(routerDir, token); throw e; }
116
- } catch (e) { return { status: 'blocked', launched: false, reason: e.message.slice(0, 240), reviewRequired: false }; }
110
+ const last = json(routerDir, 'weekly-cycle-last-attempt.json');
111
+ if (['failed', 'qualification-pending'].includes(last?.status)) return { status: 'blocked', checkedAt: state?.checkedAt, reviewRequired,
112
+ reason: `last scheduled run ${last.status} at ${last.checkedAt}: ${String(last.reason || 'no reason recorded').slice(0, 200)}` };
113
+ if (reviewRequired) return { status: 'review-pending', checkedAt: state.checkedAt, reviewRequired: true };
114
+ if (discoveryDue(state, now)) return { status: 'due', checkedAt: state?.checkedAt, reviewRequired: false };
115
+ if (state?.removedPublicReleases?.length) return { status: 'blocked', checkedAt: state.checkedAt, reviewRequired: false, reason: 'Public release removal requires native access verification; policy retained' };
116
+ return { status: 'current', checkedAt: state.checkedAt, reviewRequired: false };
117
+ } catch (e) { return { status: 'blocked', reason: e.message.slice(0, 240), reviewRequired: false }; }
117
118
  }
118
119
  export async function runWeeklyCycle({ routerDir = path.join(os.homedir(), '.claude', 'model-router'), now = Date.now(), stage = runCycleStage,
119
120
  env = process.env, maxMs = 900000, fetchImpl = fetch, claimToken = null } = {}) {
@@ -28,6 +28,7 @@
28
28
  import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { substitutionVerdict, inScope, codeOf, message } from '../plugin/scripts/substitution-wall.mjs';
31
32
 
32
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
34
 
@@ -155,8 +156,71 @@ export function audit(root = ROOT) {
155
156
  return violations;
156
157
  }
157
158
 
159
+ // ── CLASS 2: STORAGE / QUEUE / JOURNAL (2026-10-12) ─────────────────────────────────────────────
160
+ // The package-name rule above said "none" on a 3.38 GB outbox, queues and replay workers in front of
161
+ // `ruflo memory store`: storage was not in CAPABILITIES, and spawning the `ruflo` binary counted as
162
+ // "rUv is used". This class is behaviour-based, shares ONE detector with the PreToolUse policy
163
+ // (plugin/scripts/substitution-wall.mjs), and is a RATCHET over config/state-files.json:
164
+ // · a file the detector refuses must be in `substitutionBaseline` (known debt, signals recorded) or
165
+ // `substitutionAllow` (owner-approved). A NEW one fails; a baseline file that GAINS a signal fails.
166
+ // · every module that appends to a file must be registered in `stateFiles` with its bound (or an
167
+ // explicit `bound: null`), and the count of unbounded ones may not rise above `maxUnbounded`.
168
+ export const STATE_CONFIG = 'config/state-files.json';
169
+ const APPEND = /\b(?:appendFileSync|appendFile|createWriteStream)\b|\bO_APPEND\b|flags?\s*:\s*['"]a\+?['"]/;
170
+ const BOUND = /\bMAX_\w*(?:BYTES|LINES|RECORDS|ROWS|ENTRIES|AGE\w*)\b|\bmax(?:Bytes|Lines|Records|Rows|Entries)\b|\brotat\w+|\bcompact\w*|\btruncateSync\b|\bprune\w*/;
171
+
172
+ function scopedFiles(root) {
173
+ return ['plugin/scripts', 'scripts'].flatMap((d) => (fs.existsSync(path.join(root, d)) ? walkAll(path.join(root, d)) : []))
174
+ .map((f) => path.relative(root, f).split(path.sep).join('/')).filter(inScope).sort();
175
+ }
176
+ function walkAll(dir, out = []) {
177
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
178
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walkAll(path.join(dir, e.name), out); }
179
+ else out.push(path.join(dir, e.name));
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** Every module that appends to a file: its literal targets and the first bound token in its code (or null). */
185
+ export function stateFileInventory(root = ROOT) {
186
+ return scopedFiles(root).flatMap((rel) => {
187
+ const code = codeOf(fs.readFileSync(path.join(root, rel), 'utf8'), rel);
188
+ if (!APPEND.test(code)) return [];
189
+ const targets = [...new Set([...code.matchAll(/['"`/]([\w.-]+\.(?:jsonl|ndjson|log))['"`]/g)].map((m) => m[1]))].sort();
190
+ return [{ module: rel, targets, bound: BOUND.exec(code)?.[0] ?? null }]; // bound = a grep-detected token, NOT proof it bounds the target
191
+ });
192
+ }
193
+
194
+ export function readStateConfig(root = ROOT) {
195
+ try { return JSON.parse(fs.readFileSync(path.join(root, STATE_CONFIG), 'utf8')); } catch { return {}; }
196
+ }
197
+
198
+ export function storageAudit(root = ROOT, config = readStateConfig(root)) {
199
+ const violations = []; const notes = [];
200
+ const allow = (config.substitutionAllow || []).map((a) => a.path);
201
+ const baseline = new Map((config.substitutionBaseline || []).map((b) => [b.path, b]));
202
+ for (const rel of scopedFiles(root)) {
203
+ const v = substitutionVerdict({ path: rel, content: fs.readFileSync(path.join(root, rel), 'utf8'), allow });
204
+ const known = baseline.get(rel);
205
+ if (v.verdict === 'refuse' && !known) violations.push({ file: rel, capability: 'storage/queue/journal', pkg: 'ruflo memory', why: message(rel, v).split('\n')[0], fix: `Call ${v.tool}; delete the transport.` });
206
+ else if (v.verdict === 'refuse' && v.signals.some((s) => !known.signals.includes(s))) violations.push({ file: rel, capability: 'storage/queue/journal', pkg: 'ruflo memory', why: `known debt GREW: signals ${v.signals.join(',')} vs baseline ${known.signals.join(',')}`, fix: 'Shrink it toward one ruflo call; never grow it.' });
207
+ else if (known && v.verdict !== 'refuse') notes.push(`${rel} no longer refuses — remove it from substitutionBaseline (the ratchet tightens).`);
208
+ }
209
+ const registered = new Map((config.stateFiles || []).map((s) => [s.module, s]));
210
+ const inventory = stateFileInventory(root);
211
+ for (const s of inventory) if (!registered.has(s.module)) violations.push({ file: s.module, capability: 'state-file ratchet', pkg: STATE_CONFIG, why: `appends to ${s.targets.join(', ') || 'a file'} and is not registered with a bound`, fix: `Register it in ${STATE_CONFIG} stateFiles (bound or explicit null), or store via ruflo memory.` });
212
+ const unbounded = [...registered.values()].filter((s) => s.bound === null && inventory.some((i) => i.module === s.module)).length;
213
+ if (unbounded > (config.maxUnbounded ?? 0)) violations.push({ file: STATE_CONFIG, capability: 'state-file ratchet', pkg: STATE_CONFIG, why: `${unbounded} unbounded append targets > maxUnbounded ${config.maxUnbounded ?? 0}`, fix: 'Bound the new one, or remove an old one first.' });
214
+ for (const m of registered.keys()) if (!inventory.some((i) => i.module === m)) notes.push(`${m} no longer appends — remove it from stateFiles.`);
215
+ return { violations, notes, unbounded };
216
+ }
217
+
158
218
  function main() {
159
- const violations = audit();
219
+ if (process.argv.includes('--inventory')) { console.log(JSON.stringify(stateFileInventory(), null, 2)); process.exit(0); }
220
+ const storage = storageAudit();
221
+ for (const n of storage.notes) console.log(`ℹ ${n}`);
222
+ console.log(`storage/queue/journal: ${(readStateConfig().substitutionBaseline || []).length} known debt file(s), ${storage.unbounded} unbounded append target(s) registered.`);
223
+ const violations = [...audit(), ...storage.violations];
160
224
  console.log('no-silent-substitution — is any local code impersonating a real rUv tool?\n');
161
225
  if (!violations.length) {
162
226
  console.log('✅ none. Every RuvNet capability this repo names is either genuinely used or openly disclosed as a hand-roll.');
@@ -46,7 +46,6 @@ import { effectivePrices, loadLabelledRows, MIN_LABELS, OUTCOMES } from './metah
46
46
  import { utilization } from './router-utilization.mjs';
47
47
  import { loadCatalog, detectProvider, frontierFor } from './model-catalog.mjs';
48
48
  import { providerAvailability } from './provider-availability.mjs';
49
- import { inspectSessionSnapshots } from './session-snapshot-contract.mjs';
50
49
  import { learnings } from './learnings.mjs';
51
50
  import { gatesSurvey } from './gates.mjs';
52
51
  // The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
@@ -71,7 +70,7 @@ import {
71
70
  saveOpenRouterCredential,
72
71
  loadRuntimePreferences,
73
72
  } from '../plugin/scripts/runtime-preferences.mjs';
74
- import { observeLearning as observeScopedLearning } from '../plugin/scripts/learning-observation.mjs';
73
+ import { observeLearning as observeScopedLearning } from '../plugin/scripts/learning-store.mjs';
75
74
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
76
75
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
77
76
  // the PreCompact snapshot producer (#85) and with memory-doctor's root scan (#107).
@@ -494,20 +493,9 @@ function probeMemory(projectDir, { now = Date.now() } = {}) {
494
493
  const stores = memoryStores(projectDir);
495
494
  const db = stores.scored;
496
495
  const probes = { stores };
497
- // compaction survival + session surfacing are filesystem facts, always checkable
498
- const snapshot = inspectSessionSnapshots(projectDir, { now });
499
- const snapshotDetail = snapshot.kind === 'canonical'
500
- ? 'a fresh versioned PreCompact snapshot is present in .swarm/agentdb-sessions.jsonl'
501
- : snapshot.kind === 'legacy'
502
- ? 'a fresh supported Ruflo session snapshot is present (legacy path; migration recommended)'
503
- : snapshot.kind === 'malformed'
504
- ? 'snapshot files were found but none matched the supported schema'
505
- : snapshot.kind !== 'absent'
506
- ? 'the newest supported snapshot is stale'
507
- : 'no supported PreCompact snapshot found for this project yet';
508
- probes.compactionSurvival = snapshot.fresh
509
- ? { status: 'ok', detail: snapshotDetail, artifact: snapshot.kind }
510
- : { status: 'warn', detail: snapshotDetail, artifact: snapshot.kind };
496
+ // Compaction survival is a ruflo write at PreCompact (project-state-auto-* + exact readback); a refusal
497
+ // reaches the hook-failures summary. It is not re-probed at render.
498
+ probes.compactionSurvival = { status: 'notTested', detail: 'PreCompact stores a project-state-auto-* checkpoint through ruflo and reads it back; failures appear in the SessionStart hook-failures summary' };
511
499
  probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' };
512
500
  // recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test)
513
501
  probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' };
@@ -776,7 +764,7 @@ function gatherInventory() {
776
764
  /**
777
765
  * Keys a project-level <project>/.swarm/ruvnet-brain-settings.json overrides FOR THE RUNTIME THAT
778
766
  * READS IT. runtime-preferences merges that file over the user-level choices, and route-cheap, the
779
- * managed-CLI gate (routing, qeFleet) and learn-capture/learn-flush (learningScope) obey the merge.
767
+ * managed-CLI gate (routing, qeFleet) and learn-capture (learningScope) obey the merge.
780
768
  * The console saves user-level values only, so without this a project seeded by "Apply these choices
781
769
  * to new projects" silently ignored every later change made here (RNBC QA 2026-10-01). Only keys whose
782
770
  * consumer honours the project file are reported — advocacy, autoApply and provider are read at user
@@ -2618,18 +2606,15 @@ function apply(ids) {
2618
2606
  const undoToken = journalUndo(undoSpec);
2619
2607
  phaseMs.undoJournalMs += elapsedMs(journalStartedAt);
2620
2608
  const remedyStartedAt = performance.now();
2621
- const learningBefore = ['learning:flush', 'learning:train', 'learning:flush-legacy-user'].includes(id) ? observeLearning() : null;
2609
+ const learningBefore = id === 'learning:train' ? observeLearning() : null;
2622
2610
  const res = runNode(plan.exec.script, args, learningBefore ? {
2623
2611
  cwd: learningBefore.projectDir,
2624
2612
  env: { ...process.env, RUVNET_BRAIN_PROJECT_DIR: learningBefore.projectDir },
2625
2613
  } : {});
2626
2614
  if (res.ok && learningBefore) {
2627
2615
  const after = observeLearning();
2628
- const progressed = id === 'learning:flush-legacy-user'
2629
- ? after.legacyUserKnown && after.legacyUserDepth < learningBefore.legacyUserDepth
2630
- : id === 'learning:flush'
2631
- ? after.queueKnown && after.queueDepth < learningBefore.queueDepth
2632
- : learningBefore.statusKnown && after.statusKnown && after.patterns > learningBefore.patterns;
2616
+ // `ruflo memory distill run` owns the patterns; progress here is a known, readable learner afterwards.
2617
+ const progressed = learningBefore.statusKnown && after.statusKnown;
2633
2618
  if (!progressed) {
2634
2619
  res.ok = false;
2635
2620
  res.log += `\nNo measured learning progress: queue ${after.queueDir}; learner ${after.learnerCwd}. Reload to inspect the remaining evidence.`;
@@ -53,7 +53,7 @@ export const PRODUCT_INTEGRITY_OBLIGATIONS = Object.freeze([
53
53
  make('S-8', 'Accepted architecture has one executable owner and source-bound trace', 'ProductIntegrityCase', [], ['scripts/product-integrity-contract.mjs', 'scripts/source-scope-receipt.mjs'], ['tests/unit/product-integrity-contract.test.mjs', 'tests/unit/source-scope-receipt.test.mjs'], ['ruvnet-brain-product-integrity-contract', 'ruvnet-brain-product-integrity-trace']),
54
54
  make('S-9', 'Every essential behavior has positive and adversarial proof', 'ProductIntegrityCase', [], ['scripts/product-integrity-contract.mjs', 'scripts/adr-072-completion.mjs'], ['tests/unit/product-integrity-contract.test.mjs', 'tests/unit/adr-072-completion.test.mjs'], ['ruvnet-brain-product-integrity-trace', 'ruvnet-brain-adr-072-completion']),
55
55
  make('S-10', 'Fable 5 and GPT-5.6-Sol review identical immutable inputs', 'ProductIntegrityCase', ['PublicVerification'], ['scripts/public-verification-aggregate.mjs', '.github/workflows/protected-release.yml'], ['tests/unit/public-verification-aggregate.test.mjs', 'tests/unit/protected-release-workflow.test.mjs'], ['ruvnet-brain-independent-review', 'ruvnet-brain-public-verification-aggregate']),
56
- make('S-11', 'Project continuity is complete and host-neutral', 'ProductIntegrityCase', ['HostConvergence', 'RefreshLifecycle'], ['plugin/scripts/project-progression-contract.mjs', 'plugin/scripts/project-progression-hook.mjs'], ['tests/unit/project-progression-contract.test.mjs', 'tests/acceptance/cross-host-project-resume.test.mjs'], ['ruvnet-brain-project-progression', 'ruvnet-brain-cross-host-resume'], ['ADR-073', 'DDD-0019']),
56
+ make('S-11', 'Project continuity is complete and host-neutral', 'ProductIntegrityCase', ['HostConvergence', 'RefreshLifecycle'], ['plugin/scripts/ruflo-memory-hooks.mjs', 'plugin/scripts/session-snapshot-hook.mjs'], ['tests/integration/ruflo-memory-hooks.test.mjs'], ['ruvnet-brain-project-progression', 'ruvnet-brain-cross-host-resume'], ['ADR-073', 'DDD-0019']),
57
57
  make('S-12', 'RuvNet capability claims are bound to live evidence before delivery', 'ProductIntegrityCase', ['HostConvergence', 'PublicVerification'], [
58
58
  'plugin/scripts/capability-inventory-receipt.mjs',
59
59
  'plugin/scripts/capability-claim-evidence.mjs',