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
|
@@ -162,7 +162,8 @@ const POLICY = (id, file, interpreter = 'bash') => ({ id, file, interpreter });
|
|
|
162
162
|
const REFUSAL_POLICIES = [
|
|
163
163
|
POLICY('protect-state', 'protect-brain-state.sh'),
|
|
164
164
|
POLICY('hijack-ruvnet', 'hijack-ruvnet.sh'),
|
|
165
|
-
POLICY('
|
|
165
|
+
POLICY('substitution-wall', 'substitution-wall.mjs', 'node'), // owner ruling 2026-10-12: only CALL rUv's memory tools; refuses a rebuilt transport (outbox/queue/journal/replay), in this repo only
|
|
166
|
+
POLICY('ground-before-write', 'ground-before-write.sh'), POLICY('decision-recall', 'decision-recall-gate.mjs', 'node'), // ADR-106
|
|
166
167
|
// adr-currency-gate fires on the EDIT, where the pre-push gate fires on the push. Same rule, same
|
|
167
168
|
// machinery (it calls doc-currency.mjs, never a second copy of the logic) — moved to the earliest
|
|
168
169
|
// moment it has enough information. On 2026-08-13 four ADRs went stale together and were caught
|
|
@@ -181,7 +182,7 @@ const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interp
|
|
|
181
182
|
|
|
182
183
|
/** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
|
|
183
184
|
const REGISTRY = {
|
|
184
|
-
'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency', 'duplicate-code'],
|
|
185
|
+
'write': ['protect-state', 'hijack-ruvnet', 'substitution-wall', 'ground-before-write', 'decision-recall', 'adr-currency', 'duplicate-code'],
|
|
185
186
|
};
|
|
186
187
|
|
|
187
188
|
export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
|
|
@@ -287,7 +288,6 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
|
|
|
287
288
|
* `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
|
|
288
289
|
*/
|
|
289
290
|
function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
|
|
290
|
-
|
|
291
291
|
// ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
|
|
292
292
|
if (isMain()) {
|
|
293
293
|
const started = Date.now();
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decision-recall-gate.mjs — "read AgentDB before deciding" as a FACT check (ADR-105 §8, ADR-106).
|
|
4
|
+
*
|
|
5
|
+
* A POLICY OF decision-gate.mjs, NOT A HOOK: exit 0 allow, exit 2 + stderr refuse; composed by the
|
|
6
|
+
* one PreToolUse refuser (ADR-067) on both hosts, so it adds no registration.
|
|
7
|
+
*
|
|
8
|
+
* WHEN IT LOOKS: a write that CREATES an ADR file (Write to a path that does not exist yet, or an
|
|
9
|
+
* apply_patch `*** Add File:`), or an Edit/MultiEdit that changes an ADR's status line. Ordinary ADR
|
|
10
|
+
* edits (wording, review stamps) are never checked.
|
|
11
|
+
*
|
|
12
|
+
* THE FACT: a `decision-record.mjs --consult` receipt for this project younger than two hours. The
|
|
13
|
+
* receipt proves the consult RAN; it does not require history to have been available — an AgentDB
|
|
14
|
+
* outage must not wedge decisions, so an `unavailable` receipt allows and says history was unknown.
|
|
15
|
+
*
|
|
16
|
+
* AT MOST ONE REFUSAL PER PATH PER SESSION (the duplicate-gate shape): the retry is allowed and
|
|
17
|
+
* recorded. Only in an adopted project (canonical `.swarm/memory.db` exists). Opt-outs win
|
|
18
|
+
* (ADR-105 §7): RUVNET_DECISION_RECALL_GATE=off, RUVNET_AGENTDB_FIRST=off, the brain-off sentinel.
|
|
19
|
+
* Fails open on its own errors.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
27
|
+
import { isAdrPath, freshConsultReceipt, brainHomeOf } from './decision-record.mjs';
|
|
28
|
+
|
|
29
|
+
const ALLOW = 0;
|
|
30
|
+
const REFUSE = 2;
|
|
31
|
+
const STATUS_LINE = /^\s*(?:status:\s*\S.*|\*\*Status\*\*\s*:.*)$/im;
|
|
32
|
+
/** The document's status line, ignoring fenced code (a YAML example is not the ADR's status). */
|
|
33
|
+
const STATUS_LINES = new RegExp(STATUS_LINE.source, 'gim');
|
|
34
|
+
/** Every status line of the document (frontmatter AND body), ignoring fenced code. */
|
|
35
|
+
export const statusOf = (text) => [...String(text || '').replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').matchAll(STATUS_LINES)]
|
|
36
|
+
.map((m) => m[0].trim()).join('\n');
|
|
37
|
+
|
|
38
|
+
function readText(file) { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which ADR decision, if any, this tool call makes. A status change is judged on the WHOLE document:
|
|
42
|
+
* the status of the file now versus the status after this edit is applied, so a whole-file Write over
|
|
43
|
+
* an existing ADR is seen (review finding 5) and a `status:` line inside a code fence is not (finding 6).
|
|
44
|
+
*/
|
|
45
|
+
export function adrDecisionIn(payload, { read = readText } = {}) {
|
|
46
|
+
const tool = String(payload?.tool_name || '');
|
|
47
|
+
const input = payload?.tool_input || {};
|
|
48
|
+
const cwd = typeof payload?.cwd === 'string' ? payload.cwd : process.cwd();
|
|
49
|
+
const abs = (p) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
|
50
|
+
if (['Write', 'Edit', 'MultiEdit'].includes(tool) && isAdrPath(input.file_path)) {
|
|
51
|
+
const before = read(abs(input.file_path));
|
|
52
|
+
if (before === null) return tool === 'Write' ? { path: input.file_path, kind: 'new ADR' } : null;
|
|
53
|
+
let after = before;
|
|
54
|
+
if (tool === 'Write') after = String(input.content ?? '');
|
|
55
|
+
else {
|
|
56
|
+
const edits = tool === 'Edit' ? [input] : (Array.isArray(input.edits) ? input.edits : []);
|
|
57
|
+
for (const e of edits) {
|
|
58
|
+
const from = String(e.old_string ?? ''); const to = String(e.new_string ?? '');
|
|
59
|
+
if (!from) continue;
|
|
60
|
+
after = e.replace_all ? after.split(from).join(to) : after.replace(from, () => to);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return statusOf(after) !== statusOf(before) ? { path: input.file_path, kind: 'ADR status change' } : null;
|
|
64
|
+
}
|
|
65
|
+
if (tool === 'apply_patch') {
|
|
66
|
+
const text = typeof input === 'string' ? input : JSON.stringify(input);
|
|
67
|
+
const patch = typeof input === 'string' ? input : String(input.input ?? input.patch ?? input.command ?? text);
|
|
68
|
+
const added = [...patch.matchAll(/\*\*\* Add File: ([^\n\\"]+)/g)].map((m) => m[1].trim()).find(isAdrPath);
|
|
69
|
+
if (added) return { path: added, kind: 'new ADR' };
|
|
70
|
+
// An Update File hunk on an ADR: compare the status lines it removes with the ones it adds.
|
|
71
|
+
for (const section of patch.split(/^\*\*\* (?=Update File: )/m).slice(1)) {
|
|
72
|
+
const file = /^Update File: ([^\n]+)/.exec(section)?.[1]?.trim();
|
|
73
|
+
if (!isAdrPath(file)) continue;
|
|
74
|
+
const lines = section.split('\n');
|
|
75
|
+
const side = (sign) => lines.filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).map((l) => l.slice(1)).join('\n');
|
|
76
|
+
if (statusOf(side('+')) !== statusOf(side('-'))) return { path: file, kind: 'ADR status change' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function brainOff(env) {
|
|
83
|
+
const dir = env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain');
|
|
84
|
+
try { fs.statSync(path.join(dir, 'brain-off')); return true; } catch (e) { return !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ackFile = (brainHome) => path.join(brainHome, 'decision-recall-acks.json');
|
|
88
|
+
function readAcks(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }
|
|
89
|
+
|
|
90
|
+
/** The verdict. Returns { code, stderr?, stdout? }. */
|
|
91
|
+
export function evaluate(payload, { env = process.env, now = Date.now() } = {}) {
|
|
92
|
+
if (['off'].includes(String(env.RUVNET_DECISION_RECALL_GATE || '').toLowerCase())
|
|
93
|
+
|| String(env.RUVNET_AGENTDB_FIRST || '').toLowerCase() === 'off' || brainOff(env)) return { code: ALLOW };
|
|
94
|
+
const decision = adrDecisionIn(payload);
|
|
95
|
+
if (!decision) return { code: ALLOW };
|
|
96
|
+
let resolution;
|
|
97
|
+
try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs: 400 }); } catch { return { code: ALLOW }; }
|
|
98
|
+
try { if (!fs.statSync(resolution.canonicalAgentDbPath).isFile()) return { code: ALLOW }; } catch { return { code: ALLOW }; }
|
|
99
|
+
const brainHome = brainHomeOf(env);
|
|
100
|
+
const receipt = freshConsultReceipt({ projectRoot: resolution.projectRoot, brainHome, now });
|
|
101
|
+
if (receipt) {
|
|
102
|
+
return /^ok-/.test(receipt.outcome) ? { code: ALLOW }
|
|
103
|
+
: { code: ALLOW, stdout: `AgentDB history was ${receipt.outcome} when this decision was consulted (${receipt.at}); state in the ${decision.kind} that prior decisions are UNKNOWN.` };
|
|
104
|
+
}
|
|
105
|
+
const session = String(payload.session_id || 'no-session');
|
|
106
|
+
const key = crypto.createHash('sha256').update(`${session}\u0000${resolution.projectRoot}\u0000${decision.path}`).digest('hex').slice(0, 32);
|
|
107
|
+
const file = ackFile(brainHome);
|
|
108
|
+
const acks = readAcks(file);
|
|
109
|
+
if (acks[key]) {
|
|
110
|
+
acks[key].retriedAt = new Date(now).toISOString();
|
|
111
|
+
try { fs.writeFileSync(file, JSON.stringify(acks)); } catch { /* the ledger never blocks a write */ }
|
|
112
|
+
return { code: ALLOW, stdout: `This ${decision.kind} was written without an AgentDB consult (retry after one refusal). Record it afterwards with decision-record.mjs --record.` };
|
|
113
|
+
}
|
|
114
|
+
// Keep the ledger bounded: drop acknowledgements older than a week.
|
|
115
|
+
for (const [k, v] of Object.entries(acks)) if (now - Date.parse(v?.at) > 7 * 86_400_000) delete acks[k];
|
|
116
|
+
acks[key] = { at: new Date(now).toISOString(), kind: decision.kind };
|
|
117
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(acks), { mode: 0o600 }); } catch { return { code: ALLOW }; }
|
|
118
|
+
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'decision-record.mjs');
|
|
119
|
+
return { code: REFUSE, stderr: [
|
|
120
|
+
`⛔ BLOCKED (once) — ${decision.kind} ${decision.path}: run decision-record.mjs --consult "<topic>" first (AgentDB decisions unread).`,
|
|
121
|
+
'Consult first (exact curated records, then ranked, one deadline; failure is reported, never empty):',
|
|
122
|
+
` node ${JSON.stringify(script)} --consult "<the decision topic>"`,
|
|
123
|
+
'Then write the ADR, and record the decision after it:',
|
|
124
|
+
` node ${JSON.stringify(script)} --record --what "…" --why "…" --shape "…" --evidence adr:${decision.path}`,
|
|
125
|
+
'This path is refused at most once per session; retrying is allowed and recorded. RUVNET_DECISION_RECALL_GATE=off disables it.',
|
|
126
|
+
].join('\n') };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isMain() {
|
|
130
|
+
try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
131
|
+
}
|
|
132
|
+
if (isMain()) {
|
|
133
|
+
let verdict = { code: ALLOW };
|
|
134
|
+
try {
|
|
135
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
136
|
+
verdict = evaluate(JSON.parse(raw || '{}'));
|
|
137
|
+
} catch { verdict = { code: ALLOW }; }
|
|
138
|
+
if (verdict.stdout) process.stdout.write(verdict.stdout);
|
|
139
|
+
if (verdict.stderr) process.stderr.write(`${verdict.stderr}\n`);
|
|
140
|
+
process.exit(verdict.code);
|
|
141
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
*
|
|
10
|
+
* Two hooks consume the facts this file produces (ADR-106): decision-gate's `decision-recall` policy
|
|
11
|
+
* reads the consult receipt before a new ADR or an ADR status change; continuation-gate reads the
|
|
12
|
+
* transcript audit below at Stop. Neither hook writes memory.
|
|
13
|
+
*
|
|
14
|
+
* Store contract (ADR-105 §3, `ruflo memory store --help` read 2026-10-11): every write is
|
|
15
|
+
* `--no-upsert --require-native --append-only --path <abs>` from a private scratch cwd with no
|
|
16
|
+
* CLAUDE_FLOW_MEMORY_PATH; a row is committed only when an exact-key readback returns the same
|
|
17
|
+
* bytes; any other refusal is LOUD — a hook-failures row plus the printed reason — and is never reported as
|
|
18
|
+
* done. Nothing is kept on disk for a later replay (owner ruling 2026-10-12: no private outbox).
|
|
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 './ruflo-bin.mjs';
|
|
32
|
+
import { currentTurnRecords } from './completion-claim-evidence.mjs';
|
|
33
|
+
import { recordHookFailure } from './hook-failures.mjs';
|
|
34
|
+
|
|
35
|
+
export const DECISION_SCHEMA = 'ruvnet-brain.decision-record';
|
|
36
|
+
export const STATUSES = Object.freeze(['ratified', 'detected-unconfirmed']);
|
|
37
|
+
export const EVIDENCE_KINDS = Object.freeze(['path', 'commit', 'receipt', 'adr', 'url']);
|
|
38
|
+
export const BOUNDS = Object.freeze({ what: 400, why: 800, shape: 1200, evidence: 8, evidenceRef: 200, supersedes: 8, value: 4096 });
|
|
39
|
+
export const CONSULT_MAX_AGE_MS = 2 * 3600_000;
|
|
40
|
+
const KEY_RE = /^decision-[a-z0-9-]{1,48}-\d{8}T\d{9}Z$/;
|
|
41
|
+
const SCRIPT = fileURLToPath(import.meta.url);
|
|
42
|
+
|
|
43
|
+
/** An ADR document path (README/index files are not decisions). */
|
|
44
|
+
export function isAdrPath(file) {
|
|
45
|
+
const p = String(file || '').replace(/\\/g, '/');
|
|
46
|
+
if (!/\.md$/i.test(p) || /(^|\/)(README|index)\.md$/i.test(p)) return false;
|
|
47
|
+
return /(^|\/)adrs?\/[^/]+\.md$/i.test(p) || /(^|\/)ADR-[^/]+\.md$/i.test(p);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const bound = (s, n) => String(s ?? '').slice(0, n);
|
|
51
|
+
export const brainHomeOf = (env = process.env) => env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
52
|
+
|
|
53
|
+
/** `decision-<kebab slug ≤48>-<YYYYMMDDTHHMMSSmmmZ>` (ADR-105 §8). */
|
|
54
|
+
export function decisionKey(what, at) {
|
|
55
|
+
const slug = String(what || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48).replace(/-+$/, '') || 'decision';
|
|
56
|
+
const stamp = new Date(at).toISOString().replace(/[-:.]/g, '');
|
|
57
|
+
return `decision-${slug}-${stamp}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Structural validation of a stored record. Returns a list of problems (empty = valid). */
|
|
61
|
+
export function validateDecisionRecord(r) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return ['record must be an object'];
|
|
64
|
+
if (r.schema !== DECISION_SCHEMA || r.schemaVersion !== 1) errors.push('schema/schemaVersion');
|
|
65
|
+
for (const f of ['project', 'projectIdentity', 'at', 'host']) if (typeof r[f] !== 'string' || !r[f]) errors.push(`${f} required`);
|
|
66
|
+
if (r.session !== null && typeof r.session !== 'string') errors.push('session must be a string or null');
|
|
67
|
+
if (!['claude', 'codex', 'cli'].includes(r.host)) errors.push('host must be claude, codex or cli');
|
|
68
|
+
if (!STATUSES.includes(r.status)) errors.push(`status must be one of ${STATUSES.join(', ')}`);
|
|
69
|
+
if (typeof r.what !== 'string' || !r.what.trim()) errors.push('what required');
|
|
70
|
+
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`);
|
|
71
|
+
const evidence = Array.isArray(r.evidence) ? r.evidence : [];
|
|
72
|
+
if (r.evidence !== undefined && !Array.isArray(r.evidence)) errors.push('evidence must be an array');
|
|
73
|
+
if (evidence.length > BOUNDS.evidence) errors.push(`evidence exceeds ${BOUNDS.evidence} items`);
|
|
74
|
+
for (const e of evidence) {
|
|
75
|
+
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}');
|
|
76
|
+
}
|
|
77
|
+
if (r.status === 'ratified') {
|
|
78
|
+
if (typeof r.why !== 'string' || !r.why.trim()) errors.push('why required for a ratified decision');
|
|
79
|
+
if (typeof r.shape !== 'string' || !r.shape.trim()) errors.push('shape required for a ratified decision');
|
|
80
|
+
if (!evidence.length) errors.push('at least one evidence item required for a ratified decision');
|
|
81
|
+
}
|
|
82
|
+
const sup = r.supersedes ?? [];
|
|
83
|
+
if (!Array.isArray(sup) || sup.length > BOUNDS.supersedes || sup.some((k) => !KEY_RE.test(k))) errors.push('supersedes must be ≤8 exact decision keys');
|
|
84
|
+
// A detected record is evidence, never authority: it may not retire a decision (append-only, no undo).
|
|
85
|
+
if (Array.isArray(sup) && sup.length && r.status !== 'ratified') errors.push('only a ratified decision may supersede');
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Redact (shared scanner), mask excluded paths, then bound — in that order (ADR-105 §8). */
|
|
90
|
+
export function buildDecisionRecord(input, { projectRoot, projectIdentity, host = 'cli', session = null, now = Date.now(), exclusions = [] }) {
|
|
91
|
+
const clean = (s, n) => bound(maskExcludedPaths(redactText(String(s ?? '').trim()), exclusions, projectRoot), n);
|
|
92
|
+
const record = {
|
|
93
|
+
schema: DECISION_SCHEMA, schemaVersion: 1, project: projectRoot, projectIdentity,
|
|
94
|
+
at: new Date(now).toISOString(), host, session,
|
|
95
|
+
status: input.status || 'ratified',
|
|
96
|
+
what: clean(input.what, BOUNDS.what),
|
|
97
|
+
...(input.why ? { why: clean(input.why, BOUNDS.why) } : {}),
|
|
98
|
+
...(input.shape ? { shape: clean(input.shape, BOUNDS.shape) } : {}),
|
|
99
|
+
evidence: (input.evidence || []).slice(0, BOUNDS.evidence).map((e) => ({ kind: e.kind, ref: clean(e.ref, BOUNDS.evidenceRef) })),
|
|
100
|
+
supersedes: [...new Set(input.supersedes || [])],
|
|
101
|
+
};
|
|
102
|
+
const errors = validateDecisionRecord(record);
|
|
103
|
+
const value = JSON.stringify(record);
|
|
104
|
+
if (Buffer.byteLength(value) > BOUNDS.value) errors.push(`record exceeds ${BOUNDS.value} bytes`);
|
|
105
|
+
return { record, key: decisionKey(record.what, now), value, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Supersession is a link on the NEWER record (an append-only store cannot edit the old row).
|
|
110
|
+
* Only a valid ratified record supersedes; a detected one is evidence, never authority.
|
|
111
|
+
*/
|
|
112
|
+
export function supersededKeys(records) {
|
|
113
|
+
const out = new Set();
|
|
114
|
+
for (const r of records) {
|
|
115
|
+
if (!r || r.schema !== DECISION_SCHEMA || r.status !== 'ratified' || validateDecisionRecord(r).length) continue;
|
|
116
|
+
for (const k of r.supersedes || []) out.add(k);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Supersession tombstones (review finding 1). A newer record's `supersedes` is only visible to recall
|
|
123
|
+
* when that newer record is itself read, so a superseder that ranking or the deadline left out let the
|
|
124
|
+
* old decision be served as live. Each superseded key therefore also gets an append-only tombstone row
|
|
125
|
+
* `supersession-<old key>` in the same namespace; recall sees it in the key enumeration it already does,
|
|
126
|
+
* with no extra read. The prefix never matches recall's `^decision[-_]` decision-key test.
|
|
127
|
+
*/
|
|
128
|
+
export const SUPERSESSION_SCHEMA = 'ruvnet-brain.decision-supersession';
|
|
129
|
+
export const TOMBSTONE_PREFIX = 'supersession-';
|
|
130
|
+
export const tombstoneKey = (oldKey) => `${TOMBSTONE_PREFIX}${oldKey}`;
|
|
131
|
+
export function tombstonedKeys(keys) {
|
|
132
|
+
return new Set((keys || []).filter((k) => typeof k === 'string' && k.startsWith(`${TOMBSTONE_PREFIX}decision-`))
|
|
133
|
+
.map((k) => k.slice(TOMBSTONE_PREFIX.length)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runRuflo(ruflo, db, args, { spawn = spawnSync } = {}) {
|
|
137
|
+
const cwd = rufloRunDir(db);
|
|
138
|
+
try {
|
|
139
|
+
const { executable, args: argv } = rufloInvocation(ruflo, args);
|
|
140
|
+
const r = spawn(executable, argv, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
|
|
141
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
|
|
142
|
+
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}` : ''}` };
|
|
143
|
+
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function defaultStore({ ruflo, db, namespace, key, value }) {
|
|
147
|
+
return runRuflo(ruflo, db, ['memory', 'store', '--key', key, '--value', value, '--namespace', namespace,
|
|
148
|
+
'--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output', '--path', db]);
|
|
149
|
+
}
|
|
150
|
+
export function defaultReadBack({ ruflo, db, namespace, key }) {
|
|
151
|
+
const r = runRuflo(ruflo, db, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', db]);
|
|
152
|
+
return { content: r.status === 0 ? r.stdout.trim() : null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The §3 door. committed | already-stored | quarantined | refused — never "committed" without an
|
|
157
|
+
* exact readback of the same bytes, and a refusal is never treated as a duplicate.
|
|
158
|
+
*/
|
|
159
|
+
export function storeDecision({ ruflo, db, namespace, key, value, store = defaultStore, readBack = defaultReadBack }) {
|
|
160
|
+
const prior = readBack({ ruflo, db, namespace, key });
|
|
161
|
+
if (typeof prior.content === 'string' && prior.content === value) return { state: 'already-stored', key };
|
|
162
|
+
if (typeof prior.content === 'string' && prior.content && prior.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
|
|
163
|
+
const result = store({ ruflo, db, namespace, key, value });
|
|
164
|
+
const back = readBack({ ruflo, db, namespace, key });
|
|
165
|
+
if (result.status === 0 && back.content === value) return { state: 'committed', key };
|
|
166
|
+
if (typeof back.content === 'string' && back.content && back.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
|
|
167
|
+
const reason = redactText(String(result.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || `store exited ${result.status}`).slice(0, 300);
|
|
168
|
+
recordHookFailure({ hook: 'decision-record', event: 'record', error: `not recorded: ${reason}`.slice(0, 160) });
|
|
169
|
+
return { state: 'refused', key, reason };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── consult receipt (read before deciding) ─────────────────────────────────────────────────────
|
|
173
|
+
export const consultFile = (projectRoot, brainHome) => path.join(brainHome, 'decision-consult',
|
|
174
|
+
`${crypto.createHash('sha256').update(String(projectRoot)).digest('hex').slice(0, 24)}.json`);
|
|
175
|
+
|
|
176
|
+
export function writeConsultReceipt({ projectRoot, brainHome, outcome, topic, records = [], categories = null, now = Date.now() }) {
|
|
177
|
+
const file = consultFile(projectRoot, brainHome);
|
|
178
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
179
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
180
|
+
fs.writeFileSync(tmp, JSON.stringify({ schemaVersion: 1, kind: 'decision-consult', projectRoot, at: new Date(now).toISOString(), outcome,
|
|
181
|
+
topicDigest: crypto.createHash('sha256').update(String(topic)).digest('hex'), records, categories }), { mode: 0o600 });
|
|
182
|
+
fs.renameSync(tmp, file);
|
|
183
|
+
return file;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The receipt for this project if it is younger than maxAgeMs, else null. Any outcome counts: it proves the consult ran. */
|
|
187
|
+
export function freshConsultReceipt({ projectRoot, brainHome, now = Date.now(), maxAgeMs = CONSULT_MAX_AGE_MS }) {
|
|
188
|
+
try {
|
|
189
|
+
const r = JSON.parse(fs.readFileSync(consultFile(projectRoot, brainHome), 'utf8'));
|
|
190
|
+
const age = now - Date.parse(r.at);
|
|
191
|
+
return r.kind === 'decision-consult' && r.projectRoot === projectRoot && age >= 0 && age <= maxAgeMs ? r : null;
|
|
192
|
+
} catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── transcript audit (write after deciding) ────────────────────────────────────────────────────
|
|
196
|
+
const RECORD_COMMAND = /decision-record\.mjs["']?\s[^\n|;&]*--record\b/;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* From the CURRENT turn's transcript records: ADR files this turn CREATED (Write result type
|
|
200
|
+
* `create`, measured on real Claude transcripts) and whether a successful `--record` ran after the
|
|
201
|
+
* last of them. Pure over parsed records.
|
|
202
|
+
*/
|
|
203
|
+
export function auditDecisionTurn(records) {
|
|
204
|
+
const results = new Map();
|
|
205
|
+
for (const o of records) {
|
|
206
|
+
const c = o?.message?.content;
|
|
207
|
+
if (!Array.isArray(c)) continue;
|
|
208
|
+
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 });
|
|
209
|
+
}
|
|
210
|
+
const created = [];
|
|
211
|
+
let lastCreate = -1;
|
|
212
|
+
let recordedAt = -1;
|
|
213
|
+
let i = 0;
|
|
214
|
+
for (const o of records) {
|
|
215
|
+
const c = o?.message?.content;
|
|
216
|
+
if (o?.type !== 'assistant' || !Array.isArray(c)) continue;
|
|
217
|
+
for (const u of c) {
|
|
218
|
+
if (u?.type !== 'tool_use') continue;
|
|
219
|
+
i += 1;
|
|
220
|
+
const result = results.get(u.id);
|
|
221
|
+
if (!result || result.error) continue;
|
|
222
|
+
if (u.name === 'Write' && isAdrPath(u.input?.file_path) && result.use?.type === 'create') { created.push(u.input.file_path); lastCreate = i; }
|
|
223
|
+
if (u.name === 'Bash' && RECORD_COMMAND.test(String(u.input?.command || ''))) recordedAt = i;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { created, recorded: created.length > 0 && recordedAt > lastCreate };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Stop-side debt: an ADR created this turn in an ADOPTED project with no later decision record. */
|
|
230
|
+
export function decisionRecordDebt({ transcriptPath, cwd, read = readSettledTranscript, env = process.env }) {
|
|
231
|
+
if (String(env.RUVNET_DECISION_RECORD_CHECK || '').toLowerCase() === 'off' || String(env.RUVNET_AGENTDB_FIRST || '').toLowerCase() === 'off') return null;
|
|
232
|
+
if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath) || !cwd) return null;
|
|
233
|
+
let audit;
|
|
234
|
+
try { audit = auditDecisionTurn(currentTurnRecords(read(transcriptPath, { maxMs: 0 })).recs); } catch { return null; }
|
|
235
|
+
if (!audit.created.length || audit.recorded) return null;
|
|
236
|
+
try { if (!fs.statSync(resolveProjectStore({ projectDir: cwd, gitTimeoutMs: 500 }).canonicalAgentDbPath).isFile()) return null; } catch { return null; }
|
|
237
|
+
return { created: audit.created, command: `node ${JSON.stringify(SCRIPT)} --record --what "…" --why "…" --shape "…" --evidence adr:${audit.created.at(-1)}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
function parseArgs(argv) {
|
|
242
|
+
const o = { evidence: [], supersedes: [] };
|
|
243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
244
|
+
const a = argv[i];
|
|
245
|
+
const next = () => { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); return v; };
|
|
246
|
+
if (['--consult', '--record'].includes(a)) { o.mode = a.slice(2); if (a === '--consult') o.topic = next(); }
|
|
247
|
+
else if (a === '--what') o.what = next();
|
|
248
|
+
else if (a === '--why') o.why = next();
|
|
249
|
+
else if (a === '--shape') o.shape = next();
|
|
250
|
+
else if (a === '--status') o.status = next();
|
|
251
|
+
else if (a === '--project') o.project = next();
|
|
252
|
+
else if (a === '--supersedes') o.supersedes.push(next());
|
|
253
|
+
else if (a === '--evidence') {
|
|
254
|
+
const v = next(); const at = v.indexOf(':');
|
|
255
|
+
if (at < 1) throw new Error('--evidence must be kind:ref');
|
|
256
|
+
o.evidence.push({ kind: v.slice(0, at), ref: v.slice(at + 1) });
|
|
257
|
+
} else throw new Error(`unknown argument ${a}`);
|
|
258
|
+
}
|
|
259
|
+
return o;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function main(argv = process.argv.slice(2), { env = process.env, out = process.stdout, err = process.stderr, recallFn, store, readBack } = {}) {
|
|
263
|
+
let o;
|
|
264
|
+
try { o = parseArgs(argv); } catch (e) { err.write(`decision-record: ${e.message}\n`); return 2; }
|
|
265
|
+
if (!o.mode) { err.write('decision-record: one of --consult "<topic>", --record is required\n'); return 2; }
|
|
266
|
+
const projectDir = path.resolve(o.project || process.cwd());
|
|
267
|
+
const brainHome = brainHomeOf(env);
|
|
268
|
+
let target;
|
|
269
|
+
try { target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 }); } catch (e) { err.write(`decision-record: project store unresolved: ${redactText(e.message)}\n`); return 3; }
|
|
270
|
+
if (o.mode === 'consult') {
|
|
271
|
+
const recall = recallFn || (await import('./agentdb-recall.mjs')).recall;
|
|
272
|
+
const r = await recall({ prompt: o.topic, projectDir, env, consequential: true });
|
|
273
|
+
const projectRoot = target.projectRoot;
|
|
274
|
+
if (projectRoot) writeConsultReceipt({ projectRoot, brainHome, outcome: r.outcome || 'unavailable', topic: o.topic,
|
|
275
|
+
records: (r.receipt?.records || []).map((x) => x.key), categories: r.receipt?.categories || r.categories || null });
|
|
276
|
+
if (r.block) out.write(`${r.block}\n`);
|
|
277
|
+
const ok = /^ok-/.test(r.outcome || '');
|
|
278
|
+
out.write(`decision consult: ${r.outcome || 'unavailable'}${ok ? '' : ' — history is UNKNOWN for this decision; say so in the decision'}${r.reason ? ` (${r.reason})` : ''}\n`);
|
|
279
|
+
return ok ? 0 : 3;
|
|
280
|
+
}
|
|
281
|
+
if (target.skipped) { err.write(`decision-record: not recorded — ${target.skipped}\n`); return 3; }
|
|
282
|
+
const ruflo = resolveRuflo({ env });
|
|
283
|
+
if (!ruflo) { err.write('decision-record: not recorded — global ruflo not found\n'); return 3; }
|
|
284
|
+
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; }
|
|
285
|
+
const identity = resolveProjectStore({ projectDir, gitTimeoutMs: 1000 }).projectIdentity.id;
|
|
286
|
+
const built = buildDecisionRecord(o, { projectRoot: target.projectRoot, projectIdentity: identity,
|
|
287
|
+
host: env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : env.CLAUDE_PLUGIN_ROOT || env.CLAUDECODE ? 'claude' : 'cli',
|
|
288
|
+
exclusions: target.contentPathExcludes || [] });
|
|
289
|
+
if (built.errors.length) { err.write(`decision-record: invalid — ${built.errors.join('; ')}\n`); return 2; }
|
|
290
|
+
const namespace = path.basename(target.projectRoot);
|
|
291
|
+
// Review finding 2: the store is append-only, so a wrong --supersedes key would hide a real decision
|
|
292
|
+
// with no undo. Every key must read back as a decision record of THIS project before anything is written.
|
|
293
|
+
for (const key of built.record.supersedes) {
|
|
294
|
+
const back = (readBack || defaultReadBack)({ ruflo, db: target.db, namespace, key });
|
|
295
|
+
let row = null; try { row = back.content ? JSON.parse(back.content) : null; } catch { /* not a record */ }
|
|
296
|
+
if (row?.schema !== DECISION_SCHEMA || row.project !== target.projectRoot) {
|
|
297
|
+
err.write(`decision-record: invalid — --supersedes ${key} is not an existing decision record of this project; nothing was written\n`);
|
|
298
|
+
return 2;
|
|
299
|
+
}
|
|
300
|
+
out.write(`decision-record: supersedes ${key} — ${String(row.what).slice(0, 120)}\n`);
|
|
301
|
+
}
|
|
302
|
+
const r = storeDecision({ ruflo, db: target.db, namespace, key: built.key, value: built.value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
|
|
303
|
+
out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
|
|
304
|
+
if (!['committed', 'already-stored'].includes(r.state)) return 3;
|
|
305
|
+
let ok = true;
|
|
306
|
+
for (const oldKey of built.record.supersedes) {
|
|
307
|
+
const value = JSON.stringify({ schema: SUPERSESSION_SCHEMA, schemaVersion: 1, project: target.projectRoot, superseded: oldKey, by: built.key, at: built.record.at });
|
|
308
|
+
let t = storeDecision({ ruflo, db: target.db, namespace, key: tombstoneKey(oldKey), value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
|
|
309
|
+
if (t.state === 'quarantined') {
|
|
310
|
+
// A tombstone from an EARLIER superseder of the same decision already retires it: not a conflict.
|
|
311
|
+
let prior = null;
|
|
312
|
+
try { prior = JSON.parse((readBack || defaultReadBack)({ ruflo, db: target.db, namespace, key: t.key }).content || 'null'); } catch { /* conflict stands */ }
|
|
313
|
+
if (prior?.schema === SUPERSESSION_SCHEMA && prior.superseded === oldKey && prior.project === target.projectRoot) t = { state: 'already-stored', key: t.key };
|
|
314
|
+
}
|
|
315
|
+
out.write(`decision-record: tombstone ${t.state} ${t.key}${t.reason ? ` — ${t.reason}` : ''}\n`);
|
|
316
|
+
ok &&= ['committed', 'already-stored'].includes(t.state);
|
|
317
|
+
}
|
|
318
|
+
return ok ? 0 : 3;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isMain() {
|
|
322
|
+
try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(SCRIPT); } catch { return false; }
|
|
323
|
+
}
|
|
324
|
+
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
|
+
}
|