ruvnet-brain 4.6.3 → 4.7.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 +1 -1
- package/bin/install.mjs +6 -50
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -58
- package/plugin/hooks/hook-contracts.json +19 -220
- package/plugin/hooks/hooks.json +1 -70
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +27 -21
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-events.mjs +90 -82
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +3 -3
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +324 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +61 -0
- package/plugin/scripts/hook-shim.mjs +71 -6
- package/plugin/scripts/learn-capture.mjs +88 -37
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -332
- package/plugin/scripts/session-start-core.mjs +7 -9
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +74 -88
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/deferral-replay.mjs +47 -0
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -357
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -246
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -26,14 +26,14 @@ import fs from 'node:fs';
|
|
|
26
26
|
import os from 'node:os';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
|
-
import {
|
|
30
|
-
import { restoreWithBrief } from './continuity-brief.mjs';
|
|
29
|
+
import { restore as restoreFromRuflo } from './ruflo-memory-hooks.mjs';
|
|
31
30
|
import { turnRecordingStatus } from './turn-outcome-capture.mjs';
|
|
32
31
|
import {
|
|
33
32
|
read, json, exists, mkdir, write, runNode,
|
|
34
33
|
} from './session-start-fsutil.mjs';
|
|
35
34
|
import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
|
|
36
35
|
import { surfaceSignals } from './session-start-signals.mjs';
|
|
36
|
+
import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
|
|
37
37
|
import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
|
|
38
38
|
import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
|
|
39
39
|
import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
|
|
@@ -92,7 +92,7 @@ export async function runSessionStart({
|
|
|
92
92
|
stdout = process.stdout,
|
|
93
93
|
stderr = process.stderr,
|
|
94
94
|
platform = process.platform,
|
|
95
|
-
restoreContinuity =
|
|
95
|
+
restoreContinuity = restoreFromRuflo,
|
|
96
96
|
runHeartbeat = true,
|
|
97
97
|
sessionSource = 'startup',
|
|
98
98
|
probeConsoleAlias = probeCodexConsoleAlias,
|
|
@@ -137,7 +137,8 @@ export async function runSessionStart({
|
|
|
137
137
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
|
|
138
138
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
|
|
139
139
|
|| s.startsWith('[RuvNet Brain — COME UP TO SPEED')
|
|
140
|
-
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
|
|
140
|
+
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
|
|
141
|
+
|| s.startsWith(FAILURE_LINE_PREFIX);
|
|
141
142
|
};
|
|
142
143
|
// An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
|
|
143
144
|
// explanatory BODY lines do not, and without this they were silently dropped — a real gap this
|
|
@@ -170,6 +171,8 @@ export async function runSessionStart({
|
|
|
170
171
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
171
172
|
const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
172
173
|
resetInjectionDedupe(stateDir, Date.now());
|
|
174
|
+
// ADR-106: advisory hooks fail open, so a failed one is reported here, once, never per fire.
|
|
175
|
+
try { const failed = failureLine(stateDir); if (failed) emit(failed); } catch { /* the summary must not cost the session */ }
|
|
173
176
|
const hookDir = path.dirname(fileURLToPath(import.meta.url));
|
|
174
177
|
const now = Date.now();
|
|
175
178
|
const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
|
|
@@ -190,11 +193,6 @@ export async function runSessionStart({
|
|
|
190
193
|
}
|
|
191
194
|
};
|
|
192
195
|
|
|
193
|
-
// Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
|
|
194
|
-
// consented captured boundary, so a session that merely started leaves no store behind.
|
|
195
|
-
let enrollment;
|
|
196
|
-
try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
|
|
197
|
-
if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
|
|
198
196
|
const restoreStart = Date.now();
|
|
199
197
|
const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
|
|
200
198
|
const restoreController = new AbortController();
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
// SINGLE WRITER (DDD-0013 Context 2 invariant 3): this file is the ONLY writer of pending.jsonl.
|
|
50
50
|
// scripts/signal-watch.mjs (the poller) only ever reads it.
|
|
51
51
|
//
|
|
52
|
-
// CONTRACT (matches every other node hook body in this dir — md-stamp.mjs
|
|
52
|
+
// CONTRACT (matches every other node hook body in this dir — md-stamp.mjs):
|
|
53
53
|
// dispatched via hook-shim.mjs (mode: advisory). ALWAYS exits 0; never throws to the caller; a
|
|
54
54
|
// parse/IO failure degrades to silence, never a crash the harness has to survive (DDD invariant 4:
|
|
55
55
|
// "observation never blocks and never manufactures a refusal").
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* substitution-wall.mjs — "if you are rebuilding rUv's storage, stop and call his tool."
|
|
4
|
+
*
|
|
5
|
+
* THE RULING (owner, permanent): this plugin may only CALL rUv's memory tools (`ruflo memory
|
|
6
|
+
* store|retrieve|search|list|stats|backup|cleanup|compress`, `ruflo hooks ...`). It must never
|
|
7
|
+
* hand-roll its own storage, queue, journal or outbox for agent memory. Three incidents made the
|
|
8
|
+
* rule: 2026-07-13 (router), 2026-07-16 (router-optimizer), 2026-10-12 (a persistence layer in front
|
|
9
|
+
* of `ruflo memory store`: a 3.38 GB outbox, snapshots, queues, replay workers). The old CI gate
|
|
10
|
+
* (scripts/no-silent-substitution.mjs) said "none" on all of it: it only knew four rUv packages and
|
|
11
|
+
* counted any spawn of the `ruflo` binary as "rUv is used".
|
|
12
|
+
*
|
|
13
|
+
* WHY rUv's store is the answer, not a journal in front of it: `ruflo memory store` owns its own
|
|
14
|
+
* durability — it fails closed on a locked/damaged DB and reports the native bridge error
|
|
15
|
+
* (ruflo/v3/docs/releases/v3.32.34.md), and reports `persistWarning` instead of a blind success
|
|
16
|
+
* (ruflo/v3/@claude-flow/cli/__tests__/memory-store-persist-warning-2968.test.ts).
|
|
17
|
+
*
|
|
18
|
+
* ONE PURE FUNCTION, two callers: the decision-gate policy below (PreToolUse Write/Edit/MultiEdit/NotebookEdit/apply_patch)
|
|
19
|
+
* and the CI gate scripts/no-silent-substitution.mjs. No I/O, no clock, no network inside
|
|
20
|
+
* substitutionVerdict(); the CLI block at the bottom does the reading.
|
|
21
|
+
*
|
|
22
|
+
* ADR-106: refuse only on a FACT. The fact is a hand-built durable transport in code: a persistence
|
|
23
|
+
* primitive (fsync/append/O_APPEND) AND a transport construct (outbox/journal/queue/replay/spool/
|
|
24
|
+
* drain) AND a mechanism that only a transport needs (fsync durability, a lock file, or a
|
|
25
|
+
* self re-exec worker). Weaker evidence — state appended next to agent memory, or score ranking over
|
|
26
|
+
* it — WARNS through additionalContext and never blocks.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
export const RUV_TOOL = 'ruflo memory store|retrieve|search (or `ruflo hooks session-end|post-task`)';
|
|
33
|
+
/** Repo-relative scope: our own executable code. Tests and fixtures are never in scope. */
|
|
34
|
+
export const SCOPE = /(?:^|\/)(?:plugin\/scripts|scripts)\/(?:[^/]+\/)*[^/]+\.(?:mjs|cjs|js|sh)$/;
|
|
35
|
+
/** The rulebook names every signal in order to police it; exempting it is not a loophole. */
|
|
36
|
+
const EXEMPT = new Set(['plugin/scripts/substitution-wall.mjs', 'scripts/no-silent-substitution.mjs']);
|
|
37
|
+
const TEST = /(?:^|\/)(?:tests?|__tests__|fixtures?)\/|\.test\.[cm]?js$|\.spec\.[cm]?js$/;
|
|
38
|
+
|
|
39
|
+
export function inScope(p) {
|
|
40
|
+
const rel = String(p || '').split(path.sep).join('/');
|
|
41
|
+
return SCOPE.test(rel) && !TEST.test(rel) && ![...EXEMPT].some((e) => rel === e || rel.endsWith(`/${e}`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Code only: comments describe a design, they do not implement one (and our headers name incidents). */
|
|
45
|
+
export function codeOf(content, p = '') {
|
|
46
|
+
const s = String(content || '');
|
|
47
|
+
if (/\.sh$/.test(p)) return s.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
48
|
+
return s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// pending/flush/retry joined after review M6: decision-record kept an fsynced `pending` dir with a `--flush` replay.
|
|
52
|
+
// Bare "pending"/"retry" are status words (14 false refusals measured over this tree); only their storage/replay forms count.
|
|
53
|
+
const TRANSPORT = /outbox|journal|spool|replay|\bdrain\w*|queue(?!d)|\bpending(?:Dir|Path|Records?|Writes?|Files?|Queue)\b|\bflush(?:Pending|Queue|Outbox|Journal)\w*|['"]--(?:flush|retry)['"]|\bretry(?:Queue|Pending|Worker|Dir)\w*/i;
|
|
54
|
+
/** The distinct transport words in code: an edit to a debt file that adds a NEW one is a new transport. */
|
|
55
|
+
export const transportTerms = (content, p = '') => new Set((codeOf(content, p).match(new RegExp(TRANSPORT.source, 'gi')) || []).map((t) => t.toLowerCase()));
|
|
56
|
+
|
|
57
|
+
const RULES = {
|
|
58
|
+
persist: /\b(?:appendFileSync|appendFile|fsyncSync|fdatasyncSync|createWriteStream)\b|\bO_APPEND\b|flags?\s*:\s*['"]a\+?['"]/,
|
|
59
|
+
// A write-then-rename (the usual atomic JSON-file store) persists as surely as an append. It counts toward a
|
|
60
|
+
// TRANSPORT only: as a bare warn signal it flagged a settings writer (runtime-preferences.mjs) falsely.
|
|
61
|
+
atomicWrite: /\bwriteFileSync\([^;]{0,200};[\s\S]{0,300}?\brenameSync\(/,
|
|
62
|
+
durable: /\b(?:fsyncSync|fdatasyncSync)\b/,
|
|
63
|
+
// `readback` is verification (store, then retrieve) and `queued` is a status word: neither is a transport.
|
|
64
|
+
transport: TRANSPORT,
|
|
65
|
+
lock: /['"]wx\+?['"]|\.lock\b/,
|
|
66
|
+
worker: /spawn\(\s*process\.execPath\s*,\s*\[\s*(?:fileURLToPath\(\s*import\.meta\.url\s*\)|__filename|SELF\b)|['"]--(?:replay|drain)[\w-]*['"]/,
|
|
67
|
+
memorySink: /\.swarm\b|memory\.db\b|['"]memory['"]\s*,\s*['"](?:store|retrieve)['"]/,
|
|
68
|
+
rank: /\b(?:cosine|tf-?idf|bm25)\b/i,
|
|
69
|
+
sqlite: /node:sqlite|\bDatabaseSync\b|better-sqlite3|sql\.js/,
|
|
70
|
+
storeWrite: /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b[^;'"`]{0,40}\bmemory_entries\b/i,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The signals present in this code, as a sorted list of names (deterministic). */
|
|
74
|
+
export function signalsOf(content, p = '') {
|
|
75
|
+
const code = codeOf(content, p);
|
|
76
|
+
const hits = Object.entries(RULES).filter(([, re]) => re.test(code)).map(([k]) => k);
|
|
77
|
+
if (hits.includes('rank') && !/\.sort\(/.test(code)) hits.splice(hits.indexOf('rank'), 1);
|
|
78
|
+
return hits.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isTransport = (s) => (s.includes('persist') || s.includes('atomicWrite')) && s.includes('transport')
|
|
82
|
+
&& (s.includes('durable') || s.includes('lock') || s.includes('worker'));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The verdict for one file's FULL proposed content.
|
|
86
|
+
* before — the file's content before this change (null for a new file). A file that already
|
|
87
|
+
* refused and gains no new signal is existing debt being edited (often removed): WARN.
|
|
88
|
+
* allow — repo-relative paths the OWNER exempted in config (never a header in the file itself).
|
|
89
|
+
* Returns { verdict: 'refuse'|'warn'|'pass', signals, tool, reason }.
|
|
90
|
+
*/
|
|
91
|
+
export function substitutionVerdict({ path: p, content, before = null, allow = [] }) {
|
|
92
|
+
const rel = String(p || '').split(path.sep).join('/');
|
|
93
|
+
const base = { signals: [], tool: RUV_TOOL, reason: '' };
|
|
94
|
+
if (!inScope(rel)) return { verdict: 'pass', ...base };
|
|
95
|
+
const signals = signalsOf(content, rel);
|
|
96
|
+
const out = (verdict, reason) => ({ verdict, signals, tool: RUV_TOOL, reason });
|
|
97
|
+
if (isTransport(signals)) {
|
|
98
|
+
const allowed = allow.find((a) => rel === a || rel.endsWith(`/${a}`));
|
|
99
|
+
if (allowed) return out('warn', `owner allowlist entry ${allowed}`);
|
|
100
|
+
if (before !== null) {
|
|
101
|
+
const prior = signalsOf(before, rel);
|
|
102
|
+
const priorTerms = transportTerms(before, rel);
|
|
103
|
+
const added = [...transportTerms(content, rel)].filter((t) => !priorTerms.has(t));
|
|
104
|
+
if (isTransport(prior) && signals.every((s) => prior.includes(s)) && !added.length) return out('warn', 'existing debt; this change adds no new transport signal');
|
|
105
|
+
if (added.length) return out('refuse', `existing debt gains a new transport signal (${added.join(', ')})`);
|
|
106
|
+
}
|
|
107
|
+
return out('refuse', 'hand-built durable transport for state');
|
|
108
|
+
}
|
|
109
|
+
if (signals.includes('sqlite') && signals.includes('storeWrite')) return out('refuse', "writes rUv's memory_entries table directly instead of calling ruflo memory store");
|
|
110
|
+
if (signals.includes('persist') && signals.includes('memorySink')) return out('warn', 'appends state next to agent memory');
|
|
111
|
+
if (signals.includes('sqlite') && signals.includes('memorySink')) return out('warn', "opens rUv's store directly (couples to its private schema)");
|
|
112
|
+
if (signals.includes('rank') && signals.includes('memorySink')) return out('warn', 'ranks agent-memory content by score');
|
|
113
|
+
return out('pass', '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The refusal/warning text. Short, actionable, and says how an exception is really declared. */
|
|
117
|
+
export function message(rel, v) {
|
|
118
|
+
if (v.verdict === 'pass') return '';
|
|
119
|
+
const head = v.verdict === 'refuse'
|
|
120
|
+
? `⛔ BLOCKED — ${rel} rebuilds rUv's storage (${v.reason}; signals: ${v.signals.join(', ')}).`
|
|
121
|
+
: `⚠ substitution-wall — ${rel}: ${v.reason} (signals: ${v.signals.join(', ')}).`;
|
|
122
|
+
return [head,
|
|
123
|
+
`Call rUv's tool instead: ${v.tool}. Its store owns durability and fails closed; do not put an outbox, queue, journal, lock or replay worker in front of it.`,
|
|
124
|
+
...(v.verdict === 'refuse' ? ['A `HAND-ROLLED: ... REAL TOOL: ...` header does NOT lift this. A real exception is an owner-approved entry in config/state-files.json `substitutionAllow`.'] : []),
|
|
125
|
+
].join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Codex's apply_patch: per file, the content after its hunks (Add = the + lines; Update = hunks applied). */
|
|
129
|
+
function patchChanges(patch, read) {
|
|
130
|
+
const out = []; let cur = null; let hunk = null;
|
|
131
|
+
// A hunk with no context, or context that is not in the file, fails CLOSED (review N3): its lines are
|
|
132
|
+
// appended to the after-text, so a pure-addition outbox module is judged, never skipped.
|
|
133
|
+
const flush = () => {
|
|
134
|
+
if (cur && hunk && cur.after !== null && hunk.new.length) {
|
|
135
|
+
const norm = (t) => t.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '');
|
|
136
|
+
const from = norm(hunk.old.join('\n')); const to = hunk.new.join('\n'); const hay = norm(cur.after);
|
|
137
|
+
cur.after = from && hay.includes(from) ? hay.replace(from, () => to) : `${hay}\n${to}`;
|
|
138
|
+
}
|
|
139
|
+
hunk = null;
|
|
140
|
+
};
|
|
141
|
+
for (const line of String(patch || '').split(/\r?\n/)) {
|
|
142
|
+
const m = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
|
|
143
|
+
if (m || /^\*\*\* (?:End Patch|Move to:)/.test(line)) { flush(); if (m) { cur = m[1] === 'Delete' ? null : { file: m[2].trim(), add: m[1] === 'Add', lines: [] }; if (cur) { cur.before = cur.add ? null : read(cur.file); cur.after = cur.add ? '' : cur.before; out.push(cur); } } continue; }
|
|
144
|
+
if (!cur) continue;
|
|
145
|
+
if (cur.add) { if (line.startsWith('+')) cur.lines.push(line.slice(1)); continue; }
|
|
146
|
+
if (line.startsWith('@@')) { flush(); hunk = { old: [], new: [] }; continue; }
|
|
147
|
+
if (!hunk) hunk = { old: [], new: [] };
|
|
148
|
+
if (line.startsWith('-')) hunk.old.push(line.slice(1)); else if (line.startsWith('+')) hunk.new.push(line.slice(1));
|
|
149
|
+
else if (line.startsWith(' ') || line === '') { hunk.old.push(line.slice(1)); hunk.new.push(line.slice(1)); }
|
|
150
|
+
}
|
|
151
|
+
flush();
|
|
152
|
+
for (const c of out) if (c.add) c.after = c.lines.join('\n'); // once, not per line (review 3: 80k lines took 53 s)
|
|
153
|
+
return out.filter((c) => c.after !== null).map(({ file, before, after }) => ({ file, before, after }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every file a write tool call would produce, as { file, before, after }. Write/Edit/MultiEdit,
|
|
158
|
+
* NotebookEdit (new_source; .ipynb is out of SCOPE, judged for completeness) and Codex's apply_patch
|
|
159
|
+
* (the patch text arrives as tool_input.command / .input, or as the bare string).
|
|
160
|
+
*/
|
|
161
|
+
export function proposedChanges(payload, read) {
|
|
162
|
+
const tool = String(payload?.tool_name || '');
|
|
163
|
+
const input = payload?.tool_input ?? {};
|
|
164
|
+
if (tool === 'apply_patch') return patchChanges(typeof input === 'string' ? input : input.command || input.input || input.patch, read);
|
|
165
|
+
// The REAL Codex path (review N2): codex-hook-adapter.mjs rewrites apply_patch into an Edit whose
|
|
166
|
+
// new_string is the whole patch and which has no old_string. Judge that patch, not the Edit.
|
|
167
|
+
if (tool === 'Edit' && !input.old_string && /^\s*\*\*\* Begin Patch/.test(String(input.new_string ?? ''))) return patchChanges(input.new_string, read);
|
|
168
|
+
if (tool === 'NotebookEdit') return typeof input.notebook_path === 'string' ? [{ file: input.notebook_path, before: read(input.notebook_path), after: String(input.new_source ?? '') }] : [];
|
|
169
|
+
const one = proposedContent(payload, read);
|
|
170
|
+
return one ? [{ file: input.file_path, ...one }] : [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The file after an Edit/MultiEdit/Write, or null when this tool call is not a file write we judge. */
|
|
174
|
+
export function proposedContent(payload, read) {
|
|
175
|
+
const tool = String(payload?.tool_name || '');
|
|
176
|
+
const input = payload?.tool_input || {};
|
|
177
|
+
if (!['Write', 'Edit', 'MultiEdit'].includes(tool) || typeof input.file_path !== 'string') return null;
|
|
178
|
+
const before = read(input.file_path);
|
|
179
|
+
if (tool === 'Write') return { before, after: String(input.content ?? '') };
|
|
180
|
+
if (before === null) return null;
|
|
181
|
+
let after = before;
|
|
182
|
+
for (const e of (tool === 'Edit' ? [input] : (Array.isArray(input.edits) ? input.edits : []))) {
|
|
183
|
+
const from = String(e?.old_string ?? ''); const to = String(e?.new_string ?? '');
|
|
184
|
+
if (from) after = e.replace_all ? after.split(from).join(to) : after.replace(from, () => to);
|
|
185
|
+
}
|
|
186
|
+
return { before, after };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Repo identity: the wall REFUSES only inside this repo; in any other project it can only warn. */
|
|
190
|
+
function repoRootOf(file) {
|
|
191
|
+
let dir = path.dirname(file);
|
|
192
|
+
for (let i = 0; i < 12; i += 1) {
|
|
193
|
+
try { if (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name === 'ruvnet-brain') return dir; } catch { /* keep walking */ }
|
|
194
|
+
const up = path.dirname(dir); if (up === dir) break; dir = up;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isMain() {
|
|
200
|
+
try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
201
|
+
}
|
|
202
|
+
if (isMain()) {
|
|
203
|
+
let code = 0;
|
|
204
|
+
try {
|
|
205
|
+
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
206
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.cwd();
|
|
207
|
+
const abs = (p) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
|
208
|
+
const changes = proposedChanges(payload, (p) => { try { return fs.readFileSync(abs(p), 'utf8'); } catch { return null; } });
|
|
209
|
+
for (const change of String(process.env.RUVNET_SUBSTITUTION_WALL || '').toLowerCase() !== 'off' ? changes : []) {
|
|
210
|
+
const file = abs(change.file);
|
|
211
|
+
const root = repoRootOf(file);
|
|
212
|
+
const rel = root ? path.relative(root, file) : String(payload.tool_input.file_path);
|
|
213
|
+
let allow = [];
|
|
214
|
+
if (root) { try { allow = JSON.parse(fs.readFileSync(path.join(root, 'config', 'state-files.json'), 'utf8')).substitutionAllow?.map((a) => a.path) || []; } catch { /* no config: no exemptions */ } }
|
|
215
|
+
let v = substitutionVerdict({ path: rel, content: change.after, before: change.before, allow });
|
|
216
|
+
if (v.verdict === 'refuse' && !root) v = { ...v, verdict: 'warn', reason: `${v.reason} (outside the ruvnet-brain repo: advisory)` };
|
|
217
|
+
if (v.verdict === 'refuse') { process.stderr.write(`${message(rel, v)}\n`); code = 2; break; }
|
|
218
|
+
else if (v.verdict === 'warn') process.stdout.write(message(rel, v));
|
|
219
|
+
}
|
|
220
|
+
} catch { code = 0; /* fail open on the wall's own errors */ }
|
|
221
|
+
process.exit(code);
|
|
222
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turn-outcome-capture.mjs — record what each turn CONCLUDED, and make it recallable knowledge.
|
|
3
3
|
*
|
|
4
|
-
* Called from session-snapshot-hook.mjs's
|
|
4
|
+
* Called from session-snapshot-hook.mjs's hook entry, so it rides the capture boundary
|
|
5
5
|
* both hosts already register (Claude: hooks.json; Codex: codex-hooks.json via codex-hook-adapter,
|
|
6
6
|
* which sets RUVNET_HOOK_HOST=codex). Two jobs:
|
|
7
7
|
*
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
* SessionEnd and PreCompact recover a closing outcome when Stop payload text is absent. Tool IDs
|
|
28
28
|
* correlate observable gate outcomes; opaque custom orchestration inputs remain unclassified.
|
|
29
29
|
*
|
|
30
|
-
* LATENCY
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* LATENCY AND FAILURE (owner ruling 2026-10-12): the turn is written DIRECTLY with one bounded
|
|
31
|
+
* `ruflo memory store` and proven by exact-key readback inside the hook's deadline (~0.5-1.3 s measured
|
|
32
|
+
* on a live-size store). There is no journal, outbox or replay worker. A refused or unverified write is
|
|
33
|
+
* recorded in the hook-failures ledger, which the next SessionStart summarizes. Only `memory distill run`
|
|
34
|
+
* (SessionEnd/PreCompact) is detached. Advisory always: nothing here throws to the caller.
|
|
34
35
|
*/
|
|
35
36
|
import fs from 'node:fs';
|
|
36
37
|
import os from 'node:os';
|
|
@@ -39,17 +40,26 @@ import crypto from 'node:crypto';
|
|
|
39
40
|
import { spawn, spawnSync } from 'node:child_process';
|
|
40
41
|
import { fileURLToPath } from 'node:url';
|
|
41
42
|
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
42
|
-
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
43
|
-
import {
|
|
44
|
-
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
43
|
+
import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
44
|
+
import { redactText } from './continuity-events.mjs';
|
|
45
45
|
import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
|
|
46
|
-
import {
|
|
46
|
+
import { recordHookFailure } from './hook-failures.mjs';
|
|
47
|
+
|
|
48
|
+
const digest = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
49
|
+
function appendReceipt(file, row) {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
51
|
+
try { if (fs.statSync(file).size > RECEIPTS_MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* absent */ } // bounded like hook-failures
|
|
52
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
const flagValue = (args, flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
47
55
|
import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
|
|
48
56
|
|
|
49
57
|
export const TURN_NAMESPACE = 'turns';
|
|
50
58
|
export const MIN_OUTCOME_CHARS = 200;
|
|
51
59
|
const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
52
60
|
const STEP_TIMEOUT_MS = 60_000;
|
|
61
|
+
export const RECEIPTS_MAX_BYTES = 256 * 1024;
|
|
62
|
+
export const TURN_STATUS_FRESH_MS /* ✓ only when the newest receipt is verified and younger */ = 24 * 3_600_000;
|
|
53
63
|
const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0' };
|
|
54
64
|
|
|
55
65
|
function textOf(content) {
|
|
@@ -222,7 +232,7 @@ const projectName = (projectDir) => redactText(path.basename(path.resolve(projec
|
|
|
222
232
|
|
|
223
233
|
/** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
|
|
224
234
|
export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
|
|
225
|
-
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps
|
|
235
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
|
|
226
236
|
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
|
|
227
237
|
});
|
|
228
238
|
child.unref();
|
|
@@ -269,14 +279,18 @@ export function captureTurnOutcome({
|
|
|
269
279
|
readTranscript = readSettledTranscript,
|
|
270
280
|
settleMs = 2000,
|
|
271
281
|
now = () => new Date(),
|
|
282
|
+
deadlineAt,
|
|
283
|
+
run,
|
|
284
|
+
write = runSteps,
|
|
272
285
|
} = {}) {
|
|
273
286
|
const report = { event, host, queued: false, recorded: false, distill: { queued: false } };
|
|
274
287
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
|
|
275
288
|
return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
|
|
276
289
|
}
|
|
277
|
-
|
|
290
|
+
// Review M1: a capture that cannot happen is LOUD (a ledger row with a fixed reason, never turn text).
|
|
291
|
+
const loud = (reason, store) => { recordHookFailure({ hook: 'turn-capture', event, error: reason.slice(0, 160), ...(store ? { store: digest(store).slice(0, 16) } : {}) }, { brainHome }); return reason; };
|
|
278
292
|
let target;
|
|
279
|
-
try { target = resolveTurnDb({ projectDir, brainHome }); } catch (error) { return { ...report, skipped: `store resolution failed: ${redactText(error.message)}` }; }
|
|
293
|
+
try { target = resolveTurnDb({ projectDir, brainHome, ...(deadlineAt ? { deadlineAt } : {}) }); } catch (error) { return { ...report, skipped: loud(`store resolution failed: ${redactText(error.message)}`) }; }
|
|
280
294
|
const { db, scope, projectRoot } = target;
|
|
281
295
|
Object.assign(report, { db, scope });
|
|
282
296
|
if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
|
|
@@ -287,17 +301,10 @@ export function captureTurnOutcome({
|
|
|
287
301
|
const project = projectName(projectRoot);
|
|
288
302
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
289
303
|
|
|
290
|
-
// ONE WRITER PER TURN (ADR-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
// (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
|
|
295
|
-
const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
|
|
296
|
-
&& userLevelAgentdbHooks({ home, event, projectDir, env }).turnCapture;
|
|
297
|
-
if (deferTo) {
|
|
298
|
-
report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
|
|
299
|
-
report.deferredToUserLevel = true;
|
|
300
|
-
} else if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
|
|
304
|
+
// ONE WRITER PER TURN (ADR-105 §7): the product is the only writer of project turn history. The
|
|
305
|
+
// former deferral to a user-level ~/.claude hook (ADR-100 §3) could never fire — its probe never
|
|
306
|
+
// reported a writer — and cost a git call on every Stop, so it was removed (ADR-106).
|
|
307
|
+
if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
|
|
301
308
|
const sessionKey = String(payload.session_id || payload.transcript_path || '');
|
|
302
309
|
const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
|
|
303
310
|
let turn = { finalText: '', files: [], actions: [] };
|
|
@@ -320,16 +327,9 @@ export function captureTurnOutcome({
|
|
|
320
327
|
const at = now();
|
|
321
328
|
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
322
329
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
|
|
326
|
-
if (fs.existsSync(pendingFile)) {
|
|
327
|
-
try { const pending = readJournal(pendingFile, db); value = pending.value; }
|
|
328
|
-
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
329
|
-
}
|
|
330
|
-
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
330
|
+
const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
331
|
+
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE,
|
|
331
332
|
'--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
332
|
-
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
333
333
|
Object.assign(report, { queued: true, key, value });
|
|
334
334
|
}
|
|
335
335
|
}
|
|
@@ -343,20 +343,26 @@ export function captureTurnOutcome({
|
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
if (!steps.length) return report;
|
|
346
|
+
if (!ruflo) return { ...report, queued: false, skipped: loud('ruflo not found', db), distill: { queued: false, skipped: 'ruflo not found' } };
|
|
346
347
|
|
|
347
348
|
try {
|
|
348
349
|
// Only explicit persisted consent permits creating a project store directory.
|
|
349
350
|
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
351
|
+
// The turn is written NOW, bounded by the hook's deadline, and proven by exact-key readback (owner
|
|
352
|
+
// ruling 2026-10-12: no outbox, no journal, no replay). A refusal is recorded by runSteps in the
|
|
353
|
+
// hook-failures ledger, so SessionStart reports it. Only distillation runs detached.
|
|
354
|
+
const store = steps.filter((item) => item.kind === 'store');
|
|
355
|
+
if (store.length) {
|
|
356
|
+
const [row] = write({ steps: store, receipts, event }, { env, home, projectDir: binding.projectDir, brainHome,
|
|
357
|
+
...(deadlineAt ? { deadlineMs: deadlineAt } : {}), ...(run ? { run } : {}) });
|
|
358
|
+
Object.assign(report, { recorded: row?.verified === true, receipt: row });
|
|
359
|
+
if (row?.verified && dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
|
|
355
360
|
}
|
|
356
|
-
|
|
357
|
-
if (
|
|
361
|
+
const detached = steps.filter((item) => item.kind !== 'store');
|
|
362
|
+
if (detached.length) report.launch = launch(detached, { receipts, brainHome, projectDir: binding.projectDir, env });
|
|
358
363
|
} catch (error) {
|
|
359
|
-
|
|
364
|
+
const reason = loud(`launch failed: ${redactText(error.message)}`, db);
|
|
365
|
+
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: reason }, skipped: reason };
|
|
360
366
|
}
|
|
361
367
|
return report;
|
|
362
368
|
}
|
|
@@ -369,39 +375,39 @@ function readBack({ ruflo, db, key, run, options }) {
|
|
|
369
375
|
}
|
|
370
376
|
|
|
371
377
|
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
372
|
-
export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
378
|
+
export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
373
379
|
env = process.env, deadlineMs, home = os.homedir(),
|
|
374
380
|
projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
|
|
375
381
|
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
|
|
376
382
|
const run = (binary, args, options) => {
|
|
377
383
|
if (deadlineMs === undefined) return suppliedRun(binary, args, options);
|
|
378
384
|
const remaining = deadlineMs - Date.now();
|
|
379
|
-
if (remaining <= 0) return { status: 1, error: new Error('turn
|
|
385
|
+
if (remaining <= 0) return { status: 1, error: new Error('turn write deadline exhausted') };
|
|
380
386
|
return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
381
387
|
};
|
|
382
388
|
const results = [];
|
|
383
389
|
for (const queued of steps) {
|
|
384
390
|
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
385
|
-
const kind = queued.kind
|
|
391
|
+
const kind = queued.kind;
|
|
386
392
|
try {
|
|
387
393
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
388
|
-
if (kind === 'store'
|
|
389
|
-
if (
|
|
390
|
-
const
|
|
394
|
+
if (kind === 'store') { key = flagValue(queued.args, '--key'); db = flagValue(queued.args, '--path'); }
|
|
395
|
+
if ((!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
|
|
396
|
+
const bound = deadlineMs === undefined ? {} : { deadlineAt: deadlineMs }; // review M4: git obeys the hook deadline
|
|
397
|
+
const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000, ...bound });
|
|
391
398
|
if (target.skipped) throw new Error(target.skipped);
|
|
392
399
|
privacyPatterns = target.contentPathExcludes;
|
|
393
400
|
db = target.db;
|
|
394
401
|
let args; let value; let binding;
|
|
395
402
|
if (kind === 'store') {
|
|
396
|
-
const data = queued.
|
|
397
|
-
if (
|
|
398
|
-
if (
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
403
|
+
const data = { key: flagValue(queued.args, '--key'), value: flagValue(queued.args, '--value'), db: flagValue(queued.args, '--path') };
|
|
404
|
+
if (typeof data.key !== 'string' || typeof data.value !== 'string') throw new Error('invalid turn store step');
|
|
405
|
+
if (data.db !== db) throw new Error('foreign turn store rejected');
|
|
406
|
+
if (queued.brainHome !== brainHome) throw new Error('turn consent settings differ from trusted caller');
|
|
407
|
+
binding = queued;
|
|
402
408
|
key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
|
|
403
|
-
if (value !== redactText(data.value)) throw new Error('content exclusions changed;
|
|
404
|
-
args = ['memory', 'store', '
|
|
409
|
+
if (value !== redactText(data.value)) throw new Error('content exclusions changed; turn not written');
|
|
410
|
+
args = ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
|
|
405
411
|
} else if (kind === 'distill') {
|
|
406
412
|
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
407
413
|
const recipe = queued.args;
|
|
@@ -421,14 +427,14 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
421
427
|
const rootStat = fs.statSync(target.projectRoot);
|
|
422
428
|
if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
|
|
423
429
|
if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
|
|
424
|
-
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
430
|
+
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000, ...bound });
|
|
425
431
|
if (origin.skipped) throw new Error(origin.skipped);
|
|
426
432
|
if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
|
|
427
433
|
const ruflo = resolveRuflo({ env, home });
|
|
428
|
-
if (!ruflo) throw new Error('ruflo not found
|
|
434
|
+
if (!ruflo) throw new Error('ruflo not found');
|
|
429
435
|
const invocation = rufloInvocation(ruflo, args);
|
|
430
436
|
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
431
|
-
if (remaining <= 0) throw new Error('turn
|
|
437
|
+
if (remaining <= 0) throw new Error('turn write deadline exhausted');
|
|
432
438
|
// Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
|
|
433
439
|
// CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
|
|
434
440
|
// The explicit --path already binds the store, so run from private scratch with neither.
|
|
@@ -436,10 +442,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
436
442
|
cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
|
|
437
443
|
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
|
|
438
444
|
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
439
|
-
const
|
|
440
|
-
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
441
|
-
verified = kind === 'store' && existing === value;
|
|
442
|
-
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
445
|
+
const r = run(invocation.executable, invocation.args, options);
|
|
443
446
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
444
447
|
if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
|
|
445
448
|
if (verified) status = 0;
|
|
@@ -454,41 +457,21 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
454
457
|
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
455
458
|
...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
|
|
456
459
|
results.push(row);
|
|
457
|
-
if (receipts) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
+
if (receipts) { try { appendReceipt(receipts, row); } catch { /* the ledger row below still reports a failure */ } }
|
|
461
|
+
if (error || (kind === 'store' && !verified)) {
|
|
462
|
+
recordHookFailure({ hook: kind === 'store' ? 'turn-capture' : 'turn-distill', event, error: String(error || 'exact turn key/content readback failed').slice(0, 160), ...(db ? { store: digest(db).slice(0, 16) } : {}) }, { brainHome });
|
|
460
463
|
}
|
|
461
464
|
}
|
|
462
465
|
return results;
|
|
463
466
|
}
|
|
464
467
|
|
|
465
|
-
/** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
|
|
466
|
-
export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
|
|
467
|
-
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
|
|
468
|
-
launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
|
|
469
|
-
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
470
|
-
try {
|
|
471
|
-
const target = resolveTurnDb({ projectDir, brainHome });
|
|
472
|
-
if (target.skipped) return { queued: false, skipped: target.skipped };
|
|
473
|
-
const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
|
|
474
|
-
if (!steps.length) return { queued: false, count: 0 };
|
|
475
|
-
if (synchronous) {
|
|
476
|
-
const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
|
|
477
|
-
const pending = pendingTurnFiles(target.db, 25).length;
|
|
478
|
-
return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
|
|
479
|
-
}
|
|
480
|
-
return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
|
|
481
|
-
} catch (error) { return { queued: false, skipped: redactText(error.message) }; }
|
|
482
|
-
}
|
|
483
|
-
|
|
484
468
|
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
485
469
|
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now(), noticeOnFirstUse = false } = {}) {
|
|
486
470
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
487
471
|
let target;
|
|
488
472
|
try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
|
|
489
473
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
|
|
490
|
-
const
|
|
491
|
-
const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
|
|
474
|
+
const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
|
|
492
475
|
const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
|
|
493
476
|
let rows = [];
|
|
494
477
|
try {
|
|
@@ -497,13 +480,16 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
497
480
|
}).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
|
|
498
481
|
} catch { /* first run */ }
|
|
499
482
|
rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
|
|
500
|
-
let pending = 0;
|
|
501
|
-
try { pending = pendingTurnFiles(target.db, 25).length; } catch { return status({ state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }); }
|
|
502
483
|
const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
|
|
503
484
|
const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
|
|
504
485
|
if (failed.length) return status({ state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` });
|
|
505
486
|
if (historical.length) return status({ state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` });
|
|
506
|
-
|
|
487
|
+
// Review M1: ✓ needs the NEWEST receipt verified, < TURN_STATUS_FRESH_MS old, and no later turn-capture ledger row.
|
|
488
|
+
const newest = rows.at(-1); const id = digest(target.db).slice(0, 16); let laterFailure = null;
|
|
489
|
+
try { laterFailure = readTail(path.join(brainHome, 'hook-failures.jsonl'), 64 * 1024).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } })
|
|
490
|
+
.findLast((r) => r.hook === 'turn-capture' && (!r.store || r.store === id) && (!newest || Date.parse(r.at) > Date.parse(newest.at))); } catch { /* no ledger */ }
|
|
491
|
+
if (laterFailure) return status({ state: 'warn', line: `turn recording failing — ${redactText(String(laterFailure.error || 'capture skipped')).slice(0, 200)} (since ${laterFailure.at})` });
|
|
492
|
+
if (newest && now - Date.parse(newest.at) >= TURN_STATUS_FRESH_MS) return status({ state: 'unknown', line: `turn recording not proven recently — newest exact readback ${newest.at}` });
|
|
507
493
|
return status(rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
508
494
|
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
|
|
509
495
|
}
|