ruvnet-brain 4.5.7 → 4.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +53 -2
  3. package/package.json +5 -3
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/hooks/codex-hooks.json +31 -1
  7. package/plugin/hooks/hook-contracts.json +81 -1
  8. package/plugin/hooks/hooks.json +31 -1
  9. package/plugin/scripts/advocacy-catalog.mjs +1 -0
  10. package/plugin/scripts/agentdb-recall.mjs +38 -10
  11. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  12. package/plugin/scripts/ground-before-write.sh +27 -11
  13. package/plugin/scripts/grounding-code-projection.mjs +85 -0
  14. package/plugin/scripts/hook-shim.mjs +1 -1
  15. package/plugin/scripts/kb-copy-proof.mjs +80 -19
  16. package/plugin/scripts/learn-capture.mjs +44 -0
  17. package/plugin/scripts/learn-capture.sh +2 -241
  18. package/plugin/scripts/learn-flush.mjs +86 -191
  19. package/plugin/scripts/learning-observation.mjs +51 -0
  20. package/plugin/scripts/learning-queue.mjs +135 -0
  21. package/plugin/scripts/learning-store.mjs +105 -0
  22. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  23. package/plugin/scripts/project-progression-contract.mjs +58 -4
  24. package/plugin/scripts/runtime-preferences.mjs +45 -4
  25. package/plugin/scripts/session-start-core.mjs +8 -0
  26. package/plugin/scripts/session-start-proof.mjs +49 -0
  27. package/scripts/codex-fresh-host-proof.mjs +179 -0
  28. package/scripts/codex-host-execution-proof.mjs +246 -0
  29. package/scripts/codex-host-proof-runtime.mjs +104 -0
  30. package/scripts/console-engine.mjs +29 -16
  31. package/scripts/health-repair.mjs +47 -68
  32. package/scripts/onboarding-console.mjs +25 -54
  33. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  34. package/scripts/release-qualification-contract.mjs +58 -3
  35. package/scripts/remedy-registry.mjs +9 -2
