ruvnet-brain 4.5.7 → 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 (32) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +50 -1
  3. package/package.json +5 -3
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/hooks/codex-hooks.json +31 -1
  7. package/plugin/hooks/hook-contracts.json +81 -1
  8. package/plugin/hooks/hooks.json +31 -1
  9. package/plugin/scripts/agentdb-recall.mjs +38 -10
  10. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  11. package/plugin/scripts/hook-shim.mjs +1 -1
  12. package/plugin/scripts/kb-copy-proof.mjs +80 -19
  13. package/plugin/scripts/learn-capture.mjs +44 -0
  14. package/plugin/scripts/learn-capture.sh +2 -241
  15. package/plugin/scripts/learn-flush.mjs +86 -191
  16. package/plugin/scripts/learning-observation.mjs +51 -0
  17. package/plugin/scripts/learning-queue.mjs +135 -0
  18. package/plugin/scripts/learning-store.mjs +105 -0
  19. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  20. package/plugin/scripts/project-progression-contract.mjs +58 -4
  21. package/plugin/scripts/runtime-preferences.mjs +45 -4
  22. package/plugin/scripts/session-start-core.mjs +8 -0
  23. package/plugin/scripts/session-start-proof.mjs +49 -0
  24. package/scripts/codex-fresh-host-proof.mjs +179 -0
  25. package/scripts/codex-host-execution-proof.mjs +246 -0
  26. package/scripts/codex-host-proof-runtime.mjs +104 -0
  27. package/scripts/console-engine.mjs +29 -16
  28. package/scripts/health-repair.mjs +47 -68
  29. package/scripts/onboarding-console.mjs +25 -54
  30. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  31. package/scripts/release-qualification-contract.mjs +49 -3
  32. package/scripts/remedy-registry.mjs +9 -2
@@ -1,201 +1,96 @@
1
1
  #!/usr/bin/env node
2
- // learn-flush.mjs — SessionEnd. Reads this session's learning queue (the workflow you just performed)
3
- // and feeds the distinct steps into the GLOBAL per-user SONA learner — ruflo hooks run with cwd=$HOME
4
- // so learnings accumulate in ONE store (~/.claude-flow), shared across ALL your projects. Project FACTS
5
- // never come here (the queue holds command verbs + file basenames, no content). Each installed RuvNet
6
- // Brain does this for its own user → everyone's brain gets recursively smarter about how THEY work. ADR-0017.
7
- //
8
- // Non-blocking, best-effort. Bounded (distinct actions, short timeouts). `--sync` waits (for tests);
9
- // the hook default backgrounds so SessionEnd never stalls.
10
-
2
+ // SessionEnd schedules a finite detached learner; --sync is the explicit Console drain.
11
3
  import fs from 'node:fs';
12
- import os from 'node:os';
13
4
  import path from 'node:path';
