dual-brain 0.2.30 → 0.3.1
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/.dual-brain/docs/claude-code-extension-points.md +32 -0
- package/.dual-brain/docs/data-tools-capabilities.md +181 -0
- package/.dual-brain/docs/ecosystem-tools.md +91 -0
- package/.dual-brain/docs/panel-handoff.md +124 -0
- package/.dual-brain/docs/ruflo-analysis.md +48 -0
- package/bin/dual-brain.mjs +56 -56
- package/dist/mcp-server/index.d.ts +27 -0
- package/dist/mcp-server/index.js +359 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/agent-protocol.d.ts +163 -0
- package/dist/src/agent-protocol.js +368 -0
- package/dist/src/agent-protocol.js.map +1 -0
- package/dist/src/agents/registry.d.ts +52 -0
- package/dist/src/agents/registry.js +393 -0
- package/dist/src/agents/registry.js.map +1 -0
- package/dist/src/awareness.d.ts +93 -0
- package/dist/src/awareness.js +406 -0
- package/dist/src/awareness.js.map +1 -0
- package/dist/src/brief.d.ts +48 -0
- package/dist/src/brief.js +179 -0
- package/dist/src/brief.js.map +1 -0
- package/dist/src/calibration.d.ts +32 -0
- package/dist/src/calibration.js +133 -0
- package/dist/src/calibration.js.map +1 -0
- package/dist/src/checkpoint.d.ts +33 -0
- package/dist/src/checkpoint.js +99 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/ci-triage.d.ts +33 -0
- package/dist/src/ci-triage.js +193 -0
- package/dist/src/ci-triage.js.map +1 -0
- package/dist/src/cognitive-loop.d.ts +56 -0
- package/dist/src/cognitive-loop.js +495 -0
- package/dist/src/cognitive-loop.js.map +1 -0
- package/dist/src/collaboration.d.ts +147 -0
- package/dist/src/collaboration.js +438 -0
- package/dist/src/collaboration.js.map +1 -0
- package/dist/src/context-intel.d.ts +47 -0
- package/dist/src/context-intel.js +156 -0
- package/dist/src/context-intel.js.map +1 -0
- package/dist/src/context.d.ts +53 -0
- package/dist/src/context.js +332 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/continuity.d.ts +89 -0
- package/dist/src/continuity.js +230 -0
- package/dist/src/continuity.js.map +1 -0
- package/dist/src/cost-tracker.d.ts +47 -0
- package/dist/src/cost-tracker.js +170 -0
- package/dist/src/cost-tracker.js.map +1 -0
- package/dist/src/debrief.d.ts +53 -0
- package/dist/src/debrief.js +222 -0
- package/dist/src/debrief.js.map +1 -0
- package/dist/src/decide.d.ts +96 -0
- package/dist/src/decide.js +744 -0
- package/dist/src/decide.js.map +1 -0
- package/dist/src/decompose.d.ts +39 -0
- package/dist/src/decompose.js +218 -0
- package/dist/src/decompose.js.map +1 -0
- package/dist/src/detect.d.ts +91 -0
- package/dist/src/detect.js +544 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/dispatch.d.ts +154 -0
- package/dist/src/dispatch.js +1306 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/doctor.d.ts +421 -0
- package/dist/src/doctor.js +1689 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/engine.d.ts +70 -0
- package/dist/src/engine.js +155 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/envelope.d.ts +36 -0
- package/dist/src/envelope.js +80 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/failure-memory.d.ts +55 -0
- package/dist/src/failure-memory.js +175 -0
- package/dist/src/failure-memory.js.map +1 -0
- package/dist/src/fx.d.ts +87 -0
- package/dist/src/fx.js +272 -0
- package/dist/src/fx.js.map +1 -0
- package/dist/src/governance.d.ts +93 -0
- package/dist/src/governance.js +261 -0
- package/dist/src/governance.js.map +1 -0
- package/dist/src/handoff.d.ts +11 -0
- package/dist/src/handoff.js +90 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/head-protocol.d.ts +76 -0
- package/dist/src/head-protocol.js +109 -0
- package/dist/src/head-protocol.js.map +1 -0
- package/dist/src/head.d.ts +222 -0
- package/dist/src/head.js +765 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/health.d.ts +132 -0
- package/dist/src/health.js +435 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/inbox.d.ts +70 -0
- package/dist/src/inbox.js +218 -0
- package/dist/src/inbox.js.map +1 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +38 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install-hooks.d.ts +13 -0
- package/dist/src/install-hooks.js +88 -0
- package/dist/src/install-hooks.js.map +1 -0
- package/dist/src/integrity.d.ts +59 -0
- package/dist/src/integrity.js +206 -0
- package/dist/src/integrity.js.map +1 -0
- package/dist/src/intelligence.d.ts +104 -0
- package/dist/src/intelligence.js +391 -0
- package/dist/src/intelligence.js.map +1 -0
- package/dist/src/ledger.d.ts +54 -0
- package/dist/src/ledger.js +179 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/living-docs.d.ts +14 -0
- package/dist/src/living-docs.js +197 -0
- package/dist/src/living-docs.js.map +1 -0
- package/dist/src/memory-tiers.d.ts +37 -0
- package/dist/src/memory-tiers.js +160 -0
- package/dist/src/memory-tiers.js.map +1 -0
- package/dist/src/model-profiles.d.ts +65 -0
- package/dist/src/model-profiles.js +568 -0
- package/dist/src/model-profiles.js.map +1 -0
- package/dist/src/models.d.ts +58 -0
- package/dist/src/models.js +327 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/narrative.d.ts +54 -0
- package/dist/src/narrative.js +163 -0
- package/dist/src/narrative.js.map +1 -0
- package/dist/src/nextstep.d.ts +16 -0
- package/dist/src/nextstep.js +103 -0
- package/dist/src/nextstep.js.map +1 -0
- package/dist/src/observer.d.ts +18 -0
- package/dist/src/observer.js +251 -0
- package/dist/src/observer.js.map +1 -0
- package/dist/src/outcome.d.ts +110 -0
- package/dist/src/outcome.js +377 -0
- package/dist/src/outcome.js.map +1 -0
- package/dist/src/pipeline.d.ts +167 -0
- package/dist/src/pipeline.js +1503 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/playbook.d.ts +59 -0
- package/dist/src/playbook.js +238 -0
- package/dist/src/playbook.js.map +1 -0
- package/dist/src/pr-agent.d.ts +97 -0
- package/dist/src/pr-agent.js +195 -0
- package/dist/src/pr-agent.js.map +1 -0
- package/dist/src/predictive.d.ts +57 -0
- package/dist/src/predictive.js +230 -0
- package/dist/src/predictive.js.map +1 -0
- package/dist/src/profile.d.ts +294 -0
- package/dist/src/profile.js +1347 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/prompt-audit.d.ts +22 -0
- package/dist/src/prompt-audit.js +194 -0
- package/dist/src/prompt-audit.js.map +1 -0
- package/dist/src/prompt-intel.d.ts +12 -0
- package/dist/src/prompt-intel.js +321 -0
- package/dist/src/prompt-intel.js.map +1 -0
- package/dist/src/provider-context.d.ts +121 -0
- package/dist/src/provider-context.js +222 -0
- package/dist/src/provider-context.js.map +1 -0
- package/dist/src/provider-manager.d.ts +92 -0
- package/dist/src/provider-manager.js +428 -0
- package/dist/src/provider-manager.js.map +1 -0
- package/dist/src/receipt.d.ts +87 -0
- package/dist/src/receipt.js +326 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/recommendations.d.ts +13 -0
- package/dist/src/recommendations.js +291 -0
- package/dist/src/recommendations.js.map +1 -0
- package/dist/src/redact.d.ts +15 -0
- package/dist/src/redact.js +129 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/replit.d.ts +397 -0
- package/dist/src/replit.js +1160 -0
- package/dist/src/replit.js.map +1 -0
- package/dist/src/repo.d.ts +149 -0
- package/dist/src/repo.js +416 -0
- package/dist/src/repo.js.map +1 -0
- package/dist/src/revert.d.ts +30 -0
- package/dist/src/revert.js +166 -0
- package/dist/src/revert.js.map +1 -0
- package/dist/src/room.d.ts +102 -0
- package/dist/src/room.js +212 -0
- package/dist/src/room.js.map +1 -0
- package/dist/src/routing-advisor.d.ts +57 -0
- package/dist/src/routing-advisor.js +221 -0
- package/dist/src/routing-advisor.js.map +1 -0
- package/dist/src/self-correct.d.ts +40 -0
- package/dist/src/self-correct.js +137 -0
- package/dist/src/self-correct.js.map +1 -0
- package/dist/src/session-lock.d.ts +35 -0
- package/dist/src/session-lock.js +134 -0
- package/dist/src/session-lock.js.map +1 -0
- package/dist/src/session.d.ts +267 -0
- package/dist/src/session.js +1660 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/settings-tui.d.ts +5 -0
- package/dist/src/settings-tui.js +422 -0
- package/dist/src/settings-tui.js.map +1 -0
- package/dist/src/setup-flow.d.ts +63 -0
- package/dist/src/setup-flow.js +233 -0
- package/dist/src/setup-flow.js.map +1 -0
- package/dist/src/signal.d.ts +19 -0
- package/dist/src/signal.js +122 -0
- package/dist/src/signal.js.map +1 -0
- package/dist/src/simmer.d.ts +85 -0
- package/dist/src/simmer.js +224 -0
- package/dist/src/simmer.js.map +1 -0
- package/dist/src/state-export.d.ts +129 -0
- package/dist/src/state-export.js +233 -0
- package/dist/src/state-export.js.map +1 -0
- package/dist/src/strategy.d.ts +54 -0
- package/dist/src/strategy.js +95 -0
- package/dist/src/strategy.js.map +1 -0
- package/dist/src/subscription.d.ts +40 -0
- package/dist/src/subscription.js +189 -0
- package/dist/src/subscription.js.map +1 -0
- package/dist/src/templates.d.ts +208 -0
- package/dist/src/templates.js +238 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/test.d.ts +9 -0
- package/dist/src/test.js +1173 -0
- package/dist/src/test.js.map +1 -0
- package/dist/src/think-engine.d.ts +67 -0
- package/dist/src/think-engine.js +412 -0
- package/dist/src/think-engine.js.map +1 -0
- package/dist/src/tui.d.ts +71 -0
- package/dist/src/tui.js +242 -0
- package/dist/src/tui.js.map +1 -0
- package/dist/src/types.d.ts +177 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update-check.d.ts +7 -0
- package/dist/src/update-check.js +36 -0
- package/dist/src/update-check.js.map +1 -0
- package/dist/src/wave-planner.d.ts +30 -0
- package/dist/src/wave-planner.js +281 -0
- package/dist/src/wave-planner.js.map +1 -0
- package/hooks/head-guard.sh +41 -0
- package/hooks/precompact.mjs +3 -3
- package/hooks/session-end.mjs +3 -3
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/vibe-router.mjs +387 -0
- package/install.mjs +2 -2
- package/package.json +29 -153
- package/src/agents/registry.mjs +0 -405
- package/src/awareness.mjs +0 -425
- package/src/brief.mjs +0 -266
- package/src/calibration.mjs +0 -148
- package/src/checkpoint.mjs +0 -109
- package/src/ci-triage.mjs +0 -191
- package/src/cognitive-loop.mjs +0 -562
- package/src/collaboration.mjs +0 -545
- package/src/context-intel.mjs +0 -158
- package/src/context.mjs +0 -389
- package/src/continuity.mjs +0 -298
- package/src/cost-tracker.mjs +0 -184
- package/src/debrief.mjs +0 -228
- package/src/decide.mjs +0 -1099
- package/src/decompose.mjs +0 -331
- package/src/detect.mjs +0 -702
- package/src/dispatch.mjs +0 -1447
- package/src/doctor.mjs +0 -1607
- package/src/envelope.mjs +0 -139
- package/src/failure-memory.mjs +0 -178
- package/src/fx.mjs +0 -276
- package/src/governance.mjs +0 -279
- package/src/handoff.mjs +0 -87
- package/src/head-protocol.mjs +0 -128
- package/src/head.mjs +0 -952
- package/src/health.mjs +0 -528
- package/src/inbox.mjs +0 -195
- package/src/index.mjs +0 -44
- package/src/install-hooks.mjs +0 -100
- package/src/integrity.mjs +0 -245
- package/src/intelligence.mjs +0 -447
- package/src/ledger.mjs +0 -196
- package/src/living-docs.mjs +0 -210
- package/src/memory-tiers.mjs +0 -193
- package/src/models.mjs +0 -363
- package/src/narrative.mjs +0 -169
- package/src/nextstep.mjs +0 -100
- package/src/observer.mjs +0 -241
- package/src/outcome.mjs +0 -400
- package/src/pipeline.mjs +0 -1711
- package/src/playbook.mjs +0 -257
- package/src/pr-agent.mjs +0 -214
- package/src/predictive.mjs +0 -250
- package/src/profile.mjs +0 -1411
- package/src/prompt-audit.mjs +0 -231
- package/src/prompt-intel.mjs +0 -325
- package/src/provider-context.mjs +0 -257
- package/src/receipt.mjs +0 -344
- package/src/recommendations.mjs +0 -296
- package/src/redact.mjs +0 -192
- package/src/replit.mjs +0 -1210
- package/src/repo.mjs +0 -445
- package/src/revert.mjs +0 -149
- package/src/routing-advisor.mjs +0 -204
- package/src/self-correct.mjs +0 -147
- package/src/session-lock.mjs +0 -160
- package/src/session.mjs +0 -1655
- package/src/settings-tui.mjs +0 -373
- package/src/setup-flow.mjs +0 -223
- package/src/signal.mjs +0 -115
- package/src/simmer.mjs +0 -241
- package/src/strategy.mjs +0 -235
- package/src/subscription.mjs +0 -212
- package/src/templates.mjs +0 -260
- package/src/think-engine.mjs +0 -428
- package/src/tui.mjs +0 -276
- package/src/update-check.mjs +0 -35
- package/src/wave-planner.mjs +0 -294
package/src/doctor.mjs
DELETED
|
@@ -1,1607 +0,0 @@
|
|
|
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
|
-
* VERIFY system (runtime assumption verification):
|
|
20
|
-
* verify, verifyAll, getVerificationCache, getStaleAssumptions, formatVerifications
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync, readdirSync } from 'fs';
|
|
24
|
-
import { join } from 'path';
|
|
25
|
-
import { readdir, readFile } from 'fs/promises';
|
|
26
|
-
import { exec, execSync } from 'child_process';
|
|
27
|
-
import { promisify } from 'util';
|
|
28
|
-
|
|
29
|
-
const execAsync = promisify(exec);
|
|
30
|
-
|
|
31
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
32
|
-
async function mjsFilesIn(dir) {
|
|
33
|
-
try {
|
|
34
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
35
|
-
return entries.filter(e => e.isFile() && e.name.endsWith('.mjs')).map(e => join(dir, e.name));
|
|
36
|
-
} catch { return []; }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function readAuditLines(cwd) {
|
|
40
|
-
const p = join(cwd, '.dualbrain', 'audit', 'head-audit.jsonl');
|
|
41
|
-
if (!existsSync(p)) return [];
|
|
42
|
-
try { return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); } catch { return []; }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const EXPLORATORY_RE = /\b(grep|find|cat|head|tail|ls|awk|sed)\b/;
|
|
46
|
-
|
|
47
|
-
// ─── Check 1: Claim Scanner ──────────────────────────────────────────────────
|
|
48
|
-
const CLAIM_PATTERNS = [
|
|
49
|
-
{ re: /Detected\s+(Claude|GPT|OpenAI|ChatGPT)\s+(Max|Pro|Plus|Free)/i, label: 'subscription tier detection claim' },
|
|
50
|
-
{ re: /\$(?:20|100|200)\b/, label: 'hardcoded dollar amount in UI string' },
|
|
51
|
-
{ re: /\b(?:used|remaining|quota|budget)\b[^"'\n]{0,40}%/, label: 'usage percentage display' },
|
|
52
|
-
{ re: /%[^"'\n]{0,40}\b(?:used|remaining|quota|budget)\b/, label: 'usage percentage display' },
|
|
53
|
-
{ re: /\bsubscription\b/i, label: 'subscription reference' },
|
|
54
|
-
{ re: /\bplan\s+tier\b/i, label: 'plan tier reference' },
|
|
55
|
-
{ re: /\bquota\s+remaining\b/i, label: 'quota remaining reference' },
|
|
56
|
-
{ re: /\bbudget\s+left\b/i, label: 'budget left reference' },
|
|
57
|
-
{ re: /\bverified\b[^"'\n]{0,60}\b(?:subscription|plan|tier|quota)\b/i, label: 'verified subscription claim' },
|
|
58
|
-
{ re: /\b(?:subscription|plan|tier|quota)\b[^"'\n]{0,60}\bverified\b/i, label: 'verified subscription claim' },
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
const CONFIG_LINE_RE = /^\s*(?:\/\/|['"]?\w+['"]?\s*:|\bconst\b|\blet\b|\bvar\b)[^=]*=\s*['"]?\$?\d/;
|
|
62
|
-
|
|
63
|
-
export async function scanClaims(cwd) {
|
|
64
|
-
const allFiles = [
|
|
65
|
-
...(await mjsFilesIn(join(cwd, 'src'))),
|
|
66
|
-
...(await mjsFilesIn(join(cwd, 'bin'))),
|
|
67
|
-
].filter(f => !/(test|doctor)\.mjs$/.test(f));
|
|
68
|
-
|
|
69
|
-
const issues = [];
|
|
70
|
-
for (const filePath of allFiles) {
|
|
71
|
-
let text; try { text = await readFile(filePath, 'utf8'); } catch { continue; }
|
|
72
|
-
const relPath = filePath.slice(cwd.length + 1);
|
|
73
|
-
text.split('\n').forEach((line, i) => {
|
|
74
|
-
if (line.includes('// doctor:verified') || /^\s*\/\//.test(line) || CONFIG_LINE_RE.test(line)) return;
|
|
75
|
-
for (const { re, label } of CLAIM_PATTERNS) {
|
|
76
|
-
if (re.test(line)) { issues.push({ file: relPath, line: i + 1, text: line.trim().slice(0, 120), label }); return; }
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
return { issues };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ─── Check 2: Decision Artifacts ─────────────────────────────────────────────
|
|
84
|
-
const SENSITIVE_AREAS = [
|
|
85
|
-
{ pattern: /src\/detect\.mjs/, area: 'task-detection' },
|
|
86
|
-
{ pattern: /src\/decide\.mjs/, area: 'routing-decisions' },
|
|
87
|
-
{ pattern: /src\/dispatch\.mjs/, area: 'dispatch-logic' },
|
|
88
|
-
{ pattern: /src\/profile\.mjs/, area: 'provider-detection' },
|
|
89
|
-
{ pattern: /onboard|wizard/i, area: 'onboarding-flow' },
|
|
90
|
-
{ pattern: /budget|subscription|quota/i, area: 'budget-system' },
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
export async function checkDecisions(cwd) {
|
|
94
|
-
const decisionsDir = join(cwd, '.dualbrain', 'decisions');
|
|
95
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
96
|
-
const seen = new Set();
|
|
97
|
-
const areas = [];
|
|
98
|
-
for (const { area } of SENSITIVE_AREAS) {
|
|
99
|
-
if (seen.has(area)) continue;
|
|
100
|
-
seen.add(area);
|
|
101
|
-
const artifactPath = join(decisionsDir, `${area}.json`);
|
|
102
|
-
if (!existsSync(artifactPath)) { areas.push({ area, status: 'missing' }); continue; }
|
|
103
|
-
let artifact; try { artifact = JSON.parse(readFileSync(artifactPath, 'utf8')); }
|
|
104
|
-
catch { areas.push({ area, status: 'invalid' }); continue; }
|
|
105
|
-
const expired = artifact.expires_at && artifact.expires_at < today;
|
|
106
|
-
areas.push({ area, status: expired ? 'expired' : (artifact.status === 'active' ? 'active' : 'inactive'), decidedAt: artifact.decided_at || null, expiresAt: artifact.expires_at || null });
|
|
107
|
-
}
|
|
108
|
-
return { areas };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ─── Check 3: Foundation Manifest ────────────────────────────────────────────
|
|
112
|
-
export async function checkFoundations(cwd) {
|
|
113
|
-
const manifestPath = join(cwd, '.dualbrain', 'foundations.json');
|
|
114
|
-
if (!existsSync(manifestPath)) return { foundations: [], issues: [], missing: true };
|
|
115
|
-
let data; try { data = JSON.parse(readFileSync(manifestPath, 'utf8')); }
|
|
116
|
-
catch { return { foundations: [], issues: [{ type: 'parse-error', message: 'foundations.json is not valid JSON' }], missing: false }; }
|
|
117
|
-
const all = data.foundations || [];
|
|
118
|
-
const issues = [];
|
|
119
|
-
const foundations = all.map(f => {
|
|
120
|
-
const entry = { id: f.id, claim: f.claim, status: f.status, dependents: f.dependents || [] };
|
|
121
|
-
if (f.status === 'invalidated') entry.stillUsedBy = all.filter(o => o.status === 'active' && (o.dependents || []).includes(f.id)).map(o => o.id);
|
|
122
|
-
return entry;
|
|
123
|
-
});
|
|
124
|
-
for (const inv of all.filter(f => f.status === 'invalidated')) {
|
|
125
|
-
for (const active of all.filter(f => f.status === 'active')) {
|
|
126
|
-
const overlap = (active.dependents || []).filter(d => (inv.dependents || []).includes(d));
|
|
127
|
-
if (overlap.length > 0) issues.push({ type: 'dependent-on-invalidated', file: overlap, activeFoundation: active.id, invalidatedFoundation: inv.id });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return { foundations, issues, missing: false };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ─── Check 4: Role Boundary Verification ─────────────────────────────────────
|
|
134
|
-
export async function checkRoleBoundaries(cwd) {
|
|
135
|
-
const lines = readAuditLines(cwd);
|
|
136
|
-
const findings = [];
|
|
137
|
-
for (const line of lines) {
|
|
138
|
-
let entry; try { entry = JSON.parse(line); } catch { continue; }
|
|
139
|
-
const { ts, tool, event, reason } = entry;
|
|
140
|
-
if (event !== 'PreToolUse') continue;
|
|
141
|
-
if (tool === 'Read') {
|
|
142
|
-
const m = (reason || '').match(/\b[\w./]+\.(mjs|ts|js|json)\b/);
|
|
143
|
-
const file = m ? m[0] : null;
|
|
144
|
-
findings.push({ severity: 'block', type: 'role-violation',
|
|
145
|
-
message: file ? `HEAD read ${file} directly (should dispatch search agent)` : 'HEAD attempted direct file read (should dispatch search agent)',
|
|
146
|
-
file: file || null, timestamp: ts });
|
|
147
|
-
} else if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') {
|
|
148
|
-
const isMemory = /memory|MEMORY/i.test(reason || '');
|
|
149
|
-
findings.push({ severity: 'block', type: 'role-violation',
|
|
150
|
-
message: isMemory ? 'HEAD wrote memory instead of fixing code' : `HEAD modified files directly via ${tool} (should dispatch work agent)`,
|
|
151
|
-
file: null, timestamp: ts });
|
|
152
|
-
} else if (tool === 'Bash' && entry.allowed === false && EXPLORATORY_RE.test(reason || '')) {
|
|
153
|
-
findings.push({ severity: 'block', type: 'role-violation',
|
|
154
|
-
message: 'HEAD explored repo directly via Bash (should dispatch search agent)',
|
|
155
|
-
file: null, timestamp: ts });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return findings;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ─── Check 5: Evidence Verification ──────────────────────────────────────────
|
|
162
|
-
export async function checkEvidence(cwd) {
|
|
163
|
-
const outcomesDir = join(cwd, '.dualbrain', 'outcomes');
|
|
164
|
-
if (!existsSync(outcomesDir)) return [];
|
|
165
|
-
let files; try { files = await readdir(outcomesDir); } catch { return []; }
|
|
166
|
-
const findings = [];
|
|
167
|
-
for (const fname of files.filter(f => f.endsWith('.json')).slice(-20)) {
|
|
168
|
-
let outcome; try { outcome = JSON.parse(await readFile(join(outcomesDir, fname), 'utf8')); } catch { continue; }
|
|
169
|
-
for (const f of (outcome.filesChanged || [])) {
|
|
170
|
-
if (!existsSync(join(cwd, f))) {
|
|
171
|
-
findings.push({ severity: 'block', type: 'false-file-claim', message: `Outcome claims ${f} was changed but file does not exist`, file: f, source: fname });
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
try {
|
|
175
|
-
const { stdout } = await execAsync(`git diff HEAD -- "${f}"`, { cwd });
|
|
176
|
-
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 });
|
|
177
|
-
} catch { /* git unavailable */ }
|
|
178
|
-
}
|
|
179
|
-
if (outcome.testsRun === true && !outcome.testOutput && !outcome.testSummary)
|
|
180
|
-
findings.push({ severity: 'warn', type: 'missing-test-evidence', message: 'Outcome claims testsRun:true but no test output recorded', file: null, source: fname });
|
|
181
|
-
}
|
|
182
|
-
return findings;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── Check 6: Token Waste Detection ──────────────────────────────────────────
|
|
186
|
-
export async function checkTokenWaste(cwd) {
|
|
187
|
-
const lines = readAuditLines(cwd);
|
|
188
|
-
let total = 0, nonDispatch = 0;
|
|
189
|
-
for (const line of lines) {
|
|
190
|
-
let entry; try { entry = JSON.parse(line); } catch { continue; }
|
|
191
|
-
if (entry.event !== 'PreToolUse') continue;
|
|
192
|
-
total++;
|
|
193
|
-
const { tool, reason } = entry;
|
|
194
|
-
if (tool === 'Agent') continue;
|
|
195
|
-
if (tool === 'Read' || tool === 'Write' || tool === 'Edit') nonDispatch++;
|
|
196
|
-
else if (tool === 'Bash' && EXPLORATORY_RE.test(reason || '')) nonDispatch++;
|
|
197
|
-
}
|
|
198
|
-
if (total === 0) return [];
|
|
199
|
-
const ratio = nonDispatch / total; if (ratio <= 0.3) return [];
|
|
200
|
-
return [{ severity: 'warn', type: 'token-waste',
|
|
201
|
-
message: `HEAD non-dispatch calls are ${Math.round(ratio * 100)}% of total (${nonDispatch}/${total}). Dispatch agents instead of direct tool use.`,
|
|
202
|
-
file: null, nonDispatchCalls: nonDispatch, totalCalls: total }];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ─── Orchestrator ─────────────────────────────────────────────────────────────
|
|
206
|
-
export async function runDoctor(cwd = process.cwd()) {
|
|
207
|
-
const [claims, decisions, foundations, roleBoundaries, evidence, tokenWaste] = await Promise.all([
|
|
208
|
-
scanClaims(cwd), checkDecisions(cwd), checkFoundations(cwd),
|
|
209
|
-
checkRoleBoundaries(cwd), checkEvidence(cwd), checkTokenWaste(cwd),
|
|
210
|
-
]);
|
|
211
|
-
|
|
212
|
-
const allFindings = [...roleBoundaries, ...evidence, ...tokenWaste];
|
|
213
|
-
const blockCount = allFindings.filter(f => f.severity === 'block').length;
|
|
214
|
-
const warnCount = allFindings.filter(f => f.severity === 'warn').length;
|
|
215
|
-
const legacyIssues = claims.issues.length + decisions.areas.filter(a => a.status !== 'active').length + foundations.issues.length;
|
|
216
|
-
const legacyBlocking = decisions.areas.filter(a => a.status === 'missing').length + foundations.issues.filter(i => i.type === 'dependent-on-invalidated').length;
|
|
217
|
-
const totalBlocking = legacyBlocking + blockCount;
|
|
218
|
-
const verdict = totalBlocking > 0 ? 'fail' : (legacyIssues + warnCount > 0 ? 'issues' : 'pass');
|
|
219
|
-
return { claims, decisions, foundations, roleBoundaries, evidence, tokenWaste,
|
|
220
|
-
summary: { issueCount: legacyIssues + warnCount, blockingCount: totalBlocking, verdict } };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ─── Formatter ────────────────────────────────────────────────────────────────
|
|
224
|
-
function section(out, title, items, emptyMsg) {
|
|
225
|
-
out.push(`${title}:`);
|
|
226
|
-
if (!items || items.length === 0) { out.push(` ✓ ${emptyMsg}`); }
|
|
227
|
-
else { for (const item of items) out.push(item); }
|
|
228
|
-
out.push('');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function formatDoctorReport(results) {
|
|
232
|
-
const { claims, decisions, foundations, roleBoundaries, evidence, tokenWaste, summary } = results;
|
|
233
|
-
const out = ['dual-brain doctor', ''];
|
|
234
|
-
section(out, 'Claims Check',
|
|
235
|
-
claims.issues.map(i => ` ⚠ ${i.file}:${i.line} — "${i.text}" (${i.label})`),
|
|
236
|
-
'No unverified claims found');
|
|
237
|
-
|
|
238
|
-
section(out, 'Decision Artifacts',
|
|
239
|
-
decisions.areas.length === 0 ? null : decisions.areas.map(a =>
|
|
240
|
-
a.status === 'active' ? ` ✓ ${a.area} — decided ${a.decidedAt}, active` :
|
|
241
|
-
a.status === 'expired' ? ` ✗ ${a.area} — decision expired ${a.expiresAt}` :
|
|
242
|
-
a.status === 'missing' ? ` ⚠ ${a.area} — no decision artifact found` :
|
|
243
|
-
` ⚠ ${a.area} — status: ${a.status}`),
|
|
244
|
-
'No sensitive areas tracked');
|
|
245
|
-
out.push('Foundations:');
|
|
246
|
-
if (foundations.missing) {
|
|
247
|
-
out.push(' ⚠ .dualbrain/foundations.json not found — no foundation tracking');
|
|
248
|
-
} else if (foundations.foundations.length === 0) {
|
|
249
|
-
out.push(' ✓ No foundations defined');
|
|
250
|
-
} else {
|
|
251
|
-
for (const f of foundations.foundations) {
|
|
252
|
-
if (f.status === 'invalidated') {
|
|
253
|
-
const n = (f.stillUsedBy || []).length;
|
|
254
|
-
out.push(n === 0 ? ` ℹ ${f.id} — invalidated, no active dependents (resolved)` : ` ✗ ${f.id} — INVALIDATED, ${n} dependent${n === 1 ? '' : 's'} still using`);
|
|
255
|
-
} else {
|
|
256
|
-
out.push(` ✓ ${f.id} — active, ${f.dependents.length} dependent${f.dependents.length === 1 ? '' : 's'}`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
for (const issue of foundations.issues) {
|
|
260
|
-
if (issue.type === 'dependent-on-invalidated') out.push(` ✗ ${issue.file.join(', ')} — uses invalidated foundation "${issue.invalidatedFoundation}"`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
out.push('');
|
|
264
|
-
section(out, 'Role Boundaries',
|
|
265
|
-
roleBoundaries && roleBoundaries.length > 0
|
|
266
|
-
? roleBoundaries.map(f => ` ${f.severity === 'block' ? '✗' : '⚠'} ${f.message}${f.file ? ` [${f.file}]` : ''}`)
|
|
267
|
-
: null,
|
|
268
|
-
'No role violations found');
|
|
269
|
-
section(out, 'Evidence Verification',
|
|
270
|
-
evidence && evidence.length > 0
|
|
271
|
-
? evidence.map(f => ` ${f.severity === 'block' ? '✗' : '⚠'} ${f.message} (${f.source})`)
|
|
272
|
-
: null,
|
|
273
|
-
'No outcome evidence issues found');
|
|
274
|
-
section(out, 'Token Waste',
|
|
275
|
-
tokenWaste && tokenWaste.length > 0 ? tokenWaste.map(f => ` ⚠ ${f.message}`) : null,
|
|
276
|
-
'HEAD dispatch ratio is healthy');
|
|
277
|
-
const { verdict, issueCount, blockingCount } = summary;
|
|
278
|
-
const label = verdict === 'pass' ? 'PASS' :
|
|
279
|
-
verdict === 'issues' ? `ISSUES (${issueCount} warning${issueCount === 1 ? '' : 's'})` :
|
|
280
|
-
`FAIL (${blockingCount} blocking)`;
|
|
281
|
-
out.push(`Doctor verdict: ${label}`);
|
|
282
|
-
|
|
283
|
-
return out.join('\n');
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ─── Health Manifest Runner ───────────────────────────────────────────────────
|
|
287
|
-
function atomicWrite(path, data) {
|
|
288
|
-
const tmp = path + '.tmp';
|
|
289
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
290
|
-
renameSync(tmp, path);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function runVerification(item) {
|
|
294
|
-
const v = item.verification || {};
|
|
295
|
-
if (!v.command) return { status: 'untested', detail: '' };
|
|
296
|
-
try {
|
|
297
|
-
const output = execSync(v.command, { timeout: 15000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
298
|
-
const ok = v.expect ? output.includes(v.expect) : output.includes('OK');
|
|
299
|
-
return { status: ok ? 'pass' : 'fail', detail: ok ? '' : output.trim().slice(0, 200) };
|
|
300
|
-
} catch (err) {
|
|
301
|
-
return { status: 'fail', detail: (err.stderr || err.stdout || err.message || '').toString().trim().slice(0, 200) };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function domainStats(items) {
|
|
306
|
-
const domains = {};
|
|
307
|
-
for (const item of items) {
|
|
308
|
-
const d = item.domain || 'other';
|
|
309
|
-
if (!domains[d]) domains[d] = { score: 0, total: 0, passed: 0, wt: 0, wp: 0 };
|
|
310
|
-
const w = item.weight || 1;
|
|
311
|
-
domains[d].total++; domains[d].wt += w;
|
|
312
|
-
if (item.status === 'pass') { domains[d].passed++; domains[d].wp += w; }
|
|
313
|
-
}
|
|
314
|
-
for (const d of Object.keys(domains)) {
|
|
315
|
-
const { wp, wt } = domains[d];
|
|
316
|
-
domains[d].score = wt > 0 ? Math.round((wp / wt) * 100) : 0;
|
|
317
|
-
delete domains[d].wt; delete domains[d].wp;
|
|
318
|
-
}
|
|
319
|
-
return domains;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export async function runHealthCheck(cwd = process.cwd(), mode = 'quick') {
|
|
323
|
-
const mpath = join(cwd, '.dualbrain', 'health-manifest.json');
|
|
324
|
-
const manifest = existsSync(mpath) ? (() => { try { return JSON.parse(readFileSync(mpath, 'utf8')); } catch { return null; } })() : null;
|
|
325
|
-
const items = manifest ? (manifest.items || []) : [];
|
|
326
|
-
const checkedAt = new Date().toISOString();
|
|
327
|
-
let wt = 0, wp = 0, passed = 0, failed = 0, untested = 0;
|
|
328
|
-
const findings = [];
|
|
329
|
-
|
|
330
|
-
for (const item of items) {
|
|
331
|
-
const isCmd = (item.verification || {}).type === 'command';
|
|
332
|
-
const w = item.weight || 1;
|
|
333
|
-
wt += w;
|
|
334
|
-
if (isCmd) {
|
|
335
|
-
const r = runVerification(item);
|
|
336
|
-
item.status = r.status; item.lastChecked = checkedAt;
|
|
337
|
-
if (r.status === 'pass') { passed++; wp += w; } else failed++;
|
|
338
|
-
findings.push({ id: item.id, name: item.name, domain: item.domain || 'other', severity: item.severity || 'medium', status: r.status, detail: r.detail || '' });
|
|
339
|
-
} else {
|
|
340
|
-
item.status = item.status || 'untested'; untested++;
|
|
341
|
-
findings.push({ id: item.id, name: item.name, domain: item.domain || 'other', severity: item.severity || 'medium', status: 'untested', detail: '' });
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const score = wt > 0 ? Math.round((wp / wt) * 100) : 0;
|
|
346
|
-
if (manifest) atomicWrite(mpath, { ...manifest, items, updatedAt: checkedAt });
|
|
347
|
-
return {
|
|
348
|
-
score, total: items.length, passed, failed, untested, findings,
|
|
349
|
-
domains: domainStats(items),
|
|
350
|
-
staticChecks: mode === 'full' ? await runDoctor(cwd) : null,
|
|
351
|
-
checkedAt,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ─── Health Report Formatter ──────────────────────────────────────────────────
|
|
356
|
-
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
357
|
-
function bar(passed, total, w = 10) {
|
|
358
|
-
const f = total > 0 ? Math.round((passed / total) * w) : 0;
|
|
359
|
-
return '█'.repeat(f) + '░'.repeat(w - f);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
export function formatHealthReport(results) {
|
|
363
|
-
const { score, domains, findings, staticChecks } = results;
|
|
364
|
-
const out = [`🩺 Health Report — ${score}/100`, ''];
|
|
365
|
-
|
|
366
|
-
for (const [domain, d] of Object.entries(domains)) {
|
|
367
|
-
const hasUntested = findings.some(f => f.domain === domain && f.status === 'untested');
|
|
368
|
-
out.push(` ${domain.padEnd(12)} ${bar(d.passed, d.total)} ${d.passed}/${d.total}${hasUntested ? ' (manual)' : ''}`);
|
|
369
|
-
}
|
|
370
|
-
out.push('');
|
|
371
|
-
|
|
372
|
-
const failed = findings.filter(f => f.status === 'fail' || f.status === 'error');
|
|
373
|
-
for (const f of failed) {
|
|
374
|
-
const detail = f.detail ? ` — ${f.detail.split('\n')[0].slice(0, 80)}` : '';
|
|
375
|
-
out.push(` ✗ FAIL: ${f.domain}.${f.id}${detail}`);
|
|
376
|
-
}
|
|
377
|
-
if (failed.length > 0) {
|
|
378
|
-
out.push('');
|
|
379
|
-
const top = [...failed].sort((a, b) => (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9)).slice(0, 3);
|
|
380
|
-
out.push(' Top priorities:');
|
|
381
|
-
top.forEach((f, i) => out.push(` ${i + 1}. Fix ${f.domain}.${f.id} (${f.severity})`));
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (staticChecks) out.push('', ' Static checks: ' + (staticChecks.summary?.verdict || 'unknown'));
|
|
385
|
-
return out.join('\n');
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ─── Pipeline Stage: Diagnose ─────────────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Pipeline-compatible diagnostic check. Called before execution to surface
|
|
392
|
-
* blocking or advisory findings based on the current pipeline run context.
|
|
393
|
-
*
|
|
394
|
-
* @param {object} run - PipelineRun object
|
|
395
|
-
* @param {object} run.context - Context pack (prompt, files, detection, profile, cwd)
|
|
396
|
-
* @param {object[]} run.failureHistory - Prior failures for this prompt fingerprint
|
|
397
|
-
* @param {object[]} run.priorOutcomes - Recent outcome records
|
|
398
|
-
* @param {object} run.plan - Execution plan (may be null before buildExecutionPlan)
|
|
399
|
-
* @returns {Promise<{
|
|
400
|
-
* findings: Array<{check: string, severity: string, message: string}>,
|
|
401
|
-
* canProceed: boolean,
|
|
402
|
-
* suggestedFixes: string[],
|
|
403
|
-
* blockedApproaches: string[]
|
|
404
|
-
* }>}
|
|
405
|
-
*/
|
|
406
|
-
export async function doctorDiagnose(run) {
|
|
407
|
-
const { context = {}, failureHistory = [], priorOutcomes = [], plan = null } = run;
|
|
408
|
-
const cwd = context.cwd ?? process.cwd();
|
|
409
|
-
|
|
410
|
-
const findings = [];
|
|
411
|
-
const suggestedFixes = [];
|
|
412
|
-
|
|
413
|
-
// ── Role boundary check: pull from audit log ──────────────────────────────
|
|
414
|
-
const roleBoundaries = await checkRoleBoundaries(cwd);
|
|
415
|
-
for (const rb of roleBoundaries) {
|
|
416
|
-
findings.push({ check: 'role-boundaries', severity: rb.severity, message: rb.message });
|
|
417
|
-
}
|
|
418
|
-
if (roleBoundaries.length > 0) {
|
|
419
|
-
suggestedFixes.push('Dispatch search/work agents instead of using Read/Write/Bash directly from HEAD.');
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// ── Evidence integrity check ──────────────────────────────────────────────
|
|
423
|
-
const evidenceIssues = await checkEvidence(cwd);
|
|
424
|
-
for (const ev of evidenceIssues) {
|
|
425
|
-
findings.push({ check: 'evidence', severity: ev.severity, message: ev.message });
|
|
426
|
-
}
|
|
427
|
-
if (evidenceIssues.some(e => e.type === 'false-file-claim')) {
|
|
428
|
-
suggestedFixes.push('Verify file claims match actual git state before recording outcomes as successful.');
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// ── Token waste check ─────────────────────────────────────────────────────
|
|
432
|
-
const wasteIssues = await checkTokenWaste(cwd);
|
|
433
|
-
for (const tw of wasteIssues) {
|
|
434
|
-
findings.push({ check: 'token-waste', severity: tw.severity, message: tw.message });
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// ── Foundation integrity check ────────────────────────────────────────────
|
|
438
|
-
const { issues: foundationIssues } = await checkFoundations(cwd);
|
|
439
|
-
for (const fi of foundationIssues) {
|
|
440
|
-
if (fi.type === 'dependent-on-invalidated') {
|
|
441
|
-
findings.push({
|
|
442
|
-
check: 'foundations',
|
|
443
|
-
severity: 'block',
|
|
444
|
-
message: `Active work depends on invalidated foundation "${fi.invalidatedFoundation}" via ${fi.file.join(', ')}`,
|
|
445
|
-
});
|
|
446
|
-
suggestedFixes.push(`Resolve dependency on invalidated foundation "${fi.invalidatedFoundation}" before proceeding.`);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// ── Repeated failure detection ────────────────────────────────────────────
|
|
451
|
-
const repeatFailures = failureHistory.filter(f => !f.resolved);
|
|
452
|
-
if (repeatFailures.length >= 2) {
|
|
453
|
-
findings.push({
|
|
454
|
-
check: 'failure-history',
|
|
455
|
-
severity: 'block',
|
|
456
|
-
message: `${repeatFailures.length} unresolved prior failures for this prompt — repeated approach likely to fail again.`,
|
|
457
|
-
});
|
|
458
|
-
suggestedFixes.push('Escalate to dual-brain think flow before retrying. Prior approaches must not be repeated.');
|
|
459
|
-
} else if (repeatFailures.length === 1) {
|
|
460
|
-
findings.push({
|
|
461
|
-
check: 'failure-history',
|
|
462
|
-
severity: 'warn',
|
|
463
|
-
message: '1 prior failure for this prompt — verify the approach differs before proceeding.',
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// ── Risk/plan consistency check ───────────────────────────────────────────
|
|
468
|
-
if (plan && context.detection) {
|
|
469
|
-
const { risk } = context.detection;
|
|
470
|
-
if (risk === 'critical' && !plan.useChallenger) {
|
|
471
|
-
findings.push({
|
|
472
|
-
check: 'plan-consistency',
|
|
473
|
-
severity: 'warn',
|
|
474
|
-
message: 'Critical-risk task routed without challenger — dual-brain think is recommended.',
|
|
475
|
-
});
|
|
476
|
-
suggestedFixes.push('Enable challenger or run dual-brain think before executing critical-risk tasks.');
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// ── Derive blocked approaches from failure history ────────────────────────
|
|
481
|
-
const blockedApproaches = repeatFailures
|
|
482
|
-
.filter(f => f.approach)
|
|
483
|
-
.map(f => f.approach);
|
|
484
|
-
|
|
485
|
-
const canProceed = !findings.some(f => f.severity === 'block');
|
|
486
|
-
|
|
487
|
-
return { findings, canProceed, suggestedFixes, blockedApproaches };
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ─── Pipeline Stage: Recover ──────────────────────────────────────────────────
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Pipeline-compatible recovery proposer. Called when pipeline execution fails.
|
|
494
|
-
* Returns a recovery proposal for the pipeline to route — never executes directly.
|
|
495
|
-
*
|
|
496
|
-
* @param {object} run - PipelineRun object (same shape as doctorDiagnose)
|
|
497
|
-
* @param {object} failure - Failure context from the failed execution
|
|
498
|
-
* @param {string} [failure.error] - Error message
|
|
499
|
-
* @param {string} [failure.approach] - What was attempted
|
|
500
|
-
* @param {string} [failure.tier] - Tier that failed ('search'|'execute'|'think')
|
|
501
|
-
* @param {number} [failure.failCount] - How many times this has failed
|
|
502
|
-
* @returns {Promise<{
|
|
503
|
-
* proposal: string,
|
|
504
|
-
* avoidApproaches: string[],
|
|
505
|
-
* escalation: string|null
|
|
506
|
-
* }>}
|
|
507
|
-
*/
|
|
508
|
-
export async function doctorRecover(run, failure = {}) {
|
|
509
|
-
const { failureHistory = [] } = run;
|
|
510
|
-
const { error = '', approach = '', tier = 'execute', failCount = 1 } = failure;
|
|
511
|
-
|
|
512
|
-
// Collect all previously failed approaches from history + this failure
|
|
513
|
-
const avoidApproaches = [
|
|
514
|
-
...failureHistory.filter(f => f.approach).map(f => f.approach),
|
|
515
|
-
...(approach ? [approach] : []),
|
|
516
|
-
].filter(Boolean);
|
|
517
|
-
|
|
518
|
-
// Determine escalation: 2+ failures → dual-brain think
|
|
519
|
-
const totalFailures = failureHistory.filter(f => !f.resolved).length + 1;
|
|
520
|
-
const escalation = totalFailures >= 2 ? 'dual-brain' : null;
|
|
521
|
-
|
|
522
|
-
// Build a concrete recovery proposal without implementing anything
|
|
523
|
-
const proposalParts = [];
|
|
524
|
-
|
|
525
|
-
if (escalation === 'dual-brain') {
|
|
526
|
-
proposalParts.push(
|
|
527
|
-
`Escalate to dual-brain think flow: ${totalFailures} failures indicate the approach is fundamentally flawed.`,
|
|
528
|
-
'Run: node .claude/hooks/dual-brain-think.mjs --question "<revised problem statement>"',
|
|
529
|
-
'Do not retry the same implementation path.',
|
|
530
|
-
);
|
|
531
|
-
} else {
|
|
532
|
-
if (tier === 'search') {
|
|
533
|
-
proposalParts.push('Retry search with narrower scope or different file patterns.');
|
|
534
|
-
} else if (tier === 'execute') {
|
|
535
|
-
proposalParts.push(
|
|
536
|
-
'Re-route through execute tier with a revised task description.',
|
|
537
|
-
error ? `Prior error was: ${error.slice(0, 120)}` : '',
|
|
538
|
-
);
|
|
539
|
-
} else if (tier === 'think') {
|
|
540
|
-
proposalParts.push('Re-run think tier with more context or an explicit constraint list.');
|
|
541
|
-
} else {
|
|
542
|
-
proposalParts.push('Retry with a revised task description that avoids the failed approach.');
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
if (avoidApproaches.length > 0) {
|
|
546
|
-
proposalParts.push(`Explicitly exclude these approaches: ${avoidApproaches.join(', ')}`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const proposal = proposalParts.filter(Boolean).join(' ');
|
|
551
|
-
|
|
552
|
-
return { proposal, avoidApproaches, escalation };
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// ─── VERIFY System ────────────────────────────────────────────────────────────
|
|
556
|
-
|
|
557
|
-
// TTL constants (ms)
|
|
558
|
-
const TTL_RUNTIME = 5 * 60 * 1000; // 5 minutes — env/key checks
|
|
559
|
-
const TTL_TOOL = 24 * 60 * 60 * 1000; // 24 hours — installed tool checks
|
|
560
|
-
const TTL_REGISTRY = 7 * 24 * 60 * 60 * 1000; // 7 days — registry freshness
|
|
561
|
-
|
|
562
|
-
const VERIFIERS = {
|
|
563
|
-
'claude-available': { ttl: TTL_TOOL, fn: () => {
|
|
564
|
-
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'claude CLI found', probe: 'which claude' }; }
|
|
565
|
-
catch { return { status: 'failed', evidence: 'claude CLI not found', probe: 'which claude' }; }
|
|
566
|
-
}},
|
|
567
|
-
'openai-key': { ttl: TTL_TOOL, fn: () => {
|
|
568
|
-
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'codex CLI found (subscription auth)', probe: 'which codex' }; }
|
|
569
|
-
catch { return { status: 'failed', evidence: 'codex CLI not found — run: codex login', probe: 'which codex' }; }
|
|
570
|
-
}},
|
|
571
|
-
'anthropic-key': { ttl: TTL_TOOL, fn: () => {
|
|
572
|
-
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'claude CLI found (subscription auth)', probe: 'which claude' }; }
|
|
573
|
-
catch { return { status: 'failed', evidence: 'claude CLI not found — run: claude login', probe: 'which claude' }; }
|
|
574
|
-
}},
|
|
575
|
-
'git-available': { ttl: TTL_TOOL, fn: () => {
|
|
576
|
-
try { const v = execSync('git --version', { stdio: 'pipe', timeout: 2000 }).toString().trim(); return { status: 'verified', evidence: v, probe: 'git --version' }; }
|
|
577
|
-
catch { return { status: 'failed', evidence: 'git not found', probe: 'git --version' }; }
|
|
578
|
-
}},
|
|
579
|
-
'npm-auth': { ttl: TTL_RUNTIME, fn: () => {
|
|
580
|
-
try { const who = execSync('npm whoami', { stdio: 'pipe', timeout: 5000 }).toString().trim(); return { status: 'verified', evidence: `logged in as ${who}`, probe: 'npm whoami' }; }
|
|
581
|
-
catch { return { status: 'failed', evidence: 'npm auth failed', probe: 'npm whoami' }; }
|
|
582
|
-
}},
|
|
583
|
-
'database-reachable': { ttl: TTL_RUNTIME, fn: () => {
|
|
584
|
-
const url = process.env.DATABASE_URL;
|
|
585
|
-
if (!url) return { status: 'failed', evidence: 'DATABASE_URL not set', probe: 'env check' };
|
|
586
|
-
return { status: 'verified', evidence: 'DATABASE_URL configured (not connection-tested)', probe: 'env check' };
|
|
587
|
-
}},
|
|
588
|
-
'codex-available': { ttl: TTL_TOOL, fn: () => {
|
|
589
|
-
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'codex CLI found', probe: 'which codex' }; }
|
|
590
|
-
catch { return { status: 'failed', evidence: 'codex CLI not found', probe: 'which codex' }; }
|
|
591
|
-
}},
|
|
592
|
-
'rg-available': { ttl: TTL_TOOL, fn: () => {
|
|
593
|
-
try { execSync('which rg', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'ripgrep found', probe: 'which rg' }; }
|
|
594
|
-
catch { return { status: 'failed', evidence: 'ripgrep not found', probe: 'which rg' }; }
|
|
595
|
-
}},
|
|
596
|
-
'living-docs-init': { ttl: TTL_RUNTIME, fn: (cwd) => {
|
|
597
|
-
const exists = existsSync(join(cwd || process.cwd(), '.dualbrain'));
|
|
598
|
-
return { status: exists ? 'verified' : 'failed', evidence: exists ? '.dualbrain/ exists' : '.dualbrain/ not initialized', probe: 'fs check' };
|
|
599
|
-
}},
|
|
600
|
-
'model-registry-fresh': { ttl: TTL_REGISTRY, fn: () => {
|
|
601
|
-
try {
|
|
602
|
-
const age = Math.floor((Date.now() - new Date('2026-05-15').getTime()) / 86400000);
|
|
603
|
-
return { status: age <= 30 ? 'verified' : 'failed', evidence: `Registry ${age} days old`, probe: 'registry age check' };
|
|
604
|
-
} catch { return { status: 'unknown', evidence: 'Could not check', probe: 'registry age' }; }
|
|
605
|
-
}},
|
|
606
|
-
};
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* verify(claim, cwd) — test a single assumption by claim identifier.
|
|
610
|
-
* Returns a verification result object with status, evidence, probe, and timestamps.
|
|
611
|
-
*/
|
|
612
|
-
export function verify(claim, cwd) {
|
|
613
|
-
const checkedAt = new Date().toISOString();
|
|
614
|
-
const verifier = VERIFIERS[claim];
|
|
615
|
-
if (!verifier) {
|
|
616
|
-
const expiresAt = new Date(Date.now() + TTL_RUNTIME).toISOString();
|
|
617
|
-
return { claim, status: 'unknown', evidence: `No verifier registered for "${claim}"`, checkedAt, expiresAt, probe: 'none' };
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
const result = verifier.fn(cwd);
|
|
621
|
-
const expiresAt = new Date(Date.now() + verifier.ttl).toISOString();
|
|
622
|
-
return { claim, status: result.status, evidence: result.evidence, checkedAt, expiresAt, probe: result.probe };
|
|
623
|
-
} catch (err) {
|
|
624
|
-
const expiresAt = new Date(Date.now() + TTL_RUNTIME).toISOString();
|
|
625
|
-
return { claim, status: 'unknown', evidence: `Verifier threw: ${err.message || String(err)}`, checkedAt, expiresAt, probe: 'error' };
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* verifyAll(cwd) — run all registered verifiers and append results to .dualbrain/verifications.jsonl.
|
|
631
|
-
* Returns array of verification result objects.
|
|
632
|
-
*/
|
|
633
|
-
export function verifyAll(cwd = process.cwd()) {
|
|
634
|
-
const results = Object.keys(VERIFIERS).map(claim => verify(claim, cwd));
|
|
635
|
-
|
|
636
|
-
// Persist to .dualbrain/verifications.jsonl (append-only)
|
|
637
|
-
try {
|
|
638
|
-
const dir = join(cwd, '.dualbrain');
|
|
639
|
-
if (existsSync(dir)) {
|
|
640
|
-
const logPath = join(dir, 'verifications.jsonl');
|
|
641
|
-
const lines = results.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
642
|
-
appendFileSync(logPath, lines, 'utf8');
|
|
643
|
-
}
|
|
644
|
-
} catch { /* storage failure is non-fatal */ }
|
|
645
|
-
|
|
646
|
-
return results;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* getVerificationCache(cwd) — read .dualbrain/verifications.jsonl, return most recent
|
|
651
|
-
* non-expired result per claim. Expired entries are skipped.
|
|
652
|
-
*/
|
|
653
|
-
export function getVerificationCache(cwd = process.cwd()) {
|
|
654
|
-
const logPath = join(cwd, '.dualbrain', 'verifications.jsonl');
|
|
655
|
-
if (!existsSync(logPath)) return [];
|
|
656
|
-
|
|
657
|
-
let lines;
|
|
658
|
-
try { lines = readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean); }
|
|
659
|
-
catch { return []; }
|
|
660
|
-
|
|
661
|
-
const now = new Date().toISOString();
|
|
662
|
-
const latest = {};
|
|
663
|
-
|
|
664
|
-
for (const line of lines) {
|
|
665
|
-
let entry;
|
|
666
|
-
try { entry = JSON.parse(line); } catch { continue; }
|
|
667
|
-
if (!entry.claim || !entry.expiresAt) continue;
|
|
668
|
-
if (entry.expiresAt < now) continue; // expired — skip
|
|
669
|
-
// Keep the most recent non-expired entry per claim
|
|
670
|
-
if (!latest[entry.claim] || entry.checkedAt > latest[entry.claim].checkedAt) {
|
|
671
|
-
latest[entry.claim] = entry;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
return Object.values(latest);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* getStaleAssumptions(cwd) — return claims that are expired or failed.
|
|
680
|
-
* Checks cache first; any claim not in cache (or failed in cache) is considered stale.
|
|
681
|
-
*/
|
|
682
|
-
export function getStaleAssumptions(cwd = process.cwd()) {
|
|
683
|
-
const cached = getVerificationCache(cwd);
|
|
684
|
-
const cachedMap = Object.fromEntries(cached.map(r => [r.claim, r]));
|
|
685
|
-
const stale = [];
|
|
686
|
-
|
|
687
|
-
for (const claim of Object.keys(VERIFIERS)) {
|
|
688
|
-
const entry = cachedMap[claim];
|
|
689
|
-
if (!entry) {
|
|
690
|
-
// No valid cached result — treat as stale
|
|
691
|
-
stale.push({ claim, reason: 'no-cache', status: 'unknown', evidence: 'Never verified or all results expired' });
|
|
692
|
-
} else if (entry.status === 'failed') {
|
|
693
|
-
stale.push({ claim, reason: 'failed', status: 'failed', evidence: entry.evidence, checkedAt: entry.checkedAt });
|
|
694
|
-
}
|
|
695
|
-
// 'verified' and 'unknown' with valid cache are not stale
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return stale;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* formatVerifications(results) — display string for a list of verification results.
|
|
703
|
-
*/
|
|
704
|
-
export function formatVerifications(results) {
|
|
705
|
-
const lines = ['SYSTEM VERIFICATION'];
|
|
706
|
-
for (const r of results) {
|
|
707
|
-
const icon = r.status === 'verified' ? '✓' : r.status === 'failed' ? '✗' : '⚠';
|
|
708
|
-
lines.push(` ${icon} ${r.claim}: ${r.evidence}`);
|
|
709
|
-
}
|
|
710
|
-
return lines.join('\n');
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// ─── LEARN System ─────────────────────────────────────────────────────────────
|
|
714
|
-
|
|
715
|
-
const THINK_TIER_MODELS = new Set(['claude-opus-4-6', 'o3', 'gpt-5.5']);
|
|
716
|
-
const FAST_TIER_MODELS = new Set(['claude-haiku-4-5-20251001', 'gpt-4o-mini']);
|
|
717
|
-
const CODE_TASK_TYPES = new Set(['fix', 'feature', 'refactor', 'implement', 'test', 'build', 'edit']);
|
|
718
|
-
const REASONING_MODELS = new Set(['o3']);
|
|
719
|
-
|
|
720
|
-
function learningsPath(cwd) {
|
|
721
|
-
return join(cwd, '.dualbrain', 'learnings.jsonl');
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
function readLearnings(cwd) {
|
|
725
|
-
const p = learningsPath(cwd);
|
|
726
|
-
if (!existsSync(p)) return [];
|
|
727
|
-
try {
|
|
728
|
-
return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).flatMap(line => {
|
|
729
|
-
try { return [JSON.parse(line)]; } catch { return []; }
|
|
730
|
-
});
|
|
731
|
-
} catch { return []; }
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
function deriveModelFit(taskResult) {
|
|
735
|
-
const { success, model, tier, taskType, duration, filesChanged } = taskResult;
|
|
736
|
-
const isThinkModel = THINK_TIER_MODELS.has(model);
|
|
737
|
-
const isFastModel = FAST_TIER_MODELS.has(model);
|
|
738
|
-
const isReasoningModel = REASONING_MODELS.has(model);
|
|
739
|
-
const isCodeTask = CODE_TASK_TYPES.has(taskType);
|
|
740
|
-
|
|
741
|
-
if (isReasoningModel && isCodeTask) return 'wrong_type';
|
|
742
|
-
if (!isCodeTask && !isReasoningModel && isThinkModel && tier === 'search') return 'wrong_type';
|
|
743
|
-
|
|
744
|
-
if (!success) {
|
|
745
|
-
if (isFastModel && tier !== 'search') return 'underpowered';
|
|
746
|
-
return 'good';
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (isThinkModel && (tier === 'search' || (filesChanged <= 1 && duration < 30000))) return 'overkill';
|
|
750
|
-
if (isFastModel && filesChanged > 3) return 'underpowered';
|
|
751
|
-
|
|
752
|
-
return 'good';
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
function deriveRoutingAccuracy(taskResult) {
|
|
756
|
-
const { success, modelFit, tier, duration, model } = taskResult;
|
|
757
|
-
const isFastModel = FAST_TIER_MODELS.has(model);
|
|
758
|
-
const isThinkModel = THINK_TIER_MODELS.has(model);
|
|
759
|
-
|
|
760
|
-
if (success && (modelFit === 'good' || modelFit === 'wrong_type')) return 'correct';
|
|
761
|
-
if (!success && isFastModel && tier !== 'search') return 'should_have_escalated';
|
|
762
|
-
if (success && isThinkModel && duration > 120000 && modelFit === 'overkill') return 'should_have_simplified';
|
|
763
|
-
if (success && modelFit === 'overkill') return 'should_have_simplified';
|
|
764
|
-
if (!success) return 'should_have_escalated';
|
|
765
|
-
return 'correct';
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
export function recordLearning(taskResult, cwd = process.cwd()) {
|
|
769
|
-
try {
|
|
770
|
-
const {
|
|
771
|
-
taskType = 'unknown',
|
|
772
|
-
prompt = '',
|
|
773
|
-
model = '',
|
|
774
|
-
provider = '',
|
|
775
|
-
tier = '',
|
|
776
|
-
reasoningDepth = 'low',
|
|
777
|
-
wasEnriched = false,
|
|
778
|
-
wasDualBrain = false,
|
|
779
|
-
success = false,
|
|
780
|
-
duration = 0,
|
|
781
|
-
filesChanged = 0,
|
|
782
|
-
} = taskResult;
|
|
783
|
-
|
|
784
|
-
const modelFit = deriveModelFit({ success, model, tier, taskType, duration, filesChanged });
|
|
785
|
-
|
|
786
|
-
const record = {
|
|
787
|
-
id: `learn_${Date.now()}`,
|
|
788
|
-
timestamp: new Date().toISOString(),
|
|
789
|
-
taskType,
|
|
790
|
-
prompt: String(prompt).slice(0, 200),
|
|
791
|
-
model,
|
|
792
|
-
provider,
|
|
793
|
-
tier,
|
|
794
|
-
reasoningDepth,
|
|
795
|
-
wasEnriched,
|
|
796
|
-
wasDualBrain,
|
|
797
|
-
success,
|
|
798
|
-
duration,
|
|
799
|
-
filesChanged,
|
|
800
|
-
modelFit,
|
|
801
|
-
routingAccuracy: deriveRoutingAccuracy({ success, modelFit, tier, duration, model }),
|
|
802
|
-
};
|
|
803
|
-
|
|
804
|
-
const p = learningsPath(cwd);
|
|
805
|
-
const dir = join(cwd, '.dualbrain');
|
|
806
|
-
if (existsSync(dir)) {
|
|
807
|
-
appendFileSync(p, JSON.stringify(record) + '\n', 'utf8');
|
|
808
|
-
}
|
|
809
|
-
return record;
|
|
810
|
-
} catch { return null; }
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
export function getModelSuccessRates(cwd = process.cwd(), days = 7) {
|
|
814
|
-
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
815
|
-
const learnings = readLearnings(cwd).filter(l => l.timestamp >= cutoff);
|
|
816
|
-
|
|
817
|
-
const stats = {};
|
|
818
|
-
for (const l of learnings) {
|
|
819
|
-
if (!l.model) continue;
|
|
820
|
-
if (!stats[l.model]) stats[l.model] = { total: 0, success: 0, totalDuration: 0, tierCounts: {} };
|
|
821
|
-
stats[l.model].total += 1;
|
|
822
|
-
if (l.success) stats[l.model].success += 1;
|
|
823
|
-
stats[l.model].totalDuration += (l.duration || 0);
|
|
824
|
-
const tierKey = `${l.tier || 'unknown'}:${l.taskType || 'unknown'}`;
|
|
825
|
-
stats[l.model].tierCounts[tierKey] = (stats[l.model].tierCounts[tierKey] || 0) + 1;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const result = {};
|
|
829
|
-
for (const [model, s] of Object.entries(stats)) {
|
|
830
|
-
const topTiers = Object.entries(s.tierCounts)
|
|
831
|
-
.sort((a, b) => b[1] - a[1])
|
|
832
|
-
.slice(0, 3)
|
|
833
|
-
.map(([key]) => key.split(':')[0] + ':' + key.split(':')[1]);
|
|
834
|
-
result[model] = {
|
|
835
|
-
total: s.total,
|
|
836
|
-
success: s.success,
|
|
837
|
-
rate: s.total > 0 ? Math.round((s.success / s.total) * 100) / 100 : 0,
|
|
838
|
-
avgDuration: s.total > 0 ? Math.round(s.totalDuration / s.total) : 0,
|
|
839
|
-
bestFor: [...new Set(topTiers.map(t => t.split(':')[0]))],
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
return result;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
export function getRoutingInsights(cwd = process.cwd()) {
|
|
846
|
-
const learnings = readLearnings(cwd);
|
|
847
|
-
if (learnings.length === 0) return [];
|
|
848
|
-
|
|
849
|
-
const insights = [];
|
|
850
|
-
const MIN_POINTS = 5;
|
|
851
|
-
|
|
852
|
-
const byModelTask = {};
|
|
853
|
-
for (const l of learnings) {
|
|
854
|
-
const key = `${l.model}:${l.taskType}`;
|
|
855
|
-
if (!byModelTask[key]) byModelTask[key] = { success: 0, total: 0, overkill: 0, underpowered: 0 };
|
|
856
|
-
byModelTask[key].total += 1;
|
|
857
|
-
if (l.success) byModelTask[key].success += 1;
|
|
858
|
-
if (l.modelFit === 'overkill') byModelTask[key].overkill += 1;
|
|
859
|
-
if (l.modelFit === 'underpowered') byModelTask[key].underpowered += 1;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
for (const [key, s] of Object.entries(byModelTask)) {
|
|
863
|
-
if (s.total < MIN_POINTS) continue;
|
|
864
|
-
const [model, taskType] = key.split(':');
|
|
865
|
-
const rate = s.success / s.total;
|
|
866
|
-
const overkillRate = s.overkill / s.total;
|
|
867
|
-
const underpoweredRate = s.underpowered / s.total;
|
|
868
|
-
|
|
869
|
-
if (rate >= 0.9 && overkillRate < 0.1) {
|
|
870
|
-
insights.push({
|
|
871
|
-
insight: `${model} succeeds ${Math.round(rate * 100)}% on ${taskType} tasks — reliable for this work`,
|
|
872
|
-
confidence: Math.min(0.95, 0.6 + s.total * 0.01),
|
|
873
|
-
evidence: `${s.success}/${s.total} tasks`,
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
if (overkillRate > 0.3 && rate >= 0.85) {
|
|
878
|
-
insights.push({
|
|
879
|
-
insight: `${model} is overkill for ${taskType} — a cheaper model likely sufficient`,
|
|
880
|
-
confidence: Math.min(0.9, 0.5 + s.total * 0.01),
|
|
881
|
-
evidence: `${s.overkill}/${s.total} tasks flagged overkill`,
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (underpoweredRate > 0.3 || rate < 0.7) {
|
|
886
|
-
insights.push({
|
|
887
|
-
insight: `${model} struggles on ${taskType} (${Math.round(rate * 100)}% success) — consider escalating`,
|
|
888
|
-
confidence: Math.min(0.9, 0.5 + s.total * 0.01),
|
|
889
|
-
evidence: `${s.success}/${s.total} tasks`,
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const enriched = learnings.filter(l => l.wasEnriched);
|
|
895
|
-
const notEnriched = learnings.filter(l => !l.wasEnriched);
|
|
896
|
-
if (enriched.length >= MIN_POINTS && notEnriched.length >= MIN_POINTS) {
|
|
897
|
-
const rateEnriched = enriched.filter(l => l.success).length / enriched.length;
|
|
898
|
-
const rateNotEnriched = notEnriched.filter(l => l.success).length / notEnriched.length;
|
|
899
|
-
const delta = Math.round((rateEnriched - rateNotEnriched) * 100);
|
|
900
|
-
if (Math.abs(delta) >= 10) {
|
|
901
|
-
insights.push({
|
|
902
|
-
insight: delta > 0
|
|
903
|
-
? `Prompt enrichment improved success rate by ${delta}%`
|
|
904
|
-
: `Prompt enrichment had no benefit — success rate ${Math.abs(delta)}% lower`,
|
|
905
|
-
confidence: Math.min(0.9, 0.5 + Math.min(enriched.length, notEnriched.length) * 0.01),
|
|
906
|
-
evidence: `${enriched.length} enriched vs ${notEnriched.length} raw`,
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
const dualBrain = learnings.filter(l => l.wasDualBrain);
|
|
912
|
-
const singleBrain = learnings.filter(l => !l.wasDualBrain);
|
|
913
|
-
if (dualBrain.length >= MIN_POINTS && singleBrain.length >= MIN_POINTS) {
|
|
914
|
-
const rateDual = dualBrain.filter(l => l.success).length / dualBrain.length;
|
|
915
|
-
const rateSingle = singleBrain.filter(l => l.success).length / singleBrain.length;
|
|
916
|
-
const delta = Math.round((rateDual - rateSingle) * 100);
|
|
917
|
-
if (delta >= 10) {
|
|
918
|
-
insights.push({
|
|
919
|
-
insight: `Dual-brain review improves success rate by ${delta}% over single-brain`,
|
|
920
|
-
confidence: Math.min(0.85, 0.5 + Math.min(dualBrain.length, singleBrain.length) * 0.015),
|
|
921
|
-
evidence: `${dualBrain.length} dual vs ${singleBrain.length} single`,
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
return insights;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
export function suggestRoutingAdjustment(taskType, currentModel, cwd = process.cwd()) {
|
|
930
|
-
const learnings = readLearnings(cwd).filter(
|
|
931
|
-
l => l.taskType === taskType && l.model === currentModel
|
|
932
|
-
);
|
|
933
|
-
|
|
934
|
-
if (learnings.length < 5) {
|
|
935
|
-
return { suggestion: 'keep', reason: 'insufficient data', confidence: 0, evidenceCount: learnings.length, suggestedModel: null };
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
const total = learnings.length;
|
|
939
|
-
const successCount = learnings.filter(l => l.success).length;
|
|
940
|
-
const successRate = successCount / total;
|
|
941
|
-
const overkillCount = learnings.filter(l => l.modelFit === 'overkill').length;
|
|
942
|
-
const overkillRate = overkillCount / total;
|
|
943
|
-
|
|
944
|
-
if (successRate > 0.9 && overkillRate > 0.3) {
|
|
945
|
-
const isFastModel = FAST_TIER_MODELS.has(currentModel);
|
|
946
|
-
const isThinkModel = THINK_TIER_MODELS.has(currentModel);
|
|
947
|
-
let suggestedModel = null;
|
|
948
|
-
if (isThinkModel) {
|
|
949
|
-
suggestedModel = currentModel.startsWith('claude') ? 'claude-sonnet-4-6' : 'gpt-4o';
|
|
950
|
-
} else if (!isFastModel) {
|
|
951
|
-
suggestedModel = currentModel.startsWith('claude') ? 'claude-haiku-4-5-20251001' : 'gpt-4o-mini';
|
|
952
|
-
}
|
|
953
|
-
return {
|
|
954
|
-
suggestion: 'simplify',
|
|
955
|
-
reason: `${Math.round(successRate * 100)}% success rate with ${Math.round(overkillRate * 100)}% overkill signal`,
|
|
956
|
-
confidence: Math.min(0.9, 0.5 + total * 0.01),
|
|
957
|
-
evidenceCount: total,
|
|
958
|
-
suggestedModel,
|
|
959
|
-
};
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
if (successRate < 0.7) {
|
|
963
|
-
const isThinkModel = THINK_TIER_MODELS.has(currentModel);
|
|
964
|
-
let suggestedModel = null;
|
|
965
|
-
if (!isThinkModel) {
|
|
966
|
-
suggestedModel = currentModel.startsWith('claude') ? 'claude-opus-4-6' : 'o3';
|
|
967
|
-
}
|
|
968
|
-
return {
|
|
969
|
-
suggestion: 'escalate',
|
|
970
|
-
reason: `${Math.round(successRate * 100)}% success rate on ${taskType} — below acceptable threshold`,
|
|
971
|
-
confidence: Math.min(0.9, 0.5 + total * 0.01),
|
|
972
|
-
evidenceCount: total,
|
|
973
|
-
suggestedModel,
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
return {
|
|
978
|
-
suggestion: 'keep',
|
|
979
|
-
reason: `${Math.round(successRate * 100)}% success rate — routing is appropriate`,
|
|
980
|
-
confidence: Math.min(0.9, 0.5 + total * 0.01),
|
|
981
|
-
evidenceCount: total,
|
|
982
|
-
suggestedModel: null,
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
export function formatLearnings(insights, cwd = process.cwd()) {
|
|
987
|
-
const learnings = readLearnings(cwd);
|
|
988
|
-
const rates = getModelSuccessRates(cwd);
|
|
989
|
-
const total = learnings.length;
|
|
990
|
-
|
|
991
|
-
const lines = [`ROUTING INTELLIGENCE (${total} task${total === 1 ? '' : 's'} analyzed)`];
|
|
992
|
-
|
|
993
|
-
for (const [model, s] of Object.entries(rates)) {
|
|
994
|
-
if (s.total < 3) continue;
|
|
995
|
-
const pct = Math.round(s.rate * 100);
|
|
996
|
-
const tasks = s.bestFor.join('/') || 'various';
|
|
997
|
-
const icon = pct >= 85 ? '📈' : pct >= 70 ? '📊' : '⚠️ ';
|
|
998
|
-
lines.push(` ${icon} ${model}: ${pct}% success on ${tasks} tasks (${s.total} tasks)`);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
for (const ins of (insights || [])) {
|
|
1002
|
-
const pct = Math.round(ins.confidence * 100);
|
|
1003
|
-
const isWarning = ins.insight.toLowerCase().includes('struggle') || ins.insight.toLowerCase().includes('below') || ins.insight.toLowerCase().includes('no benefit');
|
|
1004
|
-
const icon = isWarning ? '⚠️ ' : '💡';
|
|
1005
|
-
lines.push(` ${icon} ${ins.insight}`);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
return lines.join('\n');
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
export function getLearningStats(cwd = process.cwd()) {
|
|
1012
|
-
const learnings = readLearnings(cwd);
|
|
1013
|
-
if (learnings.length === 0) {
|
|
1014
|
-
return { totalLearnings: 0, oldestEntry: null, newestEntry: null, modelsTracked: 0, avgSuccessRate: 0 };
|
|
1015
|
-
}
|
|
1016
|
-
const timestamps = learnings.map(l => l.timestamp).sort();
|
|
1017
|
-
const models = new Set(learnings.map(l => l.model).filter(Boolean));
|
|
1018
|
-
const successCount = learnings.filter(l => l.success).length;
|
|
1019
|
-
return {
|
|
1020
|
-
totalLearnings: learnings.length,
|
|
1021
|
-
oldestEntry: timestamps[0],
|
|
1022
|
-
newestEntry: timestamps[timestamps.length - 1],
|
|
1023
|
-
modelsTracked: models.size,
|
|
1024
|
-
avgSuccessRate: Math.round((successCount / learnings.length) * 100) / 100,
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// ─── DISCOVER System ─────────────────────────────────────────────────────────
|
|
1029
|
-
|
|
1030
|
-
const KNOWN_TOOLS = ['git','node','npm','codex','claude','rg','gh','replit','docker','python','python3','pip','cargo','go','java','ruby','deno','bun','pnpm','yarn'];
|
|
1031
|
-
const STANDARD_AWARENESS = new Set(['git','node','npm','codex','claude','rg','gh','replit']);
|
|
1032
|
-
|
|
1033
|
-
const SERVICE_PATTERNS = {
|
|
1034
|
-
'REDIS_URL': 'Redis',
|
|
1035
|
-
'MONGODB_URI': 'MongoDB',
|
|
1036
|
-
'MONGO_URL': 'MongoDB',
|
|
1037
|
-
'ELASTICSEARCH_URL': 'Elasticsearch',
|
|
1038
|
-
'RABBITMQ_URL': 'RabbitMQ',
|
|
1039
|
-
'S3_BUCKET': 'S3 Storage',
|
|
1040
|
-
'AWS_ACCESS_KEY_ID': 'AWS',
|
|
1041
|
-
'GCP_PROJECT': 'Google Cloud',
|
|
1042
|
-
'STRIPE_SECRET_KEY': 'Stripe',
|
|
1043
|
-
'SENDGRID_API_KEY': 'SendGrid',
|
|
1044
|
-
'TWILIO_ACCOUNT_SID': 'Twilio',
|
|
1045
|
-
'SENTRY_DSN': 'Sentry',
|
|
1046
|
-
'DATADOG_API_KEY': 'Datadog',
|
|
1047
|
-
'SUPABASE_URL': 'Supabase',
|
|
1048
|
-
'FIREBASE_PROJECT_ID': 'Firebase',
|
|
1049
|
-
'NEON_DATABASE_URL': 'Neon DB',
|
|
1050
|
-
};
|
|
1051
|
-
|
|
1052
|
-
const KNOWN_FRAMEWORKS = ['express','next','react','vue','fastify','prisma','drizzle','nestjs','koa','hapi','svelte','nuxt','remix','astro','trpc'];
|
|
1053
|
-
|
|
1054
|
-
function safeExecSyncDiscover(cmd) {
|
|
1055
|
-
try {
|
|
1056
|
-
return execSync(cmd, { timeout: 2000, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
1057
|
-
} catch { return null; }
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function discoverCLITools() {
|
|
1061
|
-
const found = [];
|
|
1062
|
-
for (const tool of KNOWN_TOOLS) {
|
|
1063
|
-
const toolPath = safeExecSyncDiscover(`which ${tool}`);
|
|
1064
|
-
if (toolPath && !STANDARD_AWARENESS.has(tool)) {
|
|
1065
|
-
found.push({ type: 'tool', name: tool, detail: `${tool} CLI available at ${toolPath}`, source: 'PATH scan' });
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
return found;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* discoverMCPTools(cwd) — scan for MCP servers across known config locations.
|
|
1073
|
-
* Returns array of { name, command, args } for each configured MCP server.
|
|
1074
|
-
*/
|
|
1075
|
-
export function discoverMCPTools(cwd = process.cwd()) {
|
|
1076
|
-
const locations = [
|
|
1077
|
-
join(process.env.HOME || '/root', '.claude', 'claude_desktop_config.json'),
|
|
1078
|
-
join(cwd, '.claude', 'settings.json'),
|
|
1079
|
-
join(cwd, '.claude', 'settings.local.json'),
|
|
1080
|
-
];
|
|
1081
|
-
const servers = [];
|
|
1082
|
-
const seen = new Set();
|
|
1083
|
-
for (const loc of locations) {
|
|
1084
|
-
if (!existsSync(loc)) continue;
|
|
1085
|
-
let cfg;
|
|
1086
|
-
try { cfg = JSON.parse(readFileSync(loc, 'utf8')); } catch { continue; }
|
|
1087
|
-
const mcpServers = cfg.mcpServers || (cfg.mcp && cfg.mcp.servers) || {};
|
|
1088
|
-
for (const [name, conf] of Object.entries(mcpServers)) {
|
|
1089
|
-
if (seen.has(name)) continue;
|
|
1090
|
-
seen.add(name);
|
|
1091
|
-
servers.push({ name, command: conf.command || null, args: conf.args || [] });
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return servers;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
function discoverMCPCapabilities(cwd) {
|
|
1098
|
-
const servers = discoverMCPTools(cwd);
|
|
1099
|
-
return servers.map(s => ({
|
|
1100
|
-
type: 'mcp',
|
|
1101
|
-
name: s.name,
|
|
1102
|
-
detail: `MCP server: ${[s.command, ...(s.args || [])].filter(Boolean).join(' ')}`.trim(),
|
|
1103
|
-
source: 'MCP config scan',
|
|
1104
|
-
}));
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function discoverEnvServices() {
|
|
1108
|
-
const found = [];
|
|
1109
|
-
const seen = new Set();
|
|
1110
|
-
for (const [envKey, service] of Object.entries(SERVICE_PATTERNS)) {
|
|
1111
|
-
if (process.env[envKey] !== undefined && !seen.has(service)) {
|
|
1112
|
-
seen.add(service);
|
|
1113
|
-
// Report presence only — NEVER expose values
|
|
1114
|
-
found.push({ type: 'env', name: service, detail: `${service} configured via ${envKey}`, source: 'env scan' });
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
return found;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
function discoverProjectTools(cwd) {
|
|
1121
|
-
const pkgPath = join(cwd, 'package.json');
|
|
1122
|
-
if (!existsSync(pkgPath)) return [];
|
|
1123
|
-
let pkg;
|
|
1124
|
-
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { return []; }
|
|
1125
|
-
|
|
1126
|
-
const found = [];
|
|
1127
|
-
for (const [name] of Object.entries(pkg.scripts || {})) {
|
|
1128
|
-
found.push({ type: 'cli', name: `npm run ${name}`, detail: `Project script: ${name}`, source: 'package.json scripts' });
|
|
1129
|
-
}
|
|
1130
|
-
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
1131
|
-
for (const fw of KNOWN_FRAMEWORKS) {
|
|
1132
|
-
if (allDeps[fw]) {
|
|
1133
|
-
found.push({ type: 'config', name: fw, detail: `${fw} framework detected (${allDeps[fw]})`, source: 'package.json deps' });
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
return found;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
function discoverReplitFeatures(cwd) {
|
|
1140
|
-
const replitPath = join(cwd, '.replit');
|
|
1141
|
-
if (!existsSync(replitPath)) return [];
|
|
1142
|
-
let content;
|
|
1143
|
-
try { content = readFileSync(replitPath, 'utf8'); } catch { return []; }
|
|
1144
|
-
|
|
1145
|
-
const found = [];
|
|
1146
|
-
if (/\[deployment\]/i.test(content))
|
|
1147
|
-
found.push({ type: 'service', name: 'replit-deployment', detail: 'Replit deployment config present', source: '.replit' });
|
|
1148
|
-
if (/\[auth\]/i.test(content))
|
|
1149
|
-
found.push({ type: 'service', name: 'replit-auth', detail: 'Replit auth config present', source: '.replit' });
|
|
1150
|
-
|
|
1151
|
-
const moduleMatch = content.match(/^modules\s*=\s*\[([^\]]+)\]/m);
|
|
1152
|
-
if (moduleMatch) {
|
|
1153
|
-
const modules = moduleMatch[1].split(',').map(m => m.trim().replace(/['"]/g, '')).filter(Boolean);
|
|
1154
|
-
for (const mod of modules) {
|
|
1155
|
-
found.push({ type: 'config', name: `replit-module:${mod}`, detail: `Replit module: ${mod}`, source: '.replit' });
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
const nixChannelPath = join(cwd, '.replit', 'nix', 'channel');
|
|
1160
|
-
if (existsSync(nixChannelPath)) {
|
|
1161
|
-
let channel;
|
|
1162
|
-
try { channel = readFileSync(nixChannelPath, 'utf8').trim(); } catch { channel = 'unknown'; }
|
|
1163
|
-
found.push({ type: 'config', name: 'nix', detail: `Nix channel: ${channel}`, source: '.replit/nix/channel' });
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
return found;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
function loadLastDiscovery(cwd) {
|
|
1170
|
-
const logPath = join(cwd, '.dualbrain', 'discoveries.jsonl');
|
|
1171
|
-
if (!existsSync(logPath)) return null;
|
|
1172
|
-
try {
|
|
1173
|
-
const lines = readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
1174
|
-
if (lines.length === 0) return null;
|
|
1175
|
-
return JSON.parse(lines[lines.length - 1]);
|
|
1176
|
-
} catch { return null; }
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function appendDiscoveryLog(cwd, entry) {
|
|
1180
|
-
const dir = join(cwd, '.dualbrain');
|
|
1181
|
-
try {
|
|
1182
|
-
if (!existsSync(dir)) execSync(`mkdir -p "${dir}"`, { timeout: 2000 });
|
|
1183
|
-
appendFileSync(join(dir, 'discoveries.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
|
|
1184
|
-
} catch { /* graceful degradation */ }
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
/**
|
|
1188
|
-
* discover(cwd) — scan for capabilities not in the standard awareness set.
|
|
1189
|
-
* Returns { discoveredAt, newCapabilities, knownCapabilities, totalFound }.
|
|
1190
|
-
*/
|
|
1191
|
-
export function discover(cwd = process.cwd()) {
|
|
1192
|
-
const discoveredAt = new Date().toISOString();
|
|
1193
|
-
const allFound = [];
|
|
1194
|
-
|
|
1195
|
-
// Each probe is independent — failures don't stop others
|
|
1196
|
-
try { allFound.push(...discoverCLITools()); } catch { /* ignore */ }
|
|
1197
|
-
try { allFound.push(...discoverMCPCapabilities(cwd)); } catch { /* ignore */ }
|
|
1198
|
-
try { allFound.push(...discoverEnvServices()); } catch { /* ignore */ }
|
|
1199
|
-
try { allFound.push(...discoverProjectTools(cwd)); } catch { /* ignore */ }
|
|
1200
|
-
try { allFound.push(...discoverReplitFeatures(cwd)); } catch { /* ignore */ }
|
|
1201
|
-
|
|
1202
|
-
const last = loadLastDiscovery(cwd);
|
|
1203
|
-
const lastNames = new Set(last ? (last.newCapabilities || []).map(c => `${c.type}:${c.name}`) : []);
|
|
1204
|
-
const prevKnown = last ? (last.knownCapabilities || 0) : 0;
|
|
1205
|
-
|
|
1206
|
-
const newCapabilities = allFound.filter(c => !lastNames.has(`${c.type}:${c.name}`));
|
|
1207
|
-
|
|
1208
|
-
const result = {
|
|
1209
|
-
discoveredAt,
|
|
1210
|
-
newCapabilities,
|
|
1211
|
-
knownCapabilities: prevKnown + (allFound.length - newCapabilities.length),
|
|
1212
|
-
totalFound: allFound.length,
|
|
1213
|
-
};
|
|
1214
|
-
|
|
1215
|
-
appendDiscoveryLog(cwd, result);
|
|
1216
|
-
return result;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
/**
|
|
1220
|
-
* getDiscoveryLog(cwd, limit) — read recent discovery entries from .dualbrain/discoveries.jsonl.
|
|
1221
|
-
*/
|
|
1222
|
-
export function getDiscoveryLog(cwd = process.cwd(), limit = 20) {
|
|
1223
|
-
const logPath = join(cwd, '.dualbrain', 'discoveries.jsonl');
|
|
1224
|
-
if (!existsSync(logPath)) return [];
|
|
1225
|
-
try {
|
|
1226
|
-
const lines = readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
1227
|
-
return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
1228
|
-
} catch { return []; }
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
/**
|
|
1232
|
-
* getNewSinceLastScan(cwd) — run discover(), return only capabilities not seen in previous scan.
|
|
1233
|
-
*/
|
|
1234
|
-
export function getNewSinceLastScan(cwd = process.cwd()) {
|
|
1235
|
-
const last = loadLastDiscovery(cwd);
|
|
1236
|
-
const lastNames = new Set(last ? (last.newCapabilities || []).map(c => `${c.type}:${c.name}`) : []);
|
|
1237
|
-
|
|
1238
|
-
const current = discover(cwd);
|
|
1239
|
-
const trulyNew = current.newCapabilities.filter(c => !lastNames.has(`${c.type}:${c.name}`));
|
|
1240
|
-
return { ...current, newCapabilities: trulyNew };
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* formatDiscovery(result) — format discovery result as a human-readable string.
|
|
1245
|
-
*/
|
|
1246
|
-
export function formatDiscovery(result) {
|
|
1247
|
-
const { newCapabilities = [], totalFound = 0 } = result;
|
|
1248
|
-
const newCount = newCapabilities.length;
|
|
1249
|
-
const lines = [`CAPABILITY DISCOVERY (${totalFound} found, ${newCount} new)`];
|
|
1250
|
-
|
|
1251
|
-
for (const cap of newCapabilities) {
|
|
1252
|
-
lines.push(` 🆕 ${cap.type}: ${cap.name} — ${cap.detail}`);
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
const alreadyKnown = totalFound - newCount;
|
|
1256
|
-
if (alreadyKnown > 0) {
|
|
1257
|
-
lines.push(` ── ${alreadyKnown} known capability${alreadyKnown === 1 ? '' : 'ies'} (already tracked)`);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
if (newCount === 0 && totalFound === 0) {
|
|
1261
|
-
lines.push(' (no capabilities detected)');
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
return lines.join('\n');
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// ─── EVENT LEDGER ─────────────────────────────────────────────────────────────
|
|
1268
|
-
// Append-only event log at .dualbrain/doctor/events.jsonl
|
|
1269
|
-
// Event types: check_result, gate_failure, contradiction_caught, agent_drift,
|
|
1270
|
-
// manual_fix, incident, check_proposed, check_promoted, check_demoted, check_sentineled
|
|
1271
|
-
|
|
1272
|
-
function doctorDir(cwd) {
|
|
1273
|
-
return join(cwd || process.cwd(), '.dualbrain', 'doctor');
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function eventsPath(cwd) {
|
|
1277
|
-
return join(doctorDir(cwd), 'events.jsonl');
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
function checksDir(cwd) {
|
|
1281
|
-
return join(doctorDir(cwd), 'checks');
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
function ensureDoctorDir(cwd) {
|
|
1285
|
-
try { mkdirSync(checksDir(cwd), { recursive: true }); } catch { /* ignore */ }
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
/**
|
|
1289
|
-
* recordEvent(event, cwd) — append an event to the doctor event ledger.
|
|
1290
|
-
* Event schema: { ts, type, source, checkId, severity, outcome, evidence, sessionId, release }
|
|
1291
|
-
*/
|
|
1292
|
-
export function recordEvent(event, cwd = process.cwd()) {
|
|
1293
|
-
try {
|
|
1294
|
-
ensureDoctorDir(cwd);
|
|
1295
|
-
const entry = {
|
|
1296
|
-
ts: new Date().toISOString(),
|
|
1297
|
-
type: event.type || 'unknown',
|
|
1298
|
-
source: event.source || 'pipeline',
|
|
1299
|
-
checkId: event.checkId || null,
|
|
1300
|
-
severity: event.severity || null,
|
|
1301
|
-
outcome: event.outcome || null,
|
|
1302
|
-
evidence: event.evidence || null,
|
|
1303
|
-
sessionId: event.sessionId || null,
|
|
1304
|
-
release: event.release || null,
|
|
1305
|
-
...event, // allow extra fields
|
|
1306
|
-
};
|
|
1307
|
-
appendFileSync(eventsPath(cwd), JSON.stringify(entry) + '\n', 'utf8');
|
|
1308
|
-
return entry;
|
|
1309
|
-
} catch { return null; }
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
/**
|
|
1313
|
-
* getRecentEvents(cwd, days) — read events from the last N days.
|
|
1314
|
-
*/
|
|
1315
|
-
export function getRecentEvents(cwd = process.cwd(), days = 7) {
|
|
1316
|
-
const p = eventsPath(cwd);
|
|
1317
|
-
if (!existsSync(p)) return [];
|
|
1318
|
-
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
1319
|
-
try {
|
|
1320
|
-
return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
|
|
1321
|
-
.flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } })
|
|
1322
|
-
.filter(e => e.ts >= cutoff);
|
|
1323
|
-
} catch { return []; }
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
/**
|
|
1327
|
-
* getEventsForCheck(checkId, cwd) — filter ledger events by checkId.
|
|
1328
|
-
*/
|
|
1329
|
-
export function getEventsForCheck(checkId, cwd = process.cwd()) {
|
|
1330
|
-
const p = eventsPath(cwd);
|
|
1331
|
-
if (!existsSync(p)) return [];
|
|
1332
|
-
try {
|
|
1333
|
-
return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
|
|
1334
|
-
.flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } })
|
|
1335
|
-
.filter(e => e.checkId === checkId);
|
|
1336
|
-
} catch { return []; }
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// ─── CHECK REGISTRY ───────────────────────────────────────────────────────────
|
|
1340
|
-
// Each check spec is stored as a JSON file in .dualbrain/doctor/checks/<id>.json
|
|
1341
|
-
|
|
1342
|
-
const STATIC_CHECK_SEEDS = [
|
|
1343
|
-
{ id: 'package-name', kind: 'package-json-field', severity: 'fail' },
|
|
1344
|
-
{ id: 'version-scheme', kind: 'package-json-field', severity: 'fail' },
|
|
1345
|
-
{ id: 'bin-target', kind: 'export-target', severity: 'fail' },
|
|
1346
|
-
{ id: 'exports', kind: 'export-target', severity: 'fail' },
|
|
1347
|
-
{ id: 'required-files', kind: 'file-exists', severity: 'fail' },
|
|
1348
|
-
{ id: 'branding-check', kind: 'forbidden-string', severity: 'fail' },
|
|
1349
|
-
{ id: 'readme-commands', kind: 'readme-contract', severity: 'warn' },
|
|
1350
|
-
{ id: 'dead-exports', kind: 'export-target', severity: 'warn' },
|
|
1351
|
-
{ id: 'files-array', kind: 'file-exists', severity: 'warn' },
|
|
1352
|
-
{ id: 'cli-smoke-test', kind: 'command-exit', severity: 'fail' },
|
|
1353
|
-
{ id: 'npm-pack-dry-run', kind: 'command-exit', severity: 'fail' },
|
|
1354
|
-
];
|
|
1355
|
-
|
|
1356
|
-
function checkSpecPath(checkId, cwd) {
|
|
1357
|
-
return join(checksDir(cwd), `${checkId}.json`);
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function defaultSpec(seed) {
|
|
1361
|
-
return {
|
|
1362
|
-
id: seed.id,
|
|
1363
|
-
kind: seed.kind,
|
|
1364
|
-
severity: seed.severity,
|
|
1365
|
-
source: seed.source || 'static',
|
|
1366
|
-
status: seed.status || 'active',
|
|
1367
|
-
sentinel: seed.sentinel || false,
|
|
1368
|
-
createdAt: seed.createdAt || new Date().toISOString().slice(0, 10),
|
|
1369
|
-
createdFrom: seed.createdFrom || null,
|
|
1370
|
-
signal: {
|
|
1371
|
-
hits: 0,
|
|
1372
|
-
falsePositives: 0,
|
|
1373
|
-
truePositives: 0,
|
|
1374
|
-
lastSeen: null,
|
|
1375
|
-
lastFailed: null,
|
|
1376
|
-
},
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
/**
|
|
1381
|
-
* getCheckRegistry(cwd) — load all check specs from the registry directory.
|
|
1382
|
-
* Seeds static checks if they don't exist yet.
|
|
1383
|
-
*/
|
|
1384
|
-
export function getCheckRegistry(cwd = process.cwd()) {
|
|
1385
|
-
try {
|
|
1386
|
-
ensureDoctorDir(cwd);
|
|
1387
|
-
// Seed static checks on first call
|
|
1388
|
-
for (const seed of STATIC_CHECK_SEEDS) {
|
|
1389
|
-
const p = checkSpecPath(seed.id, cwd);
|
|
1390
|
-
if (!existsSync(p)) {
|
|
1391
|
-
try {
|
|
1392
|
-
writeFileSync(p, JSON.stringify(defaultSpec(seed), null, 2) + '\n', 'utf8');
|
|
1393
|
-
} catch { /* ignore */ }
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
// Load all check specs
|
|
1397
|
-
let entries;
|
|
1398
|
-
try { entries = readdirSync(checksDir(cwd)).filter(f => f.endsWith('.json')); }
|
|
1399
|
-
catch { return []; }
|
|
1400
|
-
return entries.flatMap(fname => {
|
|
1401
|
-
try { return [JSON.parse(readFileSync(join(checksDir(cwd), fname), 'utf8'))]; }
|
|
1402
|
-
catch { return []; }
|
|
1403
|
-
});
|
|
1404
|
-
} catch { return []; }
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
/**
|
|
1408
|
-
* registerCheck(spec, cwd) — add or update a check spec in the registry.
|
|
1409
|
-
*/
|
|
1410
|
-
export function registerCheck(spec, cwd = process.cwd()) {
|
|
1411
|
-
if (!spec || !spec.id) throw new Error('registerCheck: spec.id is required');
|
|
1412
|
-
try {
|
|
1413
|
-
ensureDoctorDir(cwd);
|
|
1414
|
-
const p = checkSpecPath(spec.id, cwd);
|
|
1415
|
-
const existing = existsSync(p)
|
|
1416
|
-
? (() => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } })()
|
|
1417
|
-
: null;
|
|
1418
|
-
const merged = existing ? { ...existing, ...spec } : { ...defaultSpec(spec), ...spec };
|
|
1419
|
-
writeFileSync(p, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
1420
|
-
return merged;
|
|
1421
|
-
} catch (e) { throw new Error(`registerCheck failed: ${e.message}`); }
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
/**
|
|
1425
|
-
* updateCheckStats(checkId, outcome, cwd) — increment signal stats for a check.
|
|
1426
|
-
* outcome: 'pass' | 'fail' | 'warn' | 'false_positive'
|
|
1427
|
-
*/
|
|
1428
|
-
export function updateCheckStats(checkId, outcome, cwd = process.cwd()) {
|
|
1429
|
-
try {
|
|
1430
|
-
ensureDoctorDir(cwd);
|
|
1431
|
-
const p = checkSpecPath(checkId, cwd);
|
|
1432
|
-
if (!existsSync(p)) return; // not registered — skip silently
|
|
1433
|
-
let spec; try { spec = JSON.parse(readFileSync(p, 'utf8')); } catch { return; }
|
|
1434
|
-
const signal = spec.signal || { hits: 0, falsePositives: 0, truePositives: 0, lastSeen: null, lastFailed: null };
|
|
1435
|
-
const now = new Date().toISOString();
|
|
1436
|
-
if (outcome === 'fail' || outcome === 'warn') {
|
|
1437
|
-
signal.hits = (signal.hits || 0) + 1;
|
|
1438
|
-
signal.truePositives = (signal.truePositives || 0) + 1;
|
|
1439
|
-
signal.lastSeen = now;
|
|
1440
|
-
signal.lastFailed = now;
|
|
1441
|
-
} else if (outcome === 'false_positive') {
|
|
1442
|
-
signal.hits = (signal.hits || 0) + 1;
|
|
1443
|
-
signal.falsePositives = (signal.falsePositives || 0) + 1;
|
|
1444
|
-
signal.lastSeen = now;
|
|
1445
|
-
} else if (outcome === 'pass') {
|
|
1446
|
-
signal.lastSeen = now;
|
|
1447
|
-
}
|
|
1448
|
-
spec.signal = signal;
|
|
1449
|
-
writeFileSync(p, JSON.stringify(spec, null, 2) + '\n', 'utf8');
|
|
1450
|
-
} catch { /* graceful degradation */ }
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
/**
|
|
1454
|
-
* getCheckHealth(cwd) — summary of all checks with signal stats.
|
|
1455
|
-
*/
|
|
1456
|
-
export function getCheckHealth(cwd = process.cwd()) {
|
|
1457
|
-
const registry = getCheckRegistry(cwd);
|
|
1458
|
-
return registry.map(spec => {
|
|
1459
|
-
const sig = spec.signal || {};
|
|
1460
|
-
const hits = sig.hits || 0;
|
|
1461
|
-
const fp = sig.falsePositives || 0;
|
|
1462
|
-
const fpRate = hits >= 5 ? fp / hits : null;
|
|
1463
|
-
return {
|
|
1464
|
-
id: spec.id,
|
|
1465
|
-
kind: spec.kind,
|
|
1466
|
-
status: spec.status,
|
|
1467
|
-
sentinel: spec.sentinel,
|
|
1468
|
-
hits,
|
|
1469
|
-
falsePositives: fp,
|
|
1470
|
-
truePositives: sig.truePositives || 0,
|
|
1471
|
-
fpRate,
|
|
1472
|
-
lastSeen: sig.lastSeen,
|
|
1473
|
-
lastFailed: sig.lastFailed,
|
|
1474
|
-
};
|
|
1475
|
-
});
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
// ─── RECONCILE ────────────────────────────────────────────────────────────────
|
|
1479
|
-
// Core invariant checks that should become sentinel candidates
|
|
1480
|
-
const SENTINEL_INVARIANTS = new Set(['version-scheme', 'package-name', 'bin-target']);
|
|
1481
|
-
|
|
1482
|
-
const VALID_PRIMITIVES = new Set([
|
|
1483
|
-
'file-exists', 'forbidden-string', 'command-exit', 'command-output',
|
|
1484
|
-
'package-json-field', 'readme-contract', 'export-target',
|
|
1485
|
-
]);
|
|
1486
|
-
|
|
1487
|
-
/**
|
|
1488
|
-
* reconcile(cwd) — analyze events and check signal to surface improvement proposals.
|
|
1489
|
-
* Returns { proposals, demotions, sentinels } — never auto-applies changes.
|
|
1490
|
-
*/
|
|
1491
|
-
export function reconcile(cwd = process.cwd()) {
|
|
1492
|
-
const proposals = [];
|
|
1493
|
-
const demotions = [];
|
|
1494
|
-
const sentinels = [];
|
|
1495
|
-
|
|
1496
|
-
try {
|
|
1497
|
-
const recentEvents = getRecentEvents(cwd, 7);
|
|
1498
|
-
const registry = getCheckRegistry(cwd);
|
|
1499
|
-
const checkMap = Object.fromEntries(registry.map(c => [c.id, c]));
|
|
1500
|
-
|
|
1501
|
-
// ── 1. Find incidents/gate_failures with no matching check_result failure ──
|
|
1502
|
-
const incidents = recentEvents.filter(e =>
|
|
1503
|
-
e.type === 'incident' || e.type === 'gate_failure'
|
|
1504
|
-
);
|
|
1505
|
-
|
|
1506
|
-
for (const incident of incidents) {
|
|
1507
|
-
const sessionId = incident.sessionId;
|
|
1508
|
-
// Look for any check_result with outcome=fail in the same session
|
|
1509
|
-
const caughtByCheck = recentEvents.some(e =>
|
|
1510
|
-
e.type === 'check_result' &&
|
|
1511
|
-
e.outcome === 'fail' &&
|
|
1512
|
-
sessionId && e.sessionId === sessionId
|
|
1513
|
-
);
|
|
1514
|
-
|
|
1515
|
-
if (!caughtByCheck && incident.evidence) {
|
|
1516
|
-
// Propose a candidate check for this uncaught failure
|
|
1517
|
-
const primitive = _inferPrimitive(incident.evidence);
|
|
1518
|
-
if (primitive) {
|
|
1519
|
-
proposals.push({
|
|
1520
|
-
type: 'check_proposed',
|
|
1521
|
-
candidateId: `auto-${Date.now()}-${proposals.length}`,
|
|
1522
|
-
kind: primitive,
|
|
1523
|
-
severity: 'warn',
|
|
1524
|
-
source: 'reconcile',
|
|
1525
|
-
status: 'quarantine',
|
|
1526
|
-
createdFrom: incident.type,
|
|
1527
|
-
evidence: incident.evidence,
|
|
1528
|
-
rationale: `Uncaught ${incident.type}: ${String(incident.evidence).slice(0, 120)}`,
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// ── 2. Checks with high false positive rate → recommend demotion ──────────
|
|
1535
|
-
const health = getCheckHealth(cwd);
|
|
1536
|
-
for (const h of health) {
|
|
1537
|
-
if (h.status !== 'active') continue;
|
|
1538
|
-
if (h.hits >= 5 && h.fpRate !== null && h.fpRate > 0.3) {
|
|
1539
|
-
demotions.push({
|
|
1540
|
-
checkId: h.id,
|
|
1541
|
-
reason: `${Math.round(h.fpRate * 100)}% false positive rate over ${h.hits} runs`,
|
|
1542
|
-
fpRate: h.fpRate,
|
|
1543
|
-
hits: h.hits,
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
// ── 3. Checks that never fail in 20+ runs AND guard core invariants ────────
|
|
1549
|
-
for (const h of health) {
|
|
1550
|
-
if (h.status !== 'active') continue;
|
|
1551
|
-
if (h.sentinel) continue; // already sentinel
|
|
1552
|
-
const spec = checkMap[h.id];
|
|
1553
|
-
if (!spec) continue;
|
|
1554
|
-
const guardsInvariant = SENTINEL_INVARIANTS.has(h.id);
|
|
1555
|
-
// Check hasn't fired but is tracking
|
|
1556
|
-
const neverFailed = h.truePositives === 0 && h.hits >= 20;
|
|
1557
|
-
if (guardsInvariant && neverFailed) {
|
|
1558
|
-
sentinels.push({
|
|
1559
|
-
checkId: h.id,
|
|
1560
|
-
reason: `${h.hits} runs without failure — stable invariant guard`,
|
|
1561
|
-
hits: h.hits,
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
} catch { /* graceful degradation — return empty results */ }
|
|
1566
|
-
|
|
1567
|
-
return { proposals, demotions, sentinels };
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
function _inferPrimitive(evidence) {
|
|
1571
|
-
const s = String(evidence).toLowerCase();
|
|
1572
|
-
if (s.includes('file') || s.includes('missing') || s.includes('not found')) return 'file-exists';
|
|
1573
|
-
if (s.includes('string') || s.includes('branding') || s.includes('forbidden')) return 'forbidden-string';
|
|
1574
|
-
if (s.includes('exit') || s.includes('failed') || s.includes('command')) return 'command-exit';
|
|
1575
|
-
if (s.includes('package') || s.includes('version') || s.includes('name')) return 'package-json-field';
|
|
1576
|
-
if (s.includes('readme') || s.includes('doc') || s.includes('contract')) return 'readme-contract';
|
|
1577
|
-
if (s.includes('export')) return 'export-target';
|
|
1578
|
-
if (s.includes('output')) return 'command-output';
|
|
1579
|
-
return null; // can't infer — don't propose
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
// ─── Health Baseline Comparison ───────────────────────────────────────────────
|
|
1583
|
-
export async function compareHealth(cwd = process.cwd()) {
|
|
1584
|
-
const bpath = join(cwd, '.dualbrain', 'health-baseline.json');
|
|
1585
|
-
let baseline = null;
|
|
1586
|
-
if (existsSync(bpath)) { try { baseline = JSON.parse(readFileSync(bpath, 'utf8')); } catch { /* ignore */ } }
|
|
1587
|
-
|
|
1588
|
-
const current = await runHealthCheck(cwd, 'quick');
|
|
1589
|
-
const regressions = [], improvements = [];
|
|
1590
|
-
|
|
1591
|
-
if (baseline && baseline.findings) {
|
|
1592
|
-
const prev = Object.fromEntries(baseline.findings.map(f => [f.id, f.status]));
|
|
1593
|
-
for (const f of current.findings) {
|
|
1594
|
-
if (prev[f.id] === 'pass' && (f.status === 'fail' || f.status === 'error')) regressions.push(f.id);
|
|
1595
|
-
else if ((prev[f.id] === 'fail' || prev[f.id] === 'error') && f.status === 'pass') improvements.push(f.id);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
atomicWrite(bpath, { ...current, savedAt: new Date().toISOString() });
|
|
1600
|
-
return {
|
|
1601
|
-
current: current.score,
|
|
1602
|
-
baseline: baseline ? (baseline.score ?? 0) : null,
|
|
1603
|
-
delta: baseline != null ? current.score - (baseline.score ?? 0) : null,
|
|
1604
|
-
regressions,
|
|
1605
|
-
improvements,
|
|
1606
|
-
};
|
|
1607
|
-
}
|