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 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 (29 tests) |
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
 
@@ -437,16 +437,40 @@ function showProfilePicker(rl) {
437
437
 
438
438
  rl.question(' Choice: ', (answer) => {
439
439
  const names = Object.keys(PROFILES);
440
- const idx = parseInt(answer, 10) - 1;
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(names[idx], customOverrides);
448
- const pf = PROFILES[names[idx]];
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
  });
@@ -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 = createHash('md5').update(JSON.stringify(toolInput)).digest('hex').slice(0, 12);
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 {}
@@ -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 = createHash('sha256').update(text).digest('hex').slice(0, 12);
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 = `**[Wave] [Duplicate Warning]** A similar agent task was dispatched ${minutesAgo} minute${minutesAgo !== 1 ? 's' : ''} ago. Reuse the prior result unless the scope changed.`;
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 = `**[Duplicate Warning]** A similar agent task was dispatched ${minutesAgo} minute${minutesAgo !== 1 ? 's' : ''} ago. Reuse the prior result unless the scope changed.`;
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 = `**[Tier Enforcer]** This spans **${detectedTiers.join(' + ')}** work. Consider splitting: ` +
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 think tier (opus).' : '');
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
- ? `Dual-brain: dual-brain review recommended — ${riskResult.reason.split(':')[0]} detected`
315
- : `Dual-brain: promoting to think tier — ${riskResult.reason.split(':')[0]}`;
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, tier);
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 = 'Dual-brain: escalating to think tier — previous attempt failed';
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 = 'Dual-brain: dual-brain review recommended repeated failures detected';
325
+ autoStatus = 'Repeated failures detected — recommending dual-brain review to diagnose the issue.';
327
326
  }
328
- failureMessage = `**[Failure Loop]** ${failureCheck.count} failed attempts in 2hrs. Consider: \`node .claude/hooks/dual-brain-think.mjs --question "why is this failing?"\``;
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💡 **Balance tip:** Claude has ${balance.claudeCalls} ${tier} calls vs OpenAI's ${balance.openaiCalls} in the last 5hrs. Consider dispatching isolated work to GPT: \`node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model ${dispatchModel}\``;
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 = `**[Tier Enforcer]** This looks like **think** work (architecture/review/planning). ` +
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 = `**[Tier Enforcer]** This looks like **${tier}** work. ` +
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 };