14
- import { execFileSync } from 'node:child_process';
15
- import { readStdinBounded } from './hook-input.mjs';
16
- import { learningScope, loadRuntimePreferences } from './runtime-preferences.mjs';
17
- import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
18
- import { projectDirectory } from './project-identity.mjs';
19
-
20
- // ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
21
- // because a hook that prints a stack trace on every `/clear` gets muted — and a muted diagnostic is
22
- // no diagnostic at all (the same lesson as the session-start line that reported its own defect every
23
- // session for eight days and went unread).
24
- const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } catch { /* stderr gone */ } };
25
-
26
- const HOME = os.homedir();
27
- // RESIDUAL of #134/#104: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either host,
28
- // so it degraded back to raw cwd() in production. `projectDirectory()` (project-identity.mjs) is the
29
- // SAME CLAUDE_PROJECT_DIR-with-containment rule #85/#107 already fixed for the receipt/Console
30
- // agreement — reused here rather than trusting the variable unconditionally, which would reopen the
31
- // class of bug #107 was: an unrelated declared root overruling a cwd it does not actually contain.
32
- const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env: process.env });
33
- // ISSUE #139 — this WRITER resolved scope correctly while two READERS hardcoded it, so they agreed
34
- // only by coincidence. The resolution moved into runtime-preferences.mjs and all three now call it;
35
- // a future scope is one edit, not three. Behaviour here is unchanged by design.
36
- const LEARNING_SCOPE = learningScope({ cwd: PROJECT });
37
- if (LEARNING_SCOPE === 'off') process.exit(0);
38
-
39
- // THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
40
- //
41
- // This used to be `process.env.CLAUDE_SESSION_ID || 'default'`. Claude Code does not set that
42
- // variable, so every session on the machine read and rewrote ONE shared session-default.jsonl —
43
- // measured live at 147 lines, appended by several concurrent sessions. Both halves of this pipeline
44
- // have to agree about which file they mean, so both now read `session_id` from the payload the hook
45
- // is already handed, sanitise it the same way, and fall back the same way.
46
- //
47
- // The read is bounded: SessionEnd hands us a small JSON object and closes, but an unbounded
48
- // readFileSync(0) on a stdin that never closes is a hang with no upper bound. A payload we cannot
49
- // read in time simply yields no id, which lands on the same fallback as no payload at all.
50
- async function payloadSessionId() {
51
- if (process.stdin.isTTY) return '';
52
- try {
53
- const raw = (await readStdinBounded()).toString('utf8');
54
- const v = JSON.parse(raw)?.session_id;
55
- return typeof v === 'string' ? v : '';
56
- } catch { return ''; }
57
- }
58
- // A filename COMPONENT, never a path — the payload is untrusted input.
59
- const SID = ((await payloadSessionId()) || process.env.CLAUDE_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '') || 'default';
60
- const QUEUE_ROOT = LEARNING_SCOPE === 'user'
61
- ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
62
- : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
63
- const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
64
- // Issue #105: this was a hardcoded `path.join(HOME, '.npm-global/bin/ruflo')` — the owner's npm
65
- // prefix. On any other prefix (Homebrew, nvm, Volta, plain `npm -g`) the path simply did not exist,
66
- // every feed below threw ENOENT, and every throw landed in a `catch {}` that said nothing. One
67
- // resolver, shared with distill-project.mjs and health-repair.mjs's original — see ruflo-bin.mjs.
68
- const RUFLO = resolveRuflo();
69
- const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
70
- const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
71
-
72
- // THE DEADLINE. SessionEnd's registered timeout is 30s (plugin/hooks/hooks.json) and this hook fires
73
- // on EVERY session end — including every `/clear`. Measured on the owner's machine 2026-07-27, in all
74
- // four stdin regimes: 48–50s wall, killed at the cap every single time.
75
- //
76
- // The arithmetic was never survivable. MAX_ACTIONS is 8 and a real `ruflo hooks` call measured 3.83s,
77
- // so the feed queued ~31s of work into a 30s budget and was killed part-way through it. Worse, the
78
- // kill lands BEFORE the write-back that preserves the remainder, so the queue never shrinks and never
79
- // drains — a cap that guarantees the work it defers can never be done.
80
- //
81
- // A work limit has to be expressed in the currency the budget is denominated in. MAX_ACTIONS bounds
82
- // COUNT; this bounds TIME, and the two together mean the hook stops cleanly, keeps what it did not
83
- // feed, and exits well inside the cap. 20s leaves a full third of the budget for the write-back, the
84
- // process teardown, and a slow machine. Measured with a 4s-per-call stub and a 147-entry queue: 22.5s
85
- // wall at a 20s deadline (execFileSync's own kill handling costs a couple of seconds on top of the
86
- // budget), so the number is set at 18s to keep the real worst case around 20s — a third of the cap in
87
- // hand. The budget is the thing being bounded; the constant is chosen from the measurement, not from
88
- // how round it looks.
89
- const DEADLINE_MS = Number(process.env.LEARN_FLUSH_DEADLINE_MS) || 18_000;
90
- const DEADLINE = Date.now() + DEADLINE_MS;
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { learningContext } from './runtime-preferences.mjs';
8
+ import { recordLearningObservation, distillLearning } from './learning-store.mjs';
9
+ import { superviseLearning } from './learning-worker-supervisor.mjs';
10
+ import { resolveRuflo } from './ruflo-bin.mjs';
11
+ import { safeQueue, queueFiles, pendingRecords, acknowledge, safeAction, takeQueueLock,
12
+ ownsQueueLock, releaseQueueLock, WORKER_BUDGET_MS, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
91
13
 
92
- let lines = [];
93
- try { lines = fs.readFileSync(QUEUE, 'utf8').split('\n').filter(Boolean); } catch { process.exit(0); }
94
- if (!lines.length) process.exit(0);
95
-
96
- // Distinct workflow actions this session (dedupe → a session has only a handful of real patterns).
97
- //
98
- // COLLECT ALL, FEED SOME, KEEP THE REST (fixed 2026-07-22). This used to `break` at MAX_ACTIONS and
99
- // then delete the ENTIRE queue, so a session with 30 distinct actions fed 8 and destroyed 22 —
100
- // permanently, silently, while reporting success. Measured on the owner's machine the same day: the
101
- // queue stood at 491 raw captures, every one of which would have been discarded after feeding 8.
102
- //
103
- // The cap exists for a good reason (SessionEnd must stay fast) but a work LIMIT is not a licence to
104
- // destroy the work you didn't do. Now the remainder is written back and drains on the next flush,
105
- // so a deep queue converges instead of being truncated.
106
- const allDistinct = [];
107
- const seen = new Set();
108
- for (const line of lines) {
109
- let s; try { s = JSON.parse(line); } catch { continue; }
110
- const key = `${s.tool}|${(s.action || '').slice(0, 60)}`;
111
- if (!s.action || seen.has(key)) continue;
112
- seen.add(key);
113
- allDistinct.push(s);
114
- }
115
- const actions = allDistinct.slice(0, MAX_ACTIONS);
116
- const deferred = allDistinct.slice(MAX_ACTIONS);
117
-
118
- // ruflo is genuinely not on this machine. SAY SO — once — and keep the queue. Exiting 0 keeps the
119
- // best-effort contract (an absent optional learner must never break SessionEnd); saying nothing at
120
- // all is what turned #105 into eight invisible ENOENTs and a queue that never drained.
121
- if (!RUFLO && actions.length) {
122
- warn(`0/${actions.length} fed — ${RUFLO_MISSING}. The queue is KEPT for retry.`);
123
- process.exit(0);
124
- }
14
+ const started = Date.now();
15
+ const budget = Math.min(WORKER_BUDGET_MS, Math.max(1, Number(process.env.LEARN_FLUSH_DEADLINE_MS) || WORKER_BUDGET_MS));
16
+ const deadline = Math.min(started + budget, Number(process.env.RUVNET_LEARN_WORKER_EXPIRES) || Infinity);
17
+ const context = () => learningContext();
18
+ const initial = context();
19
+ if (!initial.enabled) process.exit(0);
20
+ const allowed = () => { const current = context(); return current.enabled && current.scope === initial.scope && current.queueDir === initial.queueDir; };
21
+ let token = process.env.RUVNET_LEARN_WORKER_TOKEN;
22
+ try { token ||= takeQueueLock(initial); } catch { process.exit(0); }
23
+ if (!token || !ownsQueueLock(initial, token)) process.exit(0);
125
24
 
126
- let fed = 0;
127
- const failures = []; // WHY each feed failed — the thing `catch {}` used to destroy
128
- let stoppedAt = actions.length; // how far the feed actually got before the deadline
129
- for (let i = 0; i < actions.length; i++) {
130
- const remaining = DEADLINE - Date.now();
131
- // STOP CLEANLY, and stop BEFORE starting work that cannot finish inside the budget. A call begun
132
- // at 19.9s with a 6s timeout would run to 25.9s, which is the whole failure in miniature — the
133
- // budget has to bound the call, not just the decision to make it.
134
- if (remaining <= 0) { stoppedAt = i; break; }
135
- const s = actions[i];
136
- const args = s.tool === 'Bash'
137
- ? ['hooks', 'post-command', '-c', s.action, '-s', 'true']
138
- : ['hooks', 'post-edit', '-f', s.action, '-s', 'true', '-o', 'session edit'];
139
- try {
140
- // One command, two real Ruflo scopes: project cwd keeps patterns local; HOME retains the
141
- // cross-project SONA learner for users who explicitly chose `user`.
142
- execFileSync(RUFLO, args, {
143
- cwd: LEARNING_SCOPE === 'user' ? HOME : PROJECT,
144
- env: RUFLO_ENV,
145
- stdio: 'ignore',
146
- timeout: Math.min(6000, remaining),
147
- });
148
- fed++;
149
- } catch (e) {
150
- // BEST-EFFORT, NOT SILENT. The old `catch { /* best-effort */ }` swallowed the reason, so a
151
- // machine where every call failed looked exactly like one where every call worked: exit 0,
152
- // no output, and the only trace a queue that never shrank. "Reports success while doing
153
- // nothing" is the defect class this project treats as the worst thing it can ship. Keep going
154
- // (one bad record must not stall session end), but keep the reason.
155
- failures.push(String(e?.message || e).split('\n')[0].slice(0, 120));
25
+ // No enumeration/sorting on the native hook's synchronous path.
26
+ if (!process.argv.includes('--worker')) {
27
+ if (process.argv.includes('--sync') || process.argv.includes('--supervisor')) {
28
+ await superviseLearning(initial, token, deadline, { report: process.argv.includes('--sync') });
29
+ } else {
30
+ try {
31
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--supervisor'], {
32
+ cwd: initial.projectDir, detached: true, stdio: 'ignore', windowsHide: true,
33
+ env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
34
+ });
35
+ child.once('error', () => releaseQueueLock(initial, token)); child.unref();
36
+ } catch { releaseQueueLock(initial, token); }
156
37
  }
157
- }
158
- // SURFACE IT. Distinct reasons only, at most two: eight copies of the same ENOENT is noise, and the
159
- // second distinct reason is usually where the real information is.
160
- if (failures.length) {
161
- const distinct = [...new Set(failures)];
162
- warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
163
- + ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
164
- + (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.'
165
- : `. ${fed} succeeded; the entire queue is KEPT for retry (successful actions may replay).`));
166
- // Preserve the original bytes on ANY failed attempt. Rewriting only the deferred tail would
167
- // discard failed actions whenever a sibling succeeded. This is at-least-once retry, not an
168
- // exactly-once or concurrent-capture protocol; successful actions may be fed again.
169
38
  process.exit(0);
170
39
  }
