ruvnet-brain 4.6.4 → 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 +1 -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 -28
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +8 -14
- 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/continuity-events.mjs +90 -2
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-record.mjs +11 -40
- package/plugin/scripts/hook-failures.mjs +16 -0
- package/plugin/scripts/hook-shim.mjs +1 -2
- package/plugin/scripts/learn-capture.mjs +60 -42
- 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 -341
- package/plugin/scripts/session-start-core.mjs +2 -12
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +68 -74
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- 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 -351
- 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 -267
- 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
|
@@ -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 { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
|
|
43
|
+
import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
44
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 } };
|
|
@@ -313,16 +327,9 @@ export function captureTurnOutcome({
|
|
|
313
327
|
const at = now();
|
|
314
328
|
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
315
329
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
|
|
319
|
-
if (fs.existsSync(pendingFile)) {
|
|
320
|
-
try { const pending = readJournal(pendingFile, db); value = pending.value; }
|
|
321
|
-
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
322
|
-
}
|
|
323
|
-
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,
|
|
324
332
|
'--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
325
|
-
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
326
333
|
Object.assign(report, { queued: true, key, value });
|
|
327
334
|
}
|
|
328
335
|
}
|
|
@@ -336,20 +343,26 @@ export function captureTurnOutcome({
|
|
|
336
343
|
}
|
|
337
344
|
}
|
|
338
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' } };
|
|
339
347
|
|
|
340
348
|
try {
|
|
341
349
|
// Only explicit persisted consent permits creating a project store directory.
|
|
342
350
|
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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);
|
|
348
360
|
}
|
|
349
|
-
|
|
350
|
-
if (
|
|
361
|
+
const detached = steps.filter((item) => item.kind !== 'store');
|
|
362
|
+
if (detached.length) report.launch = launch(detached, { receipts, brainHome, projectDir: binding.projectDir, env });
|
|
351
363
|
} catch (error) {
|
|
352
|
-
|
|
364
|
+
const reason = loud(`launch failed: ${redactText(error.message)}`, db);
|
|
365
|
+
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: reason }, skipped: reason };
|
|
353
366
|
}
|
|
354
367
|
return report;
|
|
355
368
|
}
|
|
@@ -362,39 +375,39 @@ function readBack({ ruflo, db, key, run, options }) {
|
|
|
362
375
|
}
|
|
363
376
|
|
|
364
377
|
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
365
|
-
export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
378
|
+
export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
366
379
|
env = process.env, deadlineMs, home = os.homedir(),
|
|
367
380
|
projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
|
|
368
381
|
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
|
|
369
382
|
const run = (binary, args, options) => {
|
|
370
383
|
if (deadlineMs === undefined) return suppliedRun(binary, args, options);
|
|
371
384
|
const remaining = deadlineMs - Date.now();
|
|
372
|
-
if (remaining <= 0) return { status: 1, error: new Error('turn
|
|
385
|
+
if (remaining <= 0) return { status: 1, error: new Error('turn write deadline exhausted') };
|
|
373
386
|
return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
374
387
|
};
|
|
375
388
|
const results = [];
|
|
376
389
|
for (const queued of steps) {
|
|
377
390
|
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
378
|
-
const kind = queued.kind
|
|
391
|
+
const kind = queued.kind;
|
|
379
392
|
try {
|
|
380
393
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
381
|
-
if (kind === 'store'
|
|
382
|
-
if (
|
|
383
|
-
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 });
|
|
384
398
|
if (target.skipped) throw new Error(target.skipped);
|
|
385
399
|
privacyPatterns = target.contentPathExcludes;
|
|
386
400
|
db = target.db;
|
|
387
401
|
let args; let value; let binding;
|
|
388
402
|
if (kind === 'store') {
|
|
389
|
-
const data = queued.
|
|
390
|
-
if (
|
|
391
|
-
if (
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
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;
|
|
395
408
|
key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
|
|
396
|
-
if (value !== redactText(data.value)) throw new Error('content exclusions changed;
|
|
397
|
-
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'];
|
|
398
411
|
} else if (kind === 'distill') {
|
|
399
412
|
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
400
413
|
const recipe = queued.args;
|
|
@@ -414,14 +427,14 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
414
427
|
const rootStat = fs.statSync(target.projectRoot);
|
|
415
428
|
if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
|
|
416
429
|
if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
|
|
417
|
-
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
430
|
+
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000, ...bound });
|
|
418
431
|
if (origin.skipped) throw new Error(origin.skipped);
|
|
419
432
|
if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
|
|
420
433
|
const ruflo = resolveRuflo({ env, home });
|
|
421
|
-
if (!ruflo) throw new Error('ruflo not found
|
|
434
|
+
if (!ruflo) throw new Error('ruflo not found');
|
|
422
435
|
const invocation = rufloInvocation(ruflo, args);
|
|
423
436
|
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
424
|
-
if (remaining <= 0) throw new Error('turn
|
|
437
|
+
if (remaining <= 0) throw new Error('turn write deadline exhausted');
|
|
425
438
|
// Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
|
|
426
439
|
// CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
|
|
427
440
|
// The explicit --path already binds the store, so run from private scratch with neither.
|
|
@@ -429,10 +442,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
429
442
|
cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
|
|
430
443
|
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
|
|
431
444
|
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
432
|
-
const
|
|
433
|
-
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
434
|
-
verified = kind === 'store' && existing === value;
|
|
435
|
-
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
445
|
+
const r = run(invocation.executable, invocation.args, options);
|
|
436
446
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
437
447
|
if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
|
|
438
448
|
if (verified) status = 0;
|
|
@@ -447,33 +457,14 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
447
457
|
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
448
458
|
...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
|
|
449
459
|
results.push(row);
|
|
450
|
-
if (receipts) {
|
|
451
|
-
|
|
452
|
-
|
|
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 });
|
|
453
463
|
}
|
|
454
464
|
}
|
|
455
465
|
return results;
|
|
456
466
|
}
|
|
457
467
|
|
|
458
|
-
/** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
|
|
459
|
-
export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
|
|
460
|
-
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
|
|
461
|
-
launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
|
|
462
|
-
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
463
|
-
try {
|
|
464
|
-
const target = resolveTurnDb({ projectDir, brainHome });
|
|
465
|
-
if (target.skipped) return { queued: false, skipped: target.skipped };
|
|
466
|
-
const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
|
|
467
|
-
if (!steps.length) return { queued: false, count: 0 };
|
|
468
|
-
if (synchronous) {
|
|
469
|
-
const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
|
|
470
|
-
const pending = pendingTurnFiles(target.db, 25).length;
|
|
471
|
-
return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
|
|
472
|
-
}
|
|
473
|
-
return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
|
|
474
|
-
} catch (error) { return { queued: false, skipped: redactText(error.message) }; }
|
|
475
|
-
}
|
|
476
|
-
|
|
477
468
|
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
478
469
|
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now(), noticeOnFirstUse = false } = {}) {
|
|
479
470
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -489,13 +480,16 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
489
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);
|
|
490
481
|
} catch { /* first run */ }
|
|
491
482
|
rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
|
|
492
|
-
let pending = 0;
|
|
493
|
-
try { pending = pendingTurnFiles(target.db, 25).length; } catch { return status({ state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }); }
|
|
494
483
|
const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
|
|
495
484
|
const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
|
|
496
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)}` });
|
|
497
486
|
if (historical.length) return status({ state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` });
|
|
498
|
-
|
|
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}` });
|
|
499
493
|
return status(rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
500
494
|
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
|
|
501
495
|
}
|
|
@@ -25,7 +25,7 @@ export function startupDeclaration(listed, cwd) {
|
|
|
25
25
|
const triggered = new Set(['sessionStart', 'userPromptSubmit', 'stop', 'sessionEnd']);
|
|
26
26
|
if (groups[0].hooks.some((row) => triggered.has(row.eventName) && row.enabled !== false
|
|
27
27
|
&& (row.pluginId !== OWNER || row.handlerType !== 'command' || !['trusted', 'managed'].includes(row.trustStatus)))) throw new Error('Foreign or non-command enabled turn/startup handler prevents owned execution');
|
|
28
|
-
// Independently bound published siblings (e.g.
|
|
28
|
+
// Independently bound published siblings (e.g. a learning hook) may share this event/source file.
|
|
29
29
|
const bodies = starts.filter((row) => /\s(?:\d+\s+)?session-start\s*$/.test(row.command || ''));
|
|
30
30
|
if (bodies.length !== 1 || !Number.isSafeInteger(bodies[0].displayOrder) || bodies[0].displayOrder < 0) throw new Error('Exact published SessionStart body identity is unavailable');
|
|
31
31
|
return bodies[0];
|
|
@@ -99,38 +99,6 @@ export function buildHealthRecommendations({ memory = null, learning = null } =
|
|
|
99
99
|
}));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 2. A capture queue that fills but does not drain. The depth IS the evidence.
|
|
103
|
-
const depth = learning?.enabled === false || learning?.queueKnown === false ? 0 : Number(learning?.queueDepth);
|
|
104
|
-
if (Number.isFinite(depth) && (depth > 50 || depth > 0 && learning.capturePendingFailure || learning.workerRetirementUnconfirmed)) {
|
|
105
|
-
recs.push(makeRecommendation({
|
|
106
|
-
id: 'learning:flush',
|
|
107
|
-
scope: learning?.scope === 'project' ? 'project' : 'user',
|
|
108
|
-
title: learning.workerRetirementUnconfirmed ? 'Learning recovery is paused' : 'Feed your captured work into the learner',
|
|
109
|
-
rationale: learning.workerRetirementUnconfirmed ? 'A previous learning worker could not be confirmed stopped. Its queue remains fenced and original observations are retained.' : 'Captured workflow observations remain pending until their canonical AgentDB rows are verified.',
|
|
110
|
-
severity: 'IMPORTANT',
|
|
111
|
-
touchesMachine: true,
|
|
112
|
-
plainImpact:
|
|
113
|
-
'Stores already captured workflow observations in local project memory. '
|
|
114
|
-
+ 'Canonical AgentDB readback verifies each observation; original queue bytes stay retained.',
|
|
115
|
-
evidence: [{ observed: `${depth} captured events waiting, undelivered${learning?.queueDir ? ` in ${learning.queueDir}` : ''}` }],
|
|
116
|
-
cost: { time: 'under a minute', risk: 'low — local only; queue preserved on failure' },
|
|
117
|
-
change: { human: 'drain the capture queue into the learner' },
|
|
118
|
-
undo: { human: 'nothing to reverse — this only adds observations; learned state can be cleared separately' },
|
|
119
|
-
}));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (learning?.enabled && learning.scope === 'project' && learning.legacyUserKnown && learning.legacyUserDepth > 0) {
|
|
123
|
-
recs.push(makeRecommendation({
|
|
124
|
-
id: 'learning:flush-legacy-user', scope: 'user', title: 'Process retained legacy user history',
|
|
125
|
-
rationale: 'Older captures belong to the home learner and are isolated from this project.',
|
|
126
|
-
severity: 'IMPORTANT', touchesMachine: true,
|
|
127
|
-
plainImpact: 'Minimizes old actions again and feeds only the original local home learner. Original queue bytes remain retained. Project learning is separate.',
|
|
128
|
-
evidence: [{ observed: `${learning.legacyUserDepth} legacy user events pending` }],
|
|
129
|
-
cost: { time: 'bounded rounds', risk: 'local observations; originals retained' },
|
|
130
|
-
change: { human: 'explicitly drain legacy user queue into home learner' },
|
|
131
|
-
undo: { human: 'original history retained; learned observations can be reset separately' },
|
|
132
|
-
}));
|
|
133
|
-
}
|
|
134
102
|
|
|
135
103
|
// 3. A learner that has not trained in days. Installed-but-dormant is a DEFECT, not a neutral
|
|
136
104
|
// state — the entire point of shipping a learning system is that it runs.
|