dual-brain 6.0.1 → 6.1.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/bin/dual-brain.mjs +237 -18
- package/hooks/head-guard.sh +7 -71
- package/install.mjs +38 -27
- package/package.json +12 -4
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +162 -91
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +1 -1
- package/src/dispatch.mjs +313 -19
- package/src/health.mjs +253 -0
- package/src/index.mjs +6 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +210 -0
- package/src/test.mjs +568 -1
package/bin/dual-brain.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// dual-brain — CLI entry point. Commands: init, go, status, remember, forget
|
|
3
3
|
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
ensureProfile, loadProfile, runOnboarding,
|
|
10
|
+
ensureProfile, loadProfile, saveProfile, runOnboarding,
|
|
11
11
|
rememberPreference, forgetPreference, getActivePreferences,
|
|
12
12
|
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
13
13
|
} from '../src/profile.mjs';
|
|
@@ -15,11 +15,18 @@ import {
|
|
|
15
15
|
import { detectTask } from '../src/detect.mjs';
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
|
-
decideRoute, getAvailableModels,
|
|
18
|
+
decideRoute, getAvailableModels,
|
|
19
19
|
} from '../src/decide.mjs';
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
getHealth, markHot, markHealthy, remainingCooldownMinutes, getSessionStats,
|
|
23
|
+
} from '../src/health.mjs';
|
|
24
|
+
|
|
21
25
|
import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
|
|
22
26
|
|
|
27
|
+
import { loadRepoCache } from '../src/repo.mjs';
|
|
28
|
+
import { loadSession, saveSession, formatSessionCard } from '../src/session.mjs';
|
|
29
|
+
|
|
23
30
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
24
31
|
|
|
25
32
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -30,6 +37,7 @@ function readVersion() {
|
|
|
30
37
|
}
|
|
31
38
|
function flag(args, name) { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; }
|
|
32
39
|
function err(msg) { process.stderr.write(`Error: ${msg}\n`); process.exit(1); }
|
|
40
|
+
function vtrace(msg) { process.stderr.write(`[verbose] ${msg}\n`); }
|
|
33
41
|
|
|
34
42
|
function printHelp() {
|
|
35
43
|
console.log(`
|
|
@@ -41,20 +49,47 @@ Commands:
|
|
|
41
49
|
go "task description" Detect → decide → dispatch a task
|
|
42
50
|
--dry-run Show routing decision without executing
|
|
43
51
|
--files a.mjs,b.mjs Provide file context for risk classification
|
|
44
|
-
|
|
52
|
+
--verbose, -v Print routing trace (intent, risk, health, model selection)
|
|
53
|
+
status Provider health, session stats, available models
|
|
54
|
+
--verbose, -v Also print profile file path and raw profile object
|
|
55
|
+
hot <provider> Manually mark all model classes for provider as hot
|
|
56
|
+
cool <provider> Manually clear hot state for a provider
|
|
45
57
|
remember "preference" Save a project-scoped preference
|
|
46
58
|
forget "preference" Remove a preference by fuzzy match
|
|
47
59
|
|
|
48
60
|
Options:
|
|
49
61
|
--version Print version
|
|
50
62
|
--help Show this help
|
|
63
|
+
--verbose, -v Enable verbose routing trace output (stderr)
|
|
51
64
|
`.trim());
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
// ─── Card command (default) ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function cmdCard() {
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
const { homedir } = await import('node:os');
|
|
72
|
+
const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
|
|
73
|
+
const projectPath = join(cwd, '.dualbrain', 'profile.json');
|
|
74
|
+
|
|
75
|
+
if (!existsSync(projectPath) && !existsSync(globalPath)) {
|
|
76
|
+
console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
|
|
77
|
+
await cmdInit();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const repo = loadRepoCache(cwd);
|
|
82
|
+
const session = loadSession(cwd);
|
|
83
|
+
const health = getHealth(cwd);
|
|
84
|
+
const card = formatSessionCard(session, repo, health);
|
|
85
|
+
console.log(card);
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
55
89
|
|
|
56
90
|
async function cmdInit() {
|
|
57
91
|
const profile = await runOnboarding({ interactive: true });
|
|
92
|
+
saveProfile(profile, { cwd: process.cwd() });
|
|
58
93
|
const rt = await detectRuntime();
|
|
59
94
|
const providers = getAvailableProviders(profile);
|
|
60
95
|
const providerSummary = providers.length
|
|
@@ -65,14 +100,15 @@ async function cmdInit() {
|
|
|
65
100
|
|
|
66
101
|
async function cmdGo(args) {
|
|
67
102
|
const dryRun = args.includes('--dry-run');
|
|
103
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
68
104
|
const filesRaw = flag(args, '--files');
|
|
69
105
|
const files = filesRaw && typeof filesRaw === 'string'
|
|
70
106
|
? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
|
|
71
107
|
: [];
|
|
72
108
|
|
|
73
109
|
// prompt is the first non-flag argument (or value after --dry-run which is boolean)
|
|
74
|
-
const prompt = args.find(a => !a.startsWith('--') && a !== (filesRaw ?? ''));
|
|
75
|
-
if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b]');
|
|
110
|
+
const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
|
|
111
|
+
if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b] [--verbose]');
|
|
76
112
|
|
|
77
113
|
const cwd = process.cwd();
|
|
78
114
|
const profile = await ensureProfile(cwd);
|
|
@@ -81,8 +117,44 @@ async function cmdGo(args) {
|
|
|
81
117
|
// Print the one-sentence classification
|
|
82
118
|
console.log(detection.explanation);
|
|
83
119
|
|
|
120
|
+
// Verbose: emit detection trace before routing decision
|
|
121
|
+
if (verbose) {
|
|
122
|
+
vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
|
|
123
|
+
vtrace(`Tier: ${detection.tier} | Files: ${detection.fileCount ?? files.length} | Requires write: ${detection.requiresWrite}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Verbose: emit provider health scores before dispatch
|
|
127
|
+
if (verbose) {
|
|
128
|
+
const providers = getAvailableProviders(profile);
|
|
129
|
+
const { states } = getHealth(cwd);
|
|
130
|
+
const providerScores = ['claude', 'openai'].map(name => {
|
|
131
|
+
const enabled = providers.some(p => p.name === name);
|
|
132
|
+
if (!enabled) return `${name}=unavailable`;
|
|
133
|
+
// Find any state entry for this provider
|
|
134
|
+
const statuses = Object.entries(states)
|
|
135
|
+
.filter(([k]) => k.startsWith(`${name}:`))
|
|
136
|
+
.map(([, v]) => v.status);
|
|
137
|
+
const worst = statuses.includes('hot') ? 'hot'
|
|
138
|
+
: statuses.includes('probing') ? 'probing'
|
|
139
|
+
: statuses.includes('degraded') ? 'degraded'
|
|
140
|
+
: 'healthy';
|
|
141
|
+
return `${name}=${worst}`;
|
|
142
|
+
}).join(' ');
|
|
143
|
+
vtrace(`Provider health: ${providerScores}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
const decision = decideRoute({ profile, detection, cwd });
|
|
85
147
|
|
|
148
|
+
// Verbose: emit model selection and dual-brain rationale
|
|
149
|
+
if (verbose) {
|
|
150
|
+
const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
|
|
151
|
+
const modelStatus = getAvailableModels(profile)[decision.provider]?.includes(decision.model)
|
|
152
|
+
? 'available, matches tier'
|
|
153
|
+
: 'selected';
|
|
154
|
+
vtrace(`Model selection: ${modelLabel} (${modelStatus})`);
|
|
155
|
+
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
|
|
156
|
+
}
|
|
157
|
+
|
|
86
158
|
// Print routing table
|
|
87
159
|
console.log(` provider : ${decision.provider}`);
|
|
88
160
|
console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
|
|
@@ -102,38 +174,85 @@ async function cmdGo(args) {
|
|
|
102
174
|
console.log(`\nConsensus: ${result.consensus}`);
|
|
103
175
|
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
104
176
|
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
177
|
+
// Save session state
|
|
178
|
+
saveSession({
|
|
179
|
+
objective: prompt,
|
|
180
|
+
branch: null,
|
|
181
|
+
filesChanged: files,
|
|
182
|
+
commandsRun: [`dual-brain go "${prompt}"`],
|
|
183
|
+
lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
|
|
184
|
+
provider: decision.provider,
|
|
185
|
+
nextAction: null,
|
|
186
|
+
}, cwd);
|
|
105
187
|
} else {
|
|
106
188
|
result = await dispatch({ decision, prompt, files, cwd });
|
|
107
189
|
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
108
190
|
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
109
191
|
if (result.summary) console.log(result.summary);
|
|
110
192
|
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
111
|
-
|
|
193
|
+
// Save session state regardless of success/failure
|
|
194
|
+
saveSession({
|
|
195
|
+
objective: prompt,
|
|
196
|
+
branch: null,
|
|
197
|
+
filesChanged: files,
|
|
198
|
+
commandsRun: [`dual-brain go "${prompt}"`],
|
|
199
|
+
lastResult: {
|
|
200
|
+
status: result.status === 'completed' ? 'success' : 'failure',
|
|
201
|
+
summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
|
|
202
|
+
},
|
|
203
|
+
provider: decision.provider,
|
|
204
|
+
nextAction: null,
|
|
205
|
+
}, cwd);
|
|
206
|
+
if (result.status !== 'completed') process.exit(1);
|
|
207
|
+
}
|
|
112
208
|
}
|
|
113
209
|
|
|
114
|
-
async function cmdStatus() {
|
|
210
|
+
async function cmdStatus(args = []) {
|
|
211
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
115
212
|
const cwd = process.cwd();
|
|
116
213
|
const profile = loadProfile(cwd);
|
|
117
214
|
const rt = await detectRuntime();
|
|
118
215
|
const providers = getAvailableProviders(profile);
|
|
119
|
-
const pressure = estimateBudgetPressure(profile, cwd);
|
|
120
216
|
const available = getAvailableModels(profile);
|
|
121
217
|
const prefs = getActivePreferences(cwd);
|
|
218
|
+
const { states } = getHealth(cwd);
|
|
219
|
+
const sessionStats = getSessionStats(cwd);
|
|
122
220
|
|
|
123
221
|
console.log('=== Dual-Brain Status ===\n');
|
|
124
222
|
|
|
125
|
-
// Providers
|
|
223
|
+
// Providers + health
|
|
126
224
|
console.log('Providers:');
|
|
127
225
|
if (providers.length === 0) {
|
|
128
226
|
console.log(' (none configured — run: dual-brain init)');
|
|
129
227
|
} else {
|
|
130
228
|
for (const p of providers) {
|
|
131
229
|
const label = p.name === 'claude' ? 'Claude' : 'OpenAI';
|
|
132
|
-
|
|
133
|
-
|
|
230
|
+
// Collect all model-class states for this provider
|
|
231
|
+
const provStates = Object.entries(states)
|
|
232
|
+
.filter(([k]) => k.startsWith(`${p.name}:`));
|
|
233
|
+
const sess = sessionStats[p.name] ?? { calls: 0, tokens: 0 };
|
|
234
|
+
|
|
235
|
+
if (provStates.length === 0) {
|
|
236
|
+
console.log(` ${label} plan=${p.plan} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
|
|
237
|
+
} else {
|
|
238
|
+
for (const [k, st] of provStates) {
|
|
239
|
+
const modelClass = k.split(':').slice(1).join(':');
|
|
240
|
+
let statusStr = st.status;
|
|
241
|
+
if (st.status === 'hot') {
|
|
242
|
+
const remaining = remainingCooldownMinutes(p.name, modelClass, cwd);
|
|
243
|
+
statusStr = remaining > 0 ? `hot (retry in ${remaining}m)` : 'hot (cooling)';
|
|
244
|
+
}
|
|
245
|
+
console.log(` ${label} plan=${p.plan} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
134
248
|
}
|
|
135
249
|
}
|
|
136
250
|
|
|
251
|
+
// Session totals
|
|
252
|
+
const totalCalls = Object.values(sessionStats).reduce((s, v) => s + v.calls, 0);
|
|
253
|
+
const totalTokens = Object.values(sessionStats).reduce((s, v) => s + v.tokens, 0);
|
|
254
|
+
console.log(`\nSession: ${totalCalls} dispatch${totalCalls !== 1 ? 'es' : ''}, ${totalTokens} tokens observed`);
|
|
255
|
+
|
|
137
256
|
// Models
|
|
138
257
|
console.log('\nAvailable models:');
|
|
139
258
|
if (available.claude.length) console.log(` Claude : ${available.claude.join(', ')}`);
|
|
@@ -154,20 +273,117 @@ async function cmdStatus() {
|
|
|
154
273
|
console.log(`\nPreferences: ${prefs.length ? '' : '(none)'}`);
|
|
155
274
|
for (const p of prefs) console.log(` [${p.scope}] ${p.text}`);
|
|
156
275
|
|
|
276
|
+
// Verbose: profile file path and raw object
|
|
277
|
+
if (verbose) {
|
|
278
|
+
const { homedir } = await import('node:os');
|
|
279
|
+
const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
|
|
280
|
+
const projectPath = join(cwd, '.dualbrain', 'profile.json');
|
|
281
|
+
const { existsSync } = await import('node:fs');
|
|
282
|
+
const loadedFrom = existsSync(projectPath) ? projectPath : existsSync(globalPath) ? globalPath : '(defaults)';
|
|
283
|
+
vtrace(`Profile file: ${loadedFrom}`);
|
|
284
|
+
vtrace(`Raw profile:\n${JSON.stringify(profile, null, 2)}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Enforcement health check
|
|
288
|
+
console.log('\nEnforcement:');
|
|
289
|
+
try {
|
|
290
|
+
const { readFileSync: rfs, existsSync: exs } = await import('node:fs');
|
|
291
|
+
const settingsFile = join(cwd, '.claude', 'settings.json');
|
|
292
|
+
if (!exs(settingsFile)) {
|
|
293
|
+
console.log(' NOT INSTALLED — run: dual-brain install');
|
|
294
|
+
} else {
|
|
295
|
+
const settings = JSON.parse(rfs(settingsFile, 'utf8'));
|
|
296
|
+
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
297
|
+
const guardCmd = 'bash .claude/hooks/head-guard.sh';
|
|
298
|
+
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
299
|
+
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
300
|
+
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
301
|
+
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
302
|
+
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
303
|
+
const activeCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
304
|
+
if (activeCount === 4) {
|
|
305
|
+
console.log(` active (${activeCount} guards: Edit, Write, Bash, Agent)`);
|
|
306
|
+
} else {
|
|
307
|
+
const missing = [
|
|
308
|
+
!hasEdit && 'Edit',
|
|
309
|
+
!hasWrite && 'Write',
|
|
310
|
+
!hasBash && 'Bash',
|
|
311
|
+
!hasAgent && 'Agent',
|
|
312
|
+
].filter(Boolean);
|
|
313
|
+
console.log(` PARTIAL — missing guards: ${missing.join(', ')} — run: dual-brain install`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
console.log(' unknown (could not read .claude/settings.json)');
|
|
318
|
+
}
|
|
319
|
+
|
|
157
320
|
// Update check
|
|
158
321
|
try {
|
|
159
322
|
const localVer = readVersion();
|
|
160
323
|
const remoteVer = execSync('npm view dual-brain version 2>/dev/null', { timeout: 5000 }).toString().trim();
|
|
161
|
-
if (remoteVer
|
|
162
|
-
|
|
324
|
+
if (remoteVer) {
|
|
325
|
+
const localParts = localVer.split('.').map(Number);
|
|
326
|
+
const remoteParts = remoteVer.split('.').map(Number);
|
|
327
|
+
const updateAvailable =
|
|
328
|
+
remoteParts[0] > localParts[0]
|
|
329
|
+
|| (remoteParts[0] === localParts[0] && remoteParts[1] > localParts[1])
|
|
330
|
+
|| (remoteParts[0] === localParts[0] && remoteParts[1] === localParts[1] && remoteParts[2] > localParts[2]);
|
|
331
|
+
if (updateAvailable) {
|
|
332
|
+
console.log(`\nUpdate available: npm i -g dual-brain@latest (${localVer} → ${remoteVer})`);
|
|
333
|
+
}
|
|
163
334
|
}
|
|
164
335
|
} catch { /* network unavailable — skip */ }
|
|
165
336
|
}
|
|
166
337
|
|
|
338
|
+
// ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
const PROVIDER_MODEL_CLASSES = {
|
|
341
|
+
claude: ['haiku', 'sonnet', 'opus'],
|
|
342
|
+
openai: ['o4-mini', 'o3', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-5.4', 'gpt-5.5'],
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function cmdHot(providerArg) {
|
|
346
|
+
if (!providerArg) err('Usage: dual-brain hot <provider> (claude | openai)');
|
|
347
|
+
const provider = providerArg.toLowerCase();
|
|
348
|
+
const classes = PROVIDER_MODEL_CLASSES[provider];
|
|
349
|
+
if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
|
|
350
|
+
const cwd = process.cwd();
|
|
351
|
+
for (const mc of classes) markHot(provider, mc, cwd);
|
|
352
|
+
console.log(`Marked ${classes.length} model classes as hot for ${provider}.`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function cmdCool(providerArg) {
|
|
356
|
+
if (!providerArg) err('Usage: dual-brain cool <provider> (claude | openai)');
|
|
357
|
+
const provider = providerArg.toLowerCase();
|
|
358
|
+
const classes = PROVIDER_MODEL_CLASSES[provider];
|
|
359
|
+
if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
|
|
360
|
+
const cwd = process.cwd();
|
|
361
|
+
for (const mc of classes) markHealthy(provider, mc, cwd);
|
|
362
|
+
console.log(`Cleared hot state for all ${provider} model classes.`);
|
|
363
|
+
}
|
|
364
|
+
|
|
167
365
|
async function cmdInstall() {
|
|
366
|
+
const cwd = process.cwd();
|
|
367
|
+
|
|
368
|
+
// Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
|
|
168
369
|
const { spawnSync } = await import('child_process');
|
|
169
|
-
const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd
|
|
170
|
-
process.exit(result.status ||
|
|
370
|
+
const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd });
|
|
371
|
+
if (result.status !== 0) { process.exit(result.status || 1); }
|
|
372
|
+
|
|
373
|
+
// Additionally merge enforcement hooks into .claude/settings.json
|
|
374
|
+
const { installHooks } = await import('../src/install-hooks.mjs');
|
|
375
|
+
const { installed, skipped } = installHooks(cwd);
|
|
376
|
+
|
|
377
|
+
if (installed.length > 0) {
|
|
378
|
+
console.log(`\nEnforcement hooks installed (${installed.length}):`);
|
|
379
|
+
for (const item of installed) console.log(` + ${item}`);
|
|
380
|
+
}
|
|
381
|
+
if (skipped.length > 0) {
|
|
382
|
+
console.log(`Enforcement hooks already present (${skipped.length}):`);
|
|
383
|
+
for (const item of skipped) console.log(` = ${item}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
process.exit(0);
|
|
171
387
|
}
|
|
172
388
|
|
|
173
389
|
function cmdRemember(text) {
|
|
@@ -188,13 +404,16 @@ async function main() {
|
|
|
188
404
|
const args = process.argv.slice(2);
|
|
189
405
|
const cmd = args[0];
|
|
190
406
|
|
|
191
|
-
if (
|
|
407
|
+
if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
|
|
408
|
+
if (!cmd) { await cmdCard(); return; }
|
|
192
409
|
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
193
410
|
|
|
194
411
|
if (cmd === 'init') { await cmdInit(); return; }
|
|
195
412
|
if (cmd === 'install') { await cmdInstall(); return; }
|
|
196
413
|
if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
|
|
197
|
-
if (cmd === 'status') { await cmdStatus(); return; }
|
|
414
|
+
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
415
|
+
if (cmd === 'hot') { cmdHot(args[1]); return; }
|
|
416
|
+
if (cmd === 'cool') { cmdCool(args[1]); return; }
|
|
198
417
|
if (cmd === 'remember') { cmdRemember(args[1]); return; }
|
|
199
418
|
if (cmd === 'forget') { cmdForget(args[1]); return; }
|
|
200
419
|
|
package/hooks/head-guard.sh
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
# Exit 0 → allow
|
|
9
9
|
# Exit 2 → block (stderr message is shown to Claude)
|
|
10
10
|
|
|
11
|
+
BLOCK_MSG='[dual-brain] HEAD cannot use this tool directly. Dispatch via: dual-brain go "task description"'
|
|
12
|
+
|
|
11
13
|
# ── 1. Role check ────────────────────────────────────────────────────────────
|
|
12
14
|
# Only enforce when the session has been explicitly marked as the HEAD agent.
|
|
13
15
|
# If the env var is unset we allow everything (backward compat for non-dual-brain usage).
|
|
@@ -24,80 +26,14 @@ fi
|
|
|
24
26
|
# ── 2. Tool name check ───────────────────────────────────────────────────────
|
|
25
27
|
TOOL="${CLAUDE_TOOL_NAME:-}"
|
|
26
28
|
|
|
27
|
-
# Block direct file-editing tools unconditionally for HEAD.
|
|
29
|
+
# Block direct file-editing tools and Bash unconditionally for HEAD.
|
|
30
|
+
# HEAD should use Read tool for reading and Agent (via dual-brain go) for all other work.
|
|
28
31
|
case "${TOOL}" in
|
|
29
|
-
Edit|Write|NotebookEdit)
|
|
30
|
-
echo "
|
|
32
|
+
Edit|Write|NotebookEdit|Bash)
|
|
33
|
+
echo "${BLOCK_MSG}" >&2
|
|
31
34
|
exit 2
|
|
32
35
|
;;
|
|
33
36
|
esac
|
|
34
37
|
|
|
35
|
-
# ── 3.
|
|
36
|
-
# For Bash calls, read stdin JSON and extract the "command" field, then scan for
|
|
37
|
-
# write-side shell patterns. Pure bash + standard POSIX utilities — no node
|
|
38
|
-
# startup, no network.
|
|
39
|
-
|
|
40
|
-
if [[ "${TOOL}" == "Bash" ]]; then
|
|
41
|
-
# Read the full JSON input from stdin.
|
|
42
|
-
INPUT="$(cat)"
|
|
43
|
-
|
|
44
|
-
# Extract the value of "command" from the JSON.
|
|
45
|
-
# Strategy: grep for the key+value pair, then strip key prefix with sed.
|
|
46
|
-
# Handles normal ASCII command strings (not escaped unicode — acceptable for a guard).
|
|
47
|
-
CMD="$(printf '%s' "${INPUT}" \
|
|
48
|
-
| grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
49
|
-
| head -1 \
|
|
50
|
-
| sed 's/^"command"[[:space:]]*:[[:space:]]*"//;s/"$//')"
|
|
51
|
-
|
|
52
|
-
# If we couldn't extract a command (unusual JSON shape), allow through.
|
|
53
|
-
if [[ -z "${CMD}" ]]; then
|
|
54
|
-
exit 0
|
|
55
|
-
fi
|
|
56
|
-
|
|
57
|
-
# ── Blocked patterns ─────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
# sed with in-place flag (-i or combined flags like -ni)
|
|
60
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])sed[[:space:]].*-[a-zA-Z]*i'; then
|
|
61
|
-
echo "HEAD cannot implement directly (sed -i). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
62
|
-
exit 2
|
|
63
|
-
fi
|
|
64
|
-
|
|
65
|
-
# Redirect-write: cat > file, echo > file, printf > file (single > only, not >>)
|
|
66
|
-
if printf '%s' "${CMD}" | grep -qE '(cat|echo|printf)[^|]*>[^>]'; then
|
|
67
|
-
echo "HEAD cannot implement directly (redirect write). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
68
|
-
exit 2
|
|
69
|
-
fi
|
|
70
|
-
|
|
71
|
-
# tee writing to a file path (tee /path or tee ./path or tee filename)
|
|
72
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])tee[[:space:]]+[^-]'; then
|
|
73
|
-
echo "HEAD cannot implement directly (tee). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
74
|
-
exit 2
|
|
75
|
-
fi
|
|
76
|
-
|
|
77
|
-
# patch command
|
|
78
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])patch[[:space:]]'; then
|
|
79
|
-
echo "HEAD cannot implement directly (patch). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
80
|
-
exit 2
|
|
81
|
-
fi
|
|
82
|
-
|
|
83
|
-
# mv / cp where the destination looks like a source code file
|
|
84
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])(mv|cp)[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
|
|
85
|
-
echo "HEAD cannot implement directly (mv/cp to source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
86
|
-
exit 2
|
|
87
|
-
fi
|
|
88
|
-
|
|
89
|
-
# rm on source files
|
|
90
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])rm[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
|
|
91
|
-
echo "HEAD cannot implement directly (rm on source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
92
|
-
exit 2
|
|
93
|
-
fi
|
|
94
|
-
|
|
95
|
-
# Explicitly allowed (read-only) patterns — documented here for clarity.
|
|
96
|
-
# The checks above are specific enough that these don't need explicit allow rules,
|
|
97
|
-
# but listing them makes the intent clear:
|
|
98
|
-
# grep, find, cat <file (no redirect), git status/log/diff/show,
|
|
99
|
-
# node --check, ls, wc, head, tail, jq (read), curl (read), etc.
|
|
100
|
-
fi
|
|
101
|
-
|
|
102
|
-
# ── 4. Default: allow ────────────────────────────────────────────────────────
|
|
38
|
+
# ── 3. Default: allow ────────────────────────────────────────────────────────
|
|
103
39
|
exit 0
|
package/install.mjs
CHANGED
|
@@ -744,39 +744,50 @@ function generateSettings(workspace) {
|
|
|
744
744
|
let existing = {};
|
|
745
745
|
try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {}
|
|
746
746
|
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
matcher: '',
|
|
761
|
-
hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }],
|
|
762
|
-
},
|
|
763
|
-
],
|
|
764
|
-
};
|
|
747
|
+
const HEAD_GUARD_CMD = 'bash .claude/hooks/head-guard.sh';
|
|
748
|
+
const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
|
|
749
|
+
|
|
750
|
+
// All dual-brain PreToolUse hooks we manage
|
|
751
|
+
const DESIRED_PRE = [
|
|
752
|
+
{ matcher: 'Edit', command: HEAD_GUARD_CMD },
|
|
753
|
+
{ matcher: 'Write', command: HEAD_GUARD_CMD },
|
|
754
|
+
{ matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
|
|
755
|
+
{ matcher: 'Bash', command: HEAD_GUARD_CMD },
|
|
756
|
+
{ matcher: 'Agent', command: ENFORCE_TIER_CMD },
|
|
757
|
+
];
|
|
765
758
|
|
|
766
759
|
const DUAL_BRAIN_CMDS = [
|
|
767
|
-
|
|
760
|
+
HEAD_GUARD_CMD,
|
|
761
|
+
ENFORCE_TIER_CMD,
|
|
768
762
|
'node .claude/hooks/cost-logger.mjs',
|
|
769
763
|
'node .claude/hooks/auto-update-wrapper.mjs',
|
|
770
764
|
];
|
|
771
765
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
766
|
+
// Build merged PreToolUse: keep user entries that aren't ours, then add ours
|
|
767
|
+
const existingPre = (existing.hooks?.PreToolUse || []).filter(e =>
|
|
768
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
769
|
+
);
|
|
770
|
+
const mergedPre = [...existingPre];
|
|
771
|
+
for (const { matcher, command } of DESIRED_PRE) {
|
|
772
|
+
mergedPre.push({ matcher, hooks: [{ type: 'command', command }] });
|
|
778
773
|
}
|
|
779
774
|
|
|
775
|
+
// Build merged PostToolUse
|
|
776
|
+
const postHooks = [
|
|
777
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }] },
|
|
778
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }] },
|
|
779
|
+
];
|
|
780
|
+
const existingPost = (existing.hooks?.PostToolUse || []).filter(e =>
|
|
781
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
782
|
+
);
|
|
783
|
+
const mergedPost = [...existingPost, ...postHooks];
|
|
784
|
+
|
|
785
|
+
const merged = {
|
|
786
|
+
...(existing.hooks || {}),
|
|
787
|
+
PreToolUse: mergedPre,
|
|
788
|
+
PostToolUse: mergedPost,
|
|
789
|
+
};
|
|
790
|
+
|
|
780
791
|
return { ...existing, hooks: merged };
|
|
781
792
|
}
|
|
782
793
|
|
|
@@ -875,8 +886,8 @@ function install(workspace, env, mode) {
|
|
|
875
886
|
];
|
|
876
887
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
877
888
|
|
|
878
|
-
// Copy bash hooks (auto-update.sh
|
|
879
|
-
const BASH_HOOKS = ['auto-update.sh'];
|
|
889
|
+
// Copy bash hooks (auto-update.sh and head-guard.sh live alongside .mjs hooks in the package)
|
|
890
|
+
const BASH_HOOKS = ['auto-update.sh', 'head-guard.sh'];
|
|
880
891
|
for (const h of BASH_HOOKS) {
|
|
881
892
|
const src = join(__dirname, 'hooks', h);
|
|
882
893
|
const dst = join(target, 'hooks', h);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.1",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
"./profile": "./src/profile.mjs",
|
|
13
13
|
"./detect": "./src/detect.mjs",
|
|
14
14
|
"./decide": "./src/decide.mjs",
|
|
15
|
-
"./dispatch": "./src/dispatch.mjs"
|
|
15
|
+
"./dispatch": "./src/dispatch.mjs",
|
|
16
|
+
"./playbook": "./src/playbook.mjs",
|
|
17
|
+
"./health": "./src/health.mjs",
|
|
18
|
+
"./repo": "./src/repo.mjs",
|
|
19
|
+
"./session": "./src/session.mjs",
|
|
20
|
+
"./decompose": "./src/decompose.mjs",
|
|
21
|
+
"./brief": "./src/brief.mjs",
|
|
22
|
+
"./redact": "./src/redact.mjs"
|
|
16
23
|
},
|
|
17
24
|
"keywords": [
|
|
18
25
|
"claude-code",
|
|
@@ -33,7 +40,7 @@
|
|
|
33
40
|
"scripts": {
|
|
34
41
|
"test": "node hooks/test-orchestrator.mjs",
|
|
35
42
|
"test:core": "node --test src/test.mjs",
|
|
36
|
-
"postinstall": "
|
|
43
|
+
"postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'"
|
|
37
44
|
},
|
|
38
45
|
"engines": {
|
|
39
46
|
"node": ">=20.0.0"
|
|
@@ -49,6 +56,7 @@
|
|
|
49
56
|
"review-rules.md",
|
|
50
57
|
"CLAUDE.md",
|
|
51
58
|
"README.md",
|
|
52
|
-
"LICENSE"
|
|
59
|
+
"LICENSE",
|
|
60
|
+
"playbooks/*.json"
|
|
53
61
|
]
|
|
54
62
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "debug",
|
|
3
|
+
"description": "Structured bug resolution: reproduce, isolate, hypothesize root cause, fix minimally, verify with tests",
|
|
4
|
+
"matchIntents": ["debug", "fix"],
|
|
5
|
+
"steps": [
|
|
6
|
+
{
|
|
7
|
+
"id": "reproduce",
|
|
8
|
+
"title": "Reproduce the Failure",
|
|
9
|
+
"goal": "Find the failing code path. Identify the error message, stack trace, or unexpected behavior being reported. Locate the relevant source files, the entry point where the failure originates, and any existing tests that exercise this path. Confirm you understand the expected vs actual behavior.",
|
|
10
|
+
"tier": "search",
|
|
11
|
+
"consensus": false,
|
|
12
|
+
"output": { "kind": "analysis", "required": true }
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "isolate",
|
|
16
|
+
"title": "Isolate the Root Cause",
|
|
17
|
+
"goal": "Narrow down the root cause to a specific file, function, or line range. Trace the data flow from the failing callsite back to where the incorrect value or state originates. Check recent git changes to this code path. Identify the single most likely source of the problem before moving on.",
|
|
18
|
+
"tier": "search",
|
|
19
|
+
"consensus": false,
|
|
20
|
+
"output": { "kind": "analysis", "required": true }
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "hypothesize",
|
|
24
|
+
"title": "Form a Root Cause Hypothesis",
|
|
25
|
+
"goal": "Based on the isolated evidence, form a clear hypothesis about why the bug occurs. Consider: edge cases not handled, race conditions or ordering issues, incorrect assumptions about inputs or state, stale or shared mutable state, off-by-one errors, or API contract mismatches. State your hypothesis explicitly and explain what evidence supports it.",
|
|
26
|
+
"tier": "think",
|
|
27
|
+
"consensus": false,
|
|
28
|
+
"output": { "kind": "analysis", "required": true }
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "fix",
|
|
32
|
+
"title": "Implement the Minimal Fix",
|
|
33
|
+
"goal": "Implement the smallest change that fixes the bug according to the hypothesis. Do not refactor surrounding code, rename things, or improve unrelated areas. If a regression test for this bug does not exist, add one. The fix should be easy to review and easy to revert if wrong.",
|
|
34
|
+
"tier": "execute",
|
|
35
|
+
"consensus": false,
|
|
36
|
+
"gate": { "type": "diff-review", "requiredWhen": "high-risk" },
|
|
37
|
+
"output": { "kind": "patch", "required": true }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "verify",
|
|
41
|
+
"title": "Verify Fix and Check for Regressions",
|
|
42
|
+
"goal": "Run the full test suite. Confirm the bug is no longer reproducible. Confirm no previously passing tests now fail. If regressions are found, determine whether they are related to the fix or pre-existing. Summarize: the root cause in one sentence, the fix applied, and the test evidence that the bug is resolved.",
|
|
43
|
+
"tier": "execute",
|
|
44
|
+
"consensus": false,
|
|
45
|
+
"gate": { "type": "test", "requiredWhen": "always" },
|
|
46
|
+
"output": { "kind": "summary", "required": true }
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|