171
- // Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
172
- // next flush continues from there. Dropping it would turn a time limit into the same silent data
173
- // loss the count limit used to cause.
174
- if (stoppedAt < actions.length) deferred.unshift(...actions.slice(stoppedAt));
175
-
176
- // DERIVED, not asserted (F14, 2026-07-18): the queue is EVIDENCE, and it may only be destroyed when
177
- // its contents were actually fed. The old line deleted it unconditionally — a session where every
178
- // `ruflo hooks` call failed (fed=0) silently discarded the whole learning queue with nothing learned
179
- // and no trace. Now: nothing fed + something to feed ⇒ the queue survives for the next session-end
180
- // to retry. An empty queue (nothing to feed) is safe to remove.
181
- if (fed > 0 || allDistinct.length === 0) {
182
- if (deferred.length) {
183
- // Work remains. Write back ONLY what was not fed, so the next flush continues where this one
184
- // stopped. Deleting here is what turned a rate limit into data loss.
185
- try {
186
- fs.writeFileSync(QUEUE, deferred.map((s) => JSON.stringify(s)).join('\n') + '\n');
187
- } catch { /* if we cannot rewrite it, leaving the full queue is strictly safer than removing it */ }
188
- } else {
189
- try { fs.rmSync(QUEUE); } catch { /* leave it if we can't remove */ }
40
+ const cursorFile = path.join(initial.queueDir, '.scan-cursor');
41
+ let cursor = ''; try { cursor = readSafe(cursorFile, 256).toString(); } catch { /* first pass */ }
42
+ let files; try { files = queueFiles(initial, { cursor, limit: 128, deadline }); } catch { if (!process.send) releaseQueueLock(initial, token); process.exit(0); }
43
+ const result = { schemaVersion: 1, scope: initial.scope, startedAt: new Date().toISOString(),
44
+ fed: 0, acknowledged: 0, failed: 0, malformed: 0, scannedFiles: 0, exhausted: false, recorded: [], distillation: null,
45
+ contract: 'Exact CLI plus independent canonical AgentDB row commits observations; distillation and ratified lessons are separate; originals retained' };
46
+ const binary = resolveRuflo({ home: initial.home });
47
+ try {
48
+ let bytes = 0; let scanned = null;
49
+ for (const file of files.slice(0, 128)) {
50
+ if (Date.now() >= deadline || result.fed + result.failed >= 8 || bytes >= 16 * 1024 * 1024) break;
51
+ safeQueue(initial); result.scannedFiles++; scanned = path.basename(file);
52
+ let state;
53
+ try { state = pendingRecords(file); bytes += fs.statSync(file).size; }
54
+ catch { result.failed++; continue; } // Unsafe/torn sidecars remain intact; siblings still get a fair turn.
55
+ for (const record of state.records) {
56
+ if (Date.now() >= deadline || result.fed + result.failed >= 8) break;
57
+ if (!allowed() || !ownsQueueLock(initial, token)) break;
58
+ let row;
59
+ try { row = JSON.parse(record.raw); } catch { result.malformed++; continue; }
60
+ const action = safeAction(row.tool, row.action);
61
+ if (!record.key || !action) { result.malformed++; continue; }
62
+ if (!binary) { result.failed++; break; }
63
+ let delivery;
64
+ try {
65
+ delivery = recordLearningObservation(binary, initial, file, record, { tool: row.tool, action }, {
66
+ deadline, explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1',
67
+ allowed: () => allowed() && ownsQueueLock(initial, token),
68
+ });
69
+ } catch { result.failed++; continue; }
70
+ result.recorded.push(delivery);
71
+ result.fed++;
72
+ if (!allowed() || !ownsQueueLock(initial, token)) break;
73
+ state.ack[record.key] = true;
74
+ acknowledge(file, state.ack); result.acknowledged++;
75
+ }
190
76
  }
191
- } else if (process.argv.includes('--sync')) {
192
- console.log(`learn-flush: 0/${actions.length} fed (ruflo hooks failing?) — queue KEPT for retry next session-end`);
193
- }
194
- if (process.argv.includes('--sync')) {
195
- console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the ${LEARNING_SCOPE} learner`
196
- // Say the deadline out loud when it fires. A budget that silently truncates reads as "that was
197
- // all there was", which is the same lie as the count cap that preceded it.
198
- + (stoppedAt < actions.length ? `; STOPPED at ${stoppedAt}/${actions.length} on the ${DEADLINE_MS}ms deadline` : '')
199
- + (deferred.length ? `; ${deferred.length} distinct action(s) deferred to the next flush (queue kept, nothing discarded)` : ''));
77
+ if (binary && result.fed && allowed() && ownsQueueLock(initial, token) && deadline - Date.now() > 1000) {
78
+ try { result.distillation = distillLearning(binary, initial, { deadline, automatic: true, allowed: () => allowed() && ownsQueueLock(initial, token), explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1' }); }
79
+ catch { result.distillation = { completed: false, reason: 'bounded canonical distillation unavailable', ratifiedLessons: 0 }; }
80
+ }
81
+ result.exhausted = Date.now() >= deadline || result.fed + result.failed >= 8;
82
+ if (allowed() && ownsQueueLock(initial, token)) {
83
+ if (scanned) writeAtomic(cursorFile, scanned);
84
+ const receipt = path.join(initial.queueDir, `.run-${Date.now()}-${process.pid}.json`);
85
+ writeExclusive(receipt, JSON.stringify({ ...result, endedAt: new Date().toISOString() }));
86
+ }
87
+ } catch { result.failed++; }
88
+ finally { if (!process.send) releaseQueueLock(initial, token); }
89
+ if (process.argv.includes('--report')) console.log(`learn-flush: fed ${result.fed}; acknowledged ${result.acknowledged}; failed ${result.failed}; malformed ${result.malformed}; original queue is KEPT for retry/history`);
90
+
91
+ // Keep the owned root alive until its supervisor terminates and confirms the whole tree.
92
+ // A lost supervisor cannot keep this worker alive beyond its inherited deadline.
93
+ if (process.send) {
94
+ process.send({ type: 'learning-worker-complete' });
95
+ await new Promise(resolve => setTimeout(resolve, Math.max(1, deadline - Date.now() + 600)));
200
96
  }
201
- process.exit(0);
@@ -0,0 +1,51 @@
1
+ // Read-only evidence shared by Console and the training remedy. No queue or learner is initialized.
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { learningTarget, learningStoreStatus } from './learning-store.mjs';
5
+ import { queueFiles, pendingRecords, safeQueue, readSafe } from './learning-queue.mjs';
6
+ import { learningContext } from './runtime-preferences.mjs';
7
+
8
+ const queueContext = (queueDir) => path.basename(queueDir) === 'ruvnet-brain-learn'
9
+ ? { scope: 'project', queueDir, projectDir: path.dirname(path.dirname(queueDir)) }
10
+ : { scope: 'user', queueDir, home: path.dirname(path.dirname(path.dirname(queueDir))) };
11
+
12
+ export function learningQueueFiles(queueDir) { return queueFiles(queueContext(queueDir)); }
13
+
14
+ /** Pending evidence includes malformed/torn lines; acknowledged retained originals are history. */
15
+ export function learningQueueDepth(queueDir) {
16
+ return learningQueueFiles(queueDir).reduce((depth, file) => depth + pendingRecords(file).records.length, 0);
17
+ }
18
+
19
+ export function observeLearning(options = {}) {
20
+ const env = options.env ?? process.env;
21
+ const context = learningContext(options);
22
+ const result = { ...context, queueDepth: 0, queueKnown: true, legacyUserDepth: 0, legacyUserKnown: true, lastTrainSeconds: null, trajectories: 0, statusKnown: false };
23
+ if (!context.enabled) return result;
24
+ try { learningTarget(context, { env }); } catch { result.statusKnown = false; result.queueKnown = false; return result; }
25
+ try { result.queueDepth = learningQueueDepth(context.queueDir); }
26
+ catch { result.queueKnown = false; }
27
+ try { const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock'), 4096)); result.workerRetirementUnconfirmed = lock.retirementUnconfirmed === true || (lock.retirementRequired === true && lock.expires < Date.now()); }
28
+ catch { /* Absent diagnostic lock grants no claim about a running process. */ }
29
+ try {
30
+ const latest = fs.readdirSync(safeQueue(context)).filter(n => /^\.run-\d+-\d+\.json$/.test(n))
31
+ .sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))[0];
32
+ const receipt = latest && JSON.parse(readSafe(path.join(context.queueDir, latest), 65536));
33
+ result.capturePendingFailure = result.queueDepth > 0 && ((Number.isSafeInteger(receipt?.failed) && receipt.failed > 0)
34
+ || (Number.isSafeInteger(receipt?.malformed) && receipt.malformed > 0));
35
+ result.distillationUnavailable = receipt?.distillation?.completed === false;
36
+ } catch { /* Unknown diagnostic is never fabricated success. */ }
37
+ if (context.scope === 'project') {
38
+ try { result.legacyUserDepth = learningQueueDepth(path.join(context.home, '.cache', 'ruvnet-brain', 'learn')); }
39
+ catch { result.legacyUserKnown = false; }
40
+ }
41
+ try {
42
+ result.learningDb = learningTarget(context, { env });
43
+ const state = learningStoreStatus(result.learningDb);
44
+ result.statusKnown = state.known;
45
+ result.trajectories = state.observations; // compatibility field; explicitly observations, never SONA trajectories.
46
+ result.observations = state.observations; result.patterns = state.patterns;
47
+ result.lastDistillAt = state.lastDistillAt;
48
+ if (state.lastDistillAt !== null) result.lastTrainSeconds = Math.max(0, (Date.now() - state.lastDistillAt) / 1000);
49
+ } catch { /* Missing consent/store/readback remains unknown. */ }
50
+ return result;
51
+ }
@@ -0,0 +1,135 @@
1
+ // Workflow queue transport only. Retained source bytes plus acknowledgments permit crash replay.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+
6
+ export const MAX_QUEUE_BYTES = 1024 * 1024;
7
+ export const WORKER_BUDGET_MS = 18_000;
8
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
9
+ const nofollow = fs.constants.O_NOFOLLOW || 0;
10
+ export const queueName = /^session-[A-Za-z0-9_-]+\.jsonl$/;
11
+
12
+ /** Reject symlinks/junctions at every ancestor beneath the canonical selected scope. */
13
+ export function safeQueue(context, create = false) {
14
+ const root = fs.realpathSync.native(context.scope === 'user' ? context.home : context.projectDir);
15
+ const relative = path.relative(root, context.queueDir);
16
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('queue outside scope');
17
+ let current = root;
18
+ for (const part of relative.split(path.sep)) {
19
+ current = path.join(current, part);
20
+ if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
21
+ const stat = fs.lstatSync(current);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(current) !== current) throw new Error('unsafe queue ancestor');
23
+ }
24
+ return current;
25
+ }
26
+
27
+ export function readSafe(file, maxBytes = MAX_QUEUE_BYTES) {
28
+ if (!fs.lstatSync(file).isFile()) throw new Error('unsafe queue file');
29
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
30
+ try {
31
+ const stat = fs.fstatSync(fd);
32
+ if (!stat.isFile() || stat.size > maxBytes) throw new Error('queue file exceeds bounded read');
33
+ return fs.readFileSync(fd);
34
+ } finally { fs.closeSync(fd); }
35
+ }
36
+
37
+ export function queueFiles(context, options = {}) {
38
+ try {
39
+ const dir = safeQueue(context);
40
+ if (Number.isInteger(options.limit)) {
41
+ const before = []; const after = []; let passed = !options.cursor;
42
+ const iterator = fs.opendirSync(dir);
43
+ try {
44
+ let entry;
45
+ while (Date.now() < options.deadline && (entry = iterator.readSync())) {
46
+ if (!queueName.test(entry.name)) continue;
47
+ const file = path.join(dir, entry.name);
48
+ if (!passed && entry.name === options.cursor) { passed = true; if (before.length < options.limit) before.push(file); continue; }
49
+ if (passed) { after.push(file); if (after.length >= options.limit) break; }
50
+ else if (before.length < options.limit) before.push(file);
51
+ }
52
+ } finally { iterator.closeSync(); }
53
+ return [...after, ...before].slice(0, options.limit);
54
+ }
55
+ return fs.readdirSync(dir).filter(name => queueName.test(name)).map(name => path.join(dir, name))
56
+ .sort((a, b) => fs.lstatSync(a).mtimeMs - fs.lstatSync(b).mtimeMs || a.localeCompare(b));
57
+ } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
58
+ }
59
+
60
+ /** Hash-bound offsets cannot acknowledge replaced source bytes or an appended sibling line. */
61
+ export function pendingRecords(file) {
62
+ const bytes = readSafe(file); let ack = {};
63
+ try { ack = JSON.parse(readSafe(`${file}.ack.json`).toString()); }
64
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ const records = []; let start = 0;
66
+ for (let end = 0; end < bytes.length; end++) {
67
+ if (bytes[end] !== 10) continue;
68
+ const line = bytes.subarray(start, end + 1); const key = `${start}:${hash(line)}`;
69
+ if (line.toString().trim() && ack[key] !== true) records.push({ key, raw: line.toString() });
70
+ start = end + 1;
71
+ }
72
+ // A torn final line is pending evidence; it can become complete on a legacy writer's next append.
73
+ if (start < bytes.length && bytes.subarray(start).toString().trim()) records.push({ key: null, raw: bytes.subarray(start).toString() });
74
+ return { records, ack };
75
+ }
76
+
77
+ export function writeExclusive(file, bytes) {
78
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | nofollow, 0o600);
79
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
80
+ }
81
+
82
+ export function writeAtomic(target, bytes) {
83
+ try { if (!fs.lstatSync(target).isFile()) throw new Error('unsafe acknowledgment'); }
84
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
85
+ const temporary = `${target}.${randomUUID()}`;
86
+ writeExclusive(temporary, bytes);
87
+ fs.renameSync(temporary, target);
88
+ }
89
+ export function acknowledge(file, ack) { writeAtomic(`${file}.ack.json`, JSON.stringify(ack)); }
90
+
91
+ /** Lease lasts longer than the finite worker. PID is diagnostic, never writer authority. */
92
+ export function takeQueueLock(context, now = Date.now()) {
93
+ const dir = safeQueue(context); const file = path.join(dir, '.worker-lock');
94
+ const token = randomUUID(); const body = JSON.stringify({ token, expires: now + 60_000 });
95
+ try { writeExclusive(file, body); return token; } catch (error) { if (error.code !== 'EEXIST') throw error; }
96
+ const seen = readSafe(file).toString();
97
+ let expires;
98
+ try { const owner = JSON.parse(seen); if (owner.retirementUnconfirmed || owner.retirementRequired) return null; expires = owner.expires; } catch { expires = fs.lstatSync(file).mtimeMs + 60_000; }
99
+ if (!Number.isFinite(expires)) expires = fs.lstatSync(file).mtimeMs + 60_000;
100
+ if (!(expires < now)) return null;
101
+ const aside = `${file}.${token}`;
102
+ fs.renameSync(file, aside);
103
+ if (readSafe(aside).toString() !== seen) {
104
+ try { fs.linkSync(aside, file); } catch { /* another fenced owner already exists */ }
105
+ fs.rmSync(aside); return null;
106
+ }
107
+ fs.rmSync(aside);
108
+ try { writeExclusive(file, body); return token; } catch (error) { if (error.code === 'EEXIST') return null; throw error; }
109
+ }
110
+
111
+ export function ownsQueueLock(context, token) {
112
+ try {
113
+ safeQueue(context);
114
+ const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock')).toString());
115
+ return !lock.retirementUnconfirmed && lock.token === token && Date.now() < lock.expires;
116
+ } catch { return false; }
117
+ }
118
+ export function releaseQueueLock(context, token) {
119
+ if (ownsQueueLock(context, token)) fs.rmSync(path.join(context.queueDir, '.worker-lock'));
120
+ }
121
+
122
+ // Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
123
+ const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
124
+ const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
125
+ export function safeAction(tool, value) {
126
+ if (typeof value !== 'string') return null;
127
+ if (tool === 'Bash') {
128
+ const [verb, sub] = value.trim().split(/\s+/, 3);
129
+ if (!verbs.has(verb)) return 'command';
130
+ return subs.has(sub) ? `${verb} ${sub}` : verb;
131
+ }
132
+ if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) return null;
133
+ // Legacy action is minimized again before invocation; no private basename reaches Ruflo argv.
134
+ return 'edit file';
135
+ }
@@ -0,0 +1,105 @@
1
+ // Canonical workflow observations; existing Ruflo is the sole writer, never the legacy JSON hook store.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { resolveProjectStore } from './project-store-resolver.mjs';
7
+ import { withProgressionReader } from './project-progression-reader.mjs';
8
+ import { rufloRunDir } from './project-progression-store.mjs';
9
+ import { rufloInvocation } from './ruflo-bin.mjs';
10
+ import { loadRuntimePreferences } from './runtime-preferences.mjs';
11
+ import { loadNodeSqlite } from './node-sqlite.mjs';
12
+ import { safeQueue, writeAtomic } from './learning-queue.mjs';
13
+
14
+ export const LEARNING_NAMESPACE = 'learning-observations';
15
+ export function learningTarget(context, { env = process.env, explicitLegacyApply = false } = {}) {
16
+ if (!context.enabled) throw new Error('learning consent disabled');
17
+ if (context.scope === 'project') return resolveProjectStore({ projectDir: context.projectDir, gitTimeoutMs: 1000 }).canonicalAgentDbPath;
18
+ const root = path.join(context.home, '.claude', 'global-memory');
19
+ const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
20
+ if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
21
+ safeQueue({ scope: 'user', home: context.home, queueDir: root });
22
+ const db = path.join(root, '.swarm', 'memory.db');
23
+ if (!fs.lstatSync(db).isFile()) throw new Error('user store not adopted');
24
+ const resolved = resolveProjectStore({ projectDir: root, requestedStorePath: db, gitTimeoutMs: 1000 });
25
+ if (resolved.projectRoot !== root) throw new Error('foreign global learner root');
26
+ return resolved.canonicalAgentDbPath;
27
+ }
28
+
29
+ export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000 } = {}) {
30
+ const remaining = deadline - Date.now();
31
+ if (remaining <= 0) throw new Error('learning deadline exhausted');
32
+ const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME, '.cache', 'ruvnet-brain', 'learning-ruflo') });
33
+ try {
34
+ const invocation = rufloInvocation(binary, args);
35
+ return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
36
+ encoding: 'utf8', shell: false, timeout: Math.min(6000, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
37
+ } finally { fs.rmSync(cwd, { force: true, recursive: true }); }
38
+ }
39
+
40
+ export function recordLearningObservation(binary, context, file, record, row, options = {}) {
41
+ const db = learningTarget(context, options);
42
+ const key = 'workflow-' + createHash('sha256').update(path.basename(file) + ':' + record.key).digest('hex');
43
+ const value = JSON.stringify({ schemaVersion: 1, tool: row.tool, action: row.action, scope: context.scope,
44
+ authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
45
+ const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
46
+ const existing = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
47
+ if (!(existing.ok && existing.value === value)) {
48
+ const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
49
+ '--no-upsert', '--provenance', 'system_observation', '--path', db]);
50
+ if (stored.error || stored.status !== 0) throw new Error('canonical observation write failed');
51
+ }
52
+ const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
53
+ if (retrieved.error || retrieved.status !== 0 || retrieved.stdout.trim() !== value) throw new Error('canonical exact CLI readback failed');
54
+ const independent = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
55
+ if (!independent.ok || independent.value !== value) throw new Error('canonical independent row readback failed');
56
+ return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, independentRow: true };
57
+ }
58
+
59
+ export function learningStoreStatus(db) {
60
+ const rows = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).length);
61
+ if (!rows.ok) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
62
+ const sqlite = loadNodeSqlite(); let connection;
63
+ try {
64
+ connection = new sqlite.DatabaseSync(db, { readOnly: true });
65
+ const present = name => !!connection.prepare('SELECT 1 FROM sqlite_master WHERE type=? AND name=?').get('table', name);
66
+ const patterns = present('reasoning_patterns') ? connection.prepare('SELECT count(*) AS n FROM reasoning_patterns WHERE json_valid(metadata) AND json_extract(metadata,?)=?').get('$.namespace', LEARNING_NAMESPACE).n : 0;
67
+ const at = present('distill_state') ? connection.prepare('SELECT last_run_at AS at FROM distill_state WHERE namespace=?').get(LEARNING_NAMESPACE)?.at : null;
68
+ return { known: true, observations: rows.value, patterns, lastDistillAt: at ?? null };
69
+ } catch { return { known: false, observations: rows.value, patterns: null, lastDistillAt: null }; }
70
+ finally { connection?.close(); }
71
+ }
72
+
73
+ /** Existing $0 structural distiller, with a verified native WAL-safe snapshot before mutation. */
74
+ export function distillLearning(binary, context, options = {}) {
75
+ const db = learningTarget(context, options); const before = learningStoreStatus(db);
76
+ if (!before.known) throw new Error('distillation baseline unavailable');
77
+ if (options.automatic && before.lastDistillAt !== null && Date.now() - before.lastDistillAt < 3600_000) {
78
+ return { db, completed: false, deferred: 'automatic cadence: at most hourly', patternDelta: 0, ratifiedLessons: 0 };
79
+ }
80
+ const backupDir = path.join(path.dirname(db), 'learning-backups');
81
+ const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
82
+ if (options.allowed && !options.allowed()) throw new Error('learning consent changed');
83
+ safeQueue({ scope: 'project', projectDir: path.dirname(path.dirname(db)), queueDir: backupDir }, true);
84
+ const prior = new Set(fs.readdirSync(backupDir));
85
+ const backup = run(['memory', 'backup', '--db', db, '--dir', backupDir, '--keep', '3', '--verbose']);
86
+ if (backup.error || backup.status !== 0) throw new Error('distillation backup failed');
87
+ if (!backup.stdout.includes('memory DB backed up → ') || backup.stdout.includes('byte-copy')) throw new Error('native online backup mode unverified');
88
+ const images = fs.readdirSync(backupDir).filter(name => name.endsWith('.db') && !prior.has(name));
89
+ if (images.length !== 1) throw new Error('fresh distillation snapshot absent');
90
+ const image = path.join(backupDir, images[0]);
91
+ fs.chmodSync(image, 0o600);
92
+ // The source-bound verbose marker above distinguishes native online backup from
93
+ // Ruflo's byte-copy fallback. Row verification is additional observation evidence,
94
+ // not a claim that this subset alone proves whole-database rollback.
95
+ const source = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
96
+ const copy = withProgressionReader(image, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
97
+ if (!source.ok || !copy.ok || JSON.stringify(source.value) !== JSON.stringify(copy.value)) throw new Error('distillation snapshot row verification failed');
98
+ writeAtomic(path.join(backupDir, 'latest-receipt.json'), JSON.stringify({ db, before, snapshot: image, providerMode: 'native-online-backup', observationsVerified: true, retainedSnapshots: 3, restore: 'automatic exact restore unavailable' }));
99
+ const distilled = run(['memory', 'distill', 'run', '--db', db, '--namespace', LEARNING_NAMESPACE,
100
+ '--max-entries', '8', '--judge', 'structural', '--budget-usd', '0']);
101
+ const after = learningStoreStatus(db);
102
+ return { db, before, after, completed: !distilled.error && distilled.status === 0,
103
+ patternDelta: before.known && after.known ? after.patterns - before.patterns : null,
104
+ snapshot: image, ratifiedLessons: 0 };
105
+ }