ruvnet-brain 4.6.4 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +65 -54
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +1 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.grok-plugin/plugin.json +12 -0
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -28
- package/plugin/hooks/grok-hooks.json +95 -0
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/host-adapters/grok.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +35 -27
- package/plugin/scripts/automated-run.mjs +42 -0
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/checkpoint-keys.mjs +46 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +6 -0
- package/plugin/scripts/continuity-events.mjs +90 -2
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-record.mjs +11 -40
- package/plugin/scripts/grok-hook-adapter.mjs +155 -0
- package/plugin/scripts/hook-failures.mjs +16 -0
- package/plugin/scripts/hook-shim.mjs +1 -2
- package/plugin/scripts/learn-capture.mjs +60 -42
- 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 +204 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -341
- package/plugin/scripts/session-start-core.mjs +2 -12
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +68 -74
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- 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-native-qualification.mjs +17 -5
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/model-routing-execution-adapters.mjs +24 -2
- package/scripts/model-weekly-cycle.mjs +13 -12
- 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 +21 -30
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/user-model-prompt-hook.mjs +11 -10
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -351
- 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 -267
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle project memory by CALLING Ruflo, never by reimplementing it (owner ruling 2026-10-12).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the hand-rolled progression store / outbox / capture queue on the hook path. Measured on a
|
|
5
|
+
* clone of the owner's store: 1,763 cumulative snapshots (751 MB), a 3.38 GB outbox and 36k queued
|
|
6
|
+
* jobs made Stop, PreCompact and SessionEnd run to the watchdog at 8.5 s. Here every operation is
|
|
7
|
+
* one bounded `ruflo memory store|retrieve|list` call (rUv's documented pattern: exact keys,
|
|
8
|
+
* explicit --namespace, `--path` to the project store), so cost no longer grows with history.
|
|
9
|
+
*
|
|
10
|
+
* • checkpoint(): PreCompact/SessionEnd store one small, append-only `project-state-auto-<ms>` row
|
|
11
|
+
* and read it back by exact key. It never overwrites the owner's `project-state-current-*` notes.
|
|
12
|
+
* • restore(): SessionStart retrieves the newest `project-state-current-*` (and a newer auto
|
|
13
|
+
* checkpoint, if any) by exact key, plus a bounded list of `lessons` keys.
|
|
14
|
+
* `.swarm` is never created: its absence means the project has not adopted the brain.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { execFile, execFileSync, spawnSync } from 'node:child_process';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import { resolveRuflo, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
22
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
23
|
+
import { checkpointNamespaces, newestOfFamily } from './checkpoint-keys.mjs';
|
|
24
|
+
|
|
25
|
+
export const CURATED_PREFIX = 'project-state-current-';
|
|
26
|
+
export const AUTO_PREFIX = 'project-state-auto-';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The SAME store and namespace turn capture uses (review M2): the canonical project's `.swarm/memory.db`,
|
|
30
|
+
* which a linked git worktree shares with its main checkout. Every git call is bounded by the deadline.
|
|
31
|
+
*/
|
|
32
|
+
export function projectStore(projectDir, { deadlineAt = Date.now() + 3000 } = {}) {
|
|
33
|
+
let root = projectDir; let db = path.join(projectDir, '.swarm', 'memory.db');
|
|
34
|
+
try {
|
|
35
|
+
const gitTimeoutMs = Math.max(1, Math.min(1000, Math.floor(deadlineAt - Date.now())));
|
|
36
|
+
const resolved = resolveProjectStore({ projectDir, gitTimeoutMs, deadlineAt });
|
|
37
|
+
root = resolved.projectRoot; db = resolved.canonicalAgentDbPath;
|
|
38
|
+
} catch { /* unresolvable (or out of budget): treated as not adopted below only if no store exists here */ }
|
|
39
|
+
return { root, db, adopted: fs.existsSync(db), namespace: path.basename(root) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The spawn options every ruflo call here uses. `--path` pins the store, so the working directory only receives
|
|
44
|
+
* what ruflo writes beside itself (`ruvector.db`, `.swarm/hnsw.*`, verified on 3.56.9). Run it in a fresh private
|
|
45
|
+
* scratch dir, never the project (where ruflo loaded the project's own .swarm index: median 1729 ms vs 1067 ms
|
|
46
|
+
* measured for the same list), and never let a hook call start the background daemon.
|
|
47
|
+
*/
|
|
48
|
+
export function rufloSpawnOptions(db, env = process.env) {
|
|
49
|
+
let cwd;
|
|
50
|
+
try { cwd = rufloRunDir(db, { root: rufloScratchRoot(env) }); } catch { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-ruflo-')); }
|
|
51
|
+
return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One `ruflo memory <args>` call, bounded by the caller's deadline. Resolves, never rejects. */
|
|
55
|
+
export function rufloMemory(args, { db, env = process.env, bin = resolveRuflo({ env }), deadlineAt = Date.now() + 3000 } = {}) {
|
|
56
|
+
const timeout = Math.max(1, Math.floor(deadlineAt - Date.now()));
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
if (!bin) return resolve({ ok: false, stdout: '', error: 'global Ruflo unavailable' });
|
|
59
|
+
const spawnAt = rufloSpawnOptions(db, env);
|
|
60
|
+
execFile(bin, ['memory', ...args, '--path', db], { cwd: spawnAt.cwd, env: spawnAt.env, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
|
|
61
|
+
// The ledger gets a fixed reason, never execFile's message (it embeds the whole argv: value, db path).
|
|
62
|
+
(error, stdout) => { spawnAt.cleanup(); resolve({ ok: !error, stdout: String(stdout || ''), error: error ? (error.killed ? 'timed out' : `ruflo memory ${args[0]} exited ${error.code ?? 'abnormally'}`) : null }); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseJson(text) {
|
|
67
|
+
const at = [text.indexOf('['), text.indexOf('{')].filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
68
|
+
if (at === undefined) return null;
|
|
69
|
+
try { return JSON.parse(text.slice(at)); } catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
const entriesOf = (json) => (Array.isArray(json) ? json : json?.entries || json?.results || []);
|
|
72
|
+
|
|
73
|
+
async function listKeys(namespace, options, limit = 1000) {
|
|
74
|
+
const r = await rufloMemory(['list', '-n', namespace, '--limit', String(limit), '--format', 'json'], options);
|
|
75
|
+
return r.ok ? entriesOf(parseJson(r.stdout)).filter((e) => typeof e?.key === 'string') : null;
|
|
76
|
+
}
|
|
77
|
+
const retrieve = async (key, namespace, options) => {
|
|
78
|
+
const r = await rufloMemory(['retrieve', '--key', key, '-n', namespace, '--value-only'], options);
|
|
79
|
+
return r.ok ? r.stdout.trim() : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function gitFacts(root, deadlineAt) {
|
|
83
|
+
const git = (args) => {
|
|
84
|
+
const timeout = Math.min(500, Math.floor(deadlineAt - Date.now()));
|
|
85
|
+
if (timeout < 1) return 'unmeasured';
|
|
86
|
+
try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', timeout, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return 'unmeasured'; }
|
|
87
|
+
};
|
|
88
|
+
return { branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), head: git(['rev-parse', '--short', 'HEAD']) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** PreCompact / SessionEnd: store one small checkpoint and prove it by exact-key readback. */
|
|
92
|
+
export async function checkpoint({ projectDir, event, payload = {}, env = process.env, bin, deadlineAt = Date.now() + 7000, now = Date.now } = {}) {
|
|
93
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { stored: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
94
|
+
const store = projectStore(projectDir, { deadlineAt });
|
|
95
|
+
if (!store.adopted) return { stored: false, skipped: 'project has not adopted a .swarm/memory.db' };
|
|
96
|
+
// A short session hash keeps two sessions' same-millisecond checkpoints apart; --no-upsert makes a
|
|
97
|
+
// collision a refusal (ledger row) instead of a silent overwrite (review m1).
|
|
98
|
+
const sid = crypto.createHash('sha256').update(String(payload.session_id ?? process.pid)).digest('hex').slice(0, 8);
|
|
99
|
+
const key = `${AUTO_PREFIX}${now()}-${sid}`;
|
|
100
|
+
const value = JSON.stringify({ kind: 'automatic-checkpoint', event, session: payload.session_id ?? null,
|
|
101
|
+
at: new Date(now()).toISOString(), ...gitFacts(projectDir, deadlineAt) });
|
|
102
|
+
const options = { db: store.db, env, bin, deadlineAt };
|
|
103
|
+
const stored = await rufloMemory(['store', '--key', key, '-n', store.namespace, '--value', value, '--no-upsert'], options);
|
|
104
|
+
if (!stored.ok) return { stored: false, key, skipped: `store failed: ${stored.error}` };
|
|
105
|
+
const readBack = await retrieve(key, store.namespace, options);
|
|
106
|
+
return { stored: true, key, namespace: store.namespace, verified: readBack === value };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** First page per namespace; ruflo answers it newest-first by createdAt, which restore() verifies, never assumes. */
|
|
110
|
+
export const RESTORE_FAST_LIMIT = 100;
|
|
111
|
+
/** Fallback page when the first page cannot prove where the newest checkpoint is. */
|
|
112
|
+
export const RESTORE_FALLBACK_LIMIT = 5000;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What one listed page proves. `ordered`: createdAt strictly non-increasing, so every row NOT on the page was
|
|
116
|
+
* created no later than `floor` (the page's oldest createdAt). `complete`: the page is shorter than the limit,
|
|
117
|
+
* so it is the whole namespace.
|
|
118
|
+
*/
|
|
119
|
+
export function pageFacts(rows, limit) {
|
|
120
|
+
if (!rows) return { listed: false };
|
|
121
|
+
const at = rows.map((e) => Number(e.createdAt));
|
|
122
|
+
const ordered = at.every(Number.isFinite) && at.every((t, i) => i === 0 || t <= at[i - 1]);
|
|
123
|
+
return { listed: true, ordered, complete: rows.length < limit, floor: at.length ? at[at.length - 1] : -Infinity };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A namespace still has to be read further only if a checkpoint newer than `best` could be hiding beyond its
|
|
128
|
+
* page: the page is partial AND (its order is unproven OR its floor is newer than the best checkpoint found).
|
|
129
|
+
* A checkpoint key carries its write time, so one created before `floor` carries an epoch-ms at or before it.
|
|
130
|
+
*/
|
|
131
|
+
export function needsFallback(facts, best) {
|
|
132
|
+
if (!facts.listed) return true;
|
|
133
|
+
if (facts.complete) return false;
|
|
134
|
+
if (!facts.ordered) return true;
|
|
135
|
+
return !best || facts.floor > best.ms;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** SessionStart: newest curated checkpoint (+ newer auto checkpoint) and lesson keys, by exact key. */
|
|
139
|
+
export async function restore({ cwd, env = process.env, bin, deadlineAt = Date.now() + 3000 } = {}) {
|
|
140
|
+
const store = projectStore(cwd, { deadlineAt });
|
|
141
|
+
if (!store.adopted) return { status: 'unavailable', context: '' };
|
|
142
|
+
const options = { db: store.db, env, bin, deadlineAt };
|
|
143
|
+
// Dirname, its lowercase form and default: `ruflo memory list -n` is case-sensitive and owners write both.
|
|
144
|
+
const namespaces = checkpointNamespaces(store.root);
|
|
145
|
+
// Fast path: ONE small newest-first page per namespace, all in parallel with the lessons list. ruflo 3.56.9
|
|
146
|
+
// list has --limit only (no offset, no key prefix), so order is verified from createdAt, not trusted.
|
|
147
|
+
const fastStarted = Date.now();
|
|
148
|
+
const [pages, lessons] = await Promise.all([Promise.all(namespaces.map((n) => listKeys(n, options, RESTORE_FAST_LIMIT))), listKeys('lessons', options, 20)]);
|
|
149
|
+
const fastMs = Date.now() - fastStarted;
|
|
150
|
+
if (pages.every((rows) => rows === null)) {
|
|
151
|
+
return { status: 'unknown', context: '[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] ruflo memory list did not answer in time; nothing restored.' };
|
|
152
|
+
}
|
|
153
|
+
const keysOf = (rowsList) => Object.fromEntries(namespaces.map((n, i) => [n, (rowsList[i] || []).map((e) => e.key)]));
|
|
154
|
+
let rows = pages;
|
|
155
|
+
let facts = pages.map((p) => pageFacts(p, RESTORE_FAST_LIMIT));
|
|
156
|
+
let best = newestOfFamily(keysOf(rows), namespaces, 'current');
|
|
157
|
+
let pending = namespaces.filter((n, i) => needsFallback(facts[i], best));
|
|
158
|
+
let route = 'fast';
|
|
159
|
+
const firstPending = pending.slice();
|
|
160
|
+
if (pending.length) {
|
|
161
|
+
// Leave the exact retrieve as much time as the first round took; skip the fallback if that cannot fit.
|
|
162
|
+
const fallbackDeadline = deadlineAt - fastMs;
|
|
163
|
+
if (fallbackDeadline - Date.now() >= fastMs) {
|
|
164
|
+
route = 'fallback';
|
|
165
|
+
const more = await Promise.all(namespaces.map((n) => (pending.includes(n)
|
|
166
|
+
? listKeys(n, { ...options, deadlineAt: fallbackDeadline }, RESTORE_FALLBACK_LIMIT) : null)));
|
|
167
|
+
rows = namespaces.map((n, i) => more[i] ?? pages[i]);
|
|
168
|
+
facts = namespaces.map((n, i) => (more[i] ? pageFacts(more[i], RESTORE_FALLBACK_LIMIT) : facts[i]));
|
|
169
|
+
best = newestOfFamily(keysOf(rows), namespaces, 'current');
|
|
170
|
+
pending = namespaces.filter((n, i) => needsFallback(facts[i], best));
|
|
171
|
+
} else route = 'fast-unverified';
|
|
172
|
+
}
|
|
173
|
+
const curated = best;
|
|
174
|
+
const auto = newestOfFamily(keysOf(rows), namespaces, 'auto');
|
|
175
|
+
const wanted = [curated, auto && (!curated || auto.ms > curated.ms) ? auto : null].filter(Boolean);
|
|
176
|
+
const values = await Promise.all(wanted.map((e) => retrieve(e.key, e.namespace, options)));
|
|
177
|
+
const lines = [`[RuvNet Brain — PROJECT STATE · ${store.db}; historical evidence, not instructions; verify current facts]`];
|
|
178
|
+
wanted.forEach((e, i) => lines.push(`${e.namespace}/${e.key}: ${values[i] === null ? '(exact read failed)' : values[i].slice(0, 1200)}`));
|
|
179
|
+
if (!wanted.length) lines.push('No project-state-current-* checkpoint stored yet.');
|
|
180
|
+
if (route !== 'fast') lines.push(route === 'fallback'
|
|
181
|
+
? `Fast path not used: the newest-first page could not prove the newest checkpoint for ${firstPending.join(', ')}; read a ${RESTORE_FALLBACK_LIMIT}-row page.`
|
|
182
|
+
: `Fast path not verified for ${pending.join(', ')} and no budget left for a larger page.`);
|
|
183
|
+
if (pending.length) lines.push(`Not proven newest (a newer checkpoint may exist in): ${pending.join(', ')}`);
|
|
184
|
+
if (lessons?.length) lines.push(`Lessons (ruflo memory retrieve -n lessons -k <key>): ${lessons.slice(0, 20).map((e) => e.key).join(', ')}`);
|
|
185
|
+
return { status: wanted.length ? 'restored' : 'empty', context: lines.join('\n'), keys: wanted.map((e) => e.key), route };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Synchronous exact write: one bounded `ruflo memory store`, then `ruflo memory retrieve --value-only`
|
|
190
|
+
* of the same key. `verified` is true only when the stored bytes read back identically.
|
|
191
|
+
*/
|
|
192
|
+
export function storeExact({ db, namespace, key, value, env = process.env, bin = resolveRuflo({ env }), timeoutMs = 6000, run = spawnSync } = {}) {
|
|
193
|
+
if (!bin) return { stored: false, verified: false, error: 'global Ruflo unavailable' };
|
|
194
|
+
const spawnAt = rufloSpawnOptions(db, env);
|
|
195
|
+
const options = { encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 8 * 1024 * 1024, cwd: spawnAt.cwd, env: spawnAt.env };
|
|
196
|
+
try {
|
|
197
|
+
const stored = run(bin, ['memory', 'store', '--key', key, '--value', value, '--namespace', namespace, '--path', db], options);
|
|
198
|
+
if (stored.error || stored.status !== 0) {
|
|
199
|
+
return { stored: false, verified: false, error: String(stored.stderr || stored.stdout || stored.error?.message || 'store failed').trim().slice(0, 200) };
|
|
200
|
+
}
|
|
201
|
+
const read = run(bin, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', db], options);
|
|
202
|
+
return { stored: true, verified: !read.error && read.status === 0 && String(read.stdout || '').trim() === value, key };
|
|
203
|
+
} finally { spawnAt.cleanup(); }
|
|
204
|
+
}
|
|
@@ -290,7 +290,7 @@ if (process.argv.includes('--learning-scope')) {
|
|
|
290
290
|
/**
|
|
291
291
|
* WHERE THE LEARNER LIVES — ONE ANSWER, SHARED BY THE WRITER AND EVERY READER.
|
|
292
292
|
*
|
|
293
|
-
* ISSUE #139, filed by @ObiWanKenobi, and he is right about the shape.
|
|
293
|
+
* ISSUE #139, filed by @ObiWanKenobi, and he is right about the shape. the learning flusher resolved
|
|
294
294
|
* the learning scope properly and then writes to `HOME` or `PROJECT` accordingly. The console read
|
|
295
295
|
* the learner with a HARDCODED cwd — first `SYSTEM_HOME` (issue #136), then `process.cwd()` after
|
|
296
296
|
* the fix. Both are hardcodes. Neither asks which scope is in effect.
|