dual-brain 4.0.1 → 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.
@@ -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.1",
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": {