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
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
//
|
|
3
3
|
// kb-copy-proof.mjs — may a full copy of the knowledge base be deleted? The ONE proof used by the
|
|
4
4
|
// footprint sweep (plugin/scripts/brain-footprint.mjs) and by the installer right after it activates a
|
|
5
|
-
// new generation (bin/install.mjs unzipInto). Pure read;
|
|
5
|
+
// new generation (bin/install.mjs unzipInto). Pure read; checks KB-root ancestors and file/link types
|
|
6
|
+
// before comparing content; never writes.
|
|
6
7
|
//
|
|
7
8
|
// A copy is DISPOSABLE only when nothing in it is unique. Every file must be one of:
|
|
8
9
|
// * a PRIVATE-store file (names from the PRIVATE-STORES.json fence of the live brain AND of the copy, plus
|
|
9
10
|
// every updateManaged:false store in either SOURCE.json; membership rule = kb/forge-update.mjs
|
|
10
11
|
// capturePrivateOverlayState) that exists BYTE-IDENTICAL at the same path in the live brain — nothing
|
|
11
12
|
// else excuses a private file;
|
|
12
|
-
// * a
|
|
13
|
-
//
|
|
13
|
+
// * a file whose regular-file bytes survive exactly at the same path in live, or a public release file
|
|
14
|
+
// listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a public store family (named by either COVERAGE.json
|
|
14
15
|
// or the live SOURCE.json) that a newer release replaced or retired;
|
|
15
16
|
// * installer-written or reinstallable (node_modules/, the updater/validator files the installer places);
|
|
16
17
|
// * a symbolic link identical in the live brain (links are compared, never followed).
|
|
17
18
|
// Anything else — a user's own file, an unfenced store, a link the live brain lacks — KEEPS the copy, and
|
|
18
|
-
// is named.
|
|
19
|
+
// is named. Paths are checked for symlink ancestors within both KB roots before any exemption; host
|
|
20
|
+
// aliases above those roots (such as macOS /tmp) are not traversed as part of this check. Invalid metadata,
|
|
21
|
+
// unreadable inventory and special file types keep the copy. The live brain itself must be present.
|
|
19
22
|
import fs from 'node:fs';
|
|
20
23
|
import path from 'node:path';
|
|
21
24
|
import crypto from 'node:crypto';
|
|
@@ -29,13 +32,13 @@ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(
|
|
|
29
32
|
/** Every regular file and link under `root`, relative, without following links. macOS volume metadata
|
|
30
33
|
* (AppleDouble `._*` shadows on an exFAT disk, .DS_Store, …) is the volume's, never a copy's unique data. */
|
|
31
34
|
function walk(root, prefix = '', out = []) {
|
|
32
|
-
for (const name of
|
|
35
|
+
for (const name of fs.readdirSync(path.join(root, prefix)).sort().filter((n) => !isVolumeMetadata(n))) {
|
|
33
36
|
const relative = prefix ? path.join(prefix, name) : name;
|
|
34
|
-
const st =
|
|
35
|
-
if (!st) continue;
|
|
37
|
+
const st = fs.lstatSync(path.join(root, relative));
|
|
36
38
|
if (st.isSymbolicLink()) out.push({ relative, link: true });
|
|
37
39
|
else if (st.isDirectory()) walk(root, relative, out);
|
|
38
40
|
else if (st.isFile()) out.push({ relative, link: false, size: st.size });
|
|
41
|
+
else throw new Error(`unsupported file type: ${relative}`);
|
|
39
42
|
}
|
|
40
43
|
return out;
|
|
41
44
|
}
|
|
@@ -88,6 +91,54 @@ const sameBytes = (left, right) => {
|
|
|
88
91
|
try { return sha256File(left) === sha256File(right); } catch { return false; }
|
|
89
92
|
};
|
|
90
93
|
|
|
94
|
+
function safeAncestors(root, relative) {
|
|
95
|
+
let dir = root;
|
|
96
|
+
for (const part of ['', ...relative.split(path.sep).slice(0, -1)]) {
|
|
97
|
+
if (part) dir = path.join(dir, part);
|
|
98
|
+
let stat;
|
|
99
|
+
try { stat = fs.lstatSync(dir); }
|
|
100
|
+
catch (error) { return error.code === 'ENOENT'; } // a retired public path may be absent in live
|
|
101
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function metadataFailure(root) {
|
|
107
|
+
for (const file of ['SOURCE.json', 'PRIVATE-STORES.json', 'RVF-GENERATIONS.json',
|
|
108
|
+
'COVERAGE.json', 'ARCHIVE-MANIFEST.json', 'repo-aliases.json']) {
|
|
109
|
+
const full = path.join(root, file);
|
|
110
|
+
let stat;
|
|
111
|
+
try { stat = fs.lstatSync(full); }
|
|
112
|
+
catch (error) { if (error.code === 'ENOENT' && file !== 'SOURCE.json') continue; return `${file}: ${error.message}`; }
|
|
113
|
+
try {
|
|
114
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular metadata file');
|
|
115
|
+
const value = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
116
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not a metadata object');
|
|
117
|
+
if (file === 'PRIVATE-STORES.json' && Object.hasOwn(value, 'privateStores')
|
|
118
|
+
&& (!Array.isArray(value.privateStores) || value.privateStores.some((name) => typeof name !== 'string' || !name.trim()))) {
|
|
119
|
+
throw new Error('private store fence is malformed');
|
|
120
|
+
}
|
|
121
|
+
if (file === 'SOURCE.json' && Object.hasOwn(value, 'stores')) {
|
|
122
|
+
if (!value.stores || typeof value.stores !== 'object'
|
|
123
|
+
|| Object.values(value.stores).some((store) => !store || typeof store !== 'object' || Array.isArray(store))) {
|
|
124
|
+
throw new Error('store metadata is malformed');
|
|
125
|
+
}
|
|
126
|
+
if (storeList(value).some((store) => !store || typeof store !== 'object'
|
|
127
|
+
|| (store.updateManaged === false && (typeof store.kbName !== 'string' || !store.kbName.trim())))) {
|
|
128
|
+
throw new Error('private store metadata is malformed');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (file === 'RVF-GENERATIONS.json' && Object.hasOwn(value, 'stores')
|
|
132
|
+
&& (!value.stores || typeof value.stores !== 'object' || Array.isArray(value.stores)
|
|
133
|
+
|| Object.values(value.stores).some((generation) => !generation || typeof generation !== 'object'
|
|
134
|
+
|| typeof generation.file !== 'string' || !generation.file.trim()))) throw new Error('generation metadata is malformed');
|
|
135
|
+
if (file === 'COVERAGE.json' && value.rows != null && !Array.isArray(value.rows)) throw new Error('coverage rows are malformed');
|
|
136
|
+
if (file === 'ARCHIVE-MANIFEST.json' && value.files != null && !Array.isArray(value.files)) throw new Error('archive inventory is malformed');
|
|
137
|
+
} catch (error) { return `${file}: ${error.message}`; }
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
91
142
|
/**
|
|
92
143
|
* @returns {{disposable: boolean, unique: {file: string, why: string}[], reason: string}} — a kept copy
|
|
93
144
|
* always names the files that keep it.
|
|
@@ -96,13 +147,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
96
147
|
const copy = lstat(copyDir);
|
|
97
148
|
if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
|
|
98
149
|
const live = lstat(liveDir);
|
|
99
|
-
if (!live || !live.isDirectory()
|
|
100
|
-
|
|
150
|
+
if (!live || !live.isDirectory()) {
|
|
151
|
+
return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
|
|
152
|
+
}
|
|
153
|
+
for (const root of [copyDir, liveDir]) {
|
|
154
|
+
const failure = metadataFailure(root);
|
|
155
|
+
if (failure) return { disposable: false, unique: [], reason: `unreadable or malformed metadata in ${root}: ${failure}` };
|
|
156
|
+
}
|
|
157
|
+
if (!names(liveDir).some((name) => /\.rvf$/i.test(name) && lstat(path.join(liveDir, name))?.isFile())) {
|
|
101
158
|
return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
|
|
102
159
|
}
|
|
103
160
|
const privateNames = privateStoreNames([liveDir, copyDir]);
|
|
104
|
-
const generations = readJson(path.join(
|
|
105
|
-
const privateArtifacts =
|
|
161
|
+
const generations = [copyDir, liveDir].flatMap((dir) => Object.entries(readJson(path.join(dir, 'RVF-GENERATIONS.json'))?.stores || {}));
|
|
162
|
+
const privateArtifacts = generations.filter(([name]) => privateNames.has(name.toLowerCase()))
|
|
106
163
|
.map(([, g]) => String(g?.file || '')).filter(Boolean).map((file) => ({ directory: path.dirname(path.normalize(file)),
|
|
107
164
|
basename: path.basename(file).toLowerCase(), stem: stemOf(file) }));
|
|
108
165
|
const coverageNames = (dir) => (readJson(path.join(dir, 'COVERAGE.json'))?.rows || [])
|
|
@@ -119,15 +176,10 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
119
176
|
let files;
|
|
120
177
|
try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
|
|
121
178
|
for (const { relative, link, size } of files) {
|
|
122
|
-
// node_modules (npm reinstalls it) and .console-runtime (bin/install.mjs installConsoleRuntime re-places it)
|
|
123
|
-
if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
|
|
124
|
-
|| (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
|
|
125
179
|
const isPrivate = belongsToPrivate(relative, privateNames, privateArtifacts);
|
|
126
180
|
const inLive = path.join(liveDir, relative);
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
|
|
130
|
-
if (!same) unique.push({ file: relative, why: 'a symbolic link the live brain does not have (never followed)' });
|
|
181
|
+
if (!safeAncestors(copyDir, relative) || !safeAncestors(liveDir, relative)) {
|
|
182
|
+
unique.push({ file: relative, why: 'unsafe path ancestor inside a KB root (never followed)' });
|
|
131
183
|
continue;
|
|
132
184
|
}
|
|
133
185
|
if (isPrivate) {
|
|
@@ -136,10 +188,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
136
188
|
}
|
|
137
189
|
continue;
|
|
138
190
|
}
|
|
191
|
+
// Private membership above takes precedence over reinstallable runtime and release exemptions.
|
|
192
|
+
if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
|
|
193
|
+
|| (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
|
|
194
|
+
if (link) {
|
|
195
|
+
const liveLink = lstat(inLive);
|
|
196
|
+
const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
|
|
197
|
+
if (!same) unique.push({ file: relative, why: 'a symbolic link the live brain does not have (never followed)' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
139
200
|
if (relative === 'ARCHIVE-MANIFEST.json' && shipped.size) continue; // the release manifest itself
|
|
140
201
|
const listed = shipped.get(relative);
|
|
141
202
|
if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
|
|
142
|
-
if (
|
|
203
|
+
if (sameBytes(path.join(copyDir, relative), inLive)) continue; // existence alone proves no ownership or redundancy
|
|
143
204
|
if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
|
|
144
205
|
unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
|
|
145
206
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse captures only a fixed workflow vocabulary, then schedules bounded orphan recovery.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { readStdinBounded } from './hook-input.mjs';
|
|
8
|
+
import { learningContext } from './runtime-preferences.mjs';
|
|
9
|
+
import { learningTarget } from './learning-store.mjs';
|
|
10
|
+
import { safeQueue, safeAction, writeExclusive, takeQueueLock, releaseQueueLock } from './learning-queue.mjs';
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const context = learningContext();
|
|
14
|
+
if (!context.enabled) process.exit(0);
|
|
15
|
+
const raw = await readStdinBounded({ maxBytes: 65536, emptyMs: 150 });
|
|
16
|
+
const input = JSON.parse(raw.toString());
|
|
17
|
+
if (input.hook_event_name && input.hook_event_name !== 'PostToolUse') process.exit(0);
|
|
18
|
+
const response = input.tool_response;
|
|
19
|
+
if (typeof response === 'string') {
|
|
20
|
+
const exit = /^Process exited with code (-?\d+)\s*$/m.exec(response);
|
|
21
|
+
if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
|
|
24
|
+
|| response?.success === false || response?.is_error === true || response?.isError === true
|
|
25
|
+
|| [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) process.exit(0);
|
|
26
|
+
const tool = input.tool_name;
|
|
27
|
+
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) process.exit(0);
|
|
28
|
+
const action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
|
|
29
|
+
if (!action) process.exit(0);
|
|
30
|
+
const fresh = learningContext();
|
|
31
|
+
if (!fresh.enabled || fresh.queueDir !== context.queueDir) process.exit(0);
|
|
32
|
+
learningTarget(fresh); // Existing authorized user store is required before creating any user queue bytes.
|
|
33
|
+
const dir = safeQueue(fresh, true);
|
|
34
|
+
const sid = createHash('sha256').update(String(input.session_id || process.env.CLAUDE_SESSION_ID || 'default')).digest('hex').slice(0, 24);
|
|
35
|
+
writeExclusive(path.join(dir, `session-${sid}-${randomUUID()}.jsonl`), JSON.stringify({ tool, action }) + '\n');
|
|
36
|
+
// Per-scope lock in the flusher suppresses duplicate workers; capture never waits on the learner.
|
|
37
|
+
const token = takeQueueLock(fresh);
|
|
38
|
+
if (!token) process.exit(0);
|
|
39
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--supervisor'], {
|
|
40
|
+
cwd: fresh.projectDir, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token },
|
|
41
|
+
});
|
|
42
|
+
child.on('error', () => releaseQueueLock(fresh, token)); child.unref();
|
|
43
|
+
} catch { /* advisory transport; unsafe/malformed input produces no persisted payload */ }
|
|
44
|
+
process.exit(0);
|
|
@@ -1,242 +1,3 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
|
|
4
|
-
# GLOBAL SONA learner at SessionEnd, so "how you work" accumulates per-user across ALL projects — while
|
|
5
|
-
# project FACTS stay in each project's .swarm/memory.db, never here. We record the workflow ACTION (a
|
|
6
|
-
# command verb, a file's basename), never file CONTENT or secrets. ADR-0017.
|
|
7
|
-
#
|
|
8
|
-
# CONTRACT: PostToolUse is non-blocking — always exit 0, swallow every failure, no process spawn (fast).
|
|
9
|
-
|
|
10
|
-
set -uo pipefail
|
|
11
|
-
|
|
12
|
-
# One policy source for both capture and flush. `off` means zero bytes written. `project` keeps the
|
|
13
|
-
# trajectory queue under this project's .swarm directory; `user` preserves the cross-project learner
|
|
14
|
-
# introduced by ADR-0017. Tests and managed hosts may pass an already-resolved snapshot in
|
|
15
|
-
# RUVNET_LEARNING_SCOPE so the two halves cannot disagree during one hook invocation.
|
|
16
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
|
17
|
-
SCOPE="${RUVNET_LEARNING_SCOPE:-}"
|
|
18
|
-
# CONFIGURED vs DEFAULTED, and this must be captured BEFORE the preferences fallback below.
|
|
19
|
-
#
|
|
20
|
-
# `project` is the DEFAULT scope, and `runtime-preferences.mjs --learning-scope` RESOLVES it —
|
|
21
|
-
# measured 2026-08-19 against an empty config root, it returns "project", not "". So after the
|
|
22
|
-
# fallback runs, an explicit opt-in and a bare default are indistinguishable. The first version of
|
|
23
|
-
# this guard read SCOPE afterwards and was therefore always "configured", which let the
|
|
24
|
-
# stranger-project mutation straight back in.
|
|
25
|
-
#
|
|
26
|
-
# The env var is the one signal that is unambiguously explicit: nothing sets it by default. A
|
|
27
|
-
# project that opted in through the console instead is still covered by the `.swarm` clause below,
|
|
28
|
-
# because adopting Ruflo's convention is itself the opt-in.
|
|
29
|
-
SCOPE_CONFIGURED=0
|
|
30
|
-
[ -n "$SCOPE" ] && SCOPE_CONFIGURED=1
|
|
31
|
-
if [ -z "$SCOPE" ] && [ -f "$HERE/runtime-preferences.mjs" ] && command -v node >/dev/null 2>&1; then
|
|
32
|
-
SCOPE=$(node "$HERE/runtime-preferences.mjs" --learning-scope 2>/dev/null) || SCOPE=""
|
|
33
|
-
fi
|
|
34
|
-
case "$SCOPE" in
|
|
35
|
-
off) exit 0 ;;
|
|
36
|
-
user|project) ;;
|
|
37
|
-
*) SCOPE="project" ;;
|
|
38
|
-
esac
|
|
39
|
-
|
|
40
|
-
# BOUNDED READ. An unqualified `read` waits forever on a stdin that is opened and never closed, and
|
|
41
|
-
# an unbounded accumulator turns a large payload into an unbounded regex scan. Claude Code always
|
|
42
|
-
# writes the payload and closes, so neither costs a normal turn — which is exactly why a hook that
|
|
43
|
-
# CAN hang survives: the only thing ending it is a timeout owned by someone else. -t 2 is ~40x a real
|
|
44
|
-
# payload's delivery time; the size cap is ~30x the largest real payload. The trailing `[ -n "$_l" ]`
|
|
45
|
-
# keeps the final unterminated line, which is what the original `||` clause was for.
|
|
46
|
-
INPUT=""
|
|
47
|
-
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
48
|
-
while IFS= read -r -t 2 _l; do
|
|
49
|
-
INPUT+="$_l"
|
|
50
|
-
[ ${#INPUT} -ge 65536 ] && break
|
|
51
|
-
done
|
|
52
|
-
# Truncate the ASSEMBLED string, not just each iteration: a hook payload is one line with no trailing
|
|
53
|
-
# newline, so `read` hands back the whole thing at once in $_l and the in-loop cap never fires.
|
|
54
|
-
[ -n "$_l" ] && INPUT+="$_l"
|
|
55
|
-
INPUT="${INPUT:0:65536}"
|
|
56
|
-
[ -n "$INPUT" ] || exit 0
|
|
57
|
-
|
|
58
|
-
TOOL=""
|
|
59
|
-
re_t='"tool_name"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
|
60
|
-
[[ $INPUT =~ $re_t ]] && TOOL="${BASH_REMATCH[1]}"
|
|
61
|
-
[ -n "$TOOL" ] || exit 0
|
|
62
|
-
|
|
63
|
-
ACTION=""
|
|
64
|
-
case "$TOOL" in
|
|
65
|
-
Bash)
|
|
66
|
-
# Capture the VERB CHAIN ONLY — "git push", "npm test", "npx vercel" — never the arguments.
|
|
67
|
-
#
|
|
68
|
-
# This previously took the first 120 chars up to an embedded quote and called that "verb, not
|
|
69
|
-
# facts". It wasn't. Unquoted inline secrets were captured in full and written to disk, proven
|
|
70
|
-
# by test: `export AWS_SECRET_ACCESS_KEY=wJalr... && psql postgres://admin:Hunter2Pass@db/prod`
|
|
71
|
-
# landed verbatim in session-*.jsonl, and from there fed the global learner. Real command lines
|
|
72
|
-
# routinely carry API keys, DB URLs with inline passwords, and internal hostnames — on a
|
|
73
|
-
# corporate laptop the hostnames alone are a DLP finding.
|
|
74
|
-
#
|
|
75
|
-
# Now: keep at most the first two tokens, and stop at the first token that carries DATA rather
|
|
76
|
-
# than INTENT (contains = / @ : , is a flag, or is improbably long). "export FOO=secret" records
|
|
77
|
-
# "export"; "cd /Users/me/ClientProject" records "cd". The learner only ever needed the verb.
|
|
78
|
-
# THE CAPTURE WAS MANGLED, and the mangling was invisible (fixed 2026-07-27).
|
|
79
|
-
#
|
|
80
|
-
# `"command"…"([^"]*)"` cannot cross a JSON-escaped quote — the exact bug hook-input.mjs exists to
|
|
81
|
-
# end — so `cd "/tmp/some dir"` captured the two bytes `cd \`, and that trailing backslash then
|
|
82
|
-
# broke the JSON line it was printed into. Measured on the owner's live queue:
|
|
83
|
-
#
|
|
84
|
-
# {"tool":"Bash","action":"cd \"} ← JSON.parse: Unterminated string at position 31
|
|
85
|
-
#
|
|
86
|
-
# learn-flush drops every unparseable line with a bare `continue`, so the capture reported success,
|
|
87
|
-
# the queue grew, and the learner received nothing. A pipe severed in the middle while both ends
|
|
88
|
-
# report health is this project's signature failure mode.
|
|
89
|
-
#
|
|
90
|
-
# The fix is to stop at the first quote OR backslash and take a PREFIX — no closing-quote anchor,
|
|
91
|
-
# because the first two tokens are all this hook ever wanted. A command that opens with a quoted
|
|
92
|
-
# path yields an empty prefix and is simply not captured, which is strictly better than writing a
|
|
93
|
-
# line that cannot be read back.
|
|
94
|
-
re_c='"command"[[:space:]]*:[[:space:]]*"([^"\]*)'
|
|
95
|
-
if [[ $INPUT =~ $re_c ]]; then
|
|
96
|
-
set -f # no globbing while we word-split untrusted text
|
|
97
|
-
_n=0
|
|
98
|
-
for _tok in ${BASH_REMATCH[1]}; do
|
|
99
|
-
case "$_tok" in
|
|
100
|
-
*=*|*/*|*@*|*:*|-*) break ;;
|
|
101
|
-
esac
|
|
102
|
-
[ ${#_tok} -gt 24 ] && break
|
|
103
|
-
ACTION="${ACTION:+$ACTION }$_tok"
|
|
104
|
-
_n=$((_n + 1))
|
|
105
|
-
[ "$_n" -ge 2 ] && break
|
|
106
|
-
done
|
|
107
|
-
set +f
|
|
108
|
-
fi
|
|
109
|
-
;;
|
|
110
|
-
Write|Edit|MultiEdit)
|
|
111
|
-
re_f='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
|
112
|
-
[[ $INPUT =~ $re_f ]] && ACTION="edit ${BASH_REMATCH[1]##*/}" # basename only — no full path
|
|
113
|
-
;;
|
|
114
|
-
esac
|
|
115
|
-
[ -n "$ACTION" ] || exit 0
|
|
116
|
-
|
|
117
|
-
# THE SESSION ID IS IN THE PAYLOAD WE ALREADY READ — and it was being thrown away.
|
|
118
|
-
#
|
|
119
|
-
# This read `${CLAUDE_SESSION_ID:-default}`, an env var Claude Code does not set, so EVERY session on
|
|
120
|
-
# a machine appended to one shared `session-default.jsonl`. Measured on the owner's machine
|
|
121
|
-
# 2026-07-27: one file, 147 lines deep, written concurrently by several live sessions — the same
|
|
122
|
-
# many-writers-one-path shape as ADR-050, and it makes "this session's trajectory" a fiction, because
|
|
123
|
-
# the queue is a blend of every session that happened to be open.
|
|
124
|
-
#
|
|
125
|
-
# `session_id` is a field on the very payload this hook already parsed. Prefer it; fall back to the
|
|
126
|
-
# env var; then to 'default'. SANITISED before it becomes a filename component: the payload is
|
|
127
|
-
# untrusted input, and `session_id` reaching an unfiltered path join is a traversal waiting to happen.
|
|
128
|
-
SID=""
|
|
129
|
-
re_s='"session_id"[[:space:]]*:[[:space:]]*"([^"\]*)"'
|
|
130
|
-
[[ $INPUT =~ $re_s ]] && SID="${BASH_REMATCH[1]}"
|
|
131
|
-
[ -n "$SID" ] || SID="${CLAUDE_SESSION_ID:-}"
|
|
132
|
-
# Dots are dropped along with everything else outside this set: real session ids are uuids, and
|
|
133
|
-
# keeping `.` would let a crafted id survive as `..`-shaped debris in a filename for no benefit.
|
|
134
|
-
SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
|
|
135
|
-
[ -n "$SID" ] || SID="default"
|
|
136
|
-
if [ "$SCOPE" = "user" ]; then
|
|
137
|
-
DIR="$HOME/.cache/ruvnet-brain/learn"
|
|
138
|
-
else
|
|
139
|
-
# ISSUE #134 — THE SAME PROJECT ROOT THE READER COMPUTES, BY THE SAME RULE.
|
|
140
|
-
#
|
|
141
|
-
# This was bare `$PWD` while learn-flush.mjs:26 and health-repair.mjs:32 both resolve
|
|
142
|
-
# `RUVNET_BRAIN_PROJECT_DIR || cwd`. This hook is wired on PostToolUse, so it runs after EVERY tool
|
|
143
|
-
# call — and any command that leaves the shell below the project root (a test run, a build, any
|
|
144
|
-
# tooling that cd's) made the WRITER create a queue in a directory the READER never looks in. Those
|
|
145
|
-
# events are not misfiled, they are orphaned: nothing ever drains them.
|
|
146
|
-
#
|
|
147
|
-
# This is issue #104's residual. #104 fixed the two halves of the FLUSH to agree about which project
|
|
148
|
-
# they mean; the component that actually creates the queue was not brought along, so the invariant
|
|
149
|
-
# held for two of three participants and was violated by the one doing the writing. Same shape as
|
|
150
|
-
# ADR-066: a writer and a reader that disagree about the store make the recording theatre.
|
|
151
|
-
# RESIDUAL of #134/#104: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either
|
|
152
|
-
# host (neither hook-shim.mjs nor codex-hook-adapter.mjs seeds it), so it degraded back to bare
|
|
153
|
-
# $PWD in production. CLAUDE_PROJECT_DIR is the one project-root signal both hosts DO provide on
|
|
154
|
-
# every invocation. Trusted only when $PWD actually lies inside it — the SAME containment rule
|
|
155
|
-
# project-identity.mjs's projectDirectory() applies for the identical reason (#85/#107: an
|
|
156
|
-
# unrelated declared root must never overrule a cwd it does not contain). A plain string-prefix
|
|
157
|
-
# check, not a realpath/inode compare, to honour this hook's own no-process-spawn contract.
|
|
158
|
-
ROOT_DIR="$PWD"
|
|
159
|
-
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
160
|
-
CPD="${CLAUDE_PROJECT_DIR%/}"
|
|
161
|
-
# Git Bash presents PWD as /c/... while Node supplies CLAUDE_PROJECT_DIR as C:\\... on
|
|
162
|
-
# Windows. Compare normalized, case-folded spellings so the real project-root signal works
|
|
163
|
-
# on both hosts without spawning a platform-specific path converter.
|
|
164
|
-
_pwd_for_compare="$PWD"
|
|
165
|
-
if [ -n "$(pwd -W 2>/dev/null || true)" ]; then _pwd_for_compare="$(pwd -W)"; fi
|
|
166
|
-
_pwd_cmp=$(printf '%s' "$_pwd_for_compare" | tr '\\\\' '/' | tr '[:upper:]' '[:lower:]')
|
|
167
|
-
_cpd_cmp=$(printf '%s' "$CPD" | tr '\\\\' '/' | tr '[:upper:]' '[:lower:]')
|
|
168
|
-
case "$_pwd_cmp/" in "$_cpd_cmp"/*) ROOT_DIR="$CPD" ;; esac
|
|
169
|
-
fi
|
|
170
|
-
DIR="${RUVNET_BRAIN_PROJECT_DIR:-$ROOT_DIR}/.swarm/ruvnet-brain-learn"
|
|
171
|
-
fi
|
|
172
|
-
# PROJECT SCOPE MEANS THE PROJECT MUST HAVE OPTED IN. In project scope $DIR sits under `.swarm`,
|
|
173
|
-
# which is Ruflo's own convention and is created by `ruflo init` — so its PRESENCE is the project's
|
|
174
|
-
# opt-in and its ABSENCE is a project that has not adopted the brain. This hook runs machine-wide on
|
|
175
|
-
# every PostToolUse, so an unconditional mkdir planted `.swarm/` in EVERY repository the user opened.
|
|
176
|
-
# Measured 2026-08-14 by the both-hosts conformance gate in a temp project with no git and no brain
|
|
177
|
-
# artifacts; ADR-058 D5 — never touch what we do not own. User scope is unaffected: that queue lives
|
|
178
|
-
# under the brain's OWN cache directory, which we do own and may create.
|
|
179
|
-
# CREATE THE QUEUE ONLY WHERE THE PROJECT ACTUALLY OPTED IN.
|
|
180
|
-
#
|
|
181
|
-
# First attempt required an existing `.swarm`, which stopped the stranger-project mutation but ALSO
|
|
182
|
-
# broke a legitimate first run: a project that explicitly sets RUVNET_LEARNING_SCOPE=project before
|
|
183
|
-
# it has ever captured anything got nothing, and `learning-scope-policy` went red. Presence of
|
|
184
|
-
# `.swarm` was the wrong discriminator — it answers "has Ruflo run here", not "did this project ask
|
|
185
|
-
# for learning".
|
|
186
|
-
#
|
|
187
|
-
# The right one is whether the scope was CONFIGURED (env or runtime-preferences) rather than
|
|
188
|
-
# inherited from the default. A stranger's repo sets neither, so nothing is created there; a project
|
|
189
|
-
# that opted in gets its queue on the very first capture, `.swarm` or not. An existing `.swarm` is
|
|
190
|
-
# still honoured on its own, because a repo already carrying Ruflo's convention has plainly adopted it.
|
|
191
|
-
case "$DIR" in
|
|
192
|
-
*/.swarm/*)
|
|
193
|
-
if [ "$SCOPE_CONFIGURED" != "1" ] && [ ! -d "$(dirname "$DIR")" ]; then exit 0; fi
|
|
194
|
-
;;
|
|
195
|
-
esac
|
|
196
|
-
# Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
|
|
197
|
-
# account is normally in `staff`, so any other user on a shared or corporate machine could read it.
|
|
198
|
-
( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
|
|
199
|
-
QUEUE="$DIR/session-$SID.jsonl"
|
|
200
|
-
[ -e "$QUEUE" ] || { : > "$QUEUE" 2>/dev/null && chmod 600 "$QUEUE" 2>/dev/null; } || true
|
|
201
|
-
printf '{"tool":"%s","action":"%s"}\n' "$TOOL" "${ACTION//\"/\\\"}" >> "$QUEUE" 2>/dev/null || true
|
|
202
|
-
|
|
203
|
-
# ── HEARTBEAT FLUSH (ADR-027) ────────────────────────────────────────────────────────────────────
|
|
204
|
-
# The flush used to fire ONLY on a clean SessionEnd. Sessions compact, crash, get resumed, or are
|
|
205
|
-
# killed — none of those reach SessionEnd — so the queue silently grew to 1,884 undelivered events
|
|
206
|
-
# over days while the learner sat at 5 trajectories, last trained six days earlier. Draining it took
|
|
207
|
-
# the learner to 412/412 in one command. A queue that only empties on a graceful exit will always
|
|
208
|
-
# leak; activity itself must be the trigger.
|
|
209
|
-
#
|
|
210
|
-
# So: every HEARTBEAT_EVERY captures, drain in the BACKGROUND. Detached and fully silent — this runs
|
|
211
|
-
# inside a PostToolUse hook and must never add latency to the user's turn or fail one. Cheap check
|
|
212
|
-
# (a line count) on the common path; real work only at the threshold.
|
|
213
|
-
# LEVEL-TRIGGERED, NOT EDGE-TRIGGERED. This is the whole fix, and the bug it replaces was severe.
|
|
214
|
-
#
|
|
215
|
-
# The condition used to be `LINES >= 200 && LINES % 200 == 0` — it fired ONLY when the count landed
|
|
216
|
-
# exactly on a multiple of 200. Two captures arriving between checks, or any concurrent write,
|
|
217
|
-
# steps the counter over the window and the flush NEVER fires again. Measured on the owner's machine
|
|
218
|
-
# 2026-07-22: the queue was at 491. It had sailed past both 200 and 400 without draining once, and
|
|
219
|
-
# would have grown forever.
|
|
220
|
-
#
|
|
221
|
-
# The failure mode is the nastiest kind: capture works, the learner works, and the PIPE BETWEEN THEM
|
|
222
|
-
# is severed — while every surface honestly reports both ends as healthy. "Is learning on?" had no
|
|
223
|
-
# true answer, because learning is not a switch; it is a chain, and one link was open.
|
|
224
|
-
#
|
|
225
|
-
# `-ge` cannot skip a window. It fires on every capture past the threshold until the queue is
|
|
226
|
-
# actually drained, which is the definition of level-triggered: the condition is the QUEUE'S DEPTH,
|
|
227
|
-
# not the instant it crossed a line.
|
|
228
|
-
HEARTBEAT_EVERY=200
|
|
229
|
-
LINES=$(wc -l < "$DIR/session-$SID.jsonl" 2>/dev/null || echo 0)
|
|
230
|
-
if [ "$LINES" -ge "$HEARTBEAT_EVERY" ]; then
|
|
231
|
-
# Debounce so a deep queue doesn't spawn a flush on EVERY subsequent capture: at most one drain
|
|
232
|
-
# per minute. Without this, level-triggering trades a stuck queue for a fork storm.
|
|
233
|
-
STAMP="$DIR/.last-flush"
|
|
234
|
-
NOW=$(date +%s)
|
|
235
|
-
LAST=$(cat "$STAMP" 2>/dev/null || echo 0)
|
|
236
|
-
if [ $((NOW - LAST)) -ge 60 ]; then
|
|
237
|
-
echo "$NOW" > "$STAMP" 2>/dev/null || true
|
|
238
|
-
FLUSH="$HERE/learn-flush.mjs"
|
|
239
|
-
[ -f "$FLUSH" ] && (RUVNET_LEARNING_SCOPE="$SCOPE" nohup node "$FLUSH" >/dev/null 2>&1 &) || true
|
|
240
|
-
fi
|
|
241
|
-
fi
|
|
242
|
-
exit 0
|
|
2
|
+
# Compatibility entry; native registrations use the bounded Node body on both hosts.
|
|
3
|
+
exec node "$(dirname "$0")/learn-capture.mjs"
|