ruvnet-brain 4.6.3 → 4.6.4

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.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * decision-record.mjs — ADR-105 §8 as code: AgentDB is CONSULTED before a decision and the decision
4
+ * is RECORDED after it, in the one canonical store, through the one door (ADR-105 §1–§3).
5
+ *
6
+ * node decision-record.mjs --consult "<topic>" consequential recall + persisted receipt
7
+ * node decision-record.mjs --record --what W --why Y --shape S --evidence kind:ref [...]
8
+ * [--supersedes <key>]... [--status ratified|detected-unconfirmed]
9
+ * node decision-record.mjs --flush retry records left pending by a refusal
10
+ *
11
+ * Two hooks consume the facts this file produces (ADR-106): decision-gate's `decision-recall` policy
12
+ * reads the consult receipt before a new ADR or an ADR status change; continuation-gate reads the
13
+ * transcript audit below at Stop. Neither hook writes memory.
14
+ *
15
+ * Store contract (ADR-105 §3, `ruflo memory store --help` read 2026-10-11): every write is
16
+ * `--no-upsert --require-native --append-only --path <abs>` from a private scratch cwd with no
17
+ * CLAUDE_FLOW_MEMORY_PATH; a row is committed only when an exact-key readback returns the same
18
+ * bytes; any other refusal stays pending on disk (mode 0600, fsynced) and is never reported as done.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import crypto from 'node:crypto';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { redactText } from './continuity-events.mjs';
27
+ import { maskExcludedPaths } from './turn-capture-privacy.mjs';
28
+ import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
29
+ import { resolveProjectStore } from './project-store-resolver.mjs';
30
+ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
31
+ import { rufloRunDir } from './project-progression-store.mjs';
32
+ import { currentTurnRecords } from './completion-claim-evidence.mjs';
33
+
34
+ export const DECISION_SCHEMA = 'ruvnet-brain.decision-record';
35
+ export const STATUSES = Object.freeze(['ratified', 'detected-unconfirmed']);
36
+ export const EVIDENCE_KINDS = Object.freeze(['path', 'commit', 'receipt', 'adr', 'url']);
37
+ export const BOUNDS = Object.freeze({ what: 400, why: 800, shape: 1200, evidence: 8, evidenceRef: 200, supersedes: 8, value: 4096 });
38
+ export const CONSULT_MAX_AGE_MS = 2 * 3600_000;
39
+ const KEY_RE = /^decision-[a-z0-9-]{1,48}-\d{8}T\d{9}Z$/;
40
+ const SCRIPT = fileURLToPath(import.meta.url);
41
+
42
+ /** An ADR document path (README/index files are not decisions). */
43
+ export function isAdrPath(file) {
44
+ const p = String(file || '').replace(/\\/g, '/');
45
+ if (!/\.md$/i.test(p) || /(^|\/)(README|index)\.md$/i.test(p)) return false;
46
+ return /(^|\/)adrs?\/[^/]+\.md$/i.test(p) || /(^|\/)ADR-[^/]+\.md$/i.test(p);
47
+ }
48
+
49
+ const bound = (s, n) => String(s ?? '').slice(0, n);
50
+ export const brainHomeOf = (env = process.env) => env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
51
+
52
+ /** `decision-<kebab slug ≤48>-<YYYYMMDDTHHMMSSmmmZ>` (ADR-105 §8). */
53
+ export function decisionKey(what, at) {
54
+ const slug = String(what || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48).replace(/-+$/, '') || 'decision';
55
+ const stamp = new Date(at).toISOString().replace(/[-:.]/g, '');
56
+ return `decision-${slug}-${stamp}`;
57
+ }
58
+
59
+ /** Structural validation of a stored record. Returns a list of problems (empty = valid). */
60
+ export function validateDecisionRecord(r) {
61
+ const errors = [];
62
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return ['record must be an object'];
63
+ if (r.schema !== DECISION_SCHEMA || r.schemaVersion !== 1) errors.push('schema/schemaVersion');
64
+ for (const f of ['project', 'projectIdentity', 'at', 'host']) if (typeof r[f] !== 'string' || !r[f]) errors.push(`${f} required`);
65
+ if (r.session !== null && typeof r.session !== 'string') errors.push('session must be a string or null');
66
+ if (!['claude', 'codex', 'cli'].includes(r.host)) errors.push('host must be claude, codex or cli');
67
+ if (!STATUSES.includes(r.status)) errors.push(`status must be one of ${STATUSES.join(', ')}`);
68
+ if (typeof r.what !== 'string' || !r.what.trim()) errors.push('what required');
69
+ for (const f of ['what', 'why', 'shape']) if (r[f] !== undefined && (typeof r[f] !== 'string' || r[f].length > BOUNDS[f])) errors.push(`${f} exceeds ${BOUNDS[f]} chars`);
70
+ const evidence = Array.isArray(r.evidence) ? r.evidence : [];
71
+ if (r.evidence !== undefined && !Array.isArray(r.evidence)) errors.push('evidence must be an array');
72
+ if (evidence.length > BOUNDS.evidence) errors.push(`evidence exceeds ${BOUNDS.evidence} items`);
73
+ for (const e of evidence) {
74
+ if (!e || !EVIDENCE_KINDS.includes(e.kind) || typeof e.ref !== 'string' || !e.ref || e.ref.length > BOUNDS.evidenceRef) errors.push('evidence item must be {kind: path|commit|receipt|adr|url, ref ≤200}');
75
+ }
76
+ if (r.status === 'ratified') {
77
+ if (typeof r.why !== 'string' || !r.why.trim()) errors.push('why required for a ratified decision');
78
+ if (typeof r.shape !== 'string' || !r.shape.trim()) errors.push('shape required for a ratified decision');
79
+ if (!evidence.length) errors.push('at least one evidence item required for a ratified decision');
80
+ }
81
+ const sup = r.supersedes ?? [];
82
+ if (!Array.isArray(sup) || sup.length > BOUNDS.supersedes || sup.some((k) => !KEY_RE.test(k))) errors.push('supersedes must be ≤8 exact decision keys');
83
+ // A detected record is evidence, never authority: it may not retire a decision (append-only, no undo).
84
+ if (Array.isArray(sup) && sup.length && r.status !== 'ratified') errors.push('only a ratified decision may supersede');
85
+ return errors;
86
+ }
87
+
88
+ /** Redact (shared scanner), mask excluded paths, then bound — in that order (ADR-105 §8). */
89
+ export function buildDecisionRecord(input, { projectRoot, projectIdentity, host = 'cli', session = null, now = Date.now(), exclusions = [] }) {
90
+ const clean = (s, n) => bound(maskExcludedPaths(redactText(String(s ?? '').trim()), exclusions, projectRoot), n);
91
+ const record = {
92
+ schema: DECISION_SCHEMA, schemaVersion: 1, project: projectRoot, projectIdentity,
93
+ at: new Date(now).toISOString(), host, session,
94
+ status: input.status || 'ratified',
95
+ what: clean(input.what, BOUNDS.what),
96
+ ...(input.why ? { why: clean(input.why, BOUNDS.why) } : {}),
97
+ ...(input.shape ? { shape: clean(input.shape, BOUNDS.shape) } : {}),
98
+ evidence: (input.evidence || []).slice(0, BOUNDS.evidence).map((e) => ({ kind: e.kind, ref: clean(e.ref, BOUNDS.evidenceRef) })),
99
+ supersedes: [...new Set(input.supersedes || [])],
100
+ };
101
+ const errors = validateDecisionRecord(record);
102
+ const value = JSON.stringify(record);
103
+ if (Buffer.byteLength(value) > BOUNDS.value) errors.push(`record exceeds ${BOUNDS.value} bytes`);
104
+ return { record, key: decisionKey(record.what, now), value, errors };
105
+ }
106
+
107
+ /**
108
+ * Supersession is a link on the NEWER record (an append-only store cannot edit the old row).
109
+ * Only a valid ratified record supersedes; a detected one is evidence, never authority.
110
+ */
111
+ export function supersededKeys(records) {
112
+ const out = new Set();
113
+ for (const r of records) {
114
+ if (!r || r.schema !== DECISION_SCHEMA || r.status !== 'ratified' || validateDecisionRecord(r).length) continue;
115
+ for (const k of r.supersedes || []) out.add(k);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Supersession tombstones (review finding 1). A newer record's `supersedes` is only visible to recall
122
+ * when that newer record is itself read, so a superseder that ranking or the deadline left out let the
123
+ * old decision be served as live. Each superseded key therefore also gets an append-only tombstone row
124
+ * `supersession-<old key>` in the same namespace; recall sees it in the key enumeration it already does,
125
+ * with no extra read. The prefix never matches recall's `^decision[-_]` decision-key test.
126
+ */
127
+ export const SUPERSESSION_SCHEMA = 'ruvnet-brain.decision-supersession';
128
+ export const TOMBSTONE_PREFIX = 'supersession-';
129
+ export const tombstoneKey = (oldKey) => `${TOMBSTONE_PREFIX}${oldKey}`;
130
+ export function tombstonedKeys(keys) {
131
+ return new Set((keys || []).filter((k) => typeof k === 'string' && k.startsWith(`${TOMBSTONE_PREFIX}decision-`))
132
+ .map((k) => k.slice(TOMBSTONE_PREFIX.length)));
133
+ }
134
+
135
+ function runRuflo(ruflo, db, args, { spawn = spawnSync } = {}) {
136
+ const cwd = rufloRunDir(db);
137
+ try {
138
+ const { executable, args: argv } = rufloInvocation(ruflo, args);
139
+ const r = spawn(executable, argv, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
140
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
141
+ return { status: Number.isInteger(r.status) ? r.status : 1, stdout: String(r.stdout || ''), output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
142
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
143
+ }
144
+
145
+ export function defaultStore({ ruflo, db, namespace, key, value }) {
146
+ return runRuflo(ruflo, db, ['memory', 'store', '--key', key, '--value', value, '--namespace', namespace,
147
+ '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output', '--path', db]);
148
+ }
149
+ export function defaultReadBack({ ruflo, db, namespace, key }) {
150
+ const r = runRuflo(ruflo, db, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', db]);
151
+ return { content: r.status === 0 ? r.stdout.trim() : null };
152
+ }
153
+
154
+ const pendingDir = (db) => path.join(path.dirname(db), '.decision-records-pending');
155
+ function writePending(db, entry) {
156
+ const dir = pendingDir(db);
157
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
158
+ const file = path.join(dir, `${entry.key}.json`);
159
+ const fd = fs.openSync(file, 'w', 0o600);
160
+ try { fs.writeSync(fd, JSON.stringify(entry)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
161
+ return file;
162
+ }
163
+
164
+ /**
165
+ * The §3 door. committed | already-stored | quarantined | pending — never "committed" without an
166
+ * exact readback of the same bytes, and a refusal is never treated as a duplicate.
167
+ */
168
+ export function storeDecision({ ruflo, db, namespace, key, value, store = defaultStore, readBack = defaultReadBack, keepPending = true }) {
169
+ const prior = readBack({ ruflo, db, namespace, key });
170
+ if (typeof prior.content === 'string' && prior.content === value) return { state: 'already-stored', key };
171
+ if (typeof prior.content === 'string' && prior.content && prior.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
172
+ const result = store({ ruflo, db, namespace, key, value });
173
+ const back = readBack({ ruflo, db, namespace, key });
174
+ if (result.status === 0 && back.content === value) return { state: 'committed', key };
175
+ if (typeof back.content === 'string' && back.content && back.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
176
+ const reason = redactText(String(result.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || `store exited ${result.status}`).slice(0, 300);
177
+ const pending = keepPending ? writePending(db, { key, namespace, value, at: new Date().toISOString(), reason }) : null;
178
+ return { state: 'pending', key, reason, pending };
179
+ }
180
+
181
+ /** Replay pending records through the same door; a committed one leaves the pending directory. */
182
+ export function flushPending({ ruflo, db, store = defaultStore, readBack = defaultReadBack }) {
183
+ const out = [];
184
+ let names = [];
185
+ try { names = fs.readdirSync(pendingDir(db)).filter((n) => n.endsWith('.json')); } catch { return out; }
186
+ for (const name of names) {
187
+ const file = path.join(pendingDir(db), name);
188
+ let entry; try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { out.push({ state: 'unreadable', file }); continue; }
189
+ const r = storeDecision({ ruflo, db, namespace: entry.namespace, key: entry.key, value: entry.value, store, readBack, keepPending: false });
190
+ if (r.state === 'committed' || r.state === 'already-stored') fs.rmSync(file, { force: true });
191
+ out.push(r);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // ── consult receipt (read before deciding) ─────────────────────────────────────────────────────
197
+ export const consultFile = (projectRoot, brainHome) => path.join(brainHome, 'decision-consult',
198
+ `${crypto.createHash('sha256').update(String(projectRoot)).digest('hex').slice(0, 24)}.json`);
199
+
200
+ export function writeConsultReceipt({ projectRoot, brainHome, outcome, topic, records = [], categories = null, now = Date.now() }) {
201
+ const file = consultFile(projectRoot, brainHome);
202
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
203
+ const tmp = `${file}.${process.pid}.tmp`;
204
+ fs.writeFileSync(tmp, JSON.stringify({ schemaVersion: 1, kind: 'decision-consult', projectRoot, at: new Date(now).toISOString(), outcome,
205
+ topicDigest: crypto.createHash('sha256').update(String(topic)).digest('hex'), records, categories }), { mode: 0o600 });
206
+ fs.renameSync(tmp, file);
207
+ return file;
208
+ }
209
+
210
+ /** The receipt for this project if it is younger than maxAgeMs, else null. Any outcome counts: it proves the consult ran. */
211
+ export function freshConsultReceipt({ projectRoot, brainHome, now = Date.now(), maxAgeMs = CONSULT_MAX_AGE_MS }) {
212
+ try {
213
+ const r = JSON.parse(fs.readFileSync(consultFile(projectRoot, brainHome), 'utf8'));
214
+ const age = now - Date.parse(r.at);
215
+ return r.kind === 'decision-consult' && r.projectRoot === projectRoot && age >= 0 && age <= maxAgeMs ? r : null;
216
+ } catch { return null; }
217
+ }
218
+
219
+ // ── transcript audit (write after deciding) ────────────────────────────────────────────────────
220
+ const RECORD_COMMAND = /decision-record\.mjs["']?\s[^\n|;&]*--record\b/;
221
+
222
+ /**
223
+ * From the CURRENT turn's transcript records: ADR files this turn CREATED (Write result type
224
+ * `create`, measured on real Claude transcripts) and whether a successful `--record` ran after the
225
+ * last of them. Pure over parsed records.
226
+ */
227
+ export function auditDecisionTurn(records) {
228
+ const results = new Map();
229
+ for (const o of records) {
230
+ const c = o?.message?.content;
231
+ if (!Array.isArray(c)) continue;
232
+ for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, { error: r.is_error === true, use: o.toolUseResult });
233
+ }
234
+ const created = [];
235
+ let lastCreate = -1;
236
+ let recordedAt = -1;
237
+ let i = 0;
238
+ for (const o of records) {
239
+ const c = o?.message?.content;
240
+ if (o?.type !== 'assistant' || !Array.isArray(c)) continue;
241
+ for (const u of c) {
242
+ if (u?.type !== 'tool_use') continue;
243
+ i += 1;
244
+ const result = results.get(u.id);
245
+ if (!result || result.error) continue;
246
+ if (u.name === 'Write' && isAdrPath(u.input?.file_path) && result.use?.type === 'create') { created.push(u.input.file_path); lastCreate = i; }
247
+ if (u.name === 'Bash' && RECORD_COMMAND.test(String(u.input?.command || ''))) recordedAt = i;
248
+ }
249
+ }
250
+ return { created, recorded: created.length > 0 && recordedAt > lastCreate };
251
+ }
252
+
253
+ /** Stop-side debt: an ADR created this turn in an ADOPTED project with no later decision record. */
254
+ export function decisionRecordDebt({ transcriptPath, cwd, read = readSettledTranscript, env = process.env }) {
255
+ if (String(env.RUVNET_DECISION_RECORD_CHECK || '').toLowerCase() === 'off' || String(env.RUVNET_AGENTDB_FIRST || '').toLowerCase() === 'off') return null;
256
+ if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath) || !cwd) return null;
257
+ let audit;
258
+ try { audit = auditDecisionTurn(currentTurnRecords(read(transcriptPath, { maxMs: 0 })).recs); } catch { return null; }
259
+ if (!audit.created.length || audit.recorded) return null;
260
+ try { if (!fs.statSync(resolveProjectStore({ projectDir: cwd, gitTimeoutMs: 500 }).canonicalAgentDbPath).isFile()) return null; } catch { return null; }
261
+ return { created: audit.created, command: `node ${JSON.stringify(SCRIPT)} --record --what "…" --why "…" --shape "…" --evidence adr:${audit.created.at(-1)}` };
262
+ }
263
+
264
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
265
+ function parseArgs(argv) {
266
+ const o = { evidence: [], supersedes: [] };
267
+ for (let i = 0; i < argv.length; i++) {
268
+ const a = argv[i];
269
+ const next = () => { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); return v; };
270
+ if (['--consult', '--record', '--flush'].includes(a)) { o.mode = a.slice(2); if (a === '--consult') o.topic = next(); }
271
+ else if (a === '--what') o.what = next();
272
+ else if (a === '--why') o.why = next();
273
+ else if (a === '--shape') o.shape = next();
274
+ else if (a === '--status') o.status = next();
275
+ else if (a === '--project') o.project = next();
276
+ else if (a === '--supersedes') o.supersedes.push(next());
277
+ else if (a === '--evidence') {
278
+ const v = next(); const at = v.indexOf(':');
279
+ if (at < 1) throw new Error('--evidence must be kind:ref');
280
+ o.evidence.push({ kind: v.slice(0, at), ref: v.slice(at + 1) });
281
+ } else throw new Error(`unknown argument ${a}`);
282
+ }
283
+ return o;
284
+ }
285
+
286
+ export async function main(argv = process.argv.slice(2), { env = process.env, out = process.stdout, err = process.stderr, recallFn, store, readBack } = {}) {
287
+ let o;
288
+ try { o = parseArgs(argv); } catch (e) { err.write(`decision-record: ${e.message}\n`); return 2; }
289
+ if (!o.mode) { err.write('decision-record: one of --consult "<topic>", --record, --flush is required\n'); return 2; }
290
+ const projectDir = path.resolve(o.project || process.cwd());
291
+ const brainHome = brainHomeOf(env);
292
+ let target;
293
+ try { target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 }); } catch (e) { err.write(`decision-record: project store unresolved: ${redactText(e.message)}\n`); return 3; }
294
+ if (o.mode === 'consult') {
295
+ const recall = recallFn || (await import('./agentdb-recall.mjs')).recall;
296
+ const r = await recall({ prompt: o.topic, projectDir, env, consequential: true });
297
+ const projectRoot = target.projectRoot;
298
+ if (projectRoot) writeConsultReceipt({ projectRoot, brainHome, outcome: r.outcome || 'unavailable', topic: o.topic,
299
+ records: (r.receipt?.records || []).map((x) => x.key), categories: r.receipt?.categories || r.categories || null });
300
+ if (r.block) out.write(`${r.block}\n`);
301
+ const ok = /^ok-/.test(r.outcome || '');
302
+ out.write(`decision consult: ${r.outcome || 'unavailable'}${ok ? '' : ' — history is UNKNOWN for this decision; say so in the decision'}${r.reason ? ` (${r.reason})` : ''}\n`);
303
+ return ok ? 0 : 3;
304
+ }
305
+ if (target.skipped) { err.write(`decision-record: not recorded — ${target.skipped}\n`); return 3; }
306
+ const ruflo = resolveRuflo({ env });
307
+ if (!ruflo) { err.write('decision-record: not recorded — global ruflo not found\n'); return 3; }
308
+ try { if (!fs.statSync(target.db).isFile()) throw new Error(); } catch { err.write('decision-record: not recorded — project has no canonical store (.swarm/memory.db)\n'); return 3; }
309
+ if (o.mode === 'flush') {
310
+ const rows = flushPending({ ruflo, db: target.db });
311
+ out.write(`decision-record flush: ${rows.map((r) => `${r.key || r.file} ${r.state}`).join('; ') || 'nothing pending'}\n`);
312
+ return rows.every((r) => ['committed', 'already-stored'].includes(r.state)) ? 0 : 3;
313
+ }
314
+ const identity = resolveProjectStore({ projectDir, gitTimeoutMs: 1000 }).projectIdentity.id;
315
+ const built = buildDecisionRecord(o, { projectRoot: target.projectRoot, projectIdentity: identity,
316
+ host: env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : env.CLAUDE_PLUGIN_ROOT || env.CLAUDECODE ? 'claude' : 'cli',
317
+ exclusions: target.contentPathExcludes || [] });
318
+ if (built.errors.length) { err.write(`decision-record: invalid — ${built.errors.join('; ')}\n`); return 2; }
319
+ const namespace = path.basename(target.projectRoot);
320
+ // Review finding 2: the store is append-only, so a wrong --supersedes key would hide a real decision
321
+ // with no undo. Every key must read back as a decision record of THIS project before anything is written.
322
+ for (const key of built.record.supersedes) {
323
+ const back = (readBack || defaultReadBack)({ ruflo, db: target.db, namespace, key });
324
+ let row = null; try { row = back.content ? JSON.parse(back.content) : null; } catch { /* not a record */ }
325
+ if (row?.schema !== DECISION_SCHEMA || row.project !== target.projectRoot) {
326
+ err.write(`decision-record: invalid — --supersedes ${key} is not an existing decision record of this project; nothing was written\n`);
327
+ return 2;
328
+ }
329
+ out.write(`decision-record: supersedes ${key} — ${String(row.what).slice(0, 120)}\n`);
330
+ }
331
+ const r = storeDecision({ ruflo, db: target.db, namespace, key: built.key, value: built.value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
332
+ out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.pending ? ` — retained for --flush` : ''}\n`);
333
+ if (!['committed', 'already-stored'].includes(r.state)) return 3;
334
+ let ok = true;
335
+ for (const oldKey of built.record.supersedes) {
336
+ const value = JSON.stringify({ schema: SUPERSESSION_SCHEMA, schemaVersion: 1, project: target.projectRoot, superseded: oldKey, by: built.key, at: built.record.at });
337
+ let t = storeDecision({ ruflo, db: target.db, namespace, key: tombstoneKey(oldKey), value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
338
+ if (t.state === 'quarantined') {
339
+ // A tombstone from an EARLIER superseder of the same decision already retires it: not a conflict.
340
+ let prior = null;
341
+ try { prior = JSON.parse((readBack || defaultReadBack)({ ruflo, db: target.db, namespace, key: t.key }).content || 'null'); } catch { /* conflict stands */ }
342
+ if (prior?.schema === SUPERSESSION_SCHEMA && prior.superseded === oldKey && prior.project === target.projectRoot) t = { state: 'already-stored', key: t.key };
343
+ }
344
+ out.write(`decision-record: tombstone ${t.state} ${t.key}${t.reason ? ` — ${t.reason}` : ''}\n`);
345
+ ok &&= ['committed', 'already-stored'].includes(t.state);
346
+ }
347
+ return ok ? 0 : 3;
348
+ }
349
+
350
+ function isMain() {
351
+ try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(SCRIPT); } catch { return false; }
352
+ }
353
+ if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`decision-record: ${e.message}\n`); process.exitCode = 1; });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * deferral-evidence.mjs — "do not end the turn on authorized work by asking permission" as a pure,
3
+ * replayable detector (ADR-106 `no-deferral`; ADR-043 "must force, not whisper").
4
+ *
5
+ * The owner's measured complaint: turns end with "Say go and I'll start", "Decisions I need from you",
6
+ * "Do you want me to…", "Next: …", "I'll come back when…" on work he had already authorized. The
7
+ * Stop gate corrects completion claims and records promises; this file supplies the third check.
8
+ *
9
+ * A turn is a DEFERRAL only when all three hold, and each is a fact read from text:
10
+ * (a) the END of the final answer seeks permission or lists its own next actions as future work;
11
+ * (b) authorization exists — a standing instruction in the session's prompts, or an open promise;
12
+ * (c) the answer names no genuine blocker class (credential, out-of-authority write, irreversible
13
+ * action needing consent, hardware-key tap, evidenced outage, or a choice only the user can make).
14
+ * Heuristic by construction. scripts/deferral-replay.mjs measures it on real transcripts; the
15
+ * measured false-positive rate is recorded in ADR-106, not assumed.
16
+ */
17
+ import { strippedProse } from './completion-claim-evidence.mjs';
18
+ import { isHarnessGenerated } from './hook-input.mjs';
19
+
20
+ const TAIL_CHARS = 700;
21
+
22
+ /** Permission-seeking / hand-back phrasing. Matched only in the last part of the answer. */
23
+ const PERMISSION = [
24
+ /\bsay\s+(?:the\s+word|go|yes)\b/i,
25
+ /\b(?:do\s+you\s+)?want\s+me\s+to\b/i,
26
+ /\bwould\s+you\s+like\s+(?:me\s+)?to\b/i,
27
+ /\b(?:should|shall)\s+I\b/i,
28
+ /\bdo\s+you\s+want\b/i,
29
+ /\blet\s+me\s+know\s+(?:if|whether|when|which|how)\b/i,
30
+ /(?<!\b(?:no|nothing|without|any)\s+(?:further\s+)?)\b(?:decisions?|input|approval|confirmation|sign-?off|answers?|go-?ahead)\s+(?:I\s+need\s+)?(?:from|needed\s+from)\s+you\b/i,
31
+ /\bI\s+need\s+(?:you|your)\b/i,
32
+ /\bwaiting\s+(?:on|for)\s+(?:you|your)\b/i,
33
+ /\b(?:ready\s+when\s+you\s+are|on\s+your\s+(?:go|word|ok))\b/i,
34
+ /\b(?:ok|okay)\s+to\s+proceed\b|\bproceed\?/i,
35
+ /\b(?:green[-\s]?light|your\s+call)\b/i,
36
+ ];
37
+
38
+ /** Own next actions stated as future work instead of done. */
39
+ const FUTURE_WORK = [
40
+ /^(?:\s*[-*•#]+\s*|\s*\*\*)*next(?:\s+steps?)?(?:\*\*)?\s*[:—–-]\s*\S/im,
41
+ /\bI(?:'ll|’ll|\s+will)\s+(?:come\s+back|follow\s+up|pick\s+(?:this|it)\s+(?:back\s+)?up|resume|continue|get\s+to|start|tackle|handle|do)\b[^.\n]{0,120}\b(?:after|when|once|later|next\s+(?:session|turn|time)|tomorrow)\b/i,
42
+ /\b(?:remaining|still\s+to\s+do|left\s+to\s+do|todo)\s*[:—–-]\s*\S/i,
43
+ ];
44
+
45
+ /** A genuine authority boundary or an answer only the user can give. Matched anywhere in the answer. */
46
+ export const BLOCKER_CLASSES = Object.freeze({
47
+ credential: /\b(?:setup\s+key|authenticator|backup\s+codes?)\b|\b(?:api[\s-]?key|token|secret|credential|password|passphrase|login|sign[\s-]?in|oauth)\b[^.\n]{0,80}\b(?:missing|needed|required|absent|expired|not\s+(?:set|available|present|found)|revoked|invalid)\b|\b(?:missing|need|needs|no)\s+(?:an?\s+)?(?:api[\s-]?key|token|secret|credential|password)\b/i,
48
+ hardwareTap: /\b(?:hardware[\s-]?key|security[\s-]?key|yubikey|touch\s?id|passkey|2fa|otp|tap\s+(?:the|your)\s+key)\b/i,
49
+ outsideAuthority: /\b(?:outside\s+(?:my|the)\s+(?:stated\s+)?(?:authority|scope)|not\s+(?:authori[sz]ed|permitted|allowed)\s+to|no\s+(?:write\s+)?authority|user-level|~\/\.claude|~\/\.codex|only\s+you\s+can|I\s+can(?:'t|’t|not)\s+(?:contact|reach|send|sign|log\s+in)|npm\s+(?:publish|deprecate)|deprecat(?:e|ed|ing)\s+(?:it\s+)?on\s+npm)\b/i,
50
+ irreversible: /\b(?:irreversible|cannot\s+be\s+undone|can't\s+be\s+undone|destructive|data\s+loss|force[\s-]?push|drop\s+(?:the\s+)?(?:table|database)|delete\s+(?:the\s+)?(?:branch|repo|database|production)|before\s+anything\s+is\s+deleted|rm\s+-rf)\b/i,
51
+ outage: /\b(?:outage|is\s+down|503|502|rate[\s-]?limit(?:ed)?|unreachable|connection\s+refused|timed?\s*out\s+(?:connecting|reaching))\b/i,
52
+ background: /\b(?:awaiting|standing\s+by\s+for|report(?:s)?\s+back|the\s+moment\b[^.\n]{0,60}\b(?:lands|has|finishes|arrives)|once\s+[^.\n]{0,60}\b(?:lands|finishes|completes|returns|is\s+back)|waiting\s+on\s+(?!you|your)[^.\n]{0,40}\b(?:CI|v\d|run|build|agent|tests?|review|suite|checks?)|still\s+running|running\s+in\s+the\s+background|I(?:'m|’m|\s+am)\s+waiting\s+for\s+(?:both|the|it|CI|checks?|reviews?|results?)|when\s+(?:the\s+)?[^.\n]{0,60}\b(?:arrives|lands|returns|reports|finishes|passes|completes|comes\s+back|is\s+back))\b/i,
53
+ userAction: /\b(?:restart\s+(?:the|your)\s+(?:Mac|computer|machine)|reboot|type\s+(?:the|your)\s+password|First\s+Aid|plug\s+(?:it\s+)?in|unplug|eject)\b/i,
54
+ purchase: /\b(?:amazon\.com|buy|purchase|price[ds]?|\$\s?\d)\b/i,
55
+ ownerFact: /\b(?:show\s+me\s+where|tell\s+me\s+(?:where|which\s+file|what\s+you\s+(?:mean|meant)))\b/i,
56
+ ownerChoice: /\b(?:should|do\s+you\s+want)\b[^?\n]{0,200}\bor\b[^?\n]{0,200}\?|\b(?:which\s+(?:one|option)\s+(?:do\s+)?you|(?:option|choice)\s+(?:A|1)\b[^.\n]{0,200}\b(?:option|choice)\s+(?:B|2)\b|only\s+you\s+(?:know|can\s+(?:decide|choose|answer)))\b/i,
57
+ });
58
+
59
+ /** Standing authorization phrases in the owner's prompts. */
60
+ const STANDING = /\b(?:don['’]?t\s+stop|do\s+not\s+stop|keep\s+going|never\s+stop|get\s+it\s+(?:done|shipped)|finish\s+(?:it|this|the\s+job|everything)|until\s+(?:it['’]?s\s+)?done|carry\s+(?:it|this)\s+to\s+done|i\s+didn['’]?t\s+tell\s+you\s+to\s+stop|just\s+do\s+it|go\s+ahead|you\s+are\s+authori[sz]ed|fix\s+it\s+now|do\s+it\s+all|make\s+the\s+call)\b/i;
61
+
62
+ const tailOf = (message) => {
63
+ const prose = strippedProse(message).replace(/\n{3,}/g, '\n\n').trim();
64
+ return prose.slice(-TAIL_CHARS);
65
+ };
66
+
67
+ /** The answer itself says work continues, or that nothing is needed from the owner: not a hand-back. */
68
+ const PROGRESSING = /\b(?:I(?:'m|’m|\s+am)\s+(?:now\s+)?(?:continuing|starting|moving|proceeding)|starting\s+(?:on\s+)?[^.\n]{0,60}\bnow|moving\s+(?:into|on\s+to)\s+[^.\n]{0,60}\bnow|continuing\s+without|nothing\s+(?:is\s+)?(?:waiting\s+on|needed\s+from|required\s+from)\s+you|no\s+(?:further\s+)?input\s+(?:is\s+)?needed)\b/i;
69
+
70
+ /** Pure: does this final answer end by deferring? Returns the matched phrase or null. */
71
+ export function deferralIn(message) {
72
+ const tail = tailOf(message);
73
+ if (!tail || PROGRESSING.test(tail)) return null;
74
+ for (const re of [...PERMISSION, ...FUTURE_WORK]) {
75
+ const m = re.exec(tail);
76
+ if (m) return { phrase: m[0].trim().slice(0, 80), kind: PERMISSION.includes(re) ? 'permission' : 'future-work' };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Pure: which genuine blocker classes the answer names (empty = none). */
82
+ export function blockersIn(message) {
83
+ const text = strippedProse(message);
84
+ return Object.entries(BLOCKER_CLASSES).filter(([, re]) => re.test(text)).map(([name]) => name);
85
+ }
86
+
87
+ /** Pure: is there a standing instruction in any of these owner prompts? */
88
+ export function standingAuthorization(prompts) {
89
+ for (const p of prompts || []) {
90
+ const m = STANDING.exec(String(p || ''));
91
+ if (m) return m[0];
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * The verdict. `prompts` = owner prompt texts of the session so far; `openPromises` = count of open
98
+ * first-person promises from this session in the work ledger.
99
+ */
100
+ export function auditDeferral(message, { prompts = [], openPromises = 0 } = {}) {
101
+ const deferral = deferralIn(message);
102
+ if (!deferral) return { verdict: 'NONE' };
103
+ const blockers = blockersIn(message);
104
+ if (blockers.length) return { verdict: 'BLOCKER', deferral, blockers };
105
+ const standing = standingAuthorization(prompts);
106
+ if (!standing && openPromises < 1) return { verdict: 'UNAUTHORIZED', deferral };
107
+ return { verdict: 'DEFERRAL', deferral, authorization: standing ? `standing instruction "${standing}"` : `${openPromises} open promise(s)` };
108
+ }
109
+
110
+ /** Owner prompt texts from raw Claude transcript lines (main thread, not tool results, not meta). */
111
+ export function ownerPrompts(lines) {
112
+ const out = [];
113
+ for (const l of lines || []) {
114
+ let o; try { o = typeof l === 'string' ? JSON.parse(l) : l; } catch { continue; }
115
+ const c = o?.message?.content;
116
+ if (o?.type !== 'user' || o.isSidechain || o.isMeta) continue;
117
+ if (Array.isArray(c) && c.some((x) => x?.type === 'tool_result')) continue;
118
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
119
+ if (text.trim() && !isHarnessGenerated(text)) out.push(text.slice(0, 8000));
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * The Stop-side read (Claude): owner prompts from the transcript tail plus the current prompt's text,
126
+ * which keys the per-prompt force counter. A transcript we cannot read yields no verdict (fail open).
127
+ */
128
+ export function deferralAtStop({ message, transcriptPath, openPromises = 0, read }) {
129
+ if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath) || !message) return null;
130
+ let lines;
131
+ try { lines = read(transcriptPath, { maxMs: 0 }); } catch { return null; }
132
+ const prompts = ownerPrompts(lines);
133
+ return { ...auditDeferral(message, { prompts, openPromises }), prompt: prompts.at(-1) || '' };
134
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * hook-failures.mjs — the reader half of "a failed advisory hook is a status line" (ADR-106).
3
+ *
4
+ * hook-shim.mjs appends one line to `<Brain home>/hook-failures.jsonl` whenever an advisory body
5
+ * exits nonzero, is killed, or cannot spawn (the writer is inlined there because the shim is
6
+ * boot-frozen). SessionStart calls failureLine() once: it summarizes failures recorded since the
7
+ * last summary and advances a byte cursor, so each failure is reported exactly once, never per fire.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const FAILURE_FILE = 'hook-failures.jsonl';
13
+ export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
14
+ const CURSOR_FILE = 'hook-failures.cursor';
15
+ const MAX_READ = 256 * 1024;
16
+
17
+ /** Summarize failures recorded after the cursor; advance it. Returns '' when there is nothing new. */
18
+ export function failureLine(brainHome, { advance = true } = {}) {
19
+ const file = path.join(brainHome, FAILURE_FILE);
20
+ const cursorFile = path.join(brainHome, CURSOR_FILE);
21
+ let size;
22
+ try { size = fs.statSync(file).size; } catch { return ''; }
23
+ let offset = 0;
24
+ try { offset = Number(JSON.parse(fs.readFileSync(cursorFile, 'utf8')).offset) || 0; } catch { /* first read */ }
25
+ if (offset > size) offset = 0; // rotated by the writer
26
+ if (offset === size) return '';
27
+ const start = Math.max(offset, size - MAX_READ);
28
+ const buf = Buffer.alloc(size - start);
29
+ const fd = fs.openSync(file, 'r');
30
+ try { fs.readSync(fd, buf, 0, buf.length, start); } finally { fs.closeSync(fd); }
31
+ const rows = buf.toString('utf8').split('\n').flatMap((l) => { try { return l ? [JSON.parse(l)] : []; } catch { return []; } });
32
+ if (advance) { try { fs.writeFileSync(cursorFile, JSON.stringify({ offset: size })); } catch { /* re-reported next session */ } }
33
+ if (!rows.length) return '';
34
+ const byHook = new Map();
35
+ for (const r of rows) {
36
+ const id = String(r.hook || 'unknown').slice(0, 40);
37
+ const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
38
+ const e = byHook.get(id) || { n: 0, why: new Set() };
39
+ e.n += 1; e.why.add(why); byHook.set(id, e);
40
+ }
41
+ const parts = [...byHook.entries()].sort((a, b) => b[1].n - a[1].n)
42
+ .map(([id, e]) => `${id} ×${e.n} (${[...e.why].slice(0, 3).join(', ')})`);
43
+ return `${FAILURE_LINE_PREFIX} ${rows.length} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
44
+ + `They failed open. Details: ${file}`;
45
+ }
@@ -312,6 +312,52 @@ function resolveCodeRoot() {
312
312
  } catch { return null; }
313
313
  }
314
314
 
315
+ /**
316
+ * A FAILED ADVISORY BODY IS RECORDED, NEVER SILENT (ADR-106). Advisory bodies return 0 to the host
317
+ * whatever happened (below), and the host shows no stderr on exit 0 — so a crashed Stop gate used to
318
+ * leave no trace anywhere (measured 2026-10-11: a throwing continuation-gate body → shim exit 0,
319
+ * 0 bytes, nothing on disk). One bounded JSONL line per failure, read and summarized ONCE by
320
+ * SessionStart (hook-failures.mjs owns the reader and the file name; tests/unit/hook-failures.test.mjs
321
+ * holds the two copies of the name together, the same way brain-off.test.mjs holds the sentinel).
322
+ * Inlined, not imported, because this file is boot-frozen. Never throws, never prints.
323
+ */
324
+ function recordFailure(fields) {
325
+ try {
326
+ const file = path.join(BRAIN_HOME, 'hook-failures.jsonl');
327
+ fs.mkdirSync(BRAIN_HOME, { recursive: true });
328
+ try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
329
+ fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), hook: hookId, ...fields })}\n`, { mode: 0o600 });
330
+ } catch { /* an unrecordable failure must not become a second one */ }
331
+ }
332
+
333
+ /**
334
+ * THE WATCHDOG (ADR-106). The host kills a hook at its registered timeout and DISCARDS its output
335
+ * (measured: "UserPromptSubmit hook … timed out after 10s — output discarded"). So the shim stops the
336
+ * body itself 1.5 s before the registered timeout of THIS command (read from the frozen manifest it
337
+ * was registered from; under Codex the stable wrapper owns the kill instead), records one
338
+ * `timed-out-internally` line in the failure ledger, writes NOTHING partial, and exits 0. SessionStart
339
+ * then reports it once. stdout is captured and forwarded only when the body finished on its own.
340
+ */
341
+ const WATCHDOG_MARGIN_MS = 1500;
342
+ function registeredTimeoutMs(extraArgs) {
343
+ try {
344
+ // Under Codex the stable wrapper already owns a process-group kill below the host timeout
345
+ // (codex-hook-wrapper.mjs); a second, shorter watchdog here would pre-empt it and orphan the
346
+ // body's own children. So the watchdog is Claude's, where nothing else stands before the host.
347
+ if (process.env.RUVNET_CODEX_BUDGET_MS || process.env.RUVNET_HOOK_HOST === 'codex') return Infinity;
348
+ const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, 'hooks', 'hooks.json'), 'utf8'));
349
+ const want = ['hook-shim.mjs"', hookId, ...extraArgs].join(' ');
350
+ let best = Infinity;
351
+ for (const groups of Object.values(manifest.hooks || {})) {
352
+ for (const g of groups || []) for (const h of g.hooks || []) {
353
+ const c = String(h.command || '').replace(/\s+\|\|\s+true\s*$/, '');
354
+ if ((c.endsWith(want) || c.includes(`${want} `)) && Number.isFinite(h.timeout)) best = Math.min(best, h.timeout * 1000);
355
+ }
356
+ }
357
+ return best;
358
+ } catch { return Infinity; }
359
+ }
360
+
315
361
  // Run one hook body. No shell is ever involved: spawnSync with an argument array, interpreter
316
362
  // chosen from the typed table — never from input.
317
363
  function runHook(file, activeVersion = '') {
@@ -345,11 +391,15 @@ function runHook(file, activeVersion = '') {
345
391
  ...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
346
392
  };
347
393
  const io = hookInput !== null
348
- ? { stdio: ['pipe', 'inherit', 'inherit'], input: hookInput }
349
- : { stdio: 'inherit' };
394
+ ? { stdio: ['pipe', 'pipe', 'inherit'], input: hookInput }
395
+ : { stdio: ['inherit', 'pipe', 'inherit'] };
396
+ const registered = registeredTimeoutMs(extraArgs);
397
+ const watchdog = Number.isFinite(registered) ? Math.max(500, registered - WATCHDOG_MARGIN_MS) : undefined;
350
398
  let sessionTimeout;
351
399
  if (hookId === 'session-start') {
352
400
  try {
401
+ // The restore plans inside the watchdog, so it finishes cleanly instead of being killed mid-write.
402
+ if (watchdog) env.RUVNET_SESSION_START_DEADLINE_AT = String(Math.min(Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity, Date.now() + watchdog));
353
403
  const deadlineAt = sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env,
354
404
  startedAt: performance.timeOrigin, pluginRoot: path.resolve(path.dirname(file), '..') });
355
405
  env.RUVNET_SESSION_START_DEADLINE_AT = String(deadlineAt);
@@ -361,12 +411,28 @@ function runHook(file, activeVersion = '') {
361
411
  }
362
412
  }
363
413
  if (advisoryBudgetFor(hookId, extraArgs[0] || '') === null) recordContextBudget({ handler: hookId, event: extraArgs[0] || hookId, scope: 'unknown-unframed-context-unchanged' }, env);
364
- const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env,
365
- ...(sessionTimeout ? { timeout: sessionTimeout, killSignal: 'SIGKILL' } : {}) });
414
+ const limit = Math.min(sessionTimeout || Infinity, watchdog || Infinity);
415
+ const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env, maxBuffer: 16 * 1024 * 1024,
416
+ ...(Number.isFinite(limit) ? { timeout: limit, killSignal: 'SIGKILL' } : {}) });
417
+ const timedOut = r.error?.code === 'ETIMEDOUT' || (r.signal === 'SIGKILL' && Number.isFinite(limit));
418
+ if (timedOut) {
419
+ recordFailure({ event: extraArgs[0] || '', error: 'timed-out-internally', limitMs: limit, signal: r.signal || null });
420
+ return 0; // nothing partial reaches the host; a killed body never refuses
421
+ }
422
+ if (r.stdout?.length) {
423
+ // Synchronous, because process.exit() follows and an async pipe write could be cut off.
424
+ for (let off = 0, spins = 0; off < r.stdout.length && spins < 2000;) {
425
+ try { off += fs.writeSync(1, r.stdout, off); } catch (e) { if (e.code !== 'EAGAIN') break; spins += 1; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); }
426
+ }
427
+ }
366
428
  if (r.error) {
429
+ recordFailure({ event: extraArgs[0] || '', error: String(r.error.code || r.error.message).slice(0, 120), signal: r.signal || null });
367
430
  process.stderr.write(`[hook-shim] ${entry.file}: ${r.error.message}\n`);
368
431
  return entry.mode === 'blocking' ? 1 : 0;
369
432
  }
433
+ if (r.signal || (entry.mode !== 'blocking' && r.status !== 0)) {
434
+ recordFailure({ event: extraArgs[0] || '', status: r.status, signal: r.signal || null });
435
+ }
370
436
  // Blocking hooks: the exit code IS the contract — ground-before-write, design-wall, protect-state,
371
437
  // and hijack-ruvnet at an opted-in managedMemoryBoundary (ADR-063). Advisory hooks: always 0.
372
438
  //