dual-brain 4.0.0 → 4.1.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/CLAUDE.md CHANGED
@@ -64,18 +64,49 @@ Profile persists to `.claude/dual-brain.profile.json` (gitignored).
64
64
  Switch profiles: `npx dual-brain mode cost-saver`
65
65
  Check status: `npx dual-brain status`
66
66
 
67
+ Natural language aliases work everywhere: "go aggressive", "be careful", "cheap mode", "fast", "thorough", "smart". The system strips prefixes like "go"/"be"/"use" and resolves to the canonical profile name.
68
+
67
69
  ## Adaptive Routing (Auto Mode)
68
70
 
69
71
  Auto mode classifies risk from file paths and adjusts routing in real-time:
70
72
 
71
73
  - **Risk classification**: auth/secrets→critical, billing/migrations→high, tests/utils→medium, docs→low
72
- - **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain
74
+ - **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain. Uses time-weighted decay (recent failures count more) and ledger pruning for entries >24hrs.
73
75
  - **Provider balance**: Routes to underused provider when one subscription is hot
76
+ - **Burst awareness**: Suppresses duplicate warnings and balance hints during agent waves (3+ agents in 90s)
77
+
78
+ ## Vibe Coding
79
+
80
+ Casual natural language → structured work. The vibe coding system translates informal requests into properly routed, risk-classified, quality-gated work.
81
+
82
+ **Intent compiler** — decompose multi-task requests:
83
+ ```bash
84
+ node .claude/hooks/vibe-router.mjs "fix the login bug and also update the nav"
85
+ ```
86
+ Returns structured tasks with tier/risk classification, complexity level, quality gates, and wave strategy.
87
+
88
+ **Plan generator** — Steve-style 3-part markdown plans:
89
+ ```bash
90
+ node .claude/hooks/plan-generator.mjs --utterance "..." [--write]
91
+ ```
92
+ Generates: (1) dependency-ordered task table, (2) user stories + edge cases, (3) questions with suggested answers. Pass `--write` to save to `.claude/plans/`.
93
+
94
+ **Durable memory** — preferences persist across sessions:
95
+ ```bash
96
+ node .claude/hooks/vibe-memory.mjs # show state
97
+ node .claude/hooks/vibe-memory.mjs --set preferences.risk_tolerance=careful
98
+ node .claude/hooks/vibe-memory.mjs --threads # active work
99
+ node .claude/hooks/vibe-memory.mjs --infer # preference suggestions
100
+ ```
101
+ Tracks preferred profile, risk tolerance, active threads, and learns from usage patterns.
74
102
 
75
103
  ## Available Tools
76
104
 
105
+ - `node .claude/hooks/vibe-router.mjs "..."` — decompose casual requests into structured work
106
+ - `node .claude/hooks/plan-generator.mjs --utterance "..."` — generate execution plans
107
+ - `node .claude/hooks/vibe-memory.mjs` — persistent preferences and work threads
77
108
  - `node .claude/hooks/cost-report.mjs` — activity and cost estimates
78
109
  - `node .claude/hooks/health-check.mjs` — verify system health
79
110
  - `node .claude/hooks/budget-balancer.mjs` — provider balance status
80
111
  - `node .claude/hooks/decision-ledger.mjs` — routing outcome insights
81
- - `node .claude/hooks/test-orchestrator.mjs` — run self-tests
112
+ - `node .claude/hooks/test-orchestrator.mjs` — run self-tests (40 tests)
package/README.md CHANGED
@@ -51,10 +51,35 @@ npx -y dual-brain
51
51
 
52
52
  **Dual-brain** is recommended automatically for high-risk decisions — hooks detect the risk level and suggest dual-brain analysis, where both providers think on the same problem independently.
53
53
 
54
+ ## Vibe Coding
55
+
56
+ Speak naturally. The orchestrator handles the structure.
57
+
58
+ ```bash
59
+ # Decompose a casual request into structured work
60
+ node .claude/hooks/vibe-router.mjs "fix the login bug and also update the nav"
61
+
62
+ # Generate a Steve-style execution plan
63
+ node .claude/hooks/plan-generator.mjs --utterance "refactor the auth flow" --write
64
+
65
+ # Switch profiles with natural language
66
+ npx dual-brain mode "go aggressive"
67
+ npx dual-brain mode "be careful"
68
+ npx dual-brain mode "cheap"
69
+
70
+ # Check persistent preferences and work threads
71
+ node .claude/hooks/vibe-memory.mjs --threads
72
+ ```
73
+
74
+ The vibe-router splits multi-task requests, classifies risk, assigns tiers, and recommends quality gates. The plan-generator produces 3-part plans (dependency-ordered tasks, user stories, questions with suggested answers). Vibe-memory learns your preferences over time.
75
+
54
76
  ## Scripts
