dual-brain 3.8.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/hooks/control-panel.mjs +27 -3
- package/hooks/cost-logger.mjs +2 -3
- package/hooks/enforce-tier.mjs +15 -18
- package/hooks/failure-detector.mjs +15 -1
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +35 -4
- package/hooks/test-orchestrator.mjs +67 -3
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +262 -0
- package/install.mjs +33 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ npx -y dual-brain
|
|
|
63
63
|
| `hooks/gpt-work-dispatcher.mjs` | Dispatch execution tasks to GPT via Codex CLI |
|
|
64
64
|
| `hooks/session-report.mjs` | Session-end summary: activity, compliance, quality |
|
|
65
65
|
| `hooks/health-check.mjs` | Verify all hooks and dependencies are working |
|
|
66
|
-
| `hooks/test-orchestrator.mjs` | Self-test harness (
|
|
66
|
+
| `hooks/test-orchestrator.mjs` | Self-test harness (39 tests) |
|
|
67
67
|
| `hooks/setup-wizard.mjs` | Interactive config (optional — for custom plans) |
|
|
68
68
|
| `hooks/install-git-hooks.mjs` | Git pre-commit hook for quality gate |
|
|
69
69
|
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -437,16 +437,40 @@ function showProfilePicker(rl) {
|
|
|
437
437
|
|
|
438
438
|
rl.question(' Choice: ', (answer) => {
|
|
439
439
|
const names = Object.keys(PROFILES);
|
|
440
|
-
const
|
|
440
|
+
const trimmed = answer.trim();
|
|
441
|
+
let selectedName = null;
|
|
442
|
+
|
|
443
|
+
// Try numeric selection first
|
|
444
|
+
const idx = parseInt(trimmed, 10) - 1;
|
|
441
445
|
if (idx >= 0 && idx < names.length) {
|
|
446
|
+
selectedName = names[idx];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Try natural language alias resolution
|
|
450
|
+
if (!selectedName && trimmed && trimmed !== 'q') {
|
|
451
|
+
const PANEL_ALIASES = {
|
|
452
|
+
'auto': 'auto', 'adaptive': 'auto', 'smart': 'auto', 'default': 'auto', 'normal': 'auto',
|
|
453
|
+
'balanced': 'balanced', 'even': 'balanced', 'equal': 'balanced',
|
|
454
|
+
'cost-saver': 'cost-saver', 'cheap': 'cost-saver', 'save': 'cost-saver', 'conservative': 'cost-saver', 'frugal': 'cost-saver', 'budget': 'cost-saver',
|
|
455
|
+
'quality-first': 'quality-first', 'aggressive': 'quality-first', 'quality': 'quality-first', 'max': 'quality-first', 'full': 'quality-first', 'both': 'quality-first',
|
|
456
|
+
};
|
|
457
|
+
const cleaned = trimmed.toLowerCase()
|
|
458
|
+
.replace(/^(go|be|use|switch to|set|mode)\s+/i, '')
|
|
459
|
+
.replace(/\s+mode$/i, '');
|
|
460
|
+
selectedName = PANEL_ALIASES[cleaned] || null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (selectedName) {
|
|
442
464
|
let customOverrides = null;
|
|
443
465
|
try {
|
|
444
466
|
const existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
445
467
|
if (existing.custom_overrides?.budgets) customOverrides = { budgets: existing.custom_overrides.budgets };
|
|
446
468
|
} catch {}
|
|
447
|
-
saveProfile(
|
|
448
|
-
const pf = PROFILES[
|
|
469
|
+
saveProfile(selectedName, customOverrides);
|
|
470
|
+
const pf = PROFILES[selectedName];
|
|
449
471
|
console.log(` ✅ Switched to ${pf.emoji} ${pf.uiLabel}`);
|
|
472
|
+
} else if (trimmed && trimmed !== 'q') {
|
|
473
|
+
console.log(` Unknown profile: ${trimmed}. Try: cheap, aggressive, quality, balanced, auto`);
|
|
450
474
|
}
|
|
451
475
|
resolve();
|
|
452
476
|
});
|
package/hooks/cost-logger.mjs
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* Output contract: must print "{}" to stdout and exit 0 within ~100 ms.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createHash } from "crypto";
|
|
12
11
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
12
|
import { dirname, join } from "path";
|
|
14
13
|
import { fileURLToPath } from "url";
|
|
@@ -265,8 +264,8 @@ async function main() {
|
|
|
265
264
|
// Record failures for adaptive routing (failure-loop detection)
|
|
266
265
|
if (status === 'error' && toolName === 'Agent') {
|
|
267
266
|
try {
|
|
268
|
-
const { recordFailure, pruneOldFailures } = await import('./failure-detector.mjs');
|
|
269
|
-
const promptHash =
|
|
267
|
+
const { computePromptHash, recordFailure, pruneOldFailures } = await import('./failure-detector.mjs');
|
|
268
|
+
const promptHash = computePromptHash(toolInput);
|
|
270
269
|
recordFailure(promptHash, tier, payload?.error || 'agent_error');
|
|
271
270
|
// Best-effort cleanup of stale failure entries (>24h old)
|
|
272
271
|
try { pruneOldFailures(); } catch {}
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
|
|
3
|
-
import { createHash } from 'crypto';
|
|
4
3
|
import { dirname, resolve, join } from 'path';
|
|
5
4
|
import { fileURLToPath } from 'url';
|
|
6
5
|
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
7
|
-
import { checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
6
|
+
import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
8
7
|
|
|
9
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
9
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
@@ -214,7 +213,7 @@ try {
|
|
|
214
213
|
const currentModel = (ti.model || '').toLowerCase();
|
|
215
214
|
|
|
216
215
|
// Compute prompt hash early for duplicate detection and logging
|
|
217
|
-
const promptHash =
|
|
216
|
+
const promptHash = computePromptHash(ti);
|
|
218
217
|
|
|
219
218
|
// Burst detection — suppress noise during wave launches (3+ agents in 90s)
|
|
220
219
|
const burstMode = detectBurst();
|
|
@@ -227,11 +226,11 @@ try {
|
|
|
227
226
|
if (burstMode) {
|
|
228
227
|
// In burst mode, only warn on exact hash matches (same description+prompt)
|
|
229
228
|
if (duplicate.prompt_hash === promptHash) {
|
|
230
|
-
duplicateWarning =
|
|
229
|
+
duplicateWarning = `Heads up — a similar task ran ${minutesAgo} minute${minutesAgo !== 1 ? 's' : ''} ago (wave detected). Reuse that result if the scope hasn't changed.`;
|
|
231
230
|
}
|
|
232
231
|
// Otherwise suppress — similar-but-different agents in a wave are expected
|
|
233
232
|
} else {
|
|
234
|
-
duplicateWarning =
|
|
233
|
+
duplicateWarning = `Heads up — a similar task ran ${minutesAgo} minute${minutesAgo !== 1 ? 's' : ''} ago. Reuse that result if the scope hasn't changed.`;
|
|
235
234
|
}
|
|
236
235
|
}
|
|
237
236
|
|
|
@@ -279,10 +278,10 @@ try {
|
|
|
279
278
|
].filter(Boolean);
|
|
280
279
|
|
|
281
280
|
if (detectedTiers.length > 1) {
|
|
282
|
-
const splitMsg =
|
|
281
|
+
const splitMsg = `This spans ${detectedTiers.join(' + ')} work. Consider splitting: ` +
|
|
283
282
|
(hasSearch ? 'search first (haiku), ' : '') +
|
|
284
283
|
(hasExecute ? 'then execute edits (sonnet), ' : '') +
|
|
285
|
-
(hasThink ? 'keep planning/review on
|
|
284
|
+
(hasThink ? 'keep planning/review on the main session (opus).' : '');
|
|
286
285
|
const fullMsg = prependWarnings(splitMsg.replace(/, $/, '.'));
|
|
287
286
|
logRecommendation({
|
|
288
287
|
tier: detectedTiers.join('+'),
|
|
@@ -311,21 +310,21 @@ try {
|
|
|
311
310
|
if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
|
|
312
311
|
tier = 'think';
|
|
313
312
|
autoStatus = riskResult.level === 'critical'
|
|
314
|
-
? `
|
|
315
|
-
: `
|
|
313
|
+
? `This touches ${riskResult.reason.split(':')[0].toLowerCase()} — recommending dual-brain review for safety.`
|
|
314
|
+
: `Promoting to think tier — this is ${riskResult.reason.split(':')[0].toLowerCase()}.`;
|
|
316
315
|
}
|
|
317
316
|
|
|
318
317
|
// Failure loop detection
|
|
319
|
-
const failureCheck = checkFailureLoop(promptHash
|
|
318
|
+
const failureCheck = checkFailureLoop(promptHash);
|
|
320
319
|
let failureMessage = null;
|
|
321
320
|
if (failureCheck.isLoop) {
|
|
322
321
|
if (failureCheck.suggestion === 'promote_tier' && tier === 'execute') {
|
|
323
322
|
tier = 'think';
|
|
324
|
-
autoStatus = '
|
|
323
|
+
autoStatus = 'Escalating to think tier — this has failed before, let\'s take a different approach.';
|
|
325
324
|
} else if (failureCheck.suggestion === 'escalate_to_dual_brain') {
|
|
326
|
-
autoStatus = '
|
|
325
|
+
autoStatus = 'Repeated failures detected — recommending dual-brain review to diagnose the issue.';
|
|
327
326
|
}
|
|
328
|
-
failureMessage =
|
|
327
|
+
failureMessage = `⚠️ This has failed ${failureCheck.count} times in the last 2 hours. Consider a dual-brain think session to diagnose the root cause.`;
|
|
329
328
|
}
|
|
330
329
|
|
|
331
330
|
// Apply profile-driven tier adjustments
|
|
@@ -345,7 +344,7 @@ try {
|
|
|
345
344
|
const biasThreshold = profileSettings.bias >= 0 ? 10 : 20;
|
|
346
345
|
if (balance && balance.claudeCalls > balance.openaiCalls * 2 && balance.claudeCalls > biasThreshold) {
|
|
347
346
|
const dispatchModel = tier === 'think' ? 'gpt-5.5' : tier === 'execute' ? 'gpt-5.4' : 'gpt-4.1-mini';
|
|
348
|
-
balanceHint = `\n\n💡
|
|
347
|
+
balanceHint = `\n\n💡 Claude is handling most work right now (${balance.claudeCalls} ${tier} calls vs ${balance.openaiCalls} GPT). For isolated tasks, consider routing to GPT to balance subscriptions.`;
|
|
349
348
|
}
|
|
350
349
|
}
|
|
351
350
|
}
|
|
@@ -375,8 +374,7 @@ try {
|
|
|
375
374
|
// If we get here, a non-think model is being used for think work
|
|
376
375
|
const thinkBestFor = intelligence[expected || 'opus']?.best_for;
|
|
377
376
|
const thinkBestForSuffix = thinkBestFor ? ` (best for: ${thinkBestFor})` : '';
|
|
378
|
-
const msg =
|
|
379
|
-
`Don't send it to "${currentModel}" — keep it on the main session (${expected || 'opus'}${thinkBestForSuffix}) for best results.`;
|
|
377
|
+
const msg = `This looks like think-level work (architecture/review/planning) — better kept on the main session (${expected || 'opus'}${thinkBestForSuffix}) rather than delegated to ${currentModel}.`;
|
|
380
378
|
logRecommendation({
|
|
381
379
|
tier,
|
|
382
380
|
recommended: expected,
|
|
@@ -407,8 +405,7 @@ try {
|
|
|
407
405
|
const savings = tier === 'search' ? 'Haiku is 19x cheaper than Opus for read-only lookups.' : 'Sonnet is 5x cheaper than Opus for implementation work.';
|
|
408
406
|
const bestFor = intelligence[expected]?.best_for;
|
|
409
407
|
const bestForSuffix = bestFor ? ` (best for: ${bestFor})` : '';
|
|
410
|
-
const msg =
|
|
411
|
-
`Use \`model: "${expected}"\`${bestForSuffix} instead of "${currentModel || 'opus (inherited)'}". ${savings}`;
|
|
408
|
+
const msg = `This looks like ${tier} work — use ${expected}${bestForSuffix} instead of ${currentModel || 'opus (inherited)'}. ${savings}`;
|
|
412
409
|
logRecommendation({
|
|
413
410
|
tier,
|
|
414
411
|
recommended: expected,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* pruneOldFailures() → { pruned, remaining }
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { createHash } from 'crypto';
|
|
11
12
|
import { readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
12
13
|
import { dirname, join } from 'path';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
@@ -16,6 +17,19 @@ import { fileURLToPath } from 'url';
|
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Canonical prompt hash used by all hooks for failure-loop correlation.
|
|
22
|
+
* Both enforce-tier (PreToolUse) and cost-logger (PostToolUse) must use this
|
|
23
|
+
* same function so that recorded failures can be matched during escalation.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} toolInput — the raw tool_input from the hook payload
|
|
26
|
+
* @returns {string} 12-char hex hash
|
|
27
|
+
*/
|
|
28
|
+
function computePromptHash(toolInput) {
|
|
29
|
+
const text = (toolInput?.description || '') + (toolInput?.prompt || '');
|
|
30
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
/**
|
|
20
34
|
* Compute a decay weight based on failure age.
|
|
21
35
|
* 0-30 min → 1.0, 30-60 min → 0.5, 60-120 min → 0.25, >120 min → 0 (excluded by window)
|
|
@@ -121,4 +135,4 @@ function pruneOldFailures() {
|
|
|
121
135
|
return { pruned, remaining };
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
export { checkFailureLoop, recordFailure, pruneOldFailures };
|
|
138
|
+
export { computePromptHash, checkFailureLoop, recordFailure, pruneOldFailures };
|