@@ -0,0 +1,85 @@
1
+ // Optional conservative #373 projection. Uncertainty keeps the original raw bash gate.
2
+ // Keep executable strings and paths: mentioning a product in code still requires grounding.
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export function codeWithoutInertText(source, python = false) {
7
+ if (/\r(?!\n)|[\u2028\u2029]/.test(source)) throw new Error('Unsupported line boundary');
8
+ // Template interpolation and Python f-string expressions need a parser, not a guessed lexer.
9
+ if ((!python && source.includes('`')) || (python && /(?:^|[^\w])(?:fr|rf|f)["']/i.test(source))) throw new Error('Interpolation requires original scan');
10
+ if (python) {
11
+ // A different source decoder can turn apparent comments into executable statements.
12
+ for (const line of source.split(/\r?\n/).slice(0, 2)) {
13
+ const cookie = line.match(/^[ \t\f]*#.*?coding[=:][ \t]*([^ \t\r\n]+)/i);
14
+ if (cookie && !/^utf[-_]?8$/i.test(cookie[1])) throw new Error('Unsupported source encoding');
15
+ }
16
+ if (/\\\r?\n/.test(source)) throw new Error('Continuation requires original scan');
17
+ }
18
+ let out = ''; let i = 0;
19
+ while (i < source.length) {
20
+ const c = source[i]; const pair = source.slice(i, i + 2);
21
+ if ((python && c === '#') || (!python && pair === '//')) {
22
+ const end = source.indexOf('\n', i); i = end < 0 ? source.length : end; continue;
23
+ }
24
+ if (!python && pair === '/*') {
25
+ const end = source.indexOf('*/', i + 2); if (end < 0) throw new Error('Incomplete comment');
26
+ out += ' '; i = end + 2; continue;
27
+ }
28
+ if (c === '"' || c === "'" || (!python && c === '`')) {
29
+ const start = i; const triple = python && source.slice(i, i + 3) === c.repeat(3);
30
+ const delimiter = triple ? c.repeat(3) : c;
31
+ i += delimiter.length;
32
+ let closed = false;
33
+ while (i < source.length) {
34
+ if (source[i] === '\\') { i += 2; continue; }
35
+ if (source.slice(i, i + delimiter.length) === delimiter) { i += delimiter.length; closed = true; break; }
36
+ if (!triple && c !== '`' && /[\r\n]/.test(source[i])) throw new Error('Incomplete string');
37
+ i++;
38
+ }
39
+ if (!closed) throw new Error('Incomplete string');
40
+ // Exempt only a leading module docstring before any executable text. Standalone-looking
41
+ // literals inside functions, open expressions or control flow retain the original scan.
42
+ const before = source.slice(source.lastIndexOf('\n', start - 1) + 1, start);
43
+ const endOfLine = source.indexOf('\n', i); const after = source.slice(i, endOfLine < 0 ? source.length : endOfLine);
44
+ const inert = triple && out.trim() === '' && !/\b(?:exec|eval|compile|__doc__)\b/.test(source) && /^[ \t]*$/.test(before) && /^[ \t\r]*(?:#.*)?$/.test(after);
45
+ out += inert ? ' ' : source.slice(start, i); continue;
46
+ }
47
+ // Regex literals, heredocs, language-specific nested comments etc. are not guessed.
48
+ if (!python && c === '/') throw new Error('Ambiguous slash');
49
+ if (!python && pair === '<' + '<') throw new Error('Unsupported heredoc');
50
+ out += c; i++;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function projectGroundingInput(raw) {
56
+ const event = JSON.parse(raw); const input = event.tool_input ?? event.toolInput;
57
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Unknown write input');
58
+ const file = input.file_path; if (typeof file !== 'string') throw new Error('Unknown path');
59
+ // Limited lexical scope; other languages retain their existing strict gate.
60
+ if (!/\.(?:py|[cm]?js|jsx|ts|tsx)$/.test(file)) throw new Error('Unsupported language');
61
+ // Partial edits have no enclosing lexical context. Only complete Write or a verified
62
+ // single Add File patch may receive an exemption; never reconstruct user files here.
63
+ const tool = event.tool_name ?? event.toolName;
64
+ let code;
65
+ if (tool === 'Write' && typeof input.content === 'string' && !Object.hasOwn(input, 'edits')) {
66
+ code = input.content;
67
+ } else if (tool === 'Edit' && typeof input.new_string === 'string' && input.new_string.startsWith('*** Begin Patch')) {
68
+ const lines = input.new_string.trimEnd().split(/\r?\n/);
69
+ if (lines[0] !== '*** Begin Patch' || lines[1] !== `*** Add File: ${file}`
70
+ || lines.at(-1) !== '*** End Patch' || !lines.slice(2, -1).every((line) => line.startsWith('+'))) {
71
+ throw new Error('Ambiguous patch');
72
+ }
73
+ code = lines.slice(2, -1).map((line) => line.slice(1)).join('\n');
74
+ } else throw new Error('Partial or unknown write context');
75
+ const projectedCode = codeWithoutInertText(code, file.endsWith('.py'));
76
+ // Never exempt a product-owned path. Preserve managed-store paths even without a product name.
77
+ const projection = `${file}\n${projectedCode}`;
78
+ if (/\.swarm|(?:agentdb-)?memory\.db|memory_entries/i.test(projection)) throw new Error('Managed memory indicators need original scan');
79
+ return projection;
80
+ }
81
+
82
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
83
+ try { process.stdout.write(projectGroundingInput(fs.readFileSync(0, 'utf8'))); }
84
+ catch { process.exitCode = 1; } // A failed exemption leaves the original guard in force.
85
+ }
@@ -119,7 +119,7 @@ const TABLE = {
119
119
  // own header already says: "remains reachable through hook-shim's dispatch table by explicit
120
120
  // invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
121
121
  // way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
122
- 'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
122
+ 'learn-capture': { file: 'learn-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 65536 },
123
123
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
124
124
  // 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
125
125
  // message truncated mid-JSON would parse as `{}` and silently drop the whole capture.
@@ -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; never follows a link; never writes.
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 public release file: listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a
13
- // name the live generation ships, or a member of a public store family (named by either COVERAGE.json
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. The live brain itself must be present, so a copy is never removed while it may be the only one.
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 names(path.join(root, prefix)).filter((n) => !isVolumeMetadata(n))) {
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 = lstat(path.join(root, relative));
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() || !readJson(path.join(liveDir, 'SOURCE.json'))
100
- || !names(liveDir).some((name) => /\.rvf$/i.test(name))) {
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(copyDir, 'RVF-GENERATIONS.json'))?.stores || {};
105
- const privateArtifacts = Object.entries(generations).filter(([name]) => privateNames.has(name.toLowerCase()))
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 (link) {
128
- const liveLink = lstat(inLive);
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 (lstat(inLive)) continue; // a release-owned name the live generation ships (same or newer bytes)
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
- # learn-capture.sh — PostToolUse (Write|Edit|Bash). Appends ONE compact step to this session's learning
3
- # queue. A session is a trajectory (task -> steps -> outcome); learn-flush.mjs feeds the queue to the
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"