ruvnet-brain 4.5.6 → 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/kb/forge-update.mjs +50 -9
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +82 -2
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +36 -12
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +3 -2
- package/plugin/scripts/kb-copy-proof.mjs +84 -22
- 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/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +16 -7
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/project-progression-outbox.mjs +22 -6
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +25 -8
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-snapshot-hook.mjs +14 -7
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- 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 +76 -7
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -2,11 +2,14 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { managedGenerationIdentity } from './managed-cli-generation.mjs';
|
|
5
7
|
import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
|
|
6
8
|
import { projectDirectory } from '../scripts/project-identity.mjs';
|
|
7
9
|
import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
|
|
8
10
|
import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
|
|
9
11
|
import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
|
|
12
|
+
import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
|
|
10
13
|
|
|
11
14
|
export const MANAGED_EXECUTABLES = Object.freeze([
|
|
12
15
|
'ruflo',
|
|
@@ -34,6 +37,12 @@ const MAX_ARG_BYTES = 8192;
|
|
|
34
37
|
const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
35
38
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
36
39
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
40
|
+
// Bind direct calls to the module loaded at process start, too. Missing shell resources do not
|
|
41
|
+
// prevent declarations from loading; the active dispatcher supplies the execution binding.
|
|
42
|
+
const LOCAL_BINDING = (() => {
|
|
43
|
+
try { return managedGenerationIdentity(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')).binding; }
|
|
44
|
+
catch { return null; }
|
|
45
|
+
})();
|
|
37
46
|
|
|
38
47
|
const executableSchema = {
|
|
39
48
|
type: 'string',
|
|
@@ -139,27 +148,32 @@ function stateDir(env) {
|
|
|
139
148
|
return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
function freshStamp(executable, argv, env, now = Date.now()) {
|
|
151
|
+
function freshStamp(executable, argv, env, binding, now = Date.now()) {
|
|
143
152
|
const stamp = path.join(stateDir(env), helpKey(executable, argv));
|
|
144
|
-
let stat;
|
|
153
|
+
let stat; let readAt;
|
|
145
154
|
try {
|
|
146
|
-
stat = fs.
|
|
155
|
+
stat = fs.lstatSync(stamp);
|
|
156
|
+
if (!stat.isFile()) return false;
|
|
157
|
+
const receipt = JSON.parse(fs.readFileSync(stamp, 'utf8'));
|
|
158
|
+
if (receipt.schemaVersion !== 1 || receipt.generation !== binding
|
|
159
|
+
|| !Number.isFinite(receipt.readAt) || receipt.readAt > now) return false;
|
|
160
|
+
readAt = receipt.readAt;
|
|
147
161
|
} catch {
|
|
148
162
|
return false;
|
|
149
163
|
}
|
|
150
164
|
const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
|
|
151
165
|
const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
|
|
152
|
-
return now - stat.mtimeMs < maxAge;
|
|
166
|
+
return now - stat.mtimeMs < maxAge && now - readAt < maxAge;
|
|
153
167
|
}
|
|
154
168
|
|
|
155
|
-
function writeStamps(executable, argv, env) {
|
|
169
|
+
function writeStamps(executable, argv, env, binding) {
|
|
156
170
|
const dir = stateDir(env);
|
|
157
171
|
fs.mkdirSync(dir, { recursive: true });
|
|
158
172
|
for (const key of stampKeysForHelp(executable, argv)) {
|
|
159
173
|
const stamp = path.join(dir, key);
|
|
160
174
|
const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
161
175
|
try {
|
|
162
|
-
fs.writeFileSync(temporary,
|
|
176
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, generation: binding, readAt: Date.now() }), { flag: 'wx', mode: 0o600 });
|
|
163
177
|
fs.renameSync(temporary, stamp);
|
|
164
178
|
} finally {
|
|
165
179
|
try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
|
|
@@ -291,7 +305,8 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
|
|
|
291
305
|
},
|
|
292
306
|
} : {}),
|
|
293
307
|
};
|
|
294
|
-
|
|
308
|
+
const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
|
|
309
|
+
return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
|
|
295
310
|
}
|
|
296
311
|
|
|
297
312
|
function resultOf(executable, argv, result) {
|
|
@@ -316,6 +331,8 @@ function resultOf(executable, argv, result) {
|
|
|
316
331
|
|
|
317
332
|
export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
|
|
318
333
|
try {
|
|
334
|
+
const binding = lifecycle.generationBinding || LOCAL_BINDING;
|
|
335
|
+
if (!binding) throw new Error('managed generation identity unavailable');
|
|
319
336
|
const executable = assertExecutable(args?.executable);
|
|
320
337
|
const argv = literalArgv(args?.argv ?? []);
|
|
321
338
|
const projectRoot = env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env });
|
|
@@ -344,13 +361,13 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
344
361
|
stampKeysForHelp(executable, argv);
|
|
345
362
|
const commandArgv = [...argv, '--help'];
|
|
346
363
|
const execution = await execute(executable, commandArgv, env);
|
|
347
|
-
if (execution.
|
|
364
|
+
if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
|
|
348
365
|
recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
|
|
349
366
|
return resultOf(executable, commandArgv, execution);
|
|
350
367
|
}
|
|
351
368
|
|
|
352
369
|
if (toolName === 'ruvnet_cli_run') {
|
|
353
|
-
if (!freshStamp(executable, argv, env)) {
|
|
370
|
+
if (!freshStamp(executable, argv, env, binding)) {
|
|
354
371
|
const key = helpKey(executable, argv).replaceAll('.', ' ');
|
|
355
372
|
return {
|
|
356
373
|
content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
|
|
@@ -391,7 +408,8 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
391
408
|
: env;
|
|
392
409
|
const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
|
|
393
410
|
const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
|
|
394
|
-
if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
|
|
411
|
+
if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
|
|
412
|
+
&& !isAutomaticProgressionSuspension(beforeCapture))) {
|
|
395
413
|
const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
|
|
396
414
|
return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
|
|
397
415
|
}
|
|
@@ -405,13 +423,19 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
405
423
|
await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
|
|
406
424
|
}
|
|
407
425
|
recordManagedCliObservation({ toolName, executable, argv, execution, env });
|
|
408
|
-
if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
|
|
426
|
+
if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
|
|
427
|
+
&& !isAutomaticProgressionSuspension(afterCapture)) {
|
|
409
428
|
return {
|
|
410
429
|
content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
|
|
411
430
|
isError: true,
|
|
412
431
|
};
|
|
413
432
|
}
|
|
414
|
-
|
|
433
|
+
const result = resultOf(executable, argv, execution);
|
|
434
|
+
if (isAutomaticProgressionSuspension(beforeCapture) || isAutomaticProgressionSuspension(afterCapture)) {
|
|
435
|
+
result.content.push({ type: 'text', text: 'Automatic project progression is operator-suspended; suspended captures produced no progression receipt. Ordinary AgentDB memory and explicit checkpoints remain available.' });
|
|
436
|
+
result.structuredContent.continuity = 'operator-suspended';
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
415
439
|
}
|
|
416
440
|
|
|
417
441
|
return {
|
package/plugin/mcp/server.mjs
CHANGED
|
@@ -35,7 +35,8 @@ import os from 'node:os';
|
|
|
35
35
|
import readline from 'node:readline';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
|
|
38
|
-
import {
|
|
38
|
+
import { MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
|
|
39
|
+
import { dispatchManagedCli } from './managed-cli-generation.mjs';
|
|
39
40
|
import { unmountedNotice } from '../scripts/brain-location.mjs';
|
|
40
41
|
|
|
41
42
|
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
@@ -362,7 +363,7 @@ async function handleClient(msg) {
|
|
|
362
363
|
case 'tools/call': {
|
|
363
364
|
if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run'
|
|
364
365
|
|| params?.name === 'ruvnet_registry_latest') {
|
|
365
|
-
return clientOk(id, await
|
|
366
|
+
return clientOk(id, await dispatchManagedCli(params.name, params.arguments || {}));
|
|
366
367
|
}
|
|
367
368
|
if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
|
|
368
369
|
refreshLease();
|
|
@@ -14,6 +14,9 @@ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
|
14
14
|
import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
|
|
15
15
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
16
16
|
import { isHarnessGenerated, readStdinBounded } from './hook-input.mjs';
|
|
17
|
+
import { learningContext } from './runtime-preferences.mjs';
|
|
18
|
+
import { learningTarget, LEARNING_NAMESPACE } from './learning-store.mjs';
|
|
19
|
+
import { safeAction } from './learning-queue.mjs';
|
|
17
20
|
|
|
18
21
|
import { redactText } from './continuity-events.mjs';
|
|
19
22
|
export const STORE_FILES = Object.freeze(['memory.db']);
|
|
@@ -146,11 +149,22 @@ export function evidenceExcerpt(value, key, prompt = '') {
|
|
|
146
149
|
return clean(start >= 0 ? text.slice(start) : text, 110);
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
export function learningObservationExcerpt(raw) {
|
|
153
|
+
try {
|
|
154
|
+
const row = JSON.parse(raw);
|
|
155
|
+
if (Object.keys(row).sort().join(',') !== 'action,authoritative,outcome,provenance,schemaVersion,scope,tool'
|
|
156
|
+
|| row.schemaVersion !== 1 || row.authoritative !== false || row.provenance !== 'system-observation'
|
|
157
|
+
|| row.outcome !== 'host-reported-success' || !['project', 'user'].includes(row.scope)
|
|
158
|
+
|| !['Bash', 'Write', 'Edit', 'MultiEdit'].includes(row.tool) || safeAction(row.tool, row.action) !== row.action) return null;
|
|
159
|
+
return `Unratified host-reported success: ${row.tool} ${row.action}. Verify current results.`;
|
|
160
|
+
} catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
149
163
|
export function pickRows(results, limit = 3) {
|
|
150
164
|
const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key))
|
|
151
165
|
&& (!r.family || p.key.toLowerCase().includes(r.family))
|
|
152
|
-
&& Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, targeted: Boolean(r.family) })));
|
|
153
|
-
const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : 0;
|
|
166
|
+
&& Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, storePath: r.storePath, targeted: Boolean(r.family) })));
|
|
167
|
+
const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : p.namespace === LEARNING_NAMESPACE ? -1 : 0;
|
|
154
168
|
const ranked = candidates.sort((a, b) => Number(b.targeted) - Number(a.targeted) || signal(b) - signal(a) || b.score - a.score);
|
|
155
169
|
// Curated lessons and patterns are signal; lifecycle transcript telemetry is not.
|
|
156
170
|
// Do not reserve a slot for a weak match just because its namespace was searched.
|
|
@@ -159,7 +173,7 @@ export function pickRows(results, limit = 3) {
|
|
|
159
173
|
if (chosen.length >= limit) break;
|
|
160
174
|
if (!chosen.some((r) => r.key === row.key && r.namespace === row.namespace)) chosen.push(row);
|
|
161
175
|
}
|
|
162
|
-
return chosen.map((p) => ({ store: 'memory.db', key: p.key, namespace: p.namespace,
|
|
176
|
+
return chosen.map((p) => ({ store: 'memory.db', ...(p.storePath ? { storePath: p.storePath } : {}), key: p.key, namespace: p.namespace,
|
|
163
177
|
score: p.score, preview: clean(p.preview, 110) }));
|
|
164
178
|
}
|
|
165
179
|
|
|
@@ -227,7 +241,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
227
241
|
const budget = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1900) : DEFAULT_DEADLINE_MS;
|
|
228
242
|
const deadline = started + budget;
|
|
229
243
|
const { root, stores } = agentdbStores(projectDir, Math.max(1, Math.min(100, Math.floor(budget / 4))));
|
|
230
|
-
|
|
244
|
+
let learningStore;
|
|
245
|
+
try {
|
|
246
|
+
const context = learningContext({ env, cwd: root });
|
|
247
|
+
if (context.enabled) {
|
|
248
|
+
const db = learningTarget(context, { env });
|
|
249
|
+
if (fs.lstatSync(db).isFile()) learningStore = { name: 'memory.db', path: db };
|
|
250
|
+
}
|
|
251
|
+
} catch { /* No global fallback: only an explicitly authorized existing learning store. */ }
|
|
252
|
+
if (!stores.length && !learningStore) return empty;
|
|
231
253
|
const bin = ruflo === undefined ? resolveRuflo({ env }) : ruflo;
|
|
232
254
|
if (!bin) return { ...empty, stores };
|
|
233
255
|
const store = stores[0];
|
|
@@ -238,10 +260,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
238
260
|
: /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
|
|
239
261
|
: /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
|
|
240
262
|
: /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
|
|
241
|
-
const jobs = namespaces.flatMap((namespace) => [{ namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
|
|
242
|
-
...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
|
|
243
|
-
const
|
|
244
|
-
|
|
263
|
+
const jobs = (store ? namespaces : []).flatMap((namespace) => [{ store, namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
|
|
264
|
+
...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ store, namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
|
|
265
|
+
const words = promptKeywords(prompt, 14);
|
|
266
|
+
const workflowQuery = words.map(word => safeAction('Bash', word))
|
|
267
|
+
.find(action => action && action !== 'command');
|
|
268
|
+
if (learningStore && workflowQuery) jobs.push({ store: learningStore, namespace: LEARNING_NAMESPACE, family: null,
|
|
269
|
+
args: ['--format', 'json', '-q', workflowQuery, '-n', LEARNING_NAMESPACE, '-t', 'keyword', '--limit', '4'] });
|
|
270
|
+
const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
|
|
271
|
+
...await searchOnce({ ruflo: bin, store: jobStore, deadline, env, scratch: scratchFor, args }) })));
|
|
245
272
|
let status = results.every((r) => r.state === 'ok') ? 'ok'
|
|
246
273
|
: results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
|
|
247
274
|
// Overfetch a bounded six exact values so rejected turn metadata cannot hide
|
|
@@ -250,11 +277,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
250
277
|
// Ruflo previews are ~60 characters. Read the actual selected values within
|
|
251
278
|
// the same deadline so the block contains useful evidence rather than titles.
|
|
252
279
|
const retrieved = await Promise.all(candidates.map(async (p) => {
|
|
253
|
-
const r = await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, operation: 'retrieve',
|
|
280
|
+
const r = await searchOnce({ ruflo: bin, store: { name: 'memory.db', path: p.storePath }, deadline, env, scratch: scratchFor, operation: 'retrieve',
|
|
254
281
|
args: ['-k', p.key, '-n', p.namespace, '--value-only'] });
|
|
255
282
|
if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
|
|
256
283
|
const clauses = p.namespace === 'turns' ? turnOutcomeClauses(r.value, prompt) : null;
|
|
257
|
-
const preview =
|
|
284
|
+
const preview = p.namespace === LEARNING_NAMESPACE ? learningObservationExcerpt(r.value)
|
|
285
|
+
: clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
|
|
258
286
|
return { pick: preview ? { ...p, preview, clauses } : null, state: 'ok' };
|
|
259
287
|
}
|
|
260
288
|
return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
|
|
@@ -385,7 +385,9 @@ function promiseBookkeeping() {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
|
|
388
|
-
|
|
388
|
+
// Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
|
|
389
|
+
const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
|
|
390
|
+
for (const promise of newPromises) {
|
|
389
391
|
if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
|
|
390
392
|
openKeys.add(promise.key);
|
|
391
393
|
led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
|
|
@@ -123,6 +123,7 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
|
|
|
123
123
|
export const CONTINUITY_EVENTS = Object.freeze({
|
|
124
124
|
SessionStart: Object.freeze([
|
|
125
125
|
registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
|
|
126
|
+
registration('learn-flush', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
|
|
126
127
|
]),
|
|
127
128
|
UserPromptSubmit: Object.freeze([
|
|
128
129
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
@@ -153,6 +154,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
153
154
|
// no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
|
|
154
155
|
// "Searched N RuvNet repos" banner and query text the script already looks for).
|
|
155
156
|
PostToolUse: Object.freeze([
|
|
157
|
+
registration('learn-capture', '^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$', ['claude', 'codex']),
|
|
156
158
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
157
159
|
registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
|
|
158
160
|
]),
|
|
@@ -184,6 +186,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
184
186
|
registration('session-snapshot', '*', ['claude']),
|
|
185
187
|
]),
|
|
186
188
|
SessionEnd: Object.freeze([
|
|
189
|
+
registration('learn-flush', '*', ['claude', 'codex']),
|
|
187
190
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
188
191
|
]),
|
|
189
192
|
});
|
|
@@ -303,24 +303,24 @@ export function recordingLine(status, now = Date.now()) {
|
|
|
303
303
|
return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
*/
|
|
310
|
-
export function stopNotice({ journal, status, session }) {
|
|
311
|
-
if (!status?.stuck || !status.problem) return '';
|
|
312
|
-
const file = path.join(journal.swarm, NOTICE_STATE);
|
|
306
|
+
/** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
|
|
307
|
+
export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
|
|
308
|
+
const file = path.join(swarm, NOTICE_STATE);
|
|
313
309
|
let state = {};
|
|
314
310
|
try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
|
|
315
311
|
const id = String(session || 'unknown');
|
|
316
312
|
const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
|
|
317
|
-
if (shown.includes(
|
|
318
|
-
state[id] = { at: new Date(
|
|
313
|
+
if (shown.includes(condition)) return '';
|
|
314
|
+
state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
|
|
319
315
|
const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
|
|
320
316
|
try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
|
|
321
|
-
return
|
|
317
|
+
return message;
|
|
318
|
+
}
|
|
319
|
+
export function stopNotice({ journal, status, session }) {
|
|
320
|
+
if (!status?.stuck || !status.problem) return '';
|
|
321
|
+
return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
|
|
322
|
+
message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
|
|
322
323
|
}
|
|
323
|
-
|
|
324
324
|
function defaultStore({ ruflo, db, key, value }) {
|
|
325
325
|
const cwd = rufloRunDir(db);
|
|
326
326
|
try {
|
|
@@ -86,7 +86,8 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
|
86
86
|
const TABLE = {
|
|
87
87
|
'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
|
|
88
88
|
'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
89
|
-
|
|
89
|
+
// Capacity advice is a Brain prompt hint; OFF must not execute its resource probes or steer work.
|
|
90
|
+
'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
90
91
|
// ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
|
|
91
92
|
// still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
|
|
92
93
|
// changes the CEILING of what it may do, not what it does.
|
|
@@ -118,7 +119,7 @@ const TABLE = {
|
|
|
118
119
|
// own header already says: "remains reachable through hook-shim's dispatch table by explicit
|
|
119
120
|
// invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
|
|
120
121
|
// way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
|
|
121
|
-
'learn-capture': { file: 'learn-capture.
|
|
122
|
+
'learn-capture': { file: 'learn-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 65536 },
|
|
122
123
|
'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
123
124
|
// 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
|
|
124
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;
|
|
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
|
}
|
|
@@ -46,10 +49,11 @@ const storeList = (source) => {
|
|
|
46
49
|
return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
|
|
47
50
|
};
|
|
48
51
|
const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
|
|
49
|
-
// The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store
|
|
50
|
-
// <store
|
|
52
|
+
// The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>[.big].meta.json,
|
|
53
|
+
// <store>[.big].passages.jsonl, <store>[.big].symbols.json, <store>-primer.md.
|
|
51
54
|
const storeStem = (file) => path.basename(String(file)).toLowerCase()
|
|
52
|
-
.replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
|
|
55
|
+
.replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
|
|
56
|
+
.replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
|
|
53
57
|
// Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
|
|
54
58
|
// placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
|
|
55
59
|
const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
|
|
@@ -87,6 +91,54 @@ const sameBytes = (left, right) => {
|
|
|
87
91
|
try { return sha256File(left) === sha256File(right); } catch { return false; }
|
|
88
92
|
};
|
|
89
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
|
+
|
|
90
142
|
/**
|
|
91
143
|
* @returns {{disposable: boolean, unique: {file: string, why: string}[], reason: string}} — a kept copy
|
|
92
144
|
* always names the files that keep it.
|
|
@@ -95,13 +147,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
95
147
|
const copy = lstat(copyDir);
|
|
96
148
|
if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
|
|
97
149
|
const live = lstat(liveDir);
|
|
98
|
-
if (!live || !live.isDirectory()
|
|
99
|
-
|
|
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())) {
|
|
100
158
|
return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
|
|
101
159
|
}
|
|
102
160
|
const privateNames = privateStoreNames([liveDir, copyDir]);
|
|
103
|
-
const generations = readJson(path.join(
|
|
104
|
-
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()))
|
|
105
163
|
.map(([, g]) => String(g?.file || '')).filter(Boolean).map((file) => ({ directory: path.dirname(path.normalize(file)),
|
|
106
164
|
basename: path.basename(file).toLowerCase(), stem: stemOf(file) }));
|
|
107
165
|
const coverageNames = (dir) => (readJson(path.join(dir, 'COVERAGE.json'))?.rows || [])
|
|
@@ -118,15 +176,10 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
118
176
|
let files;
|
|
119
177
|
try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
|
|
120
178
|
for (const { relative, link, size } of files) {
|
|
121
|
-
// node_modules (npm reinstalls it) and .console-runtime (bin/install.mjs installConsoleRuntime re-places it)
|
|
122
|
-
if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
|
|
123
|
-
|| (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
|
|
124
179
|
const isPrivate = belongsToPrivate(relative, privateNames, privateArtifacts);
|
|
125
180
|
const inLive = path.join(liveDir, relative);
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
|
|
129
|
-
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)' });
|
|
130
183
|
continue;
|
|
131
184
|
}
|
|
132
185
|
if (isPrivate) {
|
|
@@ -135,10 +188,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
135
188
|
}
|
|
136
189
|
continue;
|
|
137
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
|
+
}
|
|
138
200
|
if (relative === 'ARCHIVE-MANIFEST.json' && shipped.size) continue; // the release manifest itself
|
|
139
201
|
const listed = shipped.get(relative);
|
|
140
202
|
if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
|
|
141
|
-
if (
|
|
203
|
+
if (sameBytes(path.join(copyDir, relative), inLive)) continue; // existence alone proves no ownership or redundancy
|
|
142
204
|
if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
|
|
143
205
|
unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
|
|
144
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);
|