dual-brain 7.1.21 → 7.1.23
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/bin/dual-brain.mjs +2580 -717
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +14 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +195 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/intelligence.mjs +423 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +808 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor.mjs — Diagnostic and recovery stage in the dual-brain pipeline.
|
|
3
|
+
* Doctor is a diagnostic/recovery stage in the pipeline. It proposes, never implements.
|
|
4
|
+
*
|
|
5
|
+
* Doctor can diagnose problems and propose recovery actions, but it NEVER directly
|
|
6
|
+
* edits files, dispatches agents, or runs commands. All proposals are returned as
|
|
7
|
+
* data for the pipeline to execute through its normal gated flow.
|
|
8
|
+
*
|
|
9
|
+
* Pipeline interface:
|
|
10
|
+
* doctorDiagnose(run) — pre-execution diagnostic check
|
|
11
|
+
* doctorRecover(run, failure) — post-failure recovery proposal
|
|
12
|
+
*
|
|
13
|
+
* Internal honesty checks (for developers working on this repo):
|
|
14
|
+
* runDoctor, formatDoctorReport, scanClaims, checkDecisions,
|
|
15
|
+
* checkFoundations, checkRoleBoundaries, checkEvidence, checkTokenWaste,
|
|
16
|
+
* runHealthCheck, formatHealthReport, compareHealth,
|
|
17
|
+
* doctorDiagnose, doctorRecover
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { readdir, readFile } from 'fs/promises';
|
|
23
|
+
import { exec, execSync } from 'child_process';
|
|
24
|
+
import { promisify } from 'util';
|
|
25
|
+
|
|
26
|
+
const execAsync = promisify(exec);
|
|
27
|
+
|
|
28
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
29
|
+
async function mjsFilesIn(dir) {
|
|
30
|
+
try {
|
|
31
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
return entries.filter(e => e.isFile() && e.name.endsWith('.mjs')).map(e => join(dir, e.name));
|
|
33
|
+
} catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readAuditLines(cwd) {
|
|
37
|
+
const p = join(cwd, '.dualbrain', 'audit', 'head-audit.jsonl');
|
|
38
|
+
if (!existsSync(p)) return [];
|
|
39
|
+
try { return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); } catch { return []; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const EXPLORATORY_RE = /\b(grep|find|cat|head|tail|ls|awk|sed)\b/;
|
|
43
|
+
|
|
44
|
+
// ─── Check 1: Claim Scanner ──────────────────────────────────────────────────
|
|
45
|
+
const CLAIM_PATTERNS = [
|
|
46
|
+
{ re: /Detected\s+(Claude|GPT|OpenAI|ChatGPT)\s+(Max|Pro|Plus|Free)/i, label: 'subscription tier detection claim' },
|
|
47
|
+
{ re: /\$(?:20|100|200)\b/, label: 'hardcoded dollar amount in UI string' },
|
|
48
|
+
{ re: /\b(?:used|remaining|quota|budget)\b[^"'\n]{0,40}%/, label: 'usage percentage display' },
|
|
49
|
+
{ re: /%[^"'\n]{0,40}\b(?:used|remaining|quota|budget)\b/, label: 'usage percentage display' },
|
|
50
|
+
{ re: /\bsubscription\b/i, label: 'subscription reference' },
|
|
51
|
+
{ re: /\bplan\s+tier\b/i, label: 'plan tier reference' },
|
|
52
|
+
{ re: /\bquota\s+remaining\b/i, label: 'quota remaining reference' },
|
|
53
|
+
{ re: /\bbudget\s+left\b/i, label: 'budget left reference' },
|
|
54
|
+
{ re: /\bverified\b[^"'\n]{0,60}\b(?:subscription|plan|tier|quota)\b/i, label: 'verified subscription claim' },
|
|
55
|
+
{ re: /\b(?:subscription|plan|tier|quota)\b[^"'\n]{0,60}\bverified\b/i, label: 'verified subscription claim' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const CONFIG_LINE_RE = /^\s*(?:\/\/|['"]?\w+['"]?\s*:|\bconst\b|\blet\b|\bvar\b)[^=]*=\s*['"]?\$?\d/;
|
|
59
|
+
|
|
60
|
+
export async function scanClaims(cwd) {
|
|
61
|
+
const allFiles = [
|
|
62
|
+
...(await mjsFilesIn(join(cwd, 'src'))),
|
|
63
|
+
...(await mjsFilesIn(join(cwd, 'bin'))),
|
|
64
|
+
].filter(f => !/(test|doctor)\.mjs$/.test(f));
|
|
65
|
+
|
|
66
|
+
const issues = [];
|
|
67
|
+
for (const filePath of allFiles) {
|
|
68
|
+
let text; try { text = await readFile(filePath, 'utf8'); } catch { continue; }
|
|
69
|
+
const relPath = filePath.slice(cwd.length + 1);
|
|
70
|
+
text.split('\n').forEach((line, i) => {
|
|
71
|
+
if (line.includes('// doctor:verified') || /^\s*\/\//.test(line) || CONFIG_LINE_RE.test(line)) return;
|
|
72
|
+
for (const { re, label } of CLAIM_PATTERNS) {
|
|
73
|
+
if (re.test(line)) { issues.push({ file: relPath, line: i + 1, text: line.trim().slice(0, 120), label }); return; }
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return { issues };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Check 2: Decision Artifacts ─────────────────────────────────────────────
|
|
81
|
+
const SENSITIVE_AREAS = [
|
|
82
|
+
{ pattern: /src\/detect\.mjs/, area: 'task-detection' },
|
|
83
|
+
{ pattern: /src\/decide\.mjs/, area: 'routing-decisions' },
|
|
84
|
+
{ pattern: /src\/dispatch\.mjs/, area: 'dispatch-logic' },
|
|
85
|
+
{ pattern: /src\/profile\.mjs/, area: 'provider-detection' },
|
|
86
|
+
{ pattern: /onboard|wizard/i, area: 'onboarding-flow' },
|
|
87
|
+
{ pattern: /budget|subscription|quota/i, area: 'budget-system' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export async function checkDecisions(cwd) {
|
|
91
|
+
const decisionsDir = join(cwd, '.dualbrain', 'decisions');
|
|
92
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const areas = [];
|
|
95
|
+
for (const { area } of SENSITIVE_AREAS) {
|
|
96
|
+
if (seen.has(area)) continue;
|
|
97
|
+
seen.add(area);
|
|
98
|
+
const artifactPath = join(decisionsDir, `${area}.json`);
|
|
99
|
+
if (!existsSync(artifactPath)) { areas.push({ area, status: 'missing' }); continue; }
|
|
100
|
+
let artifact; try { artifact = JSON.parse(readFileSync(artifactPath, 'utf8')); }
|
|
101
|
+
catch { areas.push({ area, status: 'invalid' }); continue; }
|
|
102
|
+
const expired = artifact.expires_at && artifact.expires_at < today;
|
|
103
|
+
areas.push({ area, status: expired ? 'expired' : (artifact.status === 'active' ? 'active' : 'inactive'), decidedAt: artifact.decided_at || null, expiresAt: artifact.expires_at || null });
|
|
104
|
+
}
|
|
105
|
+
return { areas };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Check 3: Foundation Manifest ────────────────────────────────────────────
|
|
109
|
+
export async function checkFoundations(cwd) {
|
|
110
|
+
const manifestPath = join(cwd, '.dualbrain', 'foundations.json');
|
|
111
|
+
if (!existsSync(manifestPath)) return { foundations: [], issues: [], missing: true };
|
|
112
|
+
let data; try { data = JSON.parse(readFileSync(manifestPath, 'utf8')); }
|
|
113
|
+
catch { return { foundations: [], issues: [{ type: 'parse-error', message: 'foundations.json is not valid JSON' }], missing: false }; }
|
|
114
|
+
const all = data.foundations || [];
|
|
115
|
+
const issues = [];
|
|
116
|
+
const foundations = all.map(f => {
|
|
117
|
+
const entry = { id: f.id, claim: f.claim, status: f.status, dependents: f.dependents || [] };
|
|
118
|
+
if (f.status === 'invalidated') entry.stillUsedBy = all.filter(o => o.status === 'active' && (o.dependents || []).includes(f.id)).map(o => o.id);
|
|
119
|
+
return entry;
|
|
120
|
+
});
|
|
121
|
+
for (const inv of all.filter(f => f.status === 'invalidated')) {
|
|
122
|
+
for (const active of all.filter(f => f.status === 'active')) {
|
|
123
|
+
const overlap = (active.dependents || []).filter(d => (inv.dependents || []).includes(d));
|
|
124
|
+
if (overlap.length > 0) issues.push({ type: 'dependent-on-invalidated', file: overlap, activeFoundation: active.id, invalidatedFoundation: inv.id });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { foundations, issues, missing: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Check 4: Role Boundary Verification ─────────────────────────────────────
|
|
131
|
+
export async function checkRoleBoundaries(cwd) {
|
|
132
|
+
const lines = readAuditLines(cwd);
|
|
133
|
+
const findings = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
let entry; try { entry = JSON.parse(line); } catch { continue; }
|
|
136
|
+
const { ts, tool, event, reason } = entry;
|
|
137
|
+
if (event !== 'PreToolUse') continue;
|
|
138
|
+
if (tool === 'Read') {
|
|
139
|
+
const m = (reason || '').match(/\b[\w./]+\.(mjs|ts|js|json)\b/);
|
|
140
|
+
const file = m ? m[0] : null;
|
|
141
|
+
findings.push({ severity: 'block', type: 'role-violation',
|
|
142
|
+
message: file ? `HEAD read ${file} directly (should dispatch search agent)` : 'HEAD attempted direct file read (should dispatch search agent)',
|
|
143
|
+
file: file || null, timestamp: ts });
|
|
144
|
+
} else if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') {
|
|
145
|
+
const isMemory = /memory|MEMORY/i.test(reason || '');
|
|
146
|
+
findings.push({ severity: 'block', type: 'role-violation',
|
|
147
|
+
message: isMemory ? 'HEAD wrote memory instead of fixing code' : `HEAD modified files directly via ${tool} (should dispatch work agent)`,
|
|
148
|
+
file: null, timestamp: ts });
|
|
149
|
+
} else if (tool === 'Bash' && entry.allowed === false && EXPLORATORY_RE.test(reason || '')) {
|
|
150
|
+
findings.push({ severity: 'block', type: 'role-violation',
|
|
151
|
+
message: 'HEAD explored repo directly via Bash (should dispatch search agent)',
|
|
152
|
+
file: null, timestamp: ts });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return findings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Check 5: Evidence Verification ──────────────────────────────────────────
|
|
159
|
+
export async function checkEvidence(cwd) {
|
|
160
|
+
const outcomesDir = join(cwd, '.dualbrain', 'outcomes');
|
|
161
|
+
if (!existsSync(outcomesDir)) return [];
|
|
162
|
+
let files; try { files = await readdir(outcomesDir); } catch { return []; }
|
|
163
|
+
const findings = [];
|
|
164
|
+
for (const fname of files.filter(f => f.endsWith('.json')).slice(-20)) {
|
|
165
|
+
let outcome; try { outcome = JSON.parse(await readFile(join(outcomesDir, fname), 'utf8')); } catch { continue; }
|
|
166
|
+
for (const f of (outcome.filesChanged || [])) {
|
|
167
|
+
if (!existsSync(join(cwd, f))) {
|
|
168
|
+
findings.push({ severity: 'block', type: 'false-file-claim', message: `Outcome claims ${f} was changed but file does not exist`, file: f, source: fname });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const { stdout } = await execAsync(`git diff HEAD -- "${f}"`, { cwd });
|
|
173
|
+
if (!stdout.trim() && outcome.success === true) findings.push({ severity: 'block', type: 'false-file-claim', message: `Outcome claims success with changes to ${f} but git diff shows no changes`, file: f, source: fname });
|
|
174
|
+
} catch { /* git unavailable */ }
|
|
175
|
+
}
|
|
176
|
+
if (outcome.testsRun === true && !outcome.testOutput && !outcome.testSummary)
|
|
177
|
+
findings.push({ severity: 'warn', type: 'missing-test-evidence', message: 'Outcome claims testsRun:true but no test output recorded', file: null, source: fname });
|
|
178
|
+
}
|
|
179
|
+
return findings;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Check 6: Token Waste Detection ──────────────────────────────────────────
|
|
183
|
+
export async function checkTokenWaste(cwd) {
|
|
184
|
+
const lines = readAuditLines(cwd);
|
|
185
|
+
let total = 0, nonDispatch = 0;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
let entry; try { entry = JSON.parse(line); } catch { continue; }
|
|
188
|
+
if (entry.event !== 'PreToolUse') continue;
|
|
189
|
+
total++;
|
|
190
|
+
const { tool, reason } = entry;
|
|
191
|
+
if (tool === 'Agent') continue;
|
|
192
|
+
if (tool === 'Read' || tool === 'Write' || tool === 'Edit') nonDispatch++;
|
|
193
|
+
else if (tool === 'Bash' && EXPLORATORY_RE.test(reason || '')) nonDispatch++;
|
|
194
|
+
}
|
|
195
|
+
if (total === 0) return [];
|
|
196
|
+
const ratio = nonDispatch / total; if (ratio <= 0.3) return [];
|
|
197
|
+
return [{ severity: 'warn', type: 'token-waste',
|
|
198
|
+
message: `HEAD non-dispatch calls are ${Math.round(ratio * 100)}% of total (${nonDispatch}/${total}). Dispatch agents instead of direct tool use.`,
|
|
199
|
+
file: null, nonDispatchCalls: nonDispatch, totalCalls: total }];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Orchestrator ─────────────────────────────────────────────────────────────
|
|
203
|
+
export async function runDoctor(cwd = process.cwd()) {
|
|
204
|
+
const [claims, decisions, foundations, roleBoundaries, evidence, tokenWaste] = await Promise.all([
|
|
205
|
+
scanClaims(cwd), checkDecisions(cwd), checkFoundations(cwd),
|
|
206
|
+
checkRoleBoundaries(cwd), checkEvidence(cwd), checkTokenWaste(cwd),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const allFindings = [...roleBoundaries, ...evidence, ...tokenWaste];
|
|
210
|
+
const blockCount = allFindings.filter(f => f.severity === 'block').length;
|
|
211
|
+
const warnCount = allFindings.filter(f => f.severity === 'warn').length;
|
|
212
|
+
const legacyIssues = claims.issues.length + decisions.areas.filter(a => a.status !== 'active').length + foundations.issues.length;
|
|
213
|
+
const legacyBlocking = decisions.areas.filter(a => a.status === 'missing').length + foundations.issues.filter(i => i.type === 'dependent-on-invalidated').length;
|
|
214
|
+
const totalBlocking = legacyBlocking + blockCount;
|
|
215
|
+
const verdict = totalBlocking > 0 ? 'fail' : (legacyIssues + warnCount > 0 ? 'issues' : 'pass');
|
|
216
|
+
return { claims, decisions, foundations, roleBoundaries, evidence, tokenWaste,
|
|
217
|
+
summary: { issueCount: legacyIssues + warnCount, blockingCount: totalBlocking, verdict } };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Formatter ────────────────────────────────────────────────────────────────
|
|
221
|
+
function section(out, title, items, emptyMsg) {
|
|
222
|
+
out.push(`${title}:`);
|
|
223
|
+
if (!items || items.length === 0) { out.push(` ✓ ${emptyMsg}`); }
|
|
224
|
+
else { for (const item of items) out.push(item); }
|
|
225
|
+
out.push('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function formatDoctorReport(results) {
|
|
229
|
+
const { claims, decisions, foundations, roleBoundaries, evidence, tokenWaste, summary } = results;
|
|
230
|
+
const out = ['dual-brain doctor', ''];
|
|
231
|
+
section(out, 'Claims Check',
|
|
232
|
+
claims.issues.map(i => ` ⚠ ${i.file}:${i.line} — "${i.text}" (${i.label})`),
|
|
233
|
+
'No unverified claims found');
|
|
234
|
+
|
|
235
|
+
section(out, 'Decision Artifacts',
|
|
236
|
+
decisions.areas.length === 0 ? null : decisions.areas.map(a =>
|
|
237
|
+
a.status === 'active' ? ` ✓ ${a.area} — decided ${a.decidedAt}, active` :
|
|
238
|
+
a.status === 'expired' ? ` ✗ ${a.area} — decision expired ${a.expiresAt}` :
|
|
239
|
+
a.status === 'missing' ? ` ⚠ ${a.area} — no decision artifact found` :
|
|
240
|
+
` ⚠ ${a.area} — status: ${a.status}`),
|
|
241
|
+
'No sensitive areas tracked');
|
|
242
|
+
out.push('Foundations:');
|
|
243
|
+
if (foundations.missing) {
|
|
244
|
+
out.push(' ⚠ .dualbrain/foundations.json not found — no foundation tracking');
|
|
245
|
+
} else if (foundations.foundations.length === 0) {
|
|
246
|
+
out.push(' ✓ No foundations defined');
|
|
247
|
+
} else {
|
|
248
|
+
for (const f of foundations.foundations) {
|
|
249
|
+
if (f.status === 'invalidated') {
|
|
250
|
+
const n = (f.stillUsedBy || []).length;
|
|
251
|
+
out.push(n === 0 ? ` ℹ ${f.id} — invalidated, no active dependents (resolved)` : ` ✗ ${f.id} — INVALIDATED, ${n} dependent${n === 1 ? '' : 's'} still using`);
|
|
252
|
+
} else {
|
|
253
|
+
out.push(` ✓ ${f.id} — active, ${f.dependents.length} dependent${f.dependents.length === 1 ? '' : 's'}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const issue of foundations.issues) {
|
|
257
|
+
if (issue.type === 'dependent-on-invalidated') out.push(` ✗ ${issue.file.join(', ')} — uses invalidated foundation "${issue.invalidatedFoundation}"`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
out.push('');
|
|
261
|
+
section(out, 'Role Boundaries',
|
|
262
|
+
roleBoundaries && roleBoundaries.length > 0
|
|
263
|
+
? roleBoundaries.map(f => ` ${f.severity === 'block' ? '✗' : '⚠'} ${f.message}${f.file ? ` [${f.file}]` : ''}`)
|
|
264
|
+
: null,
|
|
265
|
+
'No role violations found');
|
|
266
|
+
section(out, 'Evidence Verification',
|
|
267
|
+
evidence && evidence.length > 0
|
|
268
|
+
? evidence.map(f => ` ${f.severity === 'block' ? '✗' : '⚠'} ${f.message} (${f.source})`)
|
|
269
|
+
: null,
|
|
270
|
+
'No outcome evidence issues found');
|
|
271
|
+
section(out, 'Token Waste',
|
|
272
|
+
tokenWaste && tokenWaste.length > 0 ? tokenWaste.map(f => ` ⚠ ${f.message}`) : null,
|
|
273
|
+
'HEAD dispatch ratio is healthy');
|
|
274
|
+
const { verdict, issueCount, blockingCount } = summary;
|
|
275
|
+
const label = verdict === 'pass' ? 'PASS' :
|
|
276
|
+
verdict === 'issues' ? `ISSUES (${issueCount} warning${issueCount === 1 ? '' : 's'})` :
|
|
277
|
+
`FAIL (${blockingCount} blocking)`;
|
|
278
|
+
out.push(`Doctor verdict: ${label}`);
|
|
279
|
+
|
|
280
|
+
return out.join('\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Health Manifest Runner ───────────────────────────────────────────────────
|
|
284
|
+
function atomicWrite(path, data) {
|
|
285
|
+
const tmp = path + '.tmp';
|
|
286
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
287
|
+
renameSync(tmp, path);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function runVerification(item) {
|
|
291
|
+
const v = item.verification || {};
|
|
292
|
+
if (!v.command) return { status: 'untested', detail: '' };
|
|
293
|
+
try {
|
|
294
|
+
const output = execSync(v.command, { timeout: 15000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
295
|
+
const ok = v.expect ? output.includes(v.expect) : output.includes('OK');
|
|
296
|
+
return { status: ok ? 'pass' : 'fail', detail: ok ? '' : output.trim().slice(0, 200) };
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return { status: 'fail', detail: (err.stderr || err.stdout || err.message || '').toString().trim().slice(0, 200) };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function domainStats(items) {
|
|
303
|
+
const domains = {};
|
|
304
|
+
for (const item of items) {
|
|
305
|
+
const d = item.domain || 'other';
|
|
306
|
+
if (!domains[d]) domains[d] = { score: 0, total: 0, passed: 0, wt: 0, wp: 0 };
|
|
307
|
+
const w = item.weight || 1;
|
|
308
|
+
domains[d].total++; domains[d].wt += w;
|
|
309
|
+
if (item.status === 'pass') { domains[d].passed++; domains[d].wp += w; }
|
|
310
|
+
}
|
|
311
|
+
for (const d of Object.keys(domains)) {
|
|
312
|
+
const { wp, wt } = domains[d];
|
|
313
|
+
domains[d].score = wt > 0 ? Math.round((wp / wt) * 100) : 0;
|
|
314
|
+
delete domains[d].wt; delete domains[d].wp;
|
|
315
|
+
}
|
|
316
|
+
return domains;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function runHealthCheck(cwd = process.cwd(), mode = 'quick') {
|
|
320
|
+
const mpath = join(cwd, '.dualbrain', 'health-manifest.json');
|
|
321
|
+
const manifest = existsSync(mpath) ? (() => { try { return JSON.parse(readFileSync(mpath, 'utf8')); } catch { return null; } })() : null;
|
|
322
|
+
const items = manifest ? (manifest.items || []) : [];
|
|
323
|
+
const checkedAt = new Date().toISOString();
|
|
324
|
+
let wt = 0, wp = 0, passed = 0, failed = 0, untested = 0;
|
|
325
|
+
const findings = [];
|
|
326
|
+
|
|
327
|
+
for (const item of items) {
|
|
328
|
+
const isCmd = (item.verification || {}).type === 'command';
|
|
329
|
+
const w = item.weight || 1;
|
|
330
|
+
wt += w;
|
|
331
|
+
if (isCmd) {
|
|
332
|
+
const r = runVerification(item);
|
|
333
|
+
item.status = r.status; item.lastChecked = checkedAt;
|
|
334
|
+
if (r.status === 'pass') { passed++; wp += w; } else failed++;
|
|
335
|
+
findings.push({ id: item.id, name: item.name, domain: item.domain || 'other', severity: item.severity || 'medium', status: r.status, detail: r.detail || '' });
|
|
336
|
+
} else {
|
|
337
|
+
item.status = item.status || 'untested'; untested++;
|
|
338
|
+
findings.push({ id: item.id, name: item.name, domain: item.domain || 'other', severity: item.severity || 'medium', status: 'untested', detail: '' });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const score = wt > 0 ? Math.round((wp / wt) * 100) : 0;
|
|
343
|
+
if (manifest) atomicWrite(mpath, { ...manifest, items, updatedAt: checkedAt });
|
|
344
|
+
return {
|
|
345
|
+
score, total: items.length, passed, failed, untested, findings,
|
|
346
|
+
domains: domainStats(items),
|
|
347
|
+
staticChecks: mode === 'full' ? await runDoctor(cwd) : null,
|
|
348
|
+
checkedAt,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Health Report Formatter ──────────────────────────────────────────────────
|
|
353
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
354
|
+
function bar(passed, total, w = 10) {
|
|
355
|
+
const f = total > 0 ? Math.round((passed / total) * w) : 0;
|
|
356
|
+
return '█'.repeat(f) + '░'.repeat(w - f);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function formatHealthReport(results) {
|
|
360
|
+
const { score, domains, findings, staticChecks } = results;
|
|
361
|
+
const out = [`🩺 Health Report — ${score}/100`, ''];
|
|
362
|
+
|
|
363
|
+
for (const [domain, d] of Object.entries(domains)) {
|
|
364
|
+
const hasUntested = findings.some(f => f.domain === domain && f.status === 'untested');
|
|
365
|
+
out.push(` ${domain.padEnd(12)} ${bar(d.passed, d.total)} ${d.passed}/${d.total}${hasUntested ? ' (manual)' : ''}`);
|
|
366
|
+
}
|
|
367
|
+
out.push('');
|
|
368
|
+
|
|
369
|
+
const failed = findings.filter(f => f.status === 'fail' || f.status === 'error');
|
|
370
|
+
for (const f of failed) {
|
|
371
|
+
const detail = f.detail ? ` — ${f.detail.split('\n')[0].slice(0, 80)}` : '';
|
|
372
|
+
out.push(` ✗ FAIL: ${f.domain}.${f.id}${detail}`);
|
|
373
|
+
}
|
|
374
|
+
if (failed.length > 0) {
|
|
375
|
+
out.push('');
|
|
376
|
+
const top = [...failed].sort((a, b) => (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9)).slice(0, 3);
|
|
377
|
+
out.push(' Top priorities:');
|
|
378
|
+
top.forEach((f, i) => out.push(` ${i + 1}. Fix ${f.domain}.${f.id} (${f.severity})`));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (staticChecks) out.push('', ' Static checks: ' + (staticChecks.summary?.verdict || 'unknown'));
|
|
382
|
+
return out.join('\n');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Pipeline Stage: Diagnose ─────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Pipeline-compatible diagnostic check. Called before execution to surface
|
|
389
|
+
* blocking or advisory findings based on the current pipeline run context.
|
|
390
|
+
*
|
|
391
|
+
* @param {object} run - PipelineRun object
|
|
392
|
+
* @param {object} run.context - Context pack (prompt, files, detection, profile, cwd)
|
|
393
|
+
* @param {object[]} run.failureHistory - Prior failures for this prompt fingerprint
|
|
394
|
+
* @param {object[]} run.priorOutcomes - Recent outcome records
|
|
395
|
+
* @param {object} run.plan - Execution plan (may be null before buildExecutionPlan)
|
|
396
|
+
* @returns {Promise<{
|
|
397
|
+
* findings: Array<{check: string, severity: string, message: string}>,
|
|
398
|
+
* canProceed: boolean,
|
|
399
|
+
* suggestedFixes: string[],
|
|
400
|
+
* blockedApproaches: string[]
|
|
401
|
+
* }>}
|
|
402
|
+
*/
|
|
403
|
+
export async function doctorDiagnose(run) {
|
|
404
|
+
const { context = {}, failureHistory = [], priorOutcomes = [], plan = null } = run;
|
|
405
|
+
const cwd = context.cwd ?? process.cwd();
|
|
406
|
+
|
|
407
|
+
const findings = [];
|
|
408
|
+
const suggestedFixes = [];
|
|
409
|
+
|
|
410
|
+
// ── Role boundary check: pull from audit log ──────────────────────────────
|
|
411
|
+
const roleBoundaries = await checkRoleBoundaries(cwd);
|
|
412
|
+
for (const rb of roleBoundaries) {
|
|
413
|
+
findings.push({ check: 'role-boundaries', severity: rb.severity, message: rb.message });
|
|
414
|
+
}
|
|
415
|
+
if (roleBoundaries.length > 0) {
|
|
416
|
+
suggestedFixes.push('Dispatch search/work agents instead of using Read/Write/Bash directly from HEAD.');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Evidence integrity check ──────────────────────────────────────────────
|
|
420
|
+
const evidenceIssues = await checkEvidence(cwd);
|
|
421
|
+
for (const ev of evidenceIssues) {
|
|
422
|
+
findings.push({ check: 'evidence', severity: ev.severity, message: ev.message });
|
|
423
|
+
}
|
|
424
|
+
if (evidenceIssues.some(e => e.type === 'false-file-claim')) {
|
|
425
|
+
suggestedFixes.push('Verify file claims match actual git state before recording outcomes as successful.');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Token waste check ─────────────────────────────────────────────────────
|
|
429
|
+
const wasteIssues = await checkTokenWaste(cwd);
|
|
430
|
+
for (const tw of wasteIssues) {
|
|
431
|
+
findings.push({ check: 'token-waste', severity: tw.severity, message: tw.message });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Foundation integrity check ────────────────────────────────────────────
|
|
435
|
+
const { issues: foundationIssues } = await checkFoundations(cwd);
|
|
436
|
+
for (const fi of foundationIssues) {
|
|
437
|
+
if (fi.type === 'dependent-on-invalidated') {
|
|
438
|
+
findings.push({
|
|
439
|
+
check: 'foundations',
|
|
440
|
+
severity: 'block',
|
|
441
|
+
message: `Active work depends on invalidated foundation "${fi.invalidatedFoundation}" via ${fi.file.join(', ')}`,
|
|
442
|
+
});
|
|
443
|
+
suggestedFixes.push(`Resolve dependency on invalidated foundation "${fi.invalidatedFoundation}" before proceeding.`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Repeated failure detection ────────────────────────────────────────────
|
|
448
|
+
const repeatFailures = failureHistory.filter(f => !f.resolved);
|
|
449
|
+
if (repeatFailures.length >= 2) {
|
|
450
|
+
findings.push({
|
|
451
|
+
check: 'failure-history',
|
|
452
|
+
severity: 'block',
|
|
453
|
+
message: `${repeatFailures.length} unresolved prior failures for this prompt — repeated approach likely to fail again.`,
|
|
454
|
+
});
|
|
455
|
+
suggestedFixes.push('Escalate to dual-brain think flow before retrying. Prior approaches must not be repeated.');
|
|
456
|
+
} else if (repeatFailures.length === 1) {
|
|
457
|
+
findings.push({
|
|
458
|
+
check: 'failure-history',
|
|
459
|
+
severity: 'warn',
|
|
460
|
+
message: '1 prior failure for this prompt — verify the approach differs before proceeding.',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Risk/plan consistency check ───────────────────────────────────────────
|
|
465
|
+
if (plan && context.detection) {
|
|
466
|
+
const { risk } = context.detection;
|
|
467
|
+
if (risk === 'critical' && !plan.useChallenger) {
|
|
468
|
+
findings.push({
|
|
469
|
+
check: 'plan-consistency',
|
|
470
|
+
severity: 'warn',
|
|
471
|
+
message: 'Critical-risk task routed without challenger — dual-brain think is recommended.',
|
|
472
|
+
});
|
|
473
|
+
suggestedFixes.push('Enable challenger or run dual-brain think before executing critical-risk tasks.');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Derive blocked approaches from failure history ────────────────────────
|
|
478
|
+
const blockedApproaches = repeatFailures
|
|
479
|
+
.filter(f => f.approach)
|
|
480
|
+
.map(f => f.approach);
|
|
481
|
+
|
|
482
|
+
const canProceed = !findings.some(f => f.severity === 'block');
|
|
483
|
+
|
|
484
|
+
return { findings, canProceed, suggestedFixes, blockedApproaches };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Pipeline Stage: Recover ──────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Pipeline-compatible recovery proposer. Called when pipeline execution fails.
|
|
491
|
+
* Returns a recovery proposal for the pipeline to route — never executes directly.
|
|
492
|
+
*
|
|
493
|
+
* @param {object} run - PipelineRun object (same shape as doctorDiagnose)
|
|
494
|
+
* @param {object} failure - Failure context from the failed execution
|
|
495
|
+
* @param {string} [failure.error] - Error message
|
|
496
|
+
* @param {string} [failure.approach] - What was attempted
|
|
497
|
+
* @param {string} [failure.tier] - Tier that failed ('search'|'execute'|'think')
|
|
498
|
+
* @param {number} [failure.failCount] - How many times this has failed
|
|
499
|
+
* @returns {Promise<{
|
|
500
|
+
* proposal: string,
|
|
501
|
+
* avoidApproaches: string[],
|
|
502
|
+
* escalation: string|null
|
|
503
|
+
* }>}
|
|
504
|
+
*/
|
|
505
|
+
export async function doctorRecover(run, failure = {}) {
|
|
506
|
+
const { failureHistory = [] } = run;
|
|
507
|
+
const { error = '', approach = '', tier = 'execute', failCount = 1 } = failure;
|
|
508
|
+
|
|
509
|
+
// Collect all previously failed approaches from history + this failure
|
|
510
|
+
const avoidApproaches = [
|
|
511
|
+
...failureHistory.filter(f => f.approach).map(f => f.approach),
|
|
512
|
+
...(approach ? [approach] : []),
|
|
513
|
+
].filter(Boolean);
|
|
514
|
+
|
|
515
|
+
// Determine escalation: 2+ failures → dual-brain think
|
|
516
|
+
const totalFailures = failureHistory.filter(f => !f.resolved).length + 1;
|
|
517
|
+
const escalation = totalFailures >= 2 ? 'dual-brain' : null;
|
|
518
|
+
|
|
519
|
+
// Build a concrete recovery proposal without implementing anything
|
|
520
|
+
const proposalParts = [];
|
|
521
|
+
|
|
522
|
+
if (escalation === 'dual-brain') {
|
|
523
|
+
proposalParts.push(
|
|
524
|
+
`Escalate to dual-brain think flow: ${totalFailures} failures indicate the approach is fundamentally flawed.`,
|
|
525
|
+
'Run: node .claude/hooks/dual-brain-think.mjs --question "<revised problem statement>"',
|
|
526
|
+
'Do not retry the same implementation path.',
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
if (tier === 'search') {
|
|
530
|
+
proposalParts.push('Retry search with narrower scope or different file patterns.');
|
|
531
|
+
} else if (tier === 'execute') {
|
|
532
|
+
proposalParts.push(
|
|
533
|
+
'Re-route through execute tier with a revised task description.',
|
|
534
|
+
error ? `Prior error was: ${error.slice(0, 120)}` : '',
|
|
535
|
+
);
|
|
536
|
+
} else if (tier === 'think') {
|
|
537
|
+
proposalParts.push('Re-run think tier with more context or an explicit constraint list.');
|
|
538
|
+
} else {
|
|
539
|
+
proposalParts.push('Retry with a revised task description that avoids the failed approach.');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (avoidApproaches.length > 0) {
|
|
543
|
+
proposalParts.push(`Explicitly exclude these approaches: ${avoidApproaches.join(', ')}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const proposal = proposalParts.filter(Boolean).join(' ');
|
|
548
|
+
|
|
549
|
+
return { proposal, avoidApproaches, escalation };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ─── Health Baseline Comparison ───────────────────────────────────────────────
|
|
553
|
+
export async function compareHealth(cwd = process.cwd()) {
|
|
554
|
+
const bpath = join(cwd, '.dualbrain', 'health-baseline.json');
|
|
555
|
+
let baseline = null;
|
|
556
|
+
if (existsSync(bpath)) { try { baseline = JSON.parse(readFileSync(bpath, 'utf8')); } catch { /* ignore */ } }
|
|
557
|
+
|
|
558
|
+
const current = await runHealthCheck(cwd, 'quick');
|
|
559
|
+
const regressions = [], improvements = [];
|
|
560
|
+
|
|
561
|
+
if (baseline && baseline.findings) {
|
|
562
|
+
const prev = Object.fromEntries(baseline.findings.map(f => [f.id, f.status]));
|
|
563
|
+
for (const f of current.findings) {
|
|
564
|
+
if (prev[f.id] === 'pass' && (f.status === 'fail' || f.status === 'error')) regressions.push(f.id);
|
|
565
|
+
else if ((prev[f.id] === 'fail' || prev[f.id] === 'error') && f.status === 'pass') improvements.push(f.id);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
atomicWrite(bpath, { ...current, savedAt: new Date().toISOString() });
|
|
570
|
+
return {
|
|
571
|
+
current: current.score,
|
|
572
|
+
baseline: baseline ? (baseline.score ?? 0) : null,
|
|
573
|
+
delta: baseline != null ? current.score - (baseline.score ?? 0) : null,
|
|
574
|
+
regressions,
|
|
575
|
+
improvements,
|
|
576
|
+
};
|
|
577
|
+
}
|