ruvnet-brain 4.5.16 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- package/scripts/stack-sync.mjs +19 -132
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* So this module turns what is OBSERVABLE at a capture boundary into small typed events:
|
|
12
12
|
*
|
|
13
13
|
* commit git, by SHA (subject, files, merge flag, branch) authoritative
|
|
14
|
-
* release git
|
|
14
|
+
* release local git tag observations, by tag + target SHA publication unverified
|
|
15
15
|
* gate a test / check / release command and its exit outcome authoritative (tool result)
|
|
16
16
|
* finding an Agent/Task completion's reported result not authoritative (agent text)
|
|
17
17
|
* decision a line the assistant marked as a decision, or explicit explicit only is authoritative
|
|
@@ -34,6 +34,7 @@ import os from 'node:os';
|
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import { execFileSync } from 'node:child_process';
|
|
36
36
|
import { redactProgression } from './project-progression-contract.mjs';
|
|
37
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
37
38
|
|
|
38
39
|
export const CONTINUITY_NAMESPACE = 'continuity-events';
|
|
39
40
|
export const EVENT_SCHEMA = 'ruvnet-brain.continuity-event';
|
|
@@ -191,9 +192,9 @@ export function collectReleases({ checkoutRoot, sinceMs, host, session, project,
|
|
|
191
192
|
if (!tag || !Number.isFinite(at) || at < sinceMs) continue;
|
|
192
193
|
const sha = peeled || object;
|
|
193
194
|
events.push(makeEvent({
|
|
194
|
-
kind: 'release', at, host, session, project, source: 'git', authoritative:
|
|
195
|
-
summary:
|
|
196
|
-
detail: { tag, sha, channel: tag.startsWith('corpus-') ? 'corpus' : 'code' },
|
|
195
|
+
kind: 'release', at, host, session, project, source: 'git-tag', authoritative: false,
|
|
196
|
+
summary: `LOCAL TAG ${tag} -> ${String(sha).slice(0, 8)} (publication unverified)`, basis: `${tag}@${sha}`,
|
|
197
|
+
detail: { tag, sha, channel: tag.startsWith('corpus-') ? 'corpus' : 'code', publicationVerified: false },
|
|
197
198
|
}));
|
|
198
199
|
if (events.length >= max) break;
|
|
199
200
|
}
|
|
@@ -236,12 +237,31 @@ export function currentTurnRecords(lines) {
|
|
|
236
237
|
return { userMessage: start >= 0 ? textOf(recs[start].message?.content) : '', records: recs.slice(start + 1) };
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
export { exitOutcome as normalizeToolOutcome };
|
|
241
|
+
|
|
239
242
|
function exitOutcome(result) {
|
|
240
243
|
const text = resultText(result?.content);
|
|
241
|
-
const
|
|
242
|
-
|
|
244
|
+
const response = result?.content && typeof result.content === 'object' && !Array.isArray(result.content)
|
|
245
|
+
? result.content : result;
|
|
246
|
+
const codes = [...text.matchAll(/^(?:Exit code\s*:?|Process exited with code)\s+(-?\d+)\s*$/gim)].map(match => Number(match[1]));
|
|
247
|
+
for (const value of [result, response]) for (const key of ['exit_code', 'exitCode', 'status']) {
|
|
248
|
+
if (Number.isSafeInteger(value?.[key])) codes.push(value[key]);
|
|
249
|
+
}
|
|
250
|
+
const distinct = [...new Set(codes)], code = distinct.length === 1 ? distinct[0] : null;
|
|
251
|
+
const states = [result, response].flatMap(value => [value?.status, value?.outcome]).filter(value => typeof value === 'string').map(value => value.toLowerCase());
|
|
252
|
+
const uncertain = states.some(value => ['unknown', 'unavailable'].includes(value));
|
|
253
|
+
const failed = states.some(value => ['fail', 'failed', 'failure', 'error', 'denied'].includes(value)) || [result, response].some(value => value?.is_error === true || value?.isError === true
|
|
254
|
+
|| value?.success === false || value?.ok === false || Boolean(value?.error)) || codes.some(value => value !== 0);
|
|
255
|
+
const interrupted = [result, response].some(value => value?.interrupted === true || value?.cancelled === true || value?.canceled === true || Boolean(value?.signal))
|
|
256
|
+
|| states.some(value => ['cancelled', 'canceled', 'interrupted', 'aborted', 'timeout', 'timed_out'].includes(value));
|
|
257
|
+
const running = /\bProcess running with session ID\b|\bScript running with cell ID\b/i.test(text)
|
|
258
|
+
|| states.some(value => ['running', 'pending', 'queued'].includes(value))
|
|
259
|
+
|| [result, response].some(value => value?.completed === false);
|
|
260
|
+
const outcome = failed ? 'fail' : interrupted ? 'interrupted' : running ? 'pending' : !uncertain && code === 0 ? 'pass' : 'unknown';
|
|
243
261
|
const tail = text.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
|
244
|
-
|
|
262
|
+
const nativeSuccess = [result, response].some(value => value?.is_error === false || value?.isError === false || value?.success === true || value?.ok === true);
|
|
263
|
+
return { outcome, exitCode: running || interrupted ? null : code, tail: bound(tail, 200),
|
|
264
|
+
uncertain, successfulToolResult: !failed && !interrupted && !running && !uncertain && (code === 0 || nativeSuccess) };
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
/**
|
|
@@ -269,7 +289,7 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
|
|
|
269
289
|
if (use.name === 'Bash' && typeof input.command === 'string' && GATE_COMMAND.test(input.command)) {
|
|
270
290
|
const command = bound(input.command, 200);
|
|
271
291
|
const { outcome, exitCode, tail } = exitOutcome(r);
|
|
272
|
-
events.push(makeEvent({ kind: 'gate', at, host, session, project, source: 'tool-result', authoritative:
|
|
292
|
+
events.push(makeEvent({ kind: 'gate', at, host, session, project, source: 'tool-result', authoritative: ['pass', 'fail'].includes(outcome),
|
|
273
293
|
summary: `${outcome.toUpperCase()} ${command}${tail ? ` — ${tail}` : ''}`,
|
|
274
294
|
basis: `${session}\u0000${use.id}`, detail: { command, outcome, exitCode, description: bound(input.description || '', 120) } }));
|
|
275
295
|
} else if (use.name === 'Agent' || use.name === 'Task') {
|
|
@@ -309,19 +329,75 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
|
|
|
309
329
|
* The owner's own user-level AgentDB hooks (~/.claude/settings.json). Read, never modified. Used to
|
|
310
330
|
* DEFER: where one of them already records a thing, the product does not record it a second time.
|
|
311
331
|
*/
|
|
312
|
-
export function userLevelAgentdbHooks({ home = os.homedir() } = {}) {
|
|
313
|
-
const found = { turnCapture: false, autocapture: false, ensure: false,
|
|
332
|
+
export function userLevelAgentdbHooks({ home = os.homedir(), event, projectDir, env = process.env } = {}) {
|
|
333
|
+
const found = { turnCapture: false, autocapture: false, ensure: false, ownership: 'unknown', collisionCandidate: false,
|
|
334
|
+
settings: path.join(home, '.claude', 'settings.json') };
|
|
335
|
+
if (!['Stop', 'PreCompact', 'SessionEnd', 'SessionStart'].includes(event) || !projectDir
|
|
336
|
+
|| String(env.RUVNET_HOOK_HOST || 'claude') !== 'claude') return found;
|
|
314
337
|
let doc;
|
|
315
338
|
try { doc = JSON.parse(fs.readFileSync(found.settings, 'utf8')); } catch { return found; }
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
339
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.disableAllHooks === true || doc.allowManagedHooksOnly === true) return found;
|
|
340
|
+
let target, executable;
|
|
341
|
+
try {
|
|
342
|
+
for (const file of [path.join(projectDir, '.claude', 'settings.json'), path.join(projectDir, '.claude', 'settings.local.json')]) {
|
|
343
|
+
let local;
|
|
344
|
+
try { local = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
345
|
+
if (!local || typeof local !== 'object' || Array.isArray(local) || local.disableAllHooks === true || local.allowManagedHooksOnly === true) return found;
|
|
346
|
+
}
|
|
347
|
+
const deadlineAt = Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity;
|
|
348
|
+
target = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt }).canonicalAgentDbPath;
|
|
349
|
+
// Bind the direct command to the declared entry of the user's one managed GLOBAL Ruflo.
|
|
350
|
+
// Arbitrary Node handlers, filenames and source comments do not prove their actual target.
|
|
351
|
+
if (!process.getuid) return found; // Native Windows ACL ownership is not attested by POSIX uid metadata.
|
|
352
|
+
const root = path.join(home, '.npm-global', 'lib', 'node_modules', 'ruflo');
|
|
353
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
354
|
+
const declared = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.ruflo;
|
|
355
|
+
if (manifest.name !== 'ruflo' || typeof declared !== 'string' || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return found;
|
|
356
|
+
executable = fs.realpathSync.native(path.join(home, '.npm-global', 'bin', 'ruflo'));
|
|
357
|
+
const entry = fs.realpathSync.native(path.join(root, declared));
|
|
358
|
+
const relative = path.relative(fs.realpathSync.native(root), entry);
|
|
359
|
+
const stat = fs.statSync(entry);
|
|
360
|
+
if (executable !== entry || relative.startsWith('..') || path.isAbsolute(relative) || !stat.isFile() || stat.uid !== process.getuid()) return found;
|
|
361
|
+
fs.accessSync(executable, fs.constants.X_OK);
|
|
362
|
+
} catch { return found; }
|
|
363
|
+
const groups = doc?.hooks?.[event];
|
|
364
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
365
|
+
if (group?.enabled === false || group?.disabled === true || (group?.matcher && group.matcher !== '*')) continue;
|
|
366
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
367
|
+
if (hook?.type !== 'command' || hook.enabled === false || hook.disabled === true || typeof hook.command !== 'string') continue;
|
|
368
|
+
const command = hook.command.trim();
|
|
369
|
+
if (/[$`;&|<>\r\n]/.test(command)) continue;
|
|
370
|
+
const tokens = []; const words = /"([^"\\]*)"|'([^']*)'|([^\s"'\\]+)/g;
|
|
371
|
+
let match, previous = 0, valid = true;
|
|
372
|
+
while ((match = words.exec(command))) {
|
|
373
|
+
if (command.slice(previous, match.index).trim()) { valid = false; break; }
|
|
374
|
+
tokens.push(match[1] ?? match[2] ?? match[3]); previous = words.lastIndex;
|
|
375
|
+
}
|
|
376
|
+
if (!valid || command.slice(previous).trim() || tokens[1] !== 'memory') continue;
|
|
377
|
+
try { if (!path.isAbsolute(tokens[0]) || fs.realpathSync.native(tokens[0]) !== executable) continue; } catch { continue; }
|
|
378
|
+
const options = tokens[2] === 'store' ? ['--path', '-p', '--namespace', '-n', '--key', '-k', '--value']
|
|
379
|
+
: tokens[2] === 'retrieve' ? ['--path', '-p', '--namespace', '-n', '--key', '-k']
|
|
380
|
+
: tokens[2] === 'init' ? ['--path', '-p'] : [];
|
|
381
|
+
for (let i = 3; i < tokens.length; i += 2) {
|
|
382
|
+
if (!options.includes(tokens[i]) || !tokens[i + 1] || tokens[i + 1].startsWith('-')) { valid = false; break; }
|
|
323
383
|
}
|
|
384
|
+
if (!valid) continue;
|
|
385
|
+
const flag = names => {
|
|
386
|
+
const positions = tokens.flatMap((token, i) => names.includes(token) ? [i] : []);
|
|
387
|
+
return positions.length === 1 ? tokens[positions[0] + 1] : undefined;
|
|
388
|
+
};
|
|
389
|
+
const db = flag(['--path', '-p']);
|
|
390
|
+
if (!db || !path.isAbsolute(db) || db.split(/[\\/]/).includes('..') || path.resolve(db) !== target) continue;
|
|
391
|
+
const namespace = flag(['--namespace', '-n']);
|
|
392
|
+
const captureRegistration = tokens[2] === 'store' && flag(['--key', '-k']) && flag(['--value'])
|
|
393
|
+
&& ((event === 'Stop' && namespace === 'turns') || (['PreCompact', 'SessionEnd'].includes(event) && namespace === 'sessions'));
|
|
394
|
+
const ensureRegistration = event === 'SessionStart' && ['init', 'retrieve'].includes(tokens[2]);
|
|
395
|
+
if (captureRegistration || ensureRegistration) found.collisionCandidate = true;
|
|
324
396
|
}
|
|
325
397
|
}
|
|
398
|
+
if (found.collisionCandidate) {
|
|
399
|
+
found.target = target; found.event = event;
|
|
400
|
+
found.reason = 'canonical registration alone does not prove capture of the current turn/session';
|
|
401
|
+
}
|
|
326
402
|
return found;
|
|
327
403
|
}
|
|
@@ -183,7 +183,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
183
183
|
registration('grounding-turn-gate', '*', ['claude', 'codex']),
|
|
184
184
|
]),
|
|
185
185
|
PreCompact: Object.freeze([
|
|
186
|
-
registration('session-snapshot', '*', ['claude']),
|
|
186
|
+
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
187
187
|
]),
|
|
188
188
|
SessionEnd: Object.freeze([
|
|
189
189
|
registration('learn-flush', '*', ['claude', 'codex']),
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
18
19
|
import { spawn, spawnSync } from 'node:child_process';
|
|
19
20
|
import { fileURLToPath } from 'node:url';
|
|
20
21
|
import { digestCanonical } from './project-progression-contract.mjs';
|
|
@@ -56,7 +57,8 @@ const ms = (iso) => Date.parse(iso || '') || 0;
|
|
|
56
57
|
|
|
57
58
|
export class ContinuityJournal {
|
|
58
59
|
/** `ruflo`: the binary (string), null = not installed, undefined = resolve it. */
|
|
59
|
-
constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo
|
|
60
|
+
constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo,
|
|
61
|
+
deadlineAt = Infinity, signal } = {}) {
|
|
60
62
|
if (typeof projectRoot !== 'string' || !projectRoot) throw new TypeError('projectRoot is required');
|
|
61
63
|
this.projectRoot = projectRoot;
|
|
62
64
|
this.projectDir = projectDir;
|
|
@@ -67,38 +69,63 @@ export class ContinuityJournal {
|
|
|
67
69
|
this.fsync = fsync;
|
|
68
70
|
this.now = now;
|
|
69
71
|
this.ruflo = ruflo === undefined ? resolveRuflo() : ruflo;
|
|
72
|
+
this.deadlineAt = deadlineAt;
|
|
73
|
+
this.signal = signal;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
requireBudget() {
|
|
77
|
+
if (!this.signal && this.deadlineAt === Infinity) return;
|
|
78
|
+
if (this.signal?.aborted || Date.now() >= this.deadlineAt) throw new Error('continuity capture deadline exceeded or aborted; unavailable');
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/** Short exclusive section shared by every appender and the compactor (never held across a store call). */
|
|
73
82
|
withAppendLock(fn) {
|
|
74
83
|
const lock = path.join(this.swarm, APPEND_LOCK_NAME);
|
|
75
|
-
|
|
84
|
+
this.requireBudget();
|
|
85
|
+
const deadline = Math.min(Date.now() + APPEND_LOCK_WAIT_MS, this.deadlineAt);
|
|
86
|
+
const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`;
|
|
87
|
+
const owns = () => {
|
|
88
|
+
try { return !fs.lstatSync(lock).isSymbolicLink() && fs.readFileSync(lock, 'utf8') === token; } catch { return false; }
|
|
89
|
+
};
|
|
76
90
|
let held = false;
|
|
77
91
|
// Only EEXIST is retried, and every iteration is deadline-bounded: EACCES/EROFS/ENOSPC used to spin at 100% CPU
|
|
78
92
|
// forever (re-review B2). Any other error is the outbox's own failure: thrown, reported as "outbox write failed".
|
|
79
93
|
for (;;) {
|
|
80
|
-
|
|
94
|
+
this.requireBudget();
|
|
95
|
+
try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); held = true; break; } catch (error) {
|
|
81
96
|
if (error?.code !== 'EEXIST') throw error;
|
|
82
97
|
}
|
|
83
98
|
let stale = false;
|
|
84
|
-
try {
|
|
99
|
+
try {
|
|
100
|
+
const st = fs.lstatSync(lock), body = fs.readFileSync(lock, 'utf8');
|
|
101
|
+
const pid = Number(body.trim().split(' ')[0]);
|
|
102
|
+
let dead = false;
|
|
103
|
+
if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
|
|
104
|
+
stale = !st.isSymbolicLink() && dead && Date.now() - st.mtimeMs > APPEND_LOCK_STALE_MS
|
|
105
|
+
&& fs.readFileSync(lock, 'utf8') === body;
|
|
106
|
+
} catch { /* unavailable ownership is not permission to remove a lock */ }
|
|
85
107
|
let removed = false; // a stale lock we cannot remove is waited out ASLEEP, never spun on (re-review a6 NIT)
|
|
86
108
|
if (stale) { try { fs.rmSync(lock, { force: true }); removed = true; } catch { /* not ours to remove: wait it out */ } }
|
|
87
|
-
|
|
88
|
-
if (Date.now() >= deadline) break;
|
|
109
|
+
if (Date.now() >= deadline) throw Object.assign(new Error('outbox append lock unavailable; mutation deferred'), { code: 'EAGAIN' });
|
|
89
110
|
if (!removed) pause(5);
|
|
90
111
|
}
|
|
91
|
-
try {
|
|
112
|
+
try {
|
|
113
|
+
if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
|
|
114
|
+
return fn(owns);
|
|
115
|
+
} finally { if (held && owns()) fs.rmSync(lock, { force: true }); }
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
/** Append records with ONE fsync. Refuses to create `.swarm` (absence = project did not adopt). */
|
|
95
119
|
appendRecords(records) {
|
|
120
|
+
this.requireBudget();
|
|
96
121
|
if (!records.length) return 0;
|
|
97
122
|
const stat = fs.lstatSync(this.swarm);
|
|
98
123
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('.swarm is not a real directory');
|
|
99
|
-
return this.withAppendLock(() => {
|
|
124
|
+
return this.withAppendLock((owns) => {
|
|
100
125
|
const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
101
126
|
try {
|
|
127
|
+
this.requireBudget();
|
|
128
|
+
if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
|
|
102
129
|
fs.writeSync(fd, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
103
130
|
this.fsync(fd);
|
|
104
131
|
} finally { fs.closeSync(fd); }
|
|
@@ -108,6 +135,7 @@ export class ContinuityJournal {
|
|
|
108
135
|
|
|
109
136
|
/** Every line, tolerant: unparseable lines are counted, never thrown. */
|
|
110
137
|
scan() {
|
|
138
|
+
this.requireBudget();
|
|
111
139
|
let text = '';
|
|
112
140
|
try { text = fs.readFileSync(this.path, 'utf8'); } catch { /* no outbox yet */ }
|
|
113
141
|
const events = new Map();
|
|
@@ -119,6 +147,7 @@ export class ContinuityJournal {
|
|
|
119
147
|
let corrupt = 0;
|
|
120
148
|
let lines = 0;
|
|
121
149
|
for (const line of text.split('\n')) {
|
|
150
|
+
this.requireBudget();
|
|
122
151
|
if (!line.trim()) continue;
|
|
123
152
|
lines += 1;
|
|
124
153
|
let rec;
|
|
@@ -146,25 +175,35 @@ export class ContinuityJournal {
|
|
|
146
175
|
|
|
147
176
|
/** `kind:id` of every event already journalled here or committed to the store (dedupe set). */
|
|
148
177
|
knownIds(scan = this.scan()) {
|
|
178
|
+
this.requireBudget();
|
|
149
179
|
const ids = new Set();
|
|
150
180
|
for (const key of scan.events.keys()) { const id = eventIdOf(key); if (id) ids.add(id); }
|
|
151
181
|
if (storeReady(this.db)) {
|
|
152
|
-
const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 })
|
|
153
|
-
|
|
182
|
+
const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }),
|
|
183
|
+
{ deadlineAt: this.deadlineAt, signal: this.signal });
|
|
184
|
+
this.requireBudget();
|
|
185
|
+
if (!listed.ok) throw new Error(`continuity deduplication unavailable: ${listed.reason}`);
|
|
186
|
+
for (const key of listed.value) { this.requireBudget(); const id = eventIdOf(key); if (id) ids.add(id); }
|
|
154
187
|
}
|
|
155
188
|
return ids;
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
captureConsent(projectDir = this.projectDir, unknownOriginalPath = false) {
|
|
159
|
-
|
|
192
|
+
this.requireBudget();
|
|
193
|
+
const result = resolveTurnDb({ projectDir, brainHome: this.brainHome, requestedStorePath: this.db, unknownOriginalPath,
|
|
194
|
+
deadlineAt: this.deadlineAt, signal: this.signal, gitTimeoutMs: Math.max(1, Math.min(1000, this.deadlineAt - Date.now())) });
|
|
195
|
+
this.requireBudget();
|
|
196
|
+
return result;
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
/** Journal the events this boundary observed that are not already known. Returns what was added. */
|
|
163
200
|
record(events) {
|
|
201
|
+
this.requireBudget();
|
|
164
202
|
const consent = this.captureConsent();
|
|
165
203
|
if (consent.skipped) throw new Error(`event capture suspended: ${consent.skipped}`);
|
|
166
204
|
const known = this.knownIds(); const fresh = [];
|
|
167
205
|
for (const original of events) { const event = privateContinuityEvent(original, consent.contentPathExcludes, consent.capturePath);
|
|
206
|
+
this.requireBudget();
|
|
168
207
|
const id = `${event.kind}:${event.id}`;
|
|
169
208
|
if (known.has(id)) continue;
|
|
170
209
|
known.add(id);
|
|
@@ -189,7 +228,7 @@ export class ContinuityJournal {
|
|
|
189
228
|
if (failureLines > scan.failures.size) return true;
|
|
190
229
|
for (const c of scan.committed.values()) if (now - ms(c.committedAt) > RETAIN_COMMITTED_MS) return true;
|
|
191
230
|
if (scan.notices.some((n) => now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt)) return true;
|
|
192
|
-
|
|
231
|
+
// Quarantine warnings expire in status(); disputed original bytes do not expire here.
|
|
193
232
|
// An unshrinkable pending backlog must not be rewritten on every capture boundary.
|
|
194
233
|
return scan.events.size > MAX_EVENT_RECORDS && [...scan.events.keys()].some((key) => scan.committed.has(key));
|
|
195
234
|
}
|
|
@@ -200,17 +239,15 @@ export class ContinuityJournal {
|
|
|
200
239
|
*/
|
|
201
240
|
compact({ beforeRename = null } = {}) {
|
|
202
241
|
const now = this.now();
|
|
203
|
-
return this.withAppendLock(() => {
|
|
242
|
+
return this.withAppendLock((owns) => {
|
|
204
243
|
let before;
|
|
205
244
|
try { before = fs.statSync(this.path); } catch { return { compacted: false, reason: 'no outbox' }; }
|
|
206
245
|
const scan = this.scan();
|
|
207
246
|
const out = [];
|
|
208
|
-
const quarantineLive = (q) => now - ms(q.at) <= QUARANTINE_REPORT_MS;
|
|
209
247
|
const keep = [...scan.events.values()].filter((rec) => {
|
|
210
248
|
const c = scan.committed.get(rec.key);
|
|
211
249
|
if (c) return now - ms(c.committedAt) <= RETAIN_COMMITTED_MS;
|
|
212
|
-
|
|
213
|
-
return q ? quarantineLive(q) : true;
|
|
250
|
+
return true; // pending and disputed originals are both durable recovery evidence
|
|
214
251
|
}).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
215
252
|
// Only read-back committed events may leave at the cap. Pending bytes are the recovery copy.
|
|
216
253
|
const gone = new Set(keep.filter((r) => scan.committed.has(r.key)).slice(0, Math.max(0, keep.length - MAX_EVENT_RECORDS)));
|
|
@@ -238,6 +275,11 @@ export class ContinuityJournal {
|
|
|
238
275
|
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
239
276
|
try { fs.writeSync(fd, out.map((r) => `${JSON.stringify(r)}\n`).join('')); this.fsync(fd); } finally { fs.closeSync(fd); }
|
|
240
277
|
beforeRename?.();
|
|
278
|
+
this.requireBudget();
|
|
279
|
+
if (!owns()) {
|
|
280
|
+
fs.rmSync(tmp, { force: true });
|
|
281
|
+
return { compacted: false, reason: 'outbox append lock ownership changed; compaction deferred' };
|
|
282
|
+
}
|
|
241
283
|
let now_;
|
|
242
284
|
try { now_ = fs.statSync(this.path); } catch { now_ = null; }
|
|
243
285
|
if (!now_ || now_.size !== before.size || now_.mtimeMs !== before.mtimeMs) {
|
|
@@ -266,7 +308,8 @@ export class ContinuityJournal {
|
|
|
266
308
|
const ready = storeReady(this.db);
|
|
267
309
|
const notApplicable = !ready ? 'no AgentDB store in this project (.swarm/memory.db is not initialized)'
|
|
268
310
|
: !this.ruflo ? 'ruflo is not installed' : null;
|
|
269
|
-
const
|
|
311
|
+
const retainedQuarantined = [...scan.quarantined.keys()].filter(key => scan.events.has(key) && !scan.committed.has(key)).length;
|
|
312
|
+
const capacityPressure = pending.length + retainedQuarantined > MAX_EVENT_RECORDS;
|
|
270
313
|
const problem = quarantined.length ? 'quarantined' : corrupt ? 'corrupt' : dropped ? 'dropped'
|
|
271
314
|
: !notApplicable && capacityPressure ? 'capacity-pressure' : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
|
|
272
315
|
return {
|
|
@@ -274,7 +317,7 @@ export class ContinuityJournal {
|
|
|
274
317
|
applicable: !notApplicable, notApplicable,
|
|
275
318
|
pending: pending.length, oldestPendingAt: oldest, lastCommitAt, eventsToday,
|
|
276
319
|
lastFailure: lastFailure && (!lastCommitAt || ms(lastFailure.at) > lastCommitAt) ? lastFailure : null,
|
|
277
|
-
quarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
|
|
320
|
+
quarantined, retainedQuarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
|
|
278
321
|
};
|
|
279
322
|
}
|
|
280
323
|
}
|
|
@@ -289,11 +332,12 @@ export function recordingLine(status, now = Date.now()) {
|
|
|
289
332
|
const why = status.problem === 'quarantined' ? `${status.quarantined.length} quarantined (a different row holds its key; never retried — ${clears})`
|
|
290
333
|
: status.problem === 'corrupt' ? `${status.corrupt} corrupt outbox line(s) removed (${clears})`
|
|
291
334
|
: status.problem === 'dropped' ? `${status.dropped} uncommitted event(s) dropped at the outbox cap (${clears})`
|
|
292
|
-
: status.problem === 'capacity-pressure' ? `
|
|
335
|
+
: status.problem === 'capacity-pressure' ? `retained backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending or disputed events discarded`
|
|
293
336
|
: status.lastFailure ? `last error: ${status.lastFailure.reason || status.lastFailure.error}` : 'not committing';
|
|
294
337
|
return `AgentDB: recording stuck — ${status.pending} event(s) pending${status.oldestPendingAt && status.problem === 'stuck-pending' ? ` for ${ago(now - status.oldestPendingAt)}` : ''}, ${why}.`
|
|
295
338
|
+ ` Pending events are durable in ${status.outbox} and retry at every capture boundary.`;
|
|
296
339
|
}
|
|
340
|
+
if (status.retainedQuarantined) return `AgentDB: recording unresolved — ${status.retainedQuarantined} disputed original event(s) retained, not committed; ${status.pending} event(s) pending. No disputed original is deleted automatically.`;
|
|
297
341
|
if (status.notApplicable) {
|
|
298
342
|
return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`
|
|
299
343
|
+ (status.capacityPressure ? ` Capacity pressure: pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded.` : '');
|
|
@@ -334,20 +378,15 @@ function defaultStore({ ruflo, db, key, value }) {
|
|
|
334
378
|
|
|
335
379
|
function defaultReadBack({ ruflo, db, key }) {
|
|
336
380
|
const fast = withProgressionReader(db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, key));
|
|
337
|
-
if (fast.ok) return { content: fast.value, readPath: 'node:sqlite' };
|
|
381
|
+
if (fast.ok) return { key, content: fast.value, readPath: 'node:sqlite' };
|
|
338
382
|
const cwd = rufloRunDir(db);
|
|
339
383
|
try {
|
|
340
384
|
const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
|
|
341
385
|
const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
|
|
342
|
-
return { content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
|
|
386
|
+
return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
|
|
343
387
|
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
|
|
344
388
|
}
|
|
345
389
|
|
|
346
|
-
/** Is a stored row the SAME event (same kind and content id), whoever observed it? */
|
|
347
|
-
const sameEvent = (content, event) => {
|
|
348
|
-
try { const row = JSON.parse(content); return row?.kind === event.kind && row?.id === event.id; } catch { return false; }
|
|
349
|
-
};
|
|
350
|
-
|
|
351
390
|
/**
|
|
352
391
|
* Commit every pending event: store → exact read-back → commit line. A refusal is retried with
|
|
353
392
|
* backoff while the budget lasts; nothing is ever dropped. Failures are ONE record per event per run
|
|
@@ -378,7 +417,7 @@ export function drain(journal, {
|
|
|
378
417
|
attempts += 1; const result = store({ ruflo, db: journal.db, key: rec.key, value });
|
|
379
418
|
const back = readBack({ ruflo, db: journal.db, key: rec.key });
|
|
380
419
|
const content = typeof back.content === 'string' ? back.content.trim() : '';
|
|
381
|
-
if (content
|
|
420
|
+
if (content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
|
|
382
421
|
journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
|
|
383
422
|
alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
|
|
384
423
|
committed += 1;
|
|
@@ -443,13 +482,18 @@ export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env }
|
|
|
443
482
|
export function captureContinuityEvents({
|
|
444
483
|
projectDir, event, payload = {}, host = 'claude', env = process.env, ruflo = resolveRuflo({ env }),
|
|
445
484
|
readTranscript = (file) => readSettledTranscript(file, { maxMs: 0 }), launch = launchDrain, now = Date.now,
|
|
485
|
+
deadlineAt = Infinity, signal,
|
|
446
486
|
} = {}) {
|
|
447
487
|
const report = { event, recorded: 0, launched: false };
|
|
448
488
|
if (String(env.RUVNET_CONTINUITY_CAPTURE || '').toLowerCase() === 'off') return { ...report, skipped: 'RUVNET_CONTINUITY_CAPTURE=off' };
|
|
489
|
+
try {
|
|
490
|
+
if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('continuity capture unavailable');
|
|
449
491
|
let resolution;
|
|
450
|
-
try { resolution = resolveProjectStore({ projectDir
|
|
451
|
-
|
|
492
|
+
try { resolution = resolveProjectStore({ projectDir, deadlineAt, signal,
|
|
493
|
+
gitTimeoutMs: Math.max(1, Math.min(1000, deadlineAt - Date.now())) }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
|
|
494
|
+
const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, projectDir, env, now, ruflo, deadlineAt, signal });
|
|
452
495
|
try {
|
|
496
|
+
journal.requireBudget();
|
|
453
497
|
const consent = journal.captureConsent();
|
|
454
498
|
if (consent.skipped) return { ...report, skipped: consent.skipped.startsWith('no project memory db') ? `not applicable: ${consent.skipped}` : consent.skipped };
|
|
455
499
|
} catch (error) { return { ...report, skipped: `capture consent unavailable: ${error.message}` }; }
|
|
@@ -483,6 +527,10 @@ export function captureContinuityEvents({
|
|
|
483
527
|
// A drainer only where it can succeed: an initialized store AND a ruflo to write it (review S4).
|
|
484
528
|
if (status.pending && status.applicable) report.launched = launch({ projectRoot: resolution.projectRoot, env });
|
|
485
529
|
return { ...report, status, journal };
|
|
530
|
+
} catch {
|
|
531
|
+
return { ...report, queued: report.recorded > 0, skipped: signal?.aborted ? 'continuity capture aborted; unavailable'
|
|
532
|
+
: Date.now() >= deadlineAt ? 'continuity capture deadline exceeded; unavailable' : 'canonical continuity unavailable' };
|
|
533
|
+
}
|
|
486
534
|
}
|
|
487
535
|
|
|
488
536
|
/** The detached worker body. */
|