55
77
 
56
78
  | Script | Purpose |
57
79
  |--------|---------|
80
+ | `hooks/vibe-router.mjs` | Decompose casual language into structured work orders |
81
+ | `hooks/plan-generator.mjs` | Generate Steve-style 3-part execution plans |
82
+ | `hooks/vibe-memory.mjs` | Persistent preferences, work threads, preference inference |
58
83
  | `hooks/cost-report.mjs` | Activity & cost estimates by model tier |
59
84
  | `hooks/dual-brain-review.mjs` | Send git diff to GPT for independent review |
60
85
  | `hooks/dual-brain-think.mjs` | Dual-perspective analysis on architecture decisions |
@@ -63,7 +88,7 @@ npx -y dual-brain
63
88
  | `hooks/gpt-work-dispatcher.mjs` | Dispatch execution tasks to GPT via Codex CLI |
64
89
  | `hooks/session-report.mjs` | Session-end summary: activity, compliance, quality |
65
90
  | `hooks/health-check.mjs` | Verify all hooks and dependencies are working |
66
- | `hooks/test-orchestrator.mjs` | Self-test harness (39 tests) |
91
+ | `hooks/test-orchestrator.mjs` | Self-test harness (40 tests) |
67
92
  | `hooks/setup-wizard.mjs` | Interactive config (optional — for custom plans) |
68
93
  | `hooks/install-git-hooks.mjs` | Git pre-commit hook for quality gate |
69
94
 
@@ -30,6 +30,40 @@ const DUAL_BRAIN = resolve(__dirname, 'dual-brain-review.mjs');
30
30
 
31
31
  const RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
32
32
 
33
+ const APPROVAL_MAP = {
34
+ low: { recommendation: 'self_check', message: 'Low risk — self-check is sufficient' },
35
+ medium: { recommendation: 'review_recommended', message: 'Medium risk — a code review would catch edge cases' },
36
+ high: { recommendation: 'dual_brain_review', message: 'High risk — recommending dual-brain review for safety' },
37
+ critical: { recommendation: 'user_approval_needed', message: 'Critical risk — this needs your explicit approval before merging' },
38
+ };
39
+
40
+ /**
41
+ * Compute approval recommendation from risk level + profile overrides.
42
+ * Profile escalation: if dual_brain_minimum is at or below the current risk,
43
+ * escalate the recommendation by one tier (e.g. medium → dual_brain_review
44
+ * under quality-first where dual_brain_minimum is 'medium').
45
+ */
46
+ function computeApproval(risk, profileGate) {
47
+ let effectiveRisk = risk;
48
+
49
+ // Profile escalation: when dual_brain_minimum <= risk and the base
50
+ // recommendation would be below dual_brain_review, escalate one level.
51
+ const riskIdx = RISK_LEVELS.indexOf(risk);
52
+ const dualBrainIdx = RISK_LEVELS.indexOf(profileGate.dual_brain_minimum);
53
+ if (dualBrainIdx >= 0 && riskIdx >= dualBrainIdx && riskIdx < RISK_LEVELS.length - 1) {
54
+ const baseRec = APPROVAL_MAP[risk].recommendation;
55
+ if (baseRec !== 'dual_brain_review' && baseRec !== 'user_approval_needed') {
56
+ effectiveRisk = RISK_LEVELS[riskIdx + 1];
57
+ }
58
+ }
59
+
60
+ const entry = APPROVAL_MAP[effectiveRisk] || APPROVAL_MAP[risk];
61
+ return {
62
+ approval_recommendation: entry.recommendation,
63
+ approval_message: entry.message,
64
+ };
65
+ }
66
+
33
67
  function loadProfileGateSettings() {
34
68
  try {
35
69
  return _getProfileOverrides('quality-gate');
@@ -189,6 +223,7 @@ function main() {
189
223
  reason: `${sensitivity.risk} risk — below profile floor (${profileGate.sensitivity_floor})`,
190
224
  profile_floor: profileGate.sensitivity_floor,
191
225
  files: qualifyingFiles,
226
+ ...computeApproval(sensitivity.risk, profileGate),
192
227
  });
193
228
  }
194
229
 
@@ -267,6 +302,7 @@ function main() {
267
302
  }
268
303
 
