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.
- package/README.md +1 -1
- package/bin/install.mjs +50 -1
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +81 -1
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/hook-shim.mjs +1 -1
- package/plugin/scripts/kb-copy-proof.mjs +80 -19
- package/plugin/scripts/learn-capture.mjs +44 -0
- package/plugin/scripts/learn-capture.sh +2 -241
- package/plugin/scripts/learn-flush.mjs +86 -191
- package/plugin/scripts/learning-observation.mjs +51 -0
- package/plugin/scripts/learning-queue.mjs +135 -0
- package/plugin/scripts/learning-store.mjs +105 -0
- package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/scripts/codex-fresh-host-proof.mjs +179 -0
- package/scripts/codex-host-execution-proof.mjs +246 -0
- package/scripts/codex-host-proof-runtime.mjs +104 -0
- package/scripts/console-engine.mjs +29 -16
- package/scripts/health-repair.mjs +47 -68
- package/scripts/onboarding-console.mjs +25 -54
- package/scripts/qa/progression-validation-benchmark.mjs +66 -0
- package/scripts/release-qualification-contract.mjs +49 -3
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -1,201 +1,96 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|