ruvnet-brain 4.6.3 → 4.7.0
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 +6 -50
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -58
- package/plugin/hooks/hook-contracts.json +19 -220
- package/plugin/hooks/hooks.json +1 -70
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +27 -21
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-events.mjs +90 -82
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +3 -3
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +324 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +61 -0
- package/plugin/scripts/hook-shim.mjs +71 -6
- package/plugin/scripts/learn-capture.mjs +88 -37
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -332
- package/plugin/scripts/session-start-core.mjs +7 -9
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +74 -88
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/deferral-replay.mjs +47 -0
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -357
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -246
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-failures.mjs — the reader half of "a failed advisory hook is a status line" (ADR-106).
|
|
3
|
+
*
|
|
4
|
+
* hook-shim.mjs appends one line to `<Brain home>/hook-failures.jsonl` whenever an advisory body
|
|
5
|
+
* exits nonzero, is killed, or cannot spawn (the writer is inlined there because the shim is
|
|
6
|
+
* boot-frozen). SessionStart calls failureLine() once: it summarizes failures recorded since the
|
|
7
|
+
* last summary and advances a byte cursor, so each failure is reported exactly once, never per fire.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
export const FAILURE_FILE = 'hook-failures.jsonl';
|
|
14
|
+
export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
|
|
15
|
+
const CURSOR_FILE = 'hook-failures.cursor';
|
|
16
|
+
const MAX_READ = 256 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Summarize failures recorded after the cursor; advance it. Returns '' when there is nothing new. */
|
|
19
|
+
export function failureLine(brainHome, { advance = true } = {}) {
|
|
20
|
+
const file = path.join(brainHome, FAILURE_FILE);
|
|
21
|
+
const cursorFile = path.join(brainHome, CURSOR_FILE);
|
|
22
|
+
let size;
|
|
23
|
+
try { size = fs.statSync(file).size; } catch { return ''; }
|
|
24
|
+
let offset = 0;
|
|
25
|
+
try { offset = Number(JSON.parse(fs.readFileSync(cursorFile, 'utf8')).offset) || 0; } catch { /* first read */ }
|
|
26
|
+
if (offset > size) offset = 0; // rotated by the writer
|
|
27
|
+
if (offset === size) return '';
|
|
28
|
+
const start = Math.max(offset, size - MAX_READ);
|
|
29
|
+
const buf = Buffer.alloc(size - start);
|
|
30
|
+
const fd = fs.openSync(file, 'r');
|
|
31
|
+
try { fs.readSync(fd, buf, 0, buf.length, start); } finally { fs.closeSync(fd); }
|
|
32
|
+
const rows = buf.toString('utf8').split('\n').flatMap((l) => { try { return l ? [JSON.parse(l)] : []; } catch { return []; } });
|
|
33
|
+
if (advance) { try { fs.writeFileSync(cursorFile, JSON.stringify({ offset: size })); } catch { /* re-reported next session */ } }
|
|
34
|
+
if (!rows.length) return '';
|
|
35
|
+
const byHook = new Map();
|
|
36
|
+
for (const r of rows) {
|
|
37
|
+
const id = String(r.hook || 'unknown').slice(0, 40);
|
|
38
|
+
const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
|
|
39
|
+
const e = byHook.get(id) || { n: 0, why: new Set() };
|
|
40
|
+
e.n += 1; e.why.add(why); byHook.set(id, e);
|
|
41
|
+
}
|
|
42
|
+
const parts = [...byHook.entries()].sort((a, b) => b[1].n - a[1].n)
|
|
43
|
+
.map(([id, e]) => `${id} ×${e.n} (${[...e.why].slice(0, 3).join(', ')})`);
|
|
44
|
+
return `${FAILURE_LINE_PREFIX} ${rows.length} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
|
|
45
|
+
+ `They failed open. Details: ${file}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The writer for bodies that run OUTSIDE the shim's watchdog (a refused `ruflo memory store`, a failed
|
|
50
|
+
* exact readback). Same file, same row shape as hook-shim.mjs's inlined recordFailure, so the next
|
|
51
|
+
* SessionStart's failureLine() reports it. Never throws: an unrecordable failure must not become a second one.
|
|
52
|
+
*/
|
|
53
|
+
export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain') } = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const file = path.join(brainHome, FAILURE_FILE);
|
|
56
|
+
fs.mkdirSync(brainHome, { recursive: true });
|
|
57
|
+
try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
|
|
58
|
+
fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...fields })}\n`, { mode: 0o600 });
|
|
59
|
+
return true;
|
|
60
|
+
} catch { return false; }
|
|
61
|
+
}
|
|
@@ -122,7 +122,6 @@ const TABLE = {
|
|
|
122
122
|
// invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
|
|
123
123
|
// way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
|
|
124
124
|
'learn-capture': { file: 'learn-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 65536 },
|
|
125
|
-
'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
126
125
|
// 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
|
|
127
126
|
// message truncated mid-JSON would parse as `{}` and silently drop the whole capture.
|
|
128
127
|
'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 1048576 },
|
|
@@ -268,7 +267,7 @@ function readHookInput(limit) {
|
|
|
268
267
|
* even if an older one was already reported.
|
|
269
268
|
*
|
|
270
269
|
* WHY A TTL RATHER THAN A TRUE SESSION ID, stated plainly rather than implied: no session identifier
|
|
271
|
-
* reaches this dispatcher. Claude Code does not set CLAUDE_SESSION_ID (
|
|
270
|
+
* reaches this dispatcher. Claude Code does not set CLAUDE_SESSION_ID (the learning hook documented the
|
|
272
271
|
* same finding), and the only place `session_id` exists is the stdin payload — which the majority of
|
|
273
272
|
* these table entries deliberately never read. So the window is an approximation of a session, not a
|
|
274
273
|
* claim to be one: it bounds a persistent breakage to roughly one line per working session instead
|
|
@@ -312,6 +311,52 @@ function resolveCodeRoot() {
|
|
|
312
311
|
} catch { return null; }
|
|
313
312
|
}
|
|
314
313
|
|
|
314
|
+
/**
|
|
315
|
+
* A FAILED ADVISORY BODY IS RECORDED, NEVER SILENT (ADR-106). Advisory bodies return 0 to the host
|
|
316
|
+
* whatever happened (below), and the host shows no stderr on exit 0 — so a crashed Stop gate used to
|
|
317
|
+
* leave no trace anywhere (measured 2026-10-11: a throwing continuation-gate body → shim exit 0,
|
|
318
|
+
* 0 bytes, nothing on disk). One bounded JSONL line per failure, read and summarized ONCE by
|
|
319
|
+
* SessionStart (hook-failures.mjs owns the reader and the file name; tests/unit/hook-failures.test.mjs
|
|
320
|
+
* holds the two copies of the name together, the same way brain-off.test.mjs holds the sentinel).
|
|
321
|
+
* Inlined, not imported, because this file is boot-frozen. Never throws, never prints.
|
|
322
|
+
*/
|
|
323
|
+
function recordFailure(fields) {
|
|
324
|
+
try {
|
|
325
|
+
const file = path.join(BRAIN_HOME, 'hook-failures.jsonl');
|
|
326
|
+
fs.mkdirSync(BRAIN_HOME, { recursive: true });
|
|
327
|
+
try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
|
|
328
|
+
fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), hook: hookId, ...fields })}\n`, { mode: 0o600 });
|
|
329
|
+
} catch { /* an unrecordable failure must not become a second one */ }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* THE WATCHDOG (ADR-106). The host kills a hook at its registered timeout and DISCARDS its output
|
|
334
|
+
* (measured: "UserPromptSubmit hook … timed out after 10s — output discarded"). So the shim stops the
|
|
335
|
+
* body itself 1.5 s before the registered timeout of THIS command (read from the frozen manifest it
|
|
336
|
+
* was registered from; under Codex the stable wrapper owns the kill instead), records one
|
|
337
|
+
* `timed-out-internally` line in the failure ledger, writes NOTHING partial, and exits 0. SessionStart
|
|
338
|
+
* then reports it once. stdout is captured and forwarded only when the body finished on its own.
|
|
339
|
+
*/
|
|
340
|
+
const WATCHDOG_MARGIN_MS = 1500;
|
|
341
|
+
function registeredTimeoutMs(extraArgs) {
|
|
342
|
+
try {
|
|
343
|
+
// Under Codex the stable wrapper already owns a process-group kill below the host timeout
|
|
344
|
+
// (codex-hook-wrapper.mjs); a second, shorter watchdog here would pre-empt it and orphan the
|
|
345
|
+
// body's own children. So the watchdog is Claude's, where nothing else stands before the host.
|
|
346
|
+
if (process.env.RUVNET_CODEX_BUDGET_MS || process.env.RUVNET_HOOK_HOST === 'codex') return Infinity;
|
|
347
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, 'hooks', 'hooks.json'), 'utf8'));
|
|
348
|
+
const want = ['hook-shim.mjs"', hookId, ...extraArgs].join(' ');
|
|
349
|
+
let best = Infinity;
|
|
350
|
+
for (const groups of Object.values(manifest.hooks || {})) {
|
|
351
|
+
for (const g of groups || []) for (const h of g.hooks || []) {
|
|
352
|
+
const c = String(h.command || '').replace(/\s+\|\|\s+true\s*$/, '');
|
|
353
|
+
if ((c.endsWith(want) || c.includes(`${want} `)) && Number.isFinite(h.timeout)) best = Math.min(best, h.timeout * 1000);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return best;
|
|
357
|
+
} catch { return Infinity; }
|
|
358
|
+
}
|
|
359
|
+
|
|
315
360
|
// Run one hook body. No shell is ever involved: spawnSync with an argument array, interpreter
|
|
316
361
|
// chosen from the typed table — never from input.
|
|
317
362
|
function runHook(file, activeVersion = '') {
|
|
@@ -345,11 +390,15 @@ function runHook(file, activeVersion = '') {
|
|
|
345
390
|
...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
|
|
346
391
|
};
|
|
347
392
|
const io = hookInput !== null
|
|
348
|
-
? { stdio: ['pipe', '
|
|
349
|
-
: { stdio: 'inherit' };
|
|
393
|
+
? { stdio: ['pipe', 'pipe', 'inherit'], input: hookInput }
|
|
394
|
+
: { stdio: ['inherit', 'pipe', 'inherit'] };
|
|
395
|
+
const registered = registeredTimeoutMs(extraArgs);
|
|
396
|
+
const watchdog = Number.isFinite(registered) ? Math.max(500, registered - WATCHDOG_MARGIN_MS) : undefined;
|
|
350
397
|
let sessionTimeout;
|
|
351
398
|
if (hookId === 'session-start') {
|
|
352
399
|
try {
|
|
400
|
+
// The restore plans inside the watchdog, so it finishes cleanly instead of being killed mid-write.
|
|
401
|
+
if (watchdog) env.RUVNET_SESSION_START_DEADLINE_AT = String(Math.min(Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity, Date.now() + watchdog));
|
|
353
402
|
const deadlineAt = sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env,
|
|
354
403
|
startedAt: performance.timeOrigin, pluginRoot: path.resolve(path.dirname(file), '..') });
|
|
355
404
|
env.RUVNET_SESSION_START_DEADLINE_AT = String(deadlineAt);
|
|
@@ -361,12 +410,28 @@ function runHook(file, activeVersion = '') {
|
|
|
361
410
|
}
|
|
362
411
|
}
|
|
363
412
|
if (advisoryBudgetFor(hookId, extraArgs[0] || '') === null) recordContextBudget({ handler: hookId, event: extraArgs[0] || hookId, scope: 'unknown-unframed-context-unchanged' }, env);
|
|
364
|
-
const
|
|
365
|
-
|
|
413
|
+
const limit = Math.min(sessionTimeout || Infinity, watchdog || Infinity);
|
|
414
|
+
const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env, maxBuffer: 16 * 1024 * 1024,
|
|
415
|
+
...(Number.isFinite(limit) ? { timeout: limit, killSignal: 'SIGKILL' } : {}) });
|
|
416
|
+
const timedOut = r.error?.code === 'ETIMEDOUT' || (r.signal === 'SIGKILL' && Number.isFinite(limit));
|
|
417
|
+
if (timedOut) {
|
|
418
|
+
recordFailure({ event: extraArgs[0] || '', error: 'timed-out-internally', limitMs: limit, signal: r.signal || null });
|
|
419
|
+
return 0; // nothing partial reaches the host; a killed body never refuses
|
|
420
|
+
}
|
|
421
|
+
if (r.stdout?.length) {
|
|
422
|
+
// Synchronous, because process.exit() follows and an async pipe write could be cut off.
|
|
423
|
+
for (let off = 0, spins = 0; off < r.stdout.length && spins < 2000;) {
|
|
424
|
+
try { off += fs.writeSync(1, r.stdout, off); } catch (e) { if (e.code !== 'EAGAIN') break; spins += 1; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
366
427
|
if (r.error) {
|
|
428
|
+
recordFailure({ event: extraArgs[0] || '', error: String(r.error.code || r.error.message).slice(0, 120), signal: r.signal || null });
|
|
367
429
|
process.stderr.write(`[hook-shim] ${entry.file}: ${r.error.message}\n`);
|
|
368
430
|
return entry.mode === 'blocking' ? 1 : 0;
|
|
369
431
|
}
|
|
432
|
+
if (r.signal || (entry.mode !== 'blocking' && r.status !== 0)) {
|
|
433
|
+
recordFailure({ event: extraArgs[0] || '', status: r.status, signal: r.signal || null });
|
|
434
|
+
}
|
|
370
435
|
// Blocking hooks: the exit code IS the contract — ground-before-write, design-wall, protect-state,
|
|
371
436
|
// and hijack-ruvnet at an opted-in managedMemoryBoundary (ADR-063). Advisory hooks: always 0.
|
|
372
437
|
//
|
|
@@ -1,44 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
5
4
|
import { spawn } from 'node:child_process';
|
|
6
5
|
import { fileURLToPath } from 'node:url';
|
|
7
6
|
import { readStdinBounded } from './hook-input.mjs';
|
|
8
7
|
import { learningContext } from './runtime-preferences.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { recordLearningObservation, distillObservations } from './learning-store.mjs';
|
|
9
|
+
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
10
|
+
import { recordHookFailure } from './hook-failures.mjs';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) process.exit(0);
|
|
12
|
+
// Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
|
|
13
|
+
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(' '));
|
|
14
|
+
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(' '));
|
|
15
|
+
export function safeAction(tool, value) {
|
|
16
|
+
if (typeof value !== 'string') return null;
|
|
17
|
+
if (tool === 'Bash') {
|
|
18
|
+
const [verb, sub] = value.trim().split(/\s+/, 3);
|
|
19
|
+
if (!verbs.has(verb)) return 'command';
|
|
20
|
+
return subs.has(sub) ? `${verb} ${sub}` : verb;
|
|
22
21
|
}
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
22
|
+
if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) return null;
|
|
23
|
+
// Legacy action is minimized again before invocation; no private basename reaches Ruflo argv.
|
|
24
|
+
return 'edit file';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One PostToolUse observation, written DIRECTLY with `ruflo memory store` + exact-key readback (owner
|
|
29
|
+
* ruling 2026-10-12: no local queue, no flush worker, no replay). It runs in-process from
|
|
30
|
+
* session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
|
|
31
|
+
* unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
|
|
32
|
+
*/
|
|
33
|
+
export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
|
|
34
|
+
let tool; let action;
|
|
35
|
+
try {
|
|
36
|
+
const context = learningContext();
|
|
37
|
+
if (!context.enabled || !input || typeof input !== 'object') return false;
|
|
38
|
+
if (input.hook_event_name && input.hook_event_name !== 'PostToolUse') return false;
|
|
39
|
+
const response = input.tool_response;
|
|
40
|
+
if (typeof response === 'string') {
|
|
41
|
+
const exit = /^Process exited with code (-?\d+)\s*$/m.exec(response);
|
|
42
|
+
if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) return false;
|
|
43
|
+
}
|
|
44
|
+
if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
|
|
45
|
+
|| response?.success === false || response?.is_error === true || response?.isError === true
|
|
46
|
+
|| [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) return false;
|
|
47
|
+
tool = input.tool_name;
|
|
48
|
+
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
|
|
49
|
+
action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
|
|
50
|
+
if (!action) return false;
|
|
51
|
+
if (!binary) throw new Error('ruflo not found');
|
|
52
|
+
const observation = { tool, action, session: String(input.session_id || '') };
|
|
53
|
+
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
54
|
+
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
55
|
+
if (detach) return detachLearning('--observe', { env, brainHome, observation });
|
|
56
|
+
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, brainHome ? { brainHome } : undefined);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields cross. */
|
|
65
|
+
export function detachLearning(mode, { env = process.env, brainHome, observation } = {}) {
|
|
66
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), mode], { cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true,
|
|
67
|
+
env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}) } });
|
|
68
|
+
child.unref();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runDetached(mode) {
|
|
73
|
+
const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
|
|
74
|
+
try {
|
|
75
|
+
const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
|
|
76
|
+
if (!context.enabled) return;
|
|
77
|
+
const binary = resolveRuflo({ env: process.env });
|
|
78
|
+
if (!binary) throw new Error('ruflo not found');
|
|
79
|
+
if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
|
|
80
|
+
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
81
|
+
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
82
|
+
recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
83
|
+
} catch (error) { fail(error); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
87
|
+
if (isMain && ['--observe', '--distill'].includes(process.argv[2])) {
|
|
88
|
+
runDetached(process.argv[2]);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
} else if (isMain) {
|
|
91
|
+
try {
|
|
92
|
+
if (learningContext().enabled) captureLearning(JSON.parse((await readStdinBounded({ maxBytes: 65536, emptyMs: 150 })).toString()));
|
|
93
|
+
} catch { /* malformed input produces no persisted payload */ }
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Canonical workflow observations; existing Ruflo is the sole writer, never the legacy JSON hook store.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import { rufloRunDir } from './project-progression-store.mjs';
|
|
9
|
-
import { rufloInvocation } from './ruflo-bin.mjs';
|
|
8
|
+
import { rufloInvocation, rufloRunDir, resolveRuflo } from './ruflo-bin.mjs';
|
|
10
9
|
import { loadRuntimePreferences } from './runtime-preferences.mjs';
|
|
11
|
-
import {
|
|
12
|
-
import { safeQueue, writeAtomic } from './learning-queue.mjs';
|
|
10
|
+
import { learningContext } from './runtime-preferences.mjs';
|
|
13
11
|
|
|
14
12
|
export const LEARNING_NAMESPACE = 'learning-observations';
|
|
15
13
|
export function learningTarget(context, { env = process.env, explicitLegacyApply = false } = {}) {
|
|
@@ -18,7 +16,8 @@ export function learningTarget(context, { env = process.env, explicitLegacyApply
|
|
|
18
16
|
const root = path.join(context.home, '.claude', 'global-memory');
|
|
19
17
|
const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
|
|
20
18
|
if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
|
|
21
|
-
|
|
19
|
+
const rootStat = fs.lstatSync(root);
|
|
20
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('user learning root is not a real directory');
|
|
22
21
|
const db = path.join(root, '.swarm', 'memory.db');
|
|
23
22
|
if (!fs.lstatSync(db).isFile()) throw new Error('user store not adopted');
|
|
24
23
|
const resolved = resolveProjectStore({ projectDir: root, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
@@ -26,80 +25,91 @@ export function learningTarget(context, { env = process.env, explicitLegacyApply
|
|
|
26
25
|
return resolved.canonicalAgentDbPath;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000 } = {}) {
|
|
28
|
+
export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000, maxStepMs = 6000 } = {}) {
|
|
30
29
|
const remaining = deadline - Date.now();
|
|
31
30
|
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') });
|
|
31
|
+
const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.cache', 'ruvnet-brain', 'learning-ruflo') });
|
|
33
32
|
try {
|
|
34
33
|
const invocation = rufloInvocation(binary, args);
|
|
35
34
|
return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
36
|
-
encoding: 'utf8', shell: false, timeout: Math.min(
|
|
35
|
+
encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
|
|
37
36
|
} finally { fs.rmSync(cwd, { force: true, recursive: true }); }
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* One observation, written DIRECTLY through `ruflo memory store` and proven by exact-key CLI readback.
|
|
41
|
+
* No local queue, no worker, no replay (owner ruling 2026-10-12): a failure throws to the caller, which
|
|
42
|
+
* records it in the hook-failures ledger so SessionStart reports it.
|
|
43
|
+
*/
|
|
44
|
+
export function recordLearningObservation(binary, context, { tool, action, session = '' }, options = {}) {
|
|
41
45
|
const db = learningTarget(context, options);
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
|
|
47
|
+
// session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
|
|
48
|
+
const key = 'workflow-' + createHash('sha256').update(JSON.stringify([session, tool, action])).digest('hex');
|
|
49
|
+
const value = JSON.stringify({ schemaVersion: 1, tool, action, scope: context.scope,
|
|
44
50
|
authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
|
|
45
51
|
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
46
|
-
const
|
|
47
|
-
|
|
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 stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
|
|
53
|
+
'--no-upsert', '--provenance', 'system_observation', '--path', db]);
|
|
52
54
|
const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
55
|
+
const exact = !retrieved.error && retrieved.status === 0 && String(retrieved.stdout || '').trim() === value;
|
|
56
|
+
// A refused store whose key already holds these exact bytes is the dedupe working, not a failure.
|
|
57
|
+
if (stored.error || stored.status !== 0) {
|
|
58
|
+
if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
|
|
59
|
+
throw new Error(`ruflo memory store refused the observation: ${String(stored.stderr || stored.stdout || stored.error?.message || '').trim().slice(0, 160)}`);
|
|
60
|
+
}
|
|
61
|
+
if (!exact) throw new Error('canonical exact CLI readback failed');
|
|
62
|
+
return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!
|
|
62
|
-
const sqlite = loadNodeSqlite(); let connection;
|
|
65
|
+
/** Observation count through `ruflo memory list` (no direct SQLite). Unknown stays unknown. */
|
|
66
|
+
export function learningStoreStatus(db, { binary = resolveRuflo(), env = process.env } = {}) {
|
|
67
|
+
if (!binary) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
|
|
63
68
|
try {
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} catch { return { known: false, observations: rows.value, patterns: null, lastDistillAt: null }; }
|
|
70
|
-
finally { connection?.close(); }
|
|
69
|
+
const listed = runLearningRuflo(binary, ['memory', 'list', '--namespace', LEARNING_NAMESPACE, '--limit', '100000', '--format', 'json', '--path', db], db, { env });
|
|
70
|
+
const text = String(listed.stdout || ''); const at = text.indexOf('[');
|
|
71
|
+
if (listed.status !== 0 || at < 0) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
|
|
72
|
+
return { known: true, observations: JSON.parse(text.slice(at)).length, patterns: null, lastDistillAt: null };
|
|
73
|
+
} catch { return { known: false, observations: 0, patterns: null, lastDistillAt: null }; }
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
/**
|
|
76
|
+
/** `ruflo memory backup` then `ruflo memory distill run`; both are rUv's own commands. */
|
|
74
77
|
export function distillLearning(binary, context, options = {}) {
|
|
75
|
-
const db = learningTarget(context, options);
|
|
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');
|
|
78
|
+
const db = learningTarget(context, options);
|
|
81
79
|
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const backup = run(['memory', 'backup', '--db', db, '--dir', backupDir, '--keep', '3', '--verbose']);
|
|
80
|
+
const backupDir = path.join(path.dirname(db), 'learning-backups');
|
|
81
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
82
|
+
const backup = run(['memory', 'backup', '--db', db, '--dir', backupDir, '--keep', '3']);
|
|
86
83
|
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
84
|
const distilled = run(['memory', 'distill', 'run', '--db', db, '--namespace', LEARNING_NAMESPACE,
|
|
100
85
|
'--max-entries', '8', '--judge', 'structural', '--budget-usd', '0']);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
86
|
+
return { db, completed: !distilled.error && distilled.status === 0, ratifiedLessons: 0 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Distill the observation rows with rUv's own `ruflo memory distill run` (incremental cursor, $0
|
|
91
|
+
* structural judge), bounded. Run detached at SessionEnd/PreCompact (review M3). No backup on this path:
|
|
92
|
+
* a whole-DB copy per session end costs more than the distill. A failure throws to the caller (ledger).
|
|
93
|
+
*/
|
|
94
|
+
export function distillObservations(binary, context, options = {}) {
|
|
95
|
+
const db = learningTarget(context, options);
|
|
96
|
+
const r = runLearningRuflo(binary, ['memory', 'distill', 'run', '--db', db, '--namespace', LEARNING_NAMESPACE,
|
|
97
|
+
'--max-entries', '500', '--judge', 'structural', '--budget-usd', '0'], db, { ...options, maxStepMs: 60_000, deadline: Date.now() + 60_000 });
|
|
98
|
+
if (r.error || r.status !== 0) throw new Error(`ruflo memory distill run did not complete (${r.error?.code || `exit ${r.status}`})`);
|
|
99
|
+
return { db, completed: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the console shows: consent, target and the observation count. There is no local queue. */
|
|
103
|
+
export function observeLearning(options = {}) {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
const context = learningContext(options);
|
|
106
|
+
const result = { ...context, queueDepth: 0, queueKnown: true, legacyUserDepth: 0, legacyUserKnown: true, lastTrainSeconds: null, trajectories: 0, statusKnown: false };
|
|
107
|
+
if (!context.enabled) return result;
|
|
108
|
+
try {
|
|
109
|
+
result.learningDb = learningTarget(context, { env });
|
|
110
|
+
const state = learningStoreStatus(result.learningDb, { env });
|
|
111
|
+
result.statusKnown = state.known; result.trajectories = state.observations; result.observations = state.observations;
|
|
112
|
+
result.patterns = state.patterns; result.lastDistillAt = state.lastDistillAt;
|
|
113
|
+
} catch { /* Missing consent/store/readback remains unknown. */ }
|
|
114
|
+
return result;
|
|
105
115
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* WHY (measured 2026-10-01, Node 22.13.1 locally and Node 22 on the Linux CI full suite, run 36894944735):
|
|
5
5
|
* on Node 22 the first load of `node:sqlite` prints
|
|
6
6
|
* (node:PID) ExperimentalWarning: SQLite is an experimental feature and might change at any time
|
|
7
|
-
* to stderr. SessionStart
|
|
7
|
+
* to stderr. SessionStart once read the store through a native reader, so every SessionStart
|
|
8
8
|
* in an adopted git project on Node 22 carried that line — hook noise a customer sees on every session.
|
|
9
9
|
* Node 24 does not warn, which is why the owner's machine never showed it.
|
|
10
10
|
*
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ruflo-bin.mjs — the ONE place this repo decides WHERE the global `ruflo` binary is.
|
|
3
3
|
*
|
|
4
4
|
* WHY THIS FILE EXISTS. Issues #99 and #105 are one defect filed twice: `scripts/distill-project.mjs`
|
|
5
|
-
* and
|
|
5
|
+
* and the learning flusher each hardcoded `~/.npm-global/bin/ruflo` — the owner's npm
|
|
6
6
|
* prefix, nobody else's. On a Homebrew, nvm, Volta, or plain `npm -g` install that path does not
|
|
7
7
|
* exist, so #99 died with "ruflo is not at ~/.npm-global/bin/ruflo" and #105 failed every feed
|
|
8
8
|
* silently — both on machines where ruflo was installed and sitting on PATH the whole time.
|
|
@@ -99,3 +99,73 @@ export function resolveRuflo({ env = process.env, home = os.homedir() } = {}) {
|
|
|
99
99
|
*/
|
|
100
100
|
export const RUFLO_MISSING = 'ruflo was not found in ~/.npm-global/bin or anywhere on your PATH'
|
|
101
101
|
+ ' — install it with `npm i -g ruflo@latest`, or set RUFLO_BIN to its full path';
|
|
102
|
+
|
|
103
|
+
// ── Private per-project ruflo working directories ──
|
|
104
|
+
import crypto from 'node:crypto';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The working directory ruflo runs in. ruflo writes into its cwd on every invocation even when --path
|
|
108
|
+
* names the store (measured 2026-10-01, ruflo 3.49.0): `.claude/`, `.claude-flow/`, `ruvector.db`, and
|
|
109
|
+
* `<cwd>/.swarm/` holding hnsw.metadata.json — the stored snapshot CONTENT — which it also LOADS when it
|
|
110
|
+
* finds one there (ruflo/v3/@claude-flow/cli/src/memory/memory-initializer.ts: getMemoryRoot() = cwd).
|
|
111
|
+
* So the cwd must be:
|
|
112
|
+
* - never the project tree (it changed the customer's working tree and broke no-op capture detection),
|
|
113
|
+
* never inside `.swarm` (nested `.swarm/.swarm`);
|
|
114
|
+
* - PER PROJECT (one shared dir would pool every project's snapshot text and let one project load
|
|
115
|
+
* another's metadata);
|
|
116
|
+
* - private and ours: under the Brain's own home (never a shared /tmp name another user could pre-create
|
|
117
|
+
* or symlink), every directory we create checked to be a real directory, owned by us, mode 0700.
|
|
118
|
+
* Every ruflo call carries --path, so the store it reads and writes is unaffected by the cwd.
|
|
119
|
+
*/
|
|
120
|
+
export function rufloScratchRoot(env = process.env) {
|
|
121
|
+
if (env.RUVNET_RUFLO_CWD_ROOT) return path.resolve(env.RUVNET_RUFLO_CWD_ROOT);
|
|
122
|
+
return path.join(env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'ruflo-cwd');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create-or-verify one private directory: a real directory (not a link), owned by us, mode 0700. */
|
|
126
|
+
export function ensurePrivateDir(dir) {
|
|
127
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
128
|
+
const stat = fs.lstatSync(dir);
|
|
129
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
130
|
+
throw new Error(`ruflo scratch directory ${dir} is not a real directory (symlink or file); refusing to use it`);
|
|
131
|
+
}
|
|
132
|
+
if (process.platform !== 'win32') {
|
|
133
|
+
if (stat.uid !== process.getuid()) {
|
|
134
|
+
throw new Error(`ruflo scratch directory ${dir} is owned by uid ${stat.uid}, not ${process.getuid()}; refusing to use it`);
|
|
135
|
+
}
|
|
136
|
+
if ((stat.mode & 0o777) !== 0o700) {
|
|
137
|
+
fs.chmodSync(dir, 0o700);
|
|
138
|
+
if ((fs.lstatSync(dir).mode & 0o777) !== 0o700) throw new Error(`ruflo scratch directory ${dir} could not be made private (0700)`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return dir;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
|
|
145
|
+
const projectKey = crypto.createHash('sha256').update(path.resolve(storePath)).digest('hex').slice(0, 32);
|
|
146
|
+
fs.mkdirSync(path.dirname(root), { recursive: true });
|
|
147
|
+
ensurePrivateDir(root);
|
|
148
|
+
return ensurePrivateDir(path.join(root, projectKey));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
|
|
152
|
+
const STALE_RUN_MS = 3_600_000;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One ruflo invocation's working directory: a fresh private `run-*` directory inside the project's
|
|
156
|
+
* scratch dir, removed again by the caller after the call. ruflo copies every snapshot it touches into
|
|
157
|
+
* `<cwd>/.swarm/hnsw.metadata.json`; with one cwd per call that copy never accumulates (4.4.0 kept one
|
|
158
|
+
* per project that grew without bound and outlived the project). Also clears what 4.4.0 left directly
|
|
159
|
+
* in the project scratch dir and any `run-*` older than an hour (a call killed before its cleanup).
|
|
160
|
+
*/
|
|
161
|
+
export function rufloRunDir(storePath, { root = rufloScratchRoot(), now = Date.now() } = {}) {
|
|
162
|
+
const projectScratch = rufloCwdFor(storePath, { root });
|
|
163
|
+
for (const name of fs.readdirSync(projectScratch)) {
|
|
164
|
+
const entry = path.join(projectScratch, name);
|
|
165
|
+
let stat;
|
|
166
|
+
try { stat = fs.lstatSync(entry); } catch { continue; }
|
|
167
|
+
const stale = name.startsWith('run-') && stat.isDirectory() && now - stat.mtimeMs > STALE_RUN_MS;
|
|
168
|
+
if (RUFLO_CWD_ARTIFACTS.includes(name) || stale) fs.rmSync(entry, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
return fs.mkdtempSync(path.join(projectScratch, 'run-'));
|
|
171
|
+
}
|