269
304
  // 9. Build output object — common fields first
305
+ const approval = computeApproval(sensitivity.risk, profileGate);
270
306
  const output = {
271
307
  gate: gateStatus,
272
308
  risk: sensitivity.risk,
@@ -278,6 +314,8 @@ function main() {
278
314
  review_path: reviewFile,
279
315
  model: reviewResult.model || null,
280
316
  auth_type: reviewResult.auth_type || null,
317
+ approval_recommendation: approval.approval_recommendation,
318
+ approval_message: approval.approval_message,
281
319
  };
282
320
 
283
321
  // High risk: recommend dual-brain-think in addition
@@ -16,6 +16,7 @@
16
16
  * getTokenAverages() → moving averages of actual tokens by tier
17
17
  */
18
18
 
19
+ import { execSync as _execSync } from 'child_process';
19
20
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
20
21
  import { dirname, join } from 'path';
21
22
  import { fileURLToPath } from 'url';
@@ -65,6 +66,15 @@ function emptySummary() {
65
66
  dual_brain_useful: false,
66
67
  balance_posture: 'no activity yet',
67
68
  },
69
+
70
+ // Session handoff fields — enriched checkpoint for cross-session continuity
71
+ session_handoff: {
72
+ gate_passed: [], // completed milestones/tasks this session
73
+ evidence: [], // concrete evidence: commit hashes, file paths, PR URLs
74
+ pickup_prompt: 'none recorded', // one-sentence continuation prompt
75
+ friction: [], // problems encountered during the session
76
+ cross_workstream_patterns: [], // generalizable lessons beyond this task
77
+ },
68
78
  };
69
79
  }
70
80
 
@@ -158,6 +168,57 @@ function applyEntry(summary, entry) {
158
168
  avg.avg_output += (entry.output_tokens - avg.avg_output) / avg.count;
159
169
  }
160
170
 
171
+ // Session handoff: auto-populate from entry metadata
172
+ if (!summary.session_handoff) {
173
+ summary.session_handoff = {
174
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
175
+ friction: [], cross_workstream_patterns: [],
176
+ };
177
+ }
178
+
179
+ // Track completed gates/milestones from quality-gate or review results
180
+ if (entry.type === 'gate_result' && entry.gate === 'pass') {
181
+ summary.session_handoff.gate_passed.push({
182
+ what: entry.reason || 'quality gate passed',
183
+ ts,
184
+ });
185
+ }
186
+
187
+ // Track evidence: file paths from execute-tier entries, commit hashes, PR URLs
188
+ if (tier === 'execute' && entry.files_changed) {
189
+ const files = Array.isArray(entry.files_changed) ? entry.files_changed : [entry.files_changed];
190
+ for (const f of files) {
191
+ if (!summary.session_handoff.evidence.includes(f)) {
192
+ summary.session_handoff.evidence.push(f);
193
+ }
194
+ }
195
+ }
196
+ if (entry.commit_hash) {
197
+ const ref = `commit:${entry.commit_hash}`;
198
+ if (!summary.session_handoff.evidence.includes(ref)) {
199
+ summary.session_handoff.evidence.push(ref);
200
+ }
201
+ }
202
+ if (entry.pr_url) {
203
+ if (!summary.session_handoff.evidence.includes(entry.pr_url)) {
204
+ summary.session_handoff.evidence.push(entry.pr_url);
205
+ }
206
+ }
207
+
208
+ // Track friction: failures, escalations, retries
209
+ if (entry.type === 'failure' || entry.escalated || entry.retry) {
210
+ summary.session_handoff.friction.push({
211
+ what: entry.error || entry.reason || 'unknown failure',
212
+ tier,
213
+ provider,
214
+ ts,
215
+ });
216
+ // Keep friction list bounded
217
+ if (summary.session_handoff.friction.length > 50) {
218
+ summary.session_handoff.friction = summary.session_handoff.friction.slice(-50);
219
+ }
220
+ }
221
+
161
222
  // Codex latencies
162
223
  if (entry.codex_startup_ms != null) {
163
224
  summary.codex_latencies.push({
@@ -237,6 +298,125 @@ function getAdaptiveCodexThreshold(date) {
237
298
  };
238
299
  }
239
300
 
301
+ /**
302
+ * Update a specific session handoff field.
303
+ * Valid keys: gate_passed, evidence, pickup_prompt, friction, cross_workstream_patterns
304
+ *
305
+ * For array fields, `value` is appended (string or object).
306
+ * For pickup_prompt, `value` replaces the current string.
307
+ */
308
+ function updateHandoff(key, value, date) {
309
+ const arrayFields = ['gate_passed', 'evidence', 'friction', 'cross_workstream_patterns'];
310
+ const validKeys = [...arrayFields, 'pickup_prompt'];
311
+ if (!validKeys.includes(key)) return;
312
+
313
+ const summary = readSummary(date);
314
+ if (!summary.session_handoff) {
315
+ summary.session_handoff = {
316
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
317
+ friction: [], cross_workstream_patterns: [],
318
+ };
319
+ }
320
+
321
+ if (key === 'pickup_prompt') {
322
+ summary.session_handoff.pickup_prompt = String(value);
323
+ } else if (arrayFields.includes(key)) {
324
+ if (!Array.isArray(summary.session_handoff[key])) {
325
+ summary.session_handoff[key] = [];
326
+ }
327
+ summary.session_handoff[key].push(value);
328
+ }
329
+
330
+ summary.updated_at = new Date().toISOString();
331
+ atomicWrite(summaryPath(date), summary);
332
+ return summary;
333
+ }
334
+
335
+ /**
336
+ * Generate a full session checkpoint for handoff.
337
+ *
338
+ * Auto-enriches evidence from git state (changed files, HEAD commit)
339
+ * and builds a pickup prompt if none was set manually.
340
+ */
341
+ function generateCheckpoint(date) {
342
+ const summary = readSummary(date);
343
+
344
+ if (!summary.session_handoff) {
345
+ summary.session_handoff = {
346
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
347
+ friction: [], cross_workstream_patterns: [],
348
+ };
349
+ }
350
+
351
+ const handoff = summary.session_handoff;
352
+
353
+ // Auto-enrich evidence from git if available
354
+ try {
355
+ // Current HEAD commit
356
+ const head = _execSync('git rev-parse --short HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
357
+ if (head) {
358
+ const ref = `commit:${head}`;
359
+ if (!handoff.evidence.includes(ref)) {
360
+ handoff.evidence.push(ref);
361
+ }
362
+ }
363
+
364
+ // Changed files in working tree
365
+ const diff = _execSync('git diff --name-only HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
366
+ if (diff) {
367
+ for (const f of diff.split('\n').filter(Boolean)) {
368
+ const ref = `changed:${f}`;
369
+ if (!handoff.evidence.includes(ref)) {
370
+ handoff.evidence.push(ref);
371
+ }
372
+ }
373
+ }
374
+
375
+ // Current branch
376
+ const branch = _execSync('git branch --show-current 2>/dev/null', { encoding: 'utf8' }).trim();
377
+ if (branch) {
378
+ handoff.evidence.push(`branch:${branch}`);
379
+ }
380
+ } catch {
381
+ // Git not available — skip enrichment
382
+ }
383
+
384
+ // Auto-generate pickup_prompt if not manually set
385
+ if (handoff.pickup_prompt === 'none recorded' && summary.totals.calls > 0) {
386
+ const topTier = Object.entries(summary.totals.by_tier)
387
+ .sort(([, a], [, b]) => b - a)[0];
388
+ const tierLabel = topTier ? topTier[0] : 'mixed';
389
+ const fileCount = handoff.evidence.filter(e => e.startsWith('changed:')).length;
390
+ const frictionCount = handoff.friction.length;
391
+
392
+ let prompt = `Session had ${summary.totals.calls} calls (mostly ${tierLabel})`;
393
+ if (fileCount > 0) prompt += `, ${fileCount} files modified`;
394
+ if (frictionCount > 0) prompt += `, ${frictionCount} friction points to review`;
395
+ prompt += '.';
396
+ handoff.pickup_prompt = prompt;
397
+ }
398
+
399
+ // Build the checkpoint object
400
+ const checkpoint = {
401
+ version: 1,
402
+ generated_at: new Date().toISOString(),
403
+ date: summary.date,
404
+
405
+ // Existing summary data
406
+ totals: summary.totals,
407
+ session_insights: summary.session_insights,
408
+
409
+ // New handoff fields
410
+ gate_passed: handoff.gate_passed,
411
+ evidence: handoff.evidence,
412
+ pickup_prompt: handoff.pickup_prompt,
413
+ friction: handoff.friction,
414
+ cross_workstream_patterns: handoff.cross_workstream_patterns,
415
+ };
416
+
417
+ return checkpoint;
418
+ }
419
+
240
420
  export {
241
421
  readSummary,
242
422
  updateSummary,
@@ -246,5 +426,7 @@ export {
246
426
  getTokenAverages,
247
427
  getAdaptiveCodexThreshold,
248
428
  updateSessionInsight,
429
+ updateHandoff,
430
+ generateCheckpoint,
249
431
  atomicWrite,
250
432
  };
@@ -140,6 +140,46 @@ function determineQualityGates(tasks) {
140
140
  return [...gates];
141
141
  }
142
142
 
143
+ // ─── Ordered Language Detection ───────────────────────────────────────────
144
+
145
+ const DEPENDENCY_MARKERS = /\b(then|after\s+that|once\s+\S+\s+is\s+done|before|first|next|finally|afterwards|subsequently|followed\s+by|depends?\s+on|requires?)\b/i;
146
+
147
+ // ─── Subsystem Detection ─────────────────────────────────────────────────
148
+
149
+ const SUBSYSTEM_PATTERNS = [
150
+ { key: 'auth', regex: /\b(auth|login|sign[-\s]?in|sign[-\s]?up|session|credential|password|oauth|jwt|token)\b/i },
151
+ { key: 'billing', regex: /\b(billing|payment|subscription|invoice|charge|stripe|pricing)\b/i },
152
+ { key: 'api', regex: /\b(api|endpoint|route|controller|handler|middleware|rest|graphql)\b/i },
153
+ { key: 'ui', regex: /\b(ui|nav|button|page|component|layout|style|css|modal|form|menu|sidebar|header|footer|dashboard)\b/i },
154
+ { key: 'db', regex: /\b(database|db|schema|migration|model|query|table|column|index|sql|prisma|sequelize|knex)\b/i },
155
+ { key: 'infra', regex: /\b(deploy|ci|cd|docker|k8s|terraform|infra|pipeline|build|config|env)\b/i },
156
+ { key: 'test', regex: /\b(test|spec|fixture|mock|stub|assert|coverage)\b/i },
157
+ { key: 'docs', regex: /\b(doc|readme|changelog|guide|tutorial|comment)\b/i },
158
+ ];
159
+
160
+ function detectSubsystems(text) {
161
+ const subs = new Set();
162
+ for (const pat of SUBSYSTEM_PATTERNS) {
163
+ if (pat.regex.test(text)) subs.add(pat.key);
164
+ }
165
+ return subs;
166
+ }
167
+
168
+ // ─── Risk Domain Extraction ──────────────────────────────────────────────
169
+
170
+ function getRiskDomains(task) {
171
+ const domains = new Set();
172
+ // Use subsystem as risk domain
173
+ const subs = detectSubsystems(task.title);
174
+ for (const s of subs) domains.add(s);
175
+ // Also include explicit risk reason label
176
+ if (task.reason) {
177
+ const match = task.reason.match(/^([^(]+)/);
178
+ if (match) domains.add(match[1].trim().toLowerCase());
179
+ }
180
+ return domains;
181
+ }
182
+
143
183
  // ─── Complexity + Wave Recommendation ──────────────────────────────────────
144
184
 
145
185
  function determineComplexity(tasks) {
@@ -157,18 +197,102 @@ function determineComplexity(tasks) {
157
197
  return 'simple';
158
198
  }
159
199
 
160
- function determineWave(tasks, complexity) {
161
- if (tasks.length === 1) return 'single';
200
+ /**
201
+ * determineWave — Sequential by default, parallel only when tasks are truly independent.
202
+ *
203
+ * Returns { wave, reasons } where reasons is an array of reason codes:
204
+ * shared_surface — tasks likely touch same files
205
+ * high_risk — risky work should be sequential for review
206
+ * dependency_marker — ordered language detected in utterance
207
+ * same_subsystem — tasks in same domain/subsystem
208
+ * independent — truly independent, safe for parallel
209
+ */
210
+ function determineWave(tasks, complexity, utterance) {
211
+ if (tasks.length === 1) return { wave: 'single', reasons: [] };
212
+
213
+ const reasons = [];
214
+
215
+ // 1. Check for ordered language in the original utterance
216
+ if (utterance && DEPENDENCY_MARKERS.test(utterance)) {
217
+ reasons.push('dependency_marker');
218
+ }
162
219
 
163
- // If any task depends on another (sequential markers like "then", "after that"
164
- // were used), we already split them but keep sequential recommendation.
165
- // For now, check if tasks share the same tier — parallel is fine for independent work.
166
- const tiers = new Set(tasks.map(t => t.tier));
220
+ // 2. Check for high/critical risk tasks
167
221
  const hasHighRisk = tasks.some(t => t.risk === 'high' || t.risk === 'critical');
222
+ if (hasHighRisk) {
223
+ reasons.push('high_risk');
224
+ }
225
+
226
+ // 3. Check for overlapping subsystems between tasks
227
+ const taskSubsystems = tasks.map(t => detectSubsystems(t.title));
228
+ let hasSharedSubsystem = false;
229
+ for (let i = 0; i < taskSubsystems.length; i++) {
230
+ for (let j = i + 1; j < taskSubsystems.length; j++) {
231
+ for (const sub of taskSubsystems[i]) {
232
+ if (taskSubsystems[j].has(sub)) {
233
+ hasSharedSubsystem = true;
234
+ break;
235
+ }
236
+ }
237
+ if (hasSharedSubsystem) break;
238
+ }
239
+ if (hasSharedSubsystem) break;
240
+ }
241
+ if (hasSharedSubsystem) {
242
+ reasons.push('same_subsystem');
243
+ }
244
+
245
+ // 4. Check for overlapping file paths / shared surface area
246
+ const taskPaths = tasks.map(t => extractPaths(t.title));
247
+ let hasSharedPaths = false;
248
+ for (let i = 0; i < taskPaths.length; i++) {
249
+ for (let j = i + 1; j < taskPaths.length; j++) {
250
+ for (const p of taskPaths[i]) {
251
+ // Check if any path from task j shares a directory prefix or exact match
252
+ for (const q of taskPaths[j]) {
253
+ if (p === q || p.startsWith(q + '/') || q.startsWith(p + '/') ||
254
+ p.split('/').slice(0, -1).join('/') === q.split('/').slice(0, -1).join('/')) {
255
+ hasSharedPaths = true;
256
+ break;
257
+ }
258
+ }
259
+ if (hasSharedPaths) break;
260
+ }
261
+ if (hasSharedPaths) break;
262
+ }
263
+ if (hasSharedPaths) break;
264
+ }
265
+ if (hasSharedPaths) {
266
+ reasons.push('shared_surface');
267
+ }
268
+
269
+ // 5. Check for shared risk domains
270
+ const taskDomains = tasks.map(t => getRiskDomains(t));
271
+ let hasSharedDomain = false;
272
+ for (let i = 0; i < taskDomains.length; i++) {
273
+ for (let j = i + 1; j < taskDomains.length; j++) {
274
+ for (const d of taskDomains[i]) {
275
+ if (taskDomains[j].has(d)) {
276
+ hasSharedDomain = true;
277
+ break;
278
+ }
279
+ }
280
+ if (hasSharedDomain) break;
281
+ }
282
+ if (hasSharedDomain) break;
283
+ }
284
+ // Only add same_subsystem if not already added (risk domains overlap with subsystems)
285
+ if (hasSharedDomain && !reasons.includes('same_subsystem')) {
286
+ reasons.push('same_subsystem');
287
+ }
288
+
289
+ // Decision: parallel ONLY when no sequential reasons found
290
+ if (reasons.length === 0) {
291
+ reasons.push('independent');
292
+ return { wave: 'parallel', reasons };
293
+ }
168
294
 
169
- if (hasHighRisk) return 'sequential'; // high-risk tasks need careful ordering
170
- if (tiers.size === 1 && complexity !== 'complex') return 'parallel';
171
- return 'parallel';
295
+ return { wave: 'sequential', reasons };
172
296
  }
173
297
 
174
298
  // ─── Summary Generation ────────────────────────────────────────────────────
@@ -229,7 +353,7 @@ function routeVibe(utterance) {
229
353
  const profileHint = detectProfileHint(utterance);
230
354
  const qualityGates = determineQualityGates(tasks);
231
355
  const complexity = determineComplexity(tasks);
232
- const wave = determineWave(tasks, complexity);
356
+ const { wave, reasons } = determineWave(tasks, complexity, utterance);
233
357
  const summary = generateSummary(tasks, complexity, wave, qualityGates, profileHint);
234
358
 
235
359
  return {
@@ -238,6 +362,7 @@ function routeVibe(utterance) {
238
362
  profile_hint: profileHint,
239
363
  quality_gates: qualityGates,
240
364
  wave_recommendation: wave,
365
+ wave_reasons: reasons,
241
366
  summary,
242
367
  };
243
368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Dual-provider orchestration for Claude Code — tiered routing, budget balancing, and GPT dual-brain review across Claude + OpenAI subscriptions",
5
5
  "type": "module",
6
6
  "bin": {