dual-brain 7.1.21 → 7.1.22

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // dual-brain — CLI entry point. Commands: init, go, status, remember, forget
2
+ // dual-brain — CLI entry point. Commands: init, go, think, review, status, remember, forget
3
3
 
4
- import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
5
- import { join, dirname } from 'node:path';
4
+ import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync, watch as fsWatch } from 'node:fs';
5
+ import { join, dirname, basename, extname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
8
8
  import { createInterface } from 'node:readline';
@@ -12,6 +12,7 @@ import {
12
12
  rememberPreference, forgetPreference, getActivePreferences,
13
13
  getAvailableProviders, isSoloBrain, getHeadModel,
14
14
  detectAuth, detectEnvironment, detectPlans,
15
+ detectCapabilities,
15
16
  saveSubscription, listSubscriptions,
16
17
  autoSetup,
17
18
  } from '../src/profile.mjs';
@@ -28,11 +29,31 @@ import {
28
29
 
29
30
  import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
30
31
 
32
+ import { runPipeline, buildExecutionPlan, formatExecutionPlan } from '../src/pipeline.mjs';
33
+
31
34
  import { loadRepoCache } from '../src/repo.mjs';
32
- import { loadSession, saveSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions } from '../src/session.mjs';
35
+ import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
33
36
 
34
37
  import { box, bar, badge, menu, separator } from '../src/tui.mjs';
35
38
 
39
+ // ─── Dynamic imports for receipts + failure memory ───────────────────────────
40
+
41
+ let _receipt = null;
42
+ async function getReceipt() {
43
+ if (!_receipt) {
44
+ try { _receipt = await import('../src/receipt.mjs'); } catch { _receipt = {}; }
45
+ }
46
+ return _receipt;
47
+ }
48
+
49
+ let _failureMem = null;
50
+ async function getFailureMem() {
51
+ if (!_failureMem) {
52
+ try { _failureMem = await import('../src/failure-memory.mjs'); } catch { _failureMem = {}; }
53
+ }
54
+ return _failureMem;
55
+ }
56
+
36
57
  // ─── Helpers ─────────────────────────────────────────────────────────────────
37
58
 
38
59
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -155,20 +176,26 @@ function printHelp() {
155
176
  dual-brain <command> [options]
156
177
 
157
178
  Commands:
179
+ plan "task" Scope and plan without executing (dry-run)
180
+ do "task" Implement — detect, route, execute, verify
181
+ review Challenge current changes with dual-brain
182
+ ship Test, commit, and prepare to ship
183
+
158
184
  init First-time setup → flows into interactive REPL
159
- auth Show subscription and login status
185
+ auth Show provider login and plan status
160
186
  install Install Claude Code hooks into the current project
161
- go "task description" Detect → decide → dispatch a task
187
+ go "task description" Detect → decide → dispatch (alias for do)
162
188
  --dry-run Show routing decision without executing
163
189
  --files a.mjs,b.mjs Provide file context for risk classification
164
190
  --verbose, -v Print routing trace (intent, risk, health, model selection)
191
+ think "question" Multi-round architecture decision with dual-brain
165
192
  status Provider health, session stats, available models
166
193
  --verbose, -v Also print profile file path and raw profile object
167
194
  hot <provider> Manually mark all model classes for provider as hot
168
195
  cool <provider> Manually clear hot state for a provider
169
196
  remember "preference" Save a project-scoped preference
170
197
  forget "preference" Remove a preference by fuzzy match
171
- search "keyword" Search across all sessions
198
+ search "keyword" Search across all sessions
172
199
  specialists List available specialist agents with descriptions
173
200
  python "task" Force Python specialist for the task
174
201
  typescript "task" Force TypeScript specialist for the task
@@ -177,6 +204,8 @@ Commands:
177
204
  security "task" Force Security specialist for the task
178
205
  --dry-run (specialist commands) Show routing without executing
179
206
  --files a,b (specialist commands) Provide file context
207
+ watch [dir] Monitor file changes and suggest actions
208
+ --auto Auto-execute safe suggestions (tests, install)
180
209
  shell-hook Output bash snippet to add dual-brain to your shell
181
210
  Usage: dual-brain shell-hook >> ~/.bashrc
182
211
 
@@ -218,7 +247,7 @@ function detectReplitTools(cwd) {
218
247
  // ─── Subscription status table ────────────────────────────────────────────────
219
248
 
220
249
  /**
221
- * Print a subscription status table to stdout.
250
+ * Print a provider status table to stdout.
222
251
  */
223
252
  function printSubscriptionTable(auth, profile) {
224
253
  const W = 55;
@@ -232,10 +261,10 @@ function printSubscriptionTable(auth, profile) {
232
261
  const openaiSub = profile?.providers?.openai;
233
262
 
234
263
  const claudePlanLabel = claudeSub?.enabled
235
- ? ({ pro: 'Pro ($20/mo)', max5: 'Max x5 ($100/mo)', max20: 'Max x20 ($200/mo)', '$20': 'Pro ($20/mo)', '$100': 'Max x5 ($100/mo)', '$200': 'Max x20 ($200/mo)' }[claudeSub.plan] ?? claudeSub.plan)
264
+ ? ({ pro: 'Pro', max5: 'Max x5', max20: 'Max x20', '$20': 'Pro', '$100': 'Max x5', '$200': 'Max x20' }[claudeSub.plan] ?? claudeSub.plan) // doctor:verified — config value lookup
236
265
  : 'disabled';
237
266
  const openaiPlanLabel = openaiSub?.enabled
238
- ? ({ plus: 'Plus ($20/mo)', pro: 'Pro ($100/mo)', pro100: 'Pro ($100/mo)', pro200: 'Pro ($200/mo)', '$20': 'Plus ($20/mo)', '$100': 'Pro ($100/mo)', '$200': 'Pro ($200/mo)' }[openaiSub.plan] ?? openaiSub.plan)
267
+ ? ({ plus: 'Plus', pro: 'Pro', pro100: 'Pro', pro200: 'Pro (higher limits)', '$20': 'Plus', '$100': 'Pro', '$200': 'Pro (higher limits)' }[openaiSub.plan] ?? openaiSub.plan) // doctor:verified — config value lookup
239
268
  : 'disabled';
240
269
 
241
270
  const claudeLabel = claudeSub?.label ? ` [${claudeSub.label}]` : '';
@@ -252,7 +281,7 @@ function printSubscriptionTable(auth, profile) {
252
281
  const openaiLine2 = ` plan: ${openaiPlanLabel}${openaiLabel}`;
253
282
 
254
283
  console.log(`╔${hbar}╗`);
255
- console.log(`║${pad(' Subscription Status')}║`);
284
+ console.log(`║${pad(' Provider Status')}║`);
256
285
  console.log(`╠${hbar}╣`);
257
286
  console.log(`║${pad(claudeLine1)}║`);
258
287
  console.log(`║${pad(claudeLine2)}║`);
@@ -297,7 +326,7 @@ async function cmdInit(rl) {
297
326
  }
298
327
 
299
328
  /**
300
- * Show subscription status (replaces old API key auth display).
329
+ * Show provider login and plan status.
301
330
  */
302
331
  async function cmdAuth(subArgs = []) {
303
332
  const auth = await detectAuth();
@@ -311,8 +340,8 @@ async function cmdAuth(subArgs = []) {
311
340
  }
312
341
  }
313
342
 
314
- async function cmdGo(args) {
315
- const dryRun = args.includes('--dry-run');
343
+ async function cmdGo(args, opts = {}) {
344
+ const dryRun = opts.dryRun || args.includes('--dry-run');
316
345
  const verbose = args.includes('--verbose') || args.includes('-v');
317
346
  const filesRaw = flag(args, '--files');
318
347
  const files = filesRaw && typeof filesRaw === 'string'
@@ -323,100 +352,310 @@ async function cmdGo(args) {
323
352
  const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
324
353
  if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b] [--verbose]');
325
354
 
326
- const cwd = process.cwd();
327
- const profile = await ensureProfile(cwd);
328
- const detection = detectTask({ prompt, files });
329
-
330
- // Print the one-sentence classification
331
- console.log(detection.explanation);
332
-
333
- // Verbose: emit detection trace before routing decision
334
- if (verbose) {
335
- vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
336
- vtrace(`Tier: ${detection.tier} | Files: ${detection.fileCount ?? files.length} | Requires write: ${detection.requiresWrite}`);
337
- }
338
-
339
- // Verbose: emit provider health scores before dispatch
340
- if (verbose) {
341
- const providers = getAvailableProviders(profile);
342
- const { states } = getHealth(cwd);
343
- const providerScores = ['claude', 'openai'].map(name => {
344
- const enabled = providers.some(p => p.name === name);
345
- if (!enabled) return `${name}=unavailable`;
346
- // Find any state entry for this provider
347
- const statuses = Object.entries(states)
348
- .filter(([k]) => k.startsWith(`${name}:`))
349
- .map(([, v]) => v.status);
350
- const worst = statuses.includes('hot') ? 'hot'
351
- : statuses.includes('probing') ? 'probing'
352
- : statuses.includes('degraded') ? 'degraded'
353
- : 'healthy';
354
- return `${name}=${worst}`;
355
- }).join(' ');
356
- vtrace(`Provider health: ${providerScores}`);
357
- }
355
+ const cwd = process.cwd();
356
+ await ensureProfile(cwd);
358
357
 
359
- const decision = decideRoute({ profile, detection, cwd });
358
+ if (verbose) console.log('\nDispatching...');
360
359
 
361
- // Verbose: emit model selection and dual-brain rationale
362
- if (verbose) {
363
- const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
364
- const modelStatus = getAvailableModels(profile)[decision.provider]?.includes(decision.model)
365
- ? 'available, matches tier'
366
- : 'selected';
367
- vtrace(`Model selection: ${modelLabel} (${modelStatus})`);
368
- vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
360
+ // ── Failure memory: check history before dispatching ──────────────────────
361
+ const failureMem = await getFailureMem();
362
+ if (failureMem.checkFailureHistory && failureMem.formatEscalation) {
363
+ try {
364
+ const failureHistory = await failureMem.checkFailureHistory(prompt, files, cwd);
365
+ if (failureHistory?.escalation?.recommended) {
366
+ console.log(failureMem.formatEscalation(failureHistory.escalation));
367
+ }
368
+ } catch { /* non-fatal */ }
369
369
  }
370
370
 
371
- // Print routing table
372
- console.log(` provider : ${decision.provider}`);
373
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
374
- console.log(` tier : ${decision.tier}`);
375
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
376
- console.log(` reason : ${decision.explanation}`);
371
+ const { plan, result } = await runPipeline('go', prompt, {
372
+ files,
373
+ cwd,
374
+ verbose,
375
+ dryRun,
376
+ });
377
377
 
378
378
  if (dryRun) {
379
+ // formatExecutionPlan already printed by pipeline when verbose/dryRun=true
379
380
  console.log('\n(dry-run — not executing)');
380
381
  return;
381
382
  }
382
383
 
383
- console.log('\nDispatching...');
384
- let result;
385
- if (decision.dualBrain) {
386
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
384
+ if (!result) return;
385
+
386
+ // Display result — dual-brain vs single-provider
387
+ if (result.consensus) {
387
388
  console.log(`\nConsensus: ${result.consensus}`);
388
389
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
389
390
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
390
- // Save session state
391
+
392
+ // Receipt
393
+ const receipt = await getReceipt();
394
+ if (receipt.buildReceipt && receipt.formatReceipt) {
395
+ try {
396
+ const r = receipt.buildReceipt(result, plan, null);
397
+ console.log(receipt.formatReceipt(r));
398
+ } catch { /* non-fatal */ }
399
+ }
400
+
391
401
  saveSession({
392
402
  objective: prompt,
393
403
  branch: null,
394
404
  filesChanged: files,
395
405
  commandsRun: [`dual-brain go "${prompt}"`],
396
406
  lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
397
- provider: decision.provider,
407
+ provider: plan?._decision?.provider ?? 'claude',
398
408
  nextAction: null,
399
409
  }, cwd);
410
+
411
+ // Clear failure memory on success
412
+ if (failureMem.clearFailures) {
413
+ try { await failureMem.clearFailures(prompt, cwd); } catch { /* non-fatal */ }
414
+ }
415
+
416
+ // ── Next steps suggestions (dual-brain consensus path) ──────────────────
417
+ try {
418
+ const { suggestNextSteps, formatNextSteps } = await import('../src/nextstep.mjs');
419
+ const steps = await suggestNextSteps(
420
+ { prompt, tier: plan?._decision?.tier ?? 'think', files, trigger: 'go' },
421
+ { success: true, filesChanged: files, error: null, duration: null },
422
+ cwd
423
+ );
424
+ if (steps?.steps?.length > 0) {
425
+ console.log('\n' + formatNextSteps(steps.steps, 3));
426
+ }
427
+ } catch { /* non-fatal */ }
400
428
  } else {
401
- result = await dispatch({ decision, prompt, files, cwd });
402
- const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
403
- console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
429
+ const succeeded = result.status === 'completed';
430
+ const statusLine = succeeded ? 'Done' : `Failed (exit ${result.exitCode})`;
431
+ console.log(`\n${statusLine}${result.durationMs != null ? ` in ${(result.durationMs / 1000).toFixed(1)}s` : ''}`);
404
432
  if (result.summary) console.log(result.summary);
405
433
  if (result.error) process.stderr.write(`${result.error}\n`);
406
- // Save session state regardless of success/failure
434
+
435
+ // Receipt
436
+ const receipt = await getReceipt();
437
+ if (succeeded && receipt.buildReceipt && receipt.formatReceipt) {
438
+ try {
439
+ const r = receipt.buildReceipt(result, plan, null);
440
+ console.log(receipt.formatReceipt(r));
441
+ } catch { /* non-fatal */ }
442
+ } else if (!succeeded && receipt.buildReceipt && receipt.formatFailureReceipt) {
443
+ try {
444
+ const r = receipt.buildReceipt(result, plan, null);
445
+ console.log(receipt.formatFailureReceipt(r, { error: result.error }));
446
+ } catch { /* non-fatal */ }
447
+ }
448
+
407
449
  saveSession({
408
450
  objective: prompt,
409
451
  branch: null,
410
452
  filesChanged: files,
411
453
  commandsRun: [`dual-brain go "${prompt}"`],
412
454
  lastResult: {
413
- status: result.status === 'completed' ? 'success' : 'failure',
414
- summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
455
+ status: succeeded ? 'success' : 'failure',
456
+ summary: result.summary || (succeeded ? 'completed' : `exit ${result.exitCode}`),
415
457
  },
416
- provider: decision.provider,
458
+ provider: plan?._decision?.provider ?? 'claude',
417
459
  nextAction: null,
418
460
  }, cwd);
419
- if (result.status !== 'completed') process.exit(1);
461
+
462
+ if (!succeeded) {
463
+ // Record failure memory
464
+ if (failureMem.recordFailure) {
465
+ try { await failureMem.recordFailure(prompt, plan, result.error, cwd); } catch { /* non-fatal */ }
466
+ }
467
+ process.exit(1);
468
+ }
469
+
470
+ // Clear failure memory on success
471
+ if (failureMem.clearFailures) {
472
+ try { await failureMem.clearFailures(prompt, cwd); } catch { /* non-fatal */ }
473
+ }
474
+
475
+ await offerAutoCommit(cwd);
476
+ // ── Next steps suggestions ──────────────────────────────────────────────
477
+ try {
478
+ const { suggestNextSteps, formatNextSteps } = await import('../src/nextstep.mjs');
479
+ const steps = await suggestNextSteps(
480
+ {
481
+ prompt,
482
+ tier: plan?._decision?.tier ?? 'execute',
483
+ files: result.filesChanged || files,
484
+ trigger: 'go',
485
+ },
486
+ {
487
+ success: result.status === 'completed',
488
+ filesChanged: result.filesChanged || files,
489
+ error: result.error,
490
+ duration: result.durationMs,
491
+ },
492
+ cwd
493
+ );
494
+ if (steps?.steps?.length > 0) {
495
+ console.log('\n' + formatNextSteps(steps.steps, 3));
496
+ }
497
+ } catch { /* non-fatal — module may not exist yet */ }
498
+ }
499
+ }
500
+
501
+ async function cmdThink(args) {
502
+ const question = args.find(a => !a.startsWith('--') && !a.startsWith('-'));
503
+ if (!question) err('Usage: dual-brain think "architecture question or design decision"');
504
+
505
+ const cwd = process.cwd();
506
+ await ensureProfile(cwd);
507
+
508
+ const { result, verification } = await runPipeline('think', question, {
509
+ cwd,
510
+ verbose: true,
511
+ });
512
+
513
+ if (!result) return;
514
+
515
+ if (result.consensus) {
516
+ console.log(`\nConsensus: ${result.consensus}`);
517
+ if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
518
+ if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
519
+ } else {
520
+ if (result.summary) console.log(`\n${result.summary}`);
521
+ if (result.error) process.stderr.write(`${result.error}\n`);
522
+ if (result.status && result.status !== 'completed') process.exit(1);
523
+ }
524
+
525
+ if (verification && !verification.ok) {
526
+ for (const note of verification.notes) process.stderr.write(` note: ${note}\n`);
527
+ }
528
+ }
529
+
530
+ async function cmdReview(_args) {
531
+ const cwd = process.cwd();
532
+ await ensureProfile(cwd);
533
+
534
+ const { result, verification } = await runPipeline('review', 'review current diff', {
535
+ cwd,
536
+ verbose: true,
537
+ });
538
+
539
+ if (!result) return;
540
+
541
+ if (result.consensus) {
542
+ console.log(`\nConsensus: ${result.consensus}`);
543
+ if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
544
+ if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
545
+ } else {
546
+ if (result.summary) console.log(`\n${result.summary}`);
547
+ if (result.error) process.stderr.write(`${result.error}\n`);
548
+ if (result.status && result.status !== 'completed') process.exit(1);
549
+ }
550
+
551
+ if (verification && !verification.ok) {
552
+ for (const note of verification.notes) process.stderr.write(` note: ${note}\n`);
553
+ }
554
+ }
555
+
556
+ async function cmdShip() {
557
+ const cwd = process.cwd();
558
+
559
+ console.log('\n── ship: finalizing ──────────────────────────────────────\n');
560
+
561
+ // 1. Check for test script
562
+ let hasTests = false;
563
+ let testScript = null;
564
+ try {
565
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
566
+ testScript = pkg?.scripts?.test;
567
+ hasTests = Boolean(testScript && !testScript.includes('echo'));
568
+ } catch { /* no package.json */ }
569
+
570
+ // 2. Run tests if available
571
+ let testsPassed = null;
572
+ if (hasTests) {
573
+ console.log('Running tests...\n');
574
+ const testResult = _spawnSyncTop('npm', ['test'], {
575
+ cwd,
576
+ encoding: 'utf8',
577
+ stdio: ['pipe', 'pipe', 'pipe'],
578
+ timeout: 60000,
579
+ });
580
+ const testOut = (testResult.stdout || '') + (testResult.stderr || '');
581
+ if (testOut) console.log(testOut.slice(0, 3000));
582
+ testsPassed = testResult.status === 0;
583
+ console.log(testsPassed ? 'Tests: PASS' : 'Tests: FAIL');
584
+ } else {
585
+ console.log('(no test script found in package.json — skipping tests)');
586
+ testsPassed = null;
587
+ }
588
+
589
+ // 3. Git status
590
+ let changedFiles = [];
591
+ let currentBranch = 'unknown';
592
+ try {
593
+ const statusResult = _spawnSyncTop('git', ['status', '--porcelain'], {
594
+ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
595
+ });
596
+ changedFiles = (statusResult.stdout || '').trim().split('\n').filter(Boolean);
597
+ const branchResult = _spawnSyncTop('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
598
+ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000,
599
+ });
600
+ currentBranch = (branchResult.stdout || '').trim() || 'unknown';
601
+ } catch { /* non-fatal */ }
602
+
603
+ if (changedFiles.length > 0) {
604
+ console.log('\nChanged files:');
605
+ changedFiles.slice(0, 20).forEach(f => console.log(` ${f}`));
606
+ if (changedFiles.length > 20) console.log(` ... and ${changedFiles.length - 20} more`);
607
+ } else {
608
+ console.log('\nNo uncommitted changes.');
609
+ }
610
+
611
+ // 4. Generate commit message suggestion
612
+ let commitMsg = null;
613
+ try {
614
+ const diffResult = _spawnSyncTop('git', ['diff', '--name-only', 'HEAD'], {
615
+ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
616
+ });
617
+ const diffFiles = (diffResult.stdout || '').trim().split('\n').filter(Boolean);
618
+ if (diffFiles.length > 0) {
619
+ const fileList = diffFiles.slice(0, 5).map(f => basename(f)).join(', ');
620
+ const suffix = diffFiles.length > 5 ? ` and ${diffFiles.length - 5} more` : '';
621
+ commitMsg = `update ${fileList}${suffix}`;
622
+ }
623
+ } catch { /* non-fatal */ }
624
+
625
+ // 5. Show suggested actions
626
+ console.log('\n── suggested actions ─────────────────────────────────────\n');
627
+
628
+ if (testsPassed === false) {
629
+ console.log(' ⚠ Tests failed — fix before committing.');
630
+ } else {
631
+ if (changedFiles.length > 0 && commitMsg) {
632
+ console.log(` Commit: git add -p && git commit -m "${commitMsg}"`);
633
+ } else if (changedFiles.length === 0) {
634
+ console.log(' Nothing to commit — working tree clean.');
635
+ }
636
+
637
+ const isMain = currentBranch === 'main' || currentBranch === 'master';
638
+ if (!isMain && currentBranch !== 'unknown') {
639
+ console.log(` PR: gh pr create --title "${commitMsg || 'update'}" --body "..."`);}
640
+ else if (isMain) {
641
+ console.log(' (on main — consider branching before PR)');
642
+ }
643
+ }
644
+
645
+ // 6. Receipt
646
+ const receipt = await getReceipt();
647
+ if (receipt.buildReceiptFromOutcome && receipt.formatReceipt) {
648
+ try {
649
+ const r = receipt.buildReceiptFromOutcome({
650
+ command: 'ship',
651
+ branch: currentBranch,
652
+ filesChanged: changedFiles.length,
653
+ testsPassed,
654
+ });
655
+ console.log('\n' + receipt.formatReceipt(r));
656
+ } catch { /* non-fatal */ }
657
+ } else {
658
+ console.log(`\nReceipt: branch=${currentBranch} files=${changedFiles.length} tests=${testsPassed === null ? 'skipped' : testsPassed ? 'pass' : 'fail'}`);
420
659
  }
421
660
  }
422
661
 
@@ -445,8 +684,9 @@ async function cmdStatus(args = []) {
445
684
  .filter(([k]) => k.startsWith(`${p.name}:`));
446
685
  const sess = sessionStats[p.name] ?? { calls: 0, tokens: 0 };
447
686
 
687
+ const planStr = p.plan ? ` plan=${p.plan}` : '';
448
688
  if (provStates.length === 0) {
449
- console.log(` ${label} plan=${p.plan} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
689
+ console.log(` ${label}${planStr} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
450
690
  } else {
451
691
  for (const [k, st] of provStates) {
452
692
  const modelClass = k.split(':').slice(1).join(':');
@@ -455,7 +695,7 @@ async function cmdStatus(args = []) {
455
695
  const remaining = remainingCooldownMinutes(p.name, modelClass, cwd);
456
696
  statusStr = remaining > 0 ? `hot (retry in ${remaining}m)` : 'hot (cooling)';
457
697
  }
458
- console.log(` ${label} plan=${p.plan} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
698
+ console.log(` ${label}${planStr} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
459
699
  }
460
700
  }
461
701
  }
@@ -669,7 +909,7 @@ function cmdBreakGlass(reason) {
669
909
  // ─── Screen helpers ───────────────────────────────────────────────────────────
670
910
 
671
911
  /**
672
- * Render the data-tools-style rounded header box for the main screen.
912
+ * Render the dual-brain-style rounded header box for the main screen.
673
913
  * Inner width is 39 chars. Lines are padded with spaces to fill the box.
674
914
  */
675
915
  function renderHeader(version, providerLines, dtVersion) {
@@ -683,16 +923,19 @@ function renderHeader(version, providerLines, dtVersion) {
683
923
  const sep = ` ├${'─'.repeat(W)}┤`;
684
924
  const bottom = ` └${'─'.repeat(W)}┘`;
685
925
 
686
- const title = dtVersion ? `DATA Tools v${dtVersion}` : `DATA Tools`;
687
- const subTitle = `🧠 Dual Brain v${version}`;
688
- const credit = `by Steve Moraco + dual-brain`;
926
+ const title = `🧠 Dual Brain v${version}`;
927
+ const credit = `dual-brain`;
928
+
929
+ const allProviderLines = [...providerLines];
930
+ if (dtVersion) {
931
+ allProviderLines.push(`📦 replit-tools v${dtVersion} detected`);
932
+ }
689
933
 
690
934
  const lines = [top];
691
935
  lines.push(` │ ${pad(title)}│`);
692
- lines.push(` │ ${pad(subTitle)}│`);
693
936
  lines.push(` │ ${pad(credit)}│`);
694
937
  lines.push(sep);
695
- for (const pl of providerLines) {
938
+ for (const pl of allProviderLines) {
696
939
  lines.push(` │ ${pad(pl)}│`);
697
940
  }
698
941
  lines.push(bottom);
@@ -709,21 +952,21 @@ function profileExists(cwd) {
709
952
  // ─── Plan label helpers ───────────────────────────────────────────────────────
710
953
 
711
954
  const CLAUDE_PLAN_LABELS = {
712
- pro: 'Pro ($20/mo)',
713
- max5: 'Max x5 ($100/mo)',
714
- max20: 'Max x20 ($200/mo)',
715
- '$20': 'Pro ($20/mo)',
716
- '$100': 'Max x5 ($100/mo)',
717
- '$200': 'Max x20 ($200/mo)',
955
+ pro: 'Pro',
956
+ max5: 'Max x5',
957
+ max20: 'Max x20',
958
+ '$20': 'Pro', // doctor:verified — backward-compat key for legacy stored plan value
959
+ '$100': 'Max x5', // doctor:verified — backward-compat key for legacy stored plan value
960
+ '$200': 'Max x20', // doctor:verified — backward-compat key for legacy stored plan value
718
961
  };
719
962
  const OPENAI_PLAN_LABELS = {
720
- plus: 'Plus ($20/mo)',
721
- pro: 'Pro ($100/mo)',
722
- pro100: 'Pro ($100/mo)',
723
- pro200: 'Pro ($200/mo)',
724
- '$20': 'Plus ($20/mo)',
725
- '$100': 'Pro ($100/mo)',
726
- '$200': 'Pro ($200/mo)',
963
+ plus: 'Plus',
964
+ pro: 'Pro',
965
+ pro100: 'Pro',
966
+ pro200: 'Pro (higher limits)',
967
+ '$20': 'Plus', // doctor:verified — backward-compat key for legacy stored plan value
968
+ '$100': 'Pro', // doctor:verified — backward-compat key for legacy stored plan value
969
+ '$200': 'Pro (higher limits)', // doctor:verified — backward-compat key for legacy stored plan value
727
970
  };
728
971
 
729
972
  // ─── Screen: welcomeScreen ────────────────────────────────────────────────────
@@ -809,7 +1052,7 @@ async function welcomeScreen(rl, ask) {
809
1052
  }
810
1053
 
811
1054
  console.log(' [Enter] Save and go');
812
- console.log(' [c] Customize plan tier');
1055
+ console.log(' [c] Customize work style');
813
1056
  if (existingSessions.length > 0) {
814
1057
  console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
815
1058
  }
@@ -872,10 +1115,10 @@ async function welcomeScreen(rl, ask) {
872
1115
  // Claude plan picker
873
1116
  if (claudeReady) {
874
1117
  console.log('');
875
- console.log(separator('Claude subscription'));
876
- console.log(' (1) Pro ($20/mo)');
877
- console.log(' (2) Max x5 ($100/mo)');
878
- console.log(' (3) Max x20 ($200/mo)');
1118
+ console.log(separator('Claude plan'));
1119
+ console.log(' (1) Pro');
1120
+ console.log(' (2) Max x5');
1121
+ console.log(' (3) Max x20');
879
1122
  console.log(' (4) Skip');
880
1123
  const claudeChoice = (await ask('> ')).trim();
881
1124
  const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
@@ -892,10 +1135,10 @@ async function welcomeScreen(rl, ask) {
892
1135
  // OpenAI plan picker
893
1136
  if (openaiReady) {
894
1137
  console.log('');
895
- console.log(separator('OpenAI subscription'));
896
- console.log(' (1) Plus ($20/mo)');
897
- console.log(' (2) Pro ($100/mo)');
898
- console.log(' (3) Pro ($200/mo higher limits)');
1138
+ console.log(separator('OpenAI plan'));
1139
+ console.log(' (1) Plus');
1140
+ console.log(' (2) Pro');
1141
+ console.log(' (3) Pro (higher limits)');
899
1142
  console.log(' (4) Skip');
900
1143
  const openaiChoice = (await ask('> ')).trim();
901
1144
  const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
@@ -920,8 +1163,8 @@ async function welcomeScreen(rl, ask) {
920
1163
 
921
1164
  // Team setup
922
1165
  console.log('');
923
- console.log(' Team auth: label subscriptions and set expiry for auto-refresh.');
924
- console.log(' When a subscription expires, dual-brain will prompt re-login automatically.');
1166
+ console.log(' Team auth: label providers and set expiry for auto-refresh.');
1167
+ console.log(' When a provider link expires, dual-brain will prompt re-login automatically.');
925
1168
  console.log('');
926
1169
  console.log(' [Enter] Skip [t] Set up team auth');
927
1170
  const teamChoice = (await ask(' Choice: ')).trim().toLowerCase();
@@ -929,7 +1172,7 @@ async function welcomeScreen(rl, ask) {
929
1172
  for (const provider of ['claude', 'openai']) {
930
1173
  if (!existingProfile.providers[provider]?.enabled) continue;
931
1174
  const provLabel = provider === 'claude' ? 'Claude' : 'OpenAI';
932
- const label = (await ask(` ${provLabel} label (e.g. "Josh's $100 sub"): `)).trim();
1175
+ const label = (await ask(` ${provLabel} label (e.g. "Josh's work account"): `)).trim();
933
1176
  if (label) existingProfile.providers[provider].label = label;
934
1177
  const expiry = await askExpiry(ask, provLabel);
935
1178
  if (expiry) existingProfile.providers[provider].expiresAt = expiry;
@@ -995,115 +1238,344 @@ function loadTerminalState(cwd, terminalId) {
995
1238
  } catch { return null; }
996
1239
  }
997
1240
 
998
- // ─── Screen: mainScreen ───────────────────────────────────────────────────────
1241
+ // ─── PR Detection ─────────────────────────────────────────────────────────────
999
1242
 
1000
- async function mainScreen(rl, ask) {
1001
- const cwd = process.cwd();
1002
- const version = readVersion();
1003
- const profile = loadProfile(cwd);
1004
- const auth = await detectAuth();
1243
+ /**
1244
+ * Detect open PRs using the gh CLI.
1245
+ * Gracefully returns [] if gh is not installed, no remote, no auth, or no PRs.
1246
+ *
1247
+ * @param {string} cwd
1248
+ * @returns {Promise<Array>}
1249
+ */
1250
+ async function detectOpenPRs(cwd) {
1251
+ try {
1252
+ // 1. Check if gh CLI exists (1s timeout)
1253
+ const ghCheck = _spawnSyncTop('which', ['gh'], {
1254
+ encoding: 'utf8',
1255
+ stdio: ['pipe', 'pipe', 'pipe'],
1256
+ timeout: 1000,
1257
+ });
1258
+ if (ghCheck.status !== 0) return [];
1005
1259
 
1006
- const claudeSub = profile?.providers?.claude;
1007
- const openaiSub = profile?.providers?.openai;
1008
- const claudePlan = claudeSub?.plan ?? 'Pro';
1009
- const openaiPlan = openaiSub?.plan ?? 'Plus';
1260
+ // 2. Check if repo has a GitHub remote
1261
+ const remoteCheck = _spawnSyncTop('git', ['remote', 'get-url', 'origin'], {
1262
+ cwd,
1263
+ encoding: 'utf8',
1264
+ stdio: ['pipe', 'pipe', 'pipe'],
1265
+ timeout: 1000,
1266
+ });
1267
+ if (remoteCheck.status !== 0) return [];
1268
+ const remoteUrl = (remoteCheck.stdout || '').trim();
1269
+ if (!remoteUrl.includes('github.com')) return [];
1270
+
1271
+ // 3. Fetch open PRs (3s timeout)
1272
+ const prResult = _spawnSyncTop('gh', [
1273
+ 'pr', 'list',
1274
+ '--state', 'open',
1275
+ '--json', 'number,title,reviewDecision,reviewRequests,additions,deletions,changedFiles,headRefName',
1276
+ '--limit', '5',
1277
+ ], {
1278
+ cwd,
1279
+ encoding: 'utf8',
1280
+ stdio: ['pipe', 'pipe', 'pipe'],
1281
+ timeout: 3000,
1282
+ });
1010
1283
 
1011
- // Check subscription expiry
1012
- const now = Date.now();
1013
- const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
1014
- const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
1284
+ if (prResult.status !== 0) return [];
1285
+ const raw = (prResult.stdout || '').trim();
1286
+ if (!raw) return [];
1287
+
1288
+ const prs = JSON.parse(raw);
1289
+ if (!Array.isArray(prs)) return [];
1290
+ return prs;
1291
+ } catch {
1292
+ return [];
1293
+ }
1294
+ }
1295
+
1296
+ // ─── Dashboard box helpers ────────────────────────────────────────────────────
1297
+
1298
+ /**
1299
+ * Detect repo state for action cards. All checks run with tight timeouts —
1300
+ * best-effort only, never blocks startup.
1301
+ *
1302
+ * Returns: { dirtyCount, lastCommitAgeDays, lastFailure, isGitRepo }
1303
+ */
1304
+ function detectRepoState(cwd) {
1305
+ const result = { dirtyCount: 0, lastCommitAgeDays: 0, lastFailure: null, isGitRepo: false };
1306
+ try {
1307
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1308
+ result.isGitRepo = true;
1309
+ } catch { return result; }
1310
+
1311
+ try {
1312
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1313
+ result.dirtyCount = status.trim().split('\n').filter(Boolean).length;
1314
+ } catch {}
1015
1315
 
1016
- const claudeDays = daysUntil(claudeSub?.expiresAt);
1017
- const openaiDays = daysUntil(openaiSub?.expiresAt);
1316
+ try {
1317
+ const logOut = execSync('git log --format="%ct" -1', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
1318
+ if (logOut) {
1319
+ const commitTs = parseInt(logOut, 10) * 1000;
1320
+ result.lastCommitAgeDays = Math.floor((Date.now() - commitTs) / 86400000);
1321
+ }
1322
+ } catch {}
1018
1323
 
1019
- function subLine(name, plan, found, expired, days, sub) {
1020
- const label = sub?.label ? ` [${sub.label}]` : '';
1021
- if (!found) return `⚠️ ${name}: not logged in — run: ${name === 'Claude' ? 'claude auth login' : 'codex login'}`;
1022
- // Multi-sub: show aggregated counts when more than one sub exists
1023
- const subs = sub?.subs;
1024
- if (subs && subs.length > 1) {
1025
- const aggregate = aggregatePlans(subs);
1026
- return `✅ ${name}: ${aggregate} [${subs.length} subs]`;
1324
+ try {
1325
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
1326
+ if (existsSync(sessionPath)) {
1327
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
1328
+ const lastResult = sess?.lastResult;
1329
+ if (lastResult?.status === 'failure') {
1330
+ const summary = lastResult.task
1331
+ ? String(lastResult.task).slice(0, 40)
1332
+ : 'last task';
1333
+ result.lastFailure = summary;
1334
+ }
1027
1335
  }
1028
- if (expired) return `🔴 ${name}: ${plan} expired${label} — will re-auth`;
1029
- const daysNote = (days !== null && days <= 7) ? ` (${days}d left)` : '';
1030
- return `✅ ${name}: ${plan}${label}${daysNote}`;
1336
+ } catch {}
1337
+
1338
+ return result;
1339
+ }
1340
+
1341
+ /**
1342
+ * Build action card rows for the dashboard based on repo state.
1343
+ * Returns an array of box row strings (may be empty).
1344
+ * openPRs is optional — if provided, a PR card is included.
1345
+ */
1346
+ function buildActionRows(repoState, rowFn, openPRs = []) {
1347
+ if (!repoState.isGitRepo) return [];
1348
+
1349
+ const YELLOW = '\x1b[33m';
1350
+ const RED = '\x1b[31m';
1351
+ const GREEN = '\x1b[32m';
1352
+ const CYAN = '\x1b[36m';
1353
+ const DIM = '\x1b[2m';
1354
+ const RESET = '\x1b[0m';
1355
+
1356
+ const cards = [];
1357
+
1358
+ if (repoState.dirtyCount > 0) {
1359
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.dirtyCount} uncommitted file${repoState.dirtyCount === 1 ? '' : 's'}`);
1031
1360
  }
1032
1361
 
1033
- const headerLines = [
1034
- subLine('Claude', claudePlan, auth.claude.found, claudeExpired, claudeDays, claudeSub),
1035
- subLine('OpenAI', openaiPlan, auth.openai.found, openaiExpired, openaiDays, openaiSub),
1036
- ];
1362
+ if (repoState.lastFailure !== null) {
1363
+ cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
1364
+ }
1037
1365
 
1038
- const rtMain = detectReplitTools(cwd);
1039
- const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1040
- if (dtVersion) {
1041
- console.log(`📦 DATA Tools v${dtVersion}`);
1042
- console.log(`🧠 Dual Brain v${version}`);
1043
- } else {
1044
- console.log(`📦 DATA Tools`);
1045
- console.log(`🧠 Dual Brain v${version}`);
1366
+ if (repoState.lastCommitAgeDays >= 3) {
1367
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
1046
1368
  }
1047
- const latestVersion = await checkForUpdates(version);
1048
- if (latestVersion) {
1049
- console.log(` ⬆️ Update available: v${version} v${latestVersion}`);
1050
- console.log(` Run: npx -y dual-brain@latest`);
1369
+
1370
+ // PR card — show a summary of open PRs when gh is available
1371
+ if (openPRs.length > 0) {
1372
+ const prSummary = openPRs.slice(0, 2)
1373
+ .map(pr => `#${pr.number} ${String(pr.title).slice(0, 22)}`)
1374
+ .join(', ');
1375
+ const trunc = openPRs.length > 2 ? ` +${openPRs.length - 2}` : '';
1376
+ cards.push(`${CYAN}⇅${RESET} ${openPRs.length} open PR${openPRs.length === 1 ? '' : 's'}: ${prSummary}${trunc}`);
1051
1377
  }
1052
- console.log('');
1053
1378
 
1054
- // Provider status (outside the box)
1055
- for (const line of headerLines) {
1056
- console.log(` ${line}`);
1379
+ if (cards.length === 0) {
1380
+ return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
1057
1381
  }
1058
1382
 
1059
- const sparkline = buildSparkline(cwd);
1060
- if (sparkline) {
1061
- console.log(` Activity: ${sparkline}`);
1383
+ return cards.map(c => rowFn(c));
1384
+ }
1385
+
1386
+ /**
1387
+ * Detect interrupted work from the most recent session.
1388
+ * Returns a continuation hint if confidence is high enough, or null to skip.
1389
+ *
1390
+ * Signals that indicate interrupted work:
1391
+ * - Session < 4 hours old with no clean exit
1392
+ * - Last result was a failure
1393
+ * - Uncommitted git changes exist
1394
+ * - Session has high message count (user was deep in work)
1395
+ *
1396
+ * Minimum thresholds: messageCount > 5 OR filesChanged > 0
1397
+ *
1398
+ * @param {Array} sessions — from importReplitSessions / enrichSessions
1399
+ * @param {string} cwd
1400
+ * @returns {{ shouldContinue: boolean, reason: string, sessionId: string, sessionName: string, lastState: string|null, ageLabel: string }|null}
1401
+ */
1402
+ function detectInterruptedWork(sessions, cwd) {
1403
+ if (!sessions || sessions.length === 0) return null;
1404
+
1405
+ const most = sessions[0]; // already sorted most-recent first
1406
+ if (!most || !most.lastActive) return null;
1407
+
1408
+ const ageMs = Date.now() - new Date(most.lastActive).getTime();
1409
+ const fourH = 4 * 60 * 60 * 1000;
1410
+
1411
+ // Must be within 4 hours
1412
+ if (ageMs >= fourH) return null;
1413
+
1414
+ // Load session.json for deeper signal
1415
+ const session = loadSession(cwd);
1416
+
1417
+ // Minimum thresholds: must have real work depth
1418
+ const msgCount = most.messageCount ?? most.promptCount ?? 0;
1419
+ const filesChanged = session?.filesChanged?.length ?? 0;
1420
+ if (msgCount <= 5 && filesChanged === 0) return null;
1421
+
1422
+ const lastResultStatus = session?.lastResult?.status ?? null;
1423
+
1424
+ // Build confidence signals
1425
+ const signals = [];
1426
+ if (lastResultStatus === 'failure') signals.push('last run failed');
1427
+ if (filesChanged > 0) signals.push(`${filesChanged} file${filesChanged !== 1 ? 's' : ''} changed`);
1428
+ if (msgCount > 10) signals.push('deep session');
1429
+
1430
+ // Check for uncommitted git changes
1431
+ try {
1432
+ const gitResult = _spawnSyncTop('git', ['status', '--porcelain'], {
1433
+ cwd,
1434
+ encoding: 'utf8',
1435
+ stdio: ['pipe', 'pipe', 'pipe'],
1436
+ timeout: 3000,
1437
+ });
1438
+ if (gitResult.status === 0 && gitResult.stdout.trim().length > 0) {
1439
+ signals.push('uncommitted changes');
1440
+ }
1441
+ } catch { /* non-fatal */ }
1442
+
1443
+ // Need at least one signal beyond base thresholds to avoid annoying low-signal cards
1444
+ if (signals.length === 0 && msgCount <= 10) return null;
1445
+
1446
+ // Build a human-readable "last state" from available data
1447
+ let lastState = null;
1448
+ if (session?.lastResult?.summary) {
1449
+ lastState = session.lastResult.summary;
1450
+ } else if (session?.objective) {
1451
+ lastState = session.objective;
1452
+ } else if (most.name && !/^Session [0-9a-f]{8}/i.test(most.name)) {
1453
+ lastState = most.name;
1062
1454
  }
1063
1455
 
1064
- // Silent OAuth token auto-refresh (like data-tools)
1456
+ // Trim lastState to fit on one line
1457
+ if (lastState && lastState.length > 45) lastState = lastState.slice(0, 42) + '...';
1458
+
1459
+ // Build reason label
1460
+ const reason = signals.length > 0 ? signals.join(', ') : `${msgCount} messages`;
1461
+
1462
+ // Age label
1463
+ const mins = Math.floor(ageMs / 60000);
1464
+ let ageLabel;
1465
+ if (mins < 1) ageLabel = 'just now';
1466
+ else if (mins < 60) ageLabel = `${mins}m ago`;
1467
+ else ageLabel = `${Math.floor(mins / 60)}h ago`;
1468
+
1469
+ return {
1470
+ shouldContinue: true,
1471
+ reason,
1472
+ sessionId: most.id,
1473
+ sessionName: most.name || most.id.slice(0, 8),
1474
+ lastState,
1475
+ ageLabel,
1476
+ };
1477
+ }
1478
+
1479
+ // ─── Provider status helpers ───────────────────────────────────────────────────
1480
+
1481
+ /**
1482
+ * Build a provider status string for the dashboard status line.
1483
+ * Shows: "● Claude ● OpenAI ⚖️ Balanced"
1484
+ * Uses ANSI color codes for the dots — no dollar amounts or usage bars.
1485
+ */
1486
+ function buildProviderStatusLine(profile, auth, maxWidth = 54) {
1487
+ const GREEN = '●';
1488
+ const RED = '●';
1489
+
1490
+ const claudeDot = auth.claude.found ? GREEN : RED;
1491
+ const openaiDot = auth.openai.found ? GREEN : RED;
1492
+
1493
+ const WORK_STYLE_LABELS = {
1494
+ 'auto': '⚡ Fast',
1495
+ 'cost-saver': '⚡ Fast',
1496
+ 'balanced': '⚖️ Balanced',
1497
+ 'quality-first': '🔥 Full Power',
1498
+ 'solo-claude': '⚡ Fast',
1499
+ 'solo-openai': '⚡ Fast',
1500
+ };
1501
+ const WORK_STYLE_TIPS = {
1502
+ 'auto': 'adapts routing by task risk',
1503
+ 'cost-saver': 'single model, minimal reviews',
1504
+ 'balanced': 'smart routing, reviews when needed',
1505
+ 'quality-first': 'dual-brain on everything important',
1506
+ 'solo-claude': 'Claude only, no GPT dispatch',
1507
+ 'solo-openai': 'OpenAI only, no Claude dispatch',
1508
+ };
1509
+ const bias = profile?.bias || profile?.mode || 'balanced';
1510
+ const label = WORK_STYLE_LABELS[bias] || '⚖️ Balanced';
1511
+ const fullTip = WORK_STYLE_TIPS[bias] || 'smart routing, reviews when needed';
1512
+
1513
+ // Trim tip to fit within box width (measure visible chars: strip ANSI + variation selectors)
1514
+ const labelPlain = label.replace(/[︀-️]/g, '').replace(/[[0-9;]*m/g, '');
1515
+ const prefixLen = ('● Claude ● OpenAI ' + labelPlain + ' — ').length;
1516
+ const tipMax = maxWidth - prefixLen;
1517
+ const tip = tipMax >= 6
1518
+ ? (fullTip.length > tipMax ? fullTip.slice(0, tipMax - 1) + '…' : fullTip)
1519
+ : '';
1520
+
1521
+ const suffix = tip ? ` — ${tip}` : '';
1522
+ return `${claudeDot} Claude ${openaiDot} OpenAI ${label}${suffix}`;
1523
+ }
1524
+
1525
+ /**
1526
+ * Render a box row padded to inner width W (stripping ANSI for length calculation).
1527
+ * Returns a string like: "│ content padded to W │"
1528
+ */
1529
+ function makeBoxRow(content, W) {
1530
+ // Strip ANSI codes, then strip zero-width variation selectors (U+FE0F etc.)
1531
+ // so that emoji like ⚖️ (U+2696+U+FE0F) don't inflate the measured length.
1532
+ const plain = content
1533
+ .replace(/\x1b\[[0-9;]*m/g, '') // ANSI color codes
1534
+ .replace(/[\uFE00-\uFE0F]/g, ''); // variation selectors (zero-width)
1535
+ const padding = Math.max(0, W - plain.length);
1536
+ return `│ ${content}${' '.repeat(padding)} │`;
1537
+ }
1538
+
1539
+ // ─── Screen: mainScreen ───────────────────────────────────────────────────────
1540
+
1541
+ async function mainScreen(rl, ask) {
1542
+ const cwd = process.cwd();
1543
+ const version = readVersion();
1544
+ const profile = loadProfile(cwd);
1545
+ const auth = await detectAuth();
1546
+
1547
+ const claudeSub = profile?.providers?.claude;
1548
+ const openaiSub = profile?.providers?.openai;
1549
+
1550
+ // Check subscription expiry for auto-refresh
1551
+ const now = Date.now();
1552
+ const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
1553
+ const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
1554
+
1555
+ // Silent OAuth token auto-refresh
1065
1556
  try {
1066
1557
  const { autoRefreshToken } = await import('../src/profile.mjs');
1067
- const refreshResult = await autoRefreshToken(cwd);
1068
- if (refreshResult.status === 'refreshed') {
1069
- console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
1070
- }
1558
+ await autoRefreshToken(cwd);
1071
1559
  } catch {}
1072
1560
 
1073
- // Append-only session archive sync (like data-tools)
1561
+ // Append-only session archive sync
1074
1562
  try {
1075
1563
  const { syncSessionMirror } = await import('../src/session.mjs');
1076
- const mirror = syncSessionMirror(cwd);
1077
- if (mirror.copied > 0 || mirror.grew > 0) {
1078
- console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
1079
- }
1564
+ syncSessionMirror(cwd);
1080
1565
  } catch {}
1081
1566
 
1082
1567
  // Auto-refresh expired subscriptions
1083
1568
  if (claudeExpired || openaiExpired) {
1084
1569
  const { spawnSync } = await import('node:child_process');
1085
- const expired = [];
1086
- if (claudeExpired) expired.push('Claude');
1087
- if (openaiExpired) expired.push('OpenAI');
1088
- console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
1089
1570
  if (claudeExpired) {
1090
1571
  const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
1091
- if (r.status === 0) {
1092
- claudeSub.expiresAt = null;
1093
- saveProfile(profile, { cwd });
1094
- console.log(' ✓ Claude re-authenticated');
1095
- }
1572
+ if (r.status === 0) { claudeSub.expiresAt = null; saveProfile(profile, { cwd }); }
1096
1573
  }
1097
1574
  if (openaiExpired) {
1098
1575
  const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
1099
- if (r.status === 0) {
1100
- openaiSub.expiresAt = null;
1101
- saveProfile(profile, { cwd });
1102
- console.log(' ✓ OpenAI re-authenticated');
1103
- }
1576
+ if (r.status === 0) { openaiSub.expiresAt = null; saveProfile(profile, { cwd }); }
1104
1577
  }
1105
1578
  }
1106
- console.log('');
1107
1579
 
1108
1580
  // Build session index in background (powers search + smart resume)
1109
1581
  try {
@@ -1111,383 +1583,1124 @@ async function mainScreen(rl, ask) {
1111
1583
  buildSessionIndex(cwd);
1112
1584
  } catch {}
1113
1585
 
1114
- const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 7);
1586
+ // Gather recent sessions
1587
+ const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1588
+ const recentSessions = allSessions.slice(0, 3);
1589
+ const staleCount = allSessions.filter(s => {
1590
+ const ageMs = s.lastActive ? Date.now() - new Date(s.lastActive).getTime() : 0;
1591
+ return ageMs >= 7 * 86400000;
1592
+ }).length;
1115
1593
 
1116
- if (recentSessions.length > 0) {
1117
- console.log(' Recent Sessions:');
1118
- recentSessions.forEach((sess, i) => {
1119
- const pin = sess.pinned ? '📌 ' : ' ';
1120
- const active = sess.isActive ? ' ●' : '';
1121
- const cat = sess.category ? ` [${sess.category}]` : '';
1122
- const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1123
- // If the name is still the "Session XXXXXXXX" fallback, try the project path instead
1124
- let rawName = sess.name || '';
1125
- if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
1126
- rawName = sess.project ? sess.project.replace(/^-/, '/').replace(/-/g, '/') : sess.id.slice(0, 8);
1127
- }
1128
- const displayName = rawName.length > 40 ? rawName.slice(0, 37) + '...' : (rawName || sess.id.slice(0, 8));
1129
- console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${displayName}${active}${cat}`);
1130
- });
1131
- console.log('');
1132
- }
1594
+ // Detect data-tools version
1595
+ const rtMain = detectReplitTools(cwd);
1596
+ const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1133
1597
 
1134
- const brandW = 37;
1135
- const brandTop = ` ┌${'─'.repeat(brandW)}┐`;
1136
- const brandBottom = ` └${'─'.repeat(brandW)}┘`;
1137
- const brandPad = (s) => {
1138
- const leftPad = Math.floor((brandW - s.length) / 2);
1139
- const rightPad = brandW - s.length - leftPad;
1140
- return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
1141
- };
1142
- console.log(brandTop);
1143
- console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
1144
- console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
1145
- console.log(brandBottom);
1146
- console.log('');
1598
+ // ── Interrupted work detection ────────────────────────────────────────────
1599
+ const interrupted = detectInterruptedWork(allSessions, cwd);
1147
1600
 
1148
- const running = countRunningInstances();
1149
- const runningParts = [];
1150
- if (running.claude > 0) runningParts.push(`${running.claude} claude`);
1151
- if (running.codex > 0) runningParts.push(`${running.codex} codex`);
1152
- if (runningParts.length > 0) {
1153
- console.log(` (${runningParts.join(', ')} running)`);
1154
- console.log('');
1155
- }
1601
+ // ── Box layout ────────────────────────────────────────────────────────────
1602
+ const termW = process.stdout.columns || 60;
1603
+ const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
1604
+ const W = boxW - 4; // inner content width ({content} )
1156
1605
 
1157
- console.log(' [c] Continue last session');
1158
- console.log(' [n] New session');
1159
- console.log('');
1160
- if (recentSessions.length > 0) {
1161
- console.log(' [1-9] Resume numbered above');
1162
- }
1163
- console.log(' [r] Resume (full list)');
1164
- console.log(' [/] Search sessions');
1165
- console.log(' [e] Manage sessions');
1166
- console.log(' [m] Manage subscriptions');
1167
- console.log(' [s] Settings');
1168
- console.log(' [?] Help & shortcuts');
1169
- console.log('');
1170
- console.log(' \x1b[2mreplit-tools:\x1b[0m');
1171
- console.log(' [i] Import sessions');
1172
- console.log(' [d] Switch to data-tools');
1173
- console.log('');
1174
- console.log(' [q] Exit');
1175
- console.log('');
1606
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
1607
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
1608
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
1176
1609
 
1177
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
1610
+ const row = (content) => makeBoxRow(content, W);
1178
1611
 
1179
- if (choice === '?') {
1180
- const W = 37;
1181
- const helpTop = ` ┌${'─'.repeat(W)}┐`;
1182
- const helpSep = ` ├${''.repeat(W)}┤`;
1183
- const helpBottom = ` └${'─'.repeat(W)}┘`;
1184
- const helpPad = (s) => s + ' '.repeat(Math.max(0, W - s.length));
1185
- console.log('');
1186
- console.log(helpTop);
1187
- console.log(` │ ${helpPad('At ~/workspace$ prompt:')}│`);
1188
- console.log(` │ ${helpPad('db = show this menu')}│`);
1189
- console.log(` │ ${helpPad('j = login to claude')}│`);
1190
- console.log(` ${helpPad('k = login to codex')}│`);
1191
- console.log(helpSep);
1192
- console.log(` │ ${helpPad('In Claude:')}│`);
1193
- console.log(` │ ${helpPad('Ctrl+C x2 = back to menu')}│`);
1194
- console.log(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│`);
1195
- console.log(helpBottom);
1196
- console.log('');
1197
- await ask(' Press Enter to continue...');
1198
- return { next: 'main' };
1612
+ // ── Header: one line above the box ────────────────────────────────────────
1613
+ process.stdout.write(`\n🧠 dual-brain v${version}\n`);
1614
+ {
1615
+ let gitName = '';
1616
+ try {
1617
+ const { execSync } = await import('node:child_process');
1618
+ gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1619
+ } catch { /* ignore */ }
1620
+ if (gitName) {
1621
+ const hour = new Date().getHours();
1622
+ let greet;
1623
+ if (hour >= 5 && hour <= 11) greet = 'Good morning';
1624
+ else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
1625
+ else if (hour >= 17 && hour <= 21) greet = 'Good evening';
1626
+ else greet = 'Late night';
1627
+ process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
1628
+ }
1199
1629
  }
1200
1630
 
1201
- if (choice === 'n') { return { next: 'new-session' }; }
1631
+ // ── Continuation card (interrupted work) ─────────────────────────────────
1632
+ if (interrupted) {
1633
+ const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
1634
+ const csep = `├${'─'.repeat(boxW - 2)}┤`;
1635
+ const cbot = `└${'─'.repeat(boxW - 2)}┘`;
1636
+ const crow = (content) => makeBoxRow(content, W);
1637
+
1638
+ const titleLine = `\x1b[33m💡\x1b[0m Continue: ${interrupted.sessionName}`;
1639
+ const lastLine = interrupted.lastState
1640
+ ? ` Last: ${interrupted.lastState} · ${interrupted.ageLabel}`
1641
+ : ` ${interrupted.reason} · ${interrupted.ageLabel}`;
1642
+ const actLine = ' [Enter] Resume [n] New session [s] Skip';
1643
+
1644
+ process.stdout.write([ctop, crow(titleLine), csep, crow(lastLine), crow(actLine), cbot].join('\n') + '\n\n');
1645
+
1646
+ // Wait for a keypress to decide what to do with the card
1647
+ const readline2 = await import('node:readline');
1648
+ readline2.emitKeypressEvents(process.stdin, rl);
1649
+
1650
+ const cardChoice = await new Promise((resolve) => {
1651
+ const wasRaw2 = process.stdin.isRaw;
1652
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1653
+ if (canRaw2) process.stdin.setRawMode(true);
1654
+
1655
+ const cleanup2 = () => {
1656
+ process.stdin.removeListener('keypress', onCardKey);
1657
+ if (canRaw2) {
1658
+ try { process.stdin.setRawMode(wasRaw2 || false); } catch {}
1659
+ }
1660
+ };
1202
1661
 
1203
- if (choice === 'c') {
1204
- const termId = getTerminalId();
1205
- const termState = loadTerminalState(cwd, termId);
1206
- const sessions = importReplitSessions(cwd);
1662
+ const onCardKey = (str, key) => {
1663
+ if (!key) return;
1664
+ const name = key.name || '';
1665
+ const seq = key.sequence || str || '';
1666
+
1667
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1668
+ cleanup2();
1669
+ process.stdout.write('\n');
1670
+ resolve('q');
1671
+ return;
1672
+ }
1207
1673
 
1208
- // Priority: terminal-specific last session, then global last session
1209
- const targetId = termState?.sessionId || (sessions.length > 0 ? sessions[0].id : null);
1674
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1675
+ cleanup2();
1676
+ process.stdout.write('\n');
1677
+ resolve('resume');
1678
+ return;
1679
+ }
1210
1680
 
1211
- if (!targetId) {
1212
- console.log('\n No recent sessions found.\n');
1213
- await ask(' Press Enter to continue...');
1681
+ if (!str || str.length === 0) return;
1682
+ const lower = str.toLowerCase();
1683
+ if (lower === 'n' || lower === 's' || lower === 'q') {
1684
+ cleanup2();
1685
+ process.stdout.write('\n');
1686
+ resolve(lower);
1687
+ return;
1688
+ }
1689
+ };
1690
+
1691
+ process.stdin.on('keypress', onCardKey);
1692
+ });
1693
+
1694
+ if (cardChoice === 'q') return { next: 'exit' };
1695
+
1696
+ if (cardChoice === 'resume') {
1697
+ const { spawnSync } = await import('node:child_process');
1698
+ process.stdout.write(` Launching: claude --resume ${interrupted.sessionId}\n\n`);
1699
+ spawnSync('claude', ['--resume', interrupted.sessionId], { stdio: 'inherit' });
1700
+ saveTerminalState(cwd, getTerminalId(), interrupted.sessionId, 'claude');
1214
1701
  return { next: 'main' };
1215
1702
  }
1216
1703
 
1217
- // Smart resume preview
1704
+ if (cardChoice === 'n') return { next: 'new-session' };
1705
+
1706
+ // 's' → fall through to normal dashboard
1707
+ }
1708
+
1709
+ // ── Status section ────────────────────────────────────────────────────────
1710
+ const providerLine = buildProviderStatusLine(profile, auth, W);
1711
+
1712
+ const statusRows = [row(providerLine)];
1713
+ if (dtVersion) {
1714
+ statusRows.push(row(`\x1b[2m📦 replit-tools v${dtVersion}\x1b[0m`));
1715
+ }
1716
+
1717
+ // ── Observer observations (top 2, high priority first) ───────────────────
1718
+ let quickObservations = [];
1719
+ try {
1720
+ const observerMod = await import('../src/observer.mjs');
1721
+ const quickState = await observerMod.getQuickState(cwd);
1722
+ if (quickState?.observations?.length > 0) {
1723
+ const PRIO = { high: 0, medium: 1, low: 2 };
1724
+ const sorted = [...quickState.observations].sort(
1725
+ (a, b) => (PRIO[a.priority] ?? 2) - (PRIO[b.priority] ?? 2)
1726
+ );
1727
+ quickObservations = sorted.slice(0, 2);
1728
+ for (const obs of quickObservations) {
1729
+ let prefix;
1730
+ if (obs.priority === 'high') prefix = '🔴';
1731
+ else if (obs.priority === 'medium') prefix = '🟡';
1732
+ else prefix = '\x1b[2m💡\x1b[0m';
1733
+ statusRows.push(row(`${prefix} ${obs.message}`));
1734
+ }
1735
+ }
1736
+ } catch { /* non-fatal — module may not exist yet */ }
1737
+
1738
+ // ── Action cards (git state + open PRs) ──────────────────────────────────
1739
+ const repoState = detectRepoState(cwd);
1740
+ const openPRs = await detectOpenPRs(cwd);
1741
+ const actionRows = buildActionRows(repoState, row, openPRs);
1742
+
1743
+ // ── High-priority observer action cards ───────────────────────────────────
1744
+ if (quickObservations.some(o => o.priority === 'high')) {
1745
+ const DIM = '\x1b[2m';
1746
+ const RESET = '\x1b[0m';
1747
+ actionRows.push(row(`${DIM}[r] Security review [t] Run tests [c] Commit${RESET}`));
1748
+ }
1749
+
1750
+ // ── Related sessions hint (only when no continuation card is showing) ─────
1751
+ if (!interrupted && recentSessions.length > 0) {
1218
1752
  try {
1219
- const { getSessionContext } = await import('../src/session.mjs');
1220
- const ctx = getSessionContext(targetId, cwd);
1221
- if (ctx) {
1222
- console.log('');
1223
- if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1224
- if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
1753
+ const { findRelatedSessions } = await import('../src/session.mjs');
1754
+ const mostRecent = recentSessions[0];
1755
+ // Build a pseudo-prompt from the most recent session's name/objective
1756
+ const recentPrompt = mostRecent.name || '';
1757
+ // Load session index to get files for the most recent session
1758
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1759
+ let recentFiles = [];
1760
+ try {
1761
+ const idx = JSON.parse(readFileSync(indexPath, 'utf8'));
1762
+ recentFiles = idx[mostRecent.id]?.files || [];
1763
+ } catch {}
1764
+ const related = findRelatedSessions(recentPrompt, recentFiles, cwd);
1765
+ if (related.length > 0) {
1766
+ const relAgeLabel = (isoDate) => {
1767
+ if (!isoDate) return '';
1768
+ const diff = Date.now() - Date.parse(isoDate);
1769
+ const days = Math.floor(diff / 86400000);
1770
+ const hours = Math.floor(diff / 3600000);
1771
+ if (days >= 1) return `${days}d`;
1772
+ return `${hours}h ago`;
1773
+ };
1774
+ const relatedParts = related.slice(0, 2).map(r => {
1775
+ const age = relAgeLabel(r.date);
1776
+ return age ? `${r.smartName} (${age})` : r.smartName;
1777
+ });
1778
+ const DIM = '\x1b[2m';
1779
+ const RESET = '\x1b[0m';
1780
+ actionRows.push(row(`${DIM}📎 Related: ${relatedParts.join(', ')}${RESET}`));
1225
1781
  }
1226
- } catch {}
1782
+ } catch { /* non-fatal */ }
1783
+ }
1784
+ // ── End related sessions hint ─────────────────────────────────────────────
1785
+
1786
+ // ── Sessions section ──────────────────────────────────────────────────────
1787
+ const sessionRows = [];
1788
+ if (recentSessions.length === 0) {
1789
+ const noSessMsg = 'No sessions yet. Press n to start.';
1790
+ sessionRows.push(row(noSessMsg));
1791
+ } else {
1792
+ recentSessions.forEach((sess, i) => {
1793
+ // Normalize name: strip "Session XXXXXXXX" fallbacks
1794
+ let rawName = sess.name || '';
1795
+ if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
1796
+ rawName = sess.project
1797
+ ? sess.project.replace(/^-/, '/').replace(/-/g, '/')
1798
+ : sess.id.slice(0, 8);
1799
+ }
1800
+
1801
+ // Build badges (ANSI color; track visible width separately)
1802
+ const badges = [];
1803
+ const badgeVisible = [];
1804
+ if (sess.isActive) {
1805
+ badges.push('\x1b[32m[active]\x1b[0m');
1806
+ badgeVisible.push('[active]'.length);
1807
+ }
1808
+ const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
1809
+ if (ageMs > 7 * 24 * 3600 * 1000) {
1810
+ badges.push('\x1b[2m[stale]\x1b[0m');
1811
+ badgeVisible.push('[stale]'.length);
1812
+ }
1813
+ const msgCount = sess.messageCount ?? sess.promptCount ?? 0;
1814
+ // Human-readable: "4 tasks" instead of "(4)"
1815
+ const taskLabel = msgCount === 1 ? '1 task' : `${msgCount} tasks`;
1816
+ const taskBadge = `\x1b[2m${taskLabel}\x1b[0m`;
1817
+ const taskBadgeW = taskLabel.length;
1818
+
1819
+ const badgeStr = badges.join('');
1820
+ const badgesW = badgeVisible.reduce((s, n) => s + n, 0);
1821
+
1822
+ // Layout: "{num} {name...}{badges} {age} {tasks}"
1823
+ // Use basename for name — strip full paths for readability
1824
+ const displayName = rawName.startsWith('/')
1825
+ ? rawName.split('/').filter(Boolean).pop() || rawName
1826
+ : rawName;
1827
+
1828
+ const numStr = String(i + 1);
1829
+ const ageStr = sess.age || '';
1830
+ // Available for name: W minus fixed chrome, badge widths, and task badge
1831
+ const nameMax = W - numStr.length - 2 - badgesW - 2 - ageStr.length - 2 - taskBadgeW;
1832
+ const truncName = displayName.length > nameMax
1833
+ ? displayName.slice(0, Math.max(0, nameMax - 3)) + '...'
1834
+ : displayName.padEnd(nameMax);
1835
+ const content = `${numStr} ${truncName}${badgeStr} ${ageStr} ${taskBadge}`;
1836
+ sessionRows.push(row(content));
1837
+ });
1838
+ }
1839
+
1840
+ // ── Actions bar — navigation only (pipeline verbs are internal stages, not menu items) ─
1841
+ const actionsContent = 'n New session / Search q Quit';
1842
+ const actionsRow = row(actionsContent);
1843
+
1844
+ // ── Print the full box ────────────────────────────────────────────────────
1845
+ // Include action cards between status and sessions (with separators only when non-empty)
1846
+ const poweredByRow = row('\x1b[2mPowered by dual-brain\x1b[0m');
1847
+ const lines = [
1848
+ top,
1849
+ ...statusRows,
1850
+ ...(actionRows.length > 0 ? [sep, ...actionRows] : []),
1851
+ sep,
1852
+ ...sessionRows,
1853
+ sep,
1854
+ actionsRow,
1855
+ sep,
1856
+ poweredByRow,
1857
+ bot,
1858
+ ];
1859
+ // ── Stale session hint ──────────────────────────────────────────────────
1860
+ if (staleCount >= 3) {
1861
+ process.stdout.write(`\x1b[2m${staleCount} stale sessions (>7d) — press s → archive to clean up\x1b[0m\n`);
1862
+ }
1863
+
1864
+ process.stdout.write(lines.join('\n') + '\n\n');
1865
+
1866
+ // ── Key handling ──────────────────────────────────────────────────────────
1867
+ // Use raw keypress mode so we can show a live type-to-start buffer.
1868
+ // Single-key commands (n, s, q, /, 1-9, Enter) only fire when buffer is empty.
1869
+ let taskBuffer = '';
1870
+
1871
+ const readline = await import('node:readline');
1872
+
1873
+ // Render the type-ahead line below the box (overwrites the current cursor line)
1874
+ const renderBuffer = (buf) => {
1875
+ // Move to the prompt line (we're already at it after printing the box + footer)
1876
+ // Use carriage return + clear-to-end-of-line to overwrite
1877
+ if (buf.length === 0) {
1878
+ process.stdout.write('\r\x1b[K');
1879
+ } else {
1880
+ const display = buf.length > W - 4 ? buf.slice(-(W - 4)) : buf;
1881
+ process.stdout.write(`\r\x1b[K> ${display}\x1b[7m \x1b[0m`);
1882
+ }
1883
+ };
1884
+
1885
+ // Enable keypress events on stdin (safe to call multiple times)
1886
+ readline.emitKeypressEvents(process.stdin, rl);
1887
+
1888
+ const raw = await new Promise((resolve) => {
1889
+ // Switch to raw mode if possible (TTY only)
1890
+ const wasRaw = process.stdin.isRaw;
1891
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1892
+ if (canRaw) process.stdin.setRawMode(true);
1893
+
1894
+ const cleanup = () => {
1895
+ process.stdin.removeListener('keypress', onKey);
1896
+ if (canRaw) {
1897
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
1898
+ }
1899
+ };
1900
+
1901
+ const onKey = (str, key) => {
1902
+ if (!key) return;
1903
+
1904
+ const name = key.name || '';
1905
+ const seq = key.sequence || str || '';
1906
+
1907
+ // Ctrl-C / Ctrl-D → exit
1908
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1909
+ cleanup();
1910
+ process.stdout.write('\n');
1911
+ resolve('q');
1912
+ return;
1913
+ }
1914
+
1915
+ // Enter key
1916
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1917
+ cleanup();
1918
+ if (taskBuffer.length > 0) {
1919
+ process.stdout.write('\n');
1920
+ resolve(`__task__:${taskBuffer}`);
1921
+ } else {
1922
+ resolve('');
1923
+ }
1924
+ return;
1925
+ }
1926
+
1927
+ // Escape → clear buffer
1928
+ if (name === 'escape') {
1929
+ taskBuffer = '';
1930
+ renderBuffer('');
1931
+ return;
1932
+ }
1933
+
1934
+ // Backspace / delete
1935
+ if (name === 'backspace' || name === 'delete') {
1936
+ if (taskBuffer.length > 0) {
1937
+ taskBuffer = taskBuffer.slice(0, -1);
1938
+ renderBuffer(taskBuffer);
1939
+ }
1940
+ return;
1941
+ }
1942
+
1943
+ // Ignore non-printable / control keys
1944
+ if (key.ctrl || key.meta || !str || str.length === 0) return;
1945
+ const code = str.codePointAt(0);
1946
+ if (code < 32 || code === 127) return;
1947
+
1948
+ // Single-key commands only fire when buffer is empty
1949
+ if (taskBuffer.length === 0) {
1950
+ const lower = str.toLowerCase();
1951
+ const singleKeySet = new Set(['n', 's', 'q', '/', 'i']);
1952
+ if (singleKeySet.has(lower)) {
1953
+ cleanup();
1954
+ process.stdout.write('\n');
1955
+ resolve(lower);
1956
+ return;
1957
+ }
1958
+ const digit = parseInt(str, 10);
1959
+ if (!isNaN(digit) && digit >= 1 && digit <= 9) {
1960
+ cleanup();
1961
+ process.stdout.write('\n');
1962
+ resolve(str);
1963
+ return;
1964
+ }
1965
+ }
1966
+
1967
+ // Accumulate into buffer
1968
+ taskBuffer += str;
1969
+ renderBuffer(taskBuffer);
1970
+ };
1971
+
1972
+ process.stdin.on('keypress', onKey);
1973
+ });
1974
+
1975
+ const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
1976
+
1977
+ // Typed task → dispatch as "dual-brain go"
1978
+ if (raw.startsWith('__task__:')) {
1979
+ const prompt = raw.slice('__task__:'.length).trim();
1980
+ if (prompt) {
1981
+ return { next: 'go', prompt };
1982
+ }
1983
+ return { next: 'main' };
1984
+ }
1227
1985
 
1986
+ // Enter (empty) → resume most recent session
1987
+ if (raw === '' || choice === '\r') {
1988
+ if (recentSessions.length === 0) {
1989
+ return { next: 'new-session' };
1990
+ }
1991
+ const sess = recentSessions[0];
1228
1992
  const { spawnSync } = await import('node:child_process');
1229
- const tool = termState?.tool || 'claude';
1230
- console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
1231
- spawnSync(tool === 'codex' ? 'codex' : 'claude', ['--resume', targetId], { stdio: 'inherit' });
1232
- saveTerminalState(cwd, termId, targetId, tool);
1993
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
1994
+ spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1995
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1233
1996
  return { next: 'main' };
1234
1997
  }
1235
1998
 
1236
- const numChoice = parseInt(choice, 10);
1999
+ // Number 1-3 resume that session
2000
+ const numChoice = parseInt(raw, 10);
1237
2001
  if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
1238
2002
  const sess = recentSessions[numChoice - 1];
1239
-
1240
- // Smart resume preview
1241
2003
  try {
1242
2004
  const { getSessionContext } = await import('../src/session.mjs');
1243
2005
  const ctx = getSessionContext(sess.id, cwd);
1244
2006
  if (ctx) {
1245
- console.log('');
1246
- if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
1247
- if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
2007
+ if (ctx.lastPrompt) process.stdout.write(`\n Last working on: ${ctx.lastPrompt}\n`);
2008
+ if (ctx.filesTouched.length > 0) process.stdout.write(` Files touched: ${ctx.filesTouched.join(', ')}\n`);
1248
2009
  }
1249
2010
  } catch {}
1250
-
1251
2011
  const { spawnSync } = await import('node:child_process');
1252
- console.log(`\n Launching: claude --resume ${sess.id}\n`);
2012
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
1253
2013
  spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1254
2014
  saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1255
2015
  return { next: 'main' };
1256
2016
  }
1257
2017
 
1258
- if (choice === 'r') {
1259
- const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1260
- if (allSessions.length === 0) {
1261
- console.log('\n No sessions found.\n');
1262
- await ask(' Press Enter to continue...');
1263
- return { next: 'main' };
1264
- }
1265
-
1266
- console.log('\n All Sessions:');
1267
- allSessions.forEach((sess, i) => {
1268
- const pin = sess.pinned ? '📌 ' : ' ';
1269
- const active = sess.isActive ? ' ●' : '';
1270
- const cat = sess.category ? ` [${sess.category}]` : '';
1271
- const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1272
- console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
1273
- });
1274
- console.log('');
1275
-
1276
- const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1277
- const num = parseInt(pick, 10);
1278
- if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
1279
- const sess = allSessions[num - 1];
1280
- const { spawnSync } = await import('node:child_process');
1281
- const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1282
- console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1283
- spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1284
- }
1285
- return { next: 'main' };
1286
- }
2018
+ if (choice === 'n') { return { next: 'new-session' }; }
1287
2019
 
1288
2020
  if (choice === '/') {
1289
2021
  const query = (await ask(' Search: ')).trim();
1290
2022
  if (!query) return { next: 'main' };
1291
2023
 
1292
2024
  const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
1293
- // Build index if needed (silent)
1294
2025
  try { buildSessionIndex(cwd); } catch {}
1295
2026
 
1296
2027
  const results = searchSessions(query, cwd);
1297
2028
  if (results.length === 0) {
1298
- console.log(`\n No sessions matching "${query}"\n`);
2029
+ process.stdout.write(`\n No sessions matching "${query}"\n\n`);
1299
2030
  await ask(' Press Enter to continue...');
1300
2031
  return { next: 'main' };
1301
2032
  }
1302
2033
 
1303
- console.log(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:`);
2034
+ process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
1304
2035
  results.slice(0, 9).forEach((sess, i) => {
1305
- const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
1306
- const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
2036
+ const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
2037
+ const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
1307
2038
  const topics = sess.topics.slice(0, 3).join(', ');
1308
- console.log(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
1309
- if (topics) console.log(` topics: ${topics}`);
2039
+ process.stdout.write(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}\n`);
2040
+ if (topics) process.stdout.write(` topics: ${topics}\n`);
1310
2041
  });
1311
- console.log('');
2042
+ process.stdout.write('\n');
1312
2043
 
1313
2044
  const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1314
- const num = parseInt(pick, 10);
2045
+ const num = parseInt(pick, 10);
1315
2046
  if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
1316
2047
  const sess = results[num - 1];
1317
2048
  const { spawnSync } = await import('node:child_process');
1318
2049
  const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1319
- console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
2050
+ process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
1320
2051
  spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1321
2052
  }
1322
2053
  return { next: 'main' };
1323
2054
  }
1324
2055
 
1325
- if (choice === 'e') { return { next: 'sessions' }; }
2056
+ if (choice === 's') { return { next: 'settings' }; }
2057
+ if (choice === 'i') { return { next: 'import-picker' }; }
2058
+ if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1326
2059
 
1327
- if (choice === 'i') {
1328
- const sessions = importReplitSessions(cwd);
1329
- if (sessions.length === 0) {
1330
- console.log('\n No replit-tools sessions found to import.\n');
1331
- } else {
1332
- console.log(`\n ✅ Found ${sessions.length} sessions from replit-tools.`);
1333
- console.log(' Sessions are automatically available in the list above.\n');
1334
- }
1335
- await ask(' Press Enter to continue...');
2060
+ return { next: 'main' };
2061
+ }
2062
+
2063
+ // ─── Screen: newSessionScreen ─────────────────────────────────────────────────
2064
+
2065
+ async function newSessionScreen(rl, ask) {
2066
+ const cwd = process.cwd();
2067
+ const input = (await ask('\n What do you want to do? ')).trim();
2068
+ if (!input) { return { next: 'main' }; }
2069
+
2070
+ // All work routes through pipeline — detect → decide → dispatch with mandatory gates.
2071
+ await cmdGo([input], { cwd });
2072
+
2073
+ return { next: 'main' };
2074
+ }
2075
+
2076
+ // ─── Screen: importPickerScreen ──────────────────────────────────────────────
2077
+
2078
+ async function importPickerScreen() {
2079
+ const cwd = process.cwd();
2080
+
2081
+ // Load all available sessions from data-tools
2082
+ const allSessions = importReplitSessions(cwd);
2083
+
2084
+ // Load existing session meta to filter already-imported ones
2085
+ const meta = getSessionMeta(cwd);
2086
+ const alreadyImported = new Set(
2087
+ Object.entries(meta)
2088
+ .filter(([, v]) => v.source === 'data-tools')
2089
+ .map(([id]) => id)
2090
+ );
2091
+
2092
+ // Filter out already-imported sessions
2093
+ const candidates = allSessions.filter(s => !alreadyImported.has(s.id));
2094
+
2095
+ // ── Box layout ────────────────────────────────────────────────────────────
2096
+ const termW = process.stdout.columns || 60;
2097
+ const boxW = Math.min(termW - 2, 60);
2098
+ const W = boxW - 4;
2099
+
2100
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2101
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2102
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2103
+
2104
+ const row = (content) => makeBoxRow(content, W);
2105
+
2106
+ // Helper: wait for any keypress (used in edge-case screens)
2107
+ const waitKey = async () => {
2108
+ const rl2 = await import('node:readline');
2109
+ rl2.emitKeypressEvents(process.stdin);
2110
+ await new Promise(resolve => {
2111
+ const wasRaw2 = process.stdin.isRaw;
2112
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
2113
+ if (canRaw2) process.stdin.setRawMode(true);
2114
+ const onKey2 = () => {
2115
+ process.stdin.removeListener('keypress', onKey2);
2116
+ if (canRaw2) { try { process.stdin.setRawMode(wasRaw2 || false); } catch {} }
2117
+ resolve();
2118
+ };
2119
+ process.stdin.once('keypress', onKey2);
2120
+ });
2121
+ };
2122
+
2123
+ // Handle edge cases
2124
+ if (allSessions.length === 0) {
2125
+ process.stdout.write('\n');
2126
+ process.stdout.write(top + '\n');
2127
+ process.stdout.write(row('Import from data-tools') + '\n');
2128
+ process.stdout.write(sep + '\n');
2129
+ process.stdout.write(row('No data-tools sessions found.') + '\n');
2130
+ process.stdout.write(row('Install replit-tools: npm i -g replit-tools') + '\n');
2131
+ process.stdout.write(sep + '\n');
2132
+ process.stdout.write(row('Press any key to go back...') + '\n');
2133
+ process.stdout.write(bot + '\n\n');
2134
+ await waitKey();
1336
2135
  return { next: 'main' };
1337
2136
  }
1338
2137
 
1339
- if (choice === 'd') {
1340
- const { spawnSync } = await import('node:child_process');
1341
- const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
1342
- if (which.status === 0) {
1343
- spawnSync('claude-menu', { stdio: 'inherit' });
1344
- } else {
1345
- console.log('\n data-tools not found — install with: npm i -g replit-tools\n');
1346
- await ask(' Press Enter to continue...');
1347
- }
2138
+ if (candidates.length === 0) {
2139
+ process.stdout.write('\n');
2140
+ process.stdout.write(top + '\n');
2141
+ process.stdout.write(row('Import from data-tools') + '\n');
2142
+ process.stdout.write(sep + '\n');
2143
+ process.stdout.write(row(`All ${allSessions.length} sessions already imported.`) + '\n');
2144
+ process.stdout.write(sep + '\n');
2145
+ process.stdout.write(row('Press any key to go back...') + '\n');
2146
+ process.stdout.write(bot + '\n\n');
2147
+ await waitKey();
1348
2148
  return { next: 'main' };
1349
2149
  }
1350
2150
 
1351
- if (choice === 'm') { return { next: 'subscriptions' }; }
2151
+ // Pre-select sessions < 3 days old
2152
+ const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
2153
+ const selected = new Set(
2154
+ candidates
2155
+ .filter(s => s.lastActive && (Date.now() - new Date(s.lastActive).getTime()) < threeDaysMs)
2156
+ .map(s => s.id)
2157
+ );
1352
2158
 
1353
- if (choice === 's') { return { next: 'settings' }; }
1354
- if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
2159
+ let cursor = 0;
2160
+
2161
+ const renderPicker = () => {
2162
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
2163
+
2164
+ const headerTitle = 'Import from data-tools';
2165
+ const footerLine = '↑↓ Navigate Space Toggle Enter Import q Back';
2166
+
2167
+ process.stdout.write('\n');
2168
+ process.stdout.write(top + '\n');
2169
+ process.stdout.write(row(headerTitle) + '\n');
2170
+ process.stdout.write(sep + '\n');
2171
+
2172
+ candidates.forEach((sess, i) => {
2173
+ const isCursor = i === cursor;
2174
+ const isSelected = selected.has(sess.id);
2175
+ const check = isSelected ? '☑' : '☐';
2176
+ const cursor_ch = isCursor ? '▸ ' : ' ';
2177
+
2178
+ // Format age compactly
2179
+ const ageStr = sess.age || '';
2180
+ // Message count
2181
+ const msgCount = sess.promptCount ?? sess.messageCount ?? 0;
2182
+ const msgStr = `${msgCount} msgs`;
2183
+
2184
+ // Name: truncate to fit
2185
+ // Layout: "cursor_ch(2) check(1) space(1) name age msgs"
2186
+ // chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length = 8 + ageStr.length + msgStr.length
2187
+ const chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length;
2188
+ const nameMax = Math.max(0, W - chrome);
2189
+ let name = sess.name || sess.id.slice(0, 8);
2190
+ if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
2191
+ else name = name.padEnd(nameMax);
2192
+
2193
+ const line = `${cursor_ch}${check} ${name} ${ageStr} ${msgStr}`;
2194
+ // Highlight cursor row with dim inverse
2195
+ const renderedLine = isCursor
2196
+ ? `\x1b[7m${cursor_ch}${check} ${name} ${ageStr} ${msgStr}\x1b[0m`
2197
+ : line;
2198
+ process.stdout.write(row(renderedLine) + '\n');
2199
+ });
2200
+
2201
+ process.stdout.write(sep + '\n');
2202
+ process.stdout.write(row(footerLine) + '\n');
2203
+ process.stdout.write(bot + '\n\n');
2204
+ };
2205
+
2206
+ // Run the interactive picker
2207
+ const readline = await import('node:readline');
2208
+ readline.emitKeypressEvents(process.stdin);
2209
+
2210
+ const result = await new Promise((resolve) => {
2211
+ const wasRaw = process.stdin.isRaw;
2212
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
2213
+ if (canRaw) process.stdin.setRawMode(true);
2214
+
2215
+ const cleanup = () => {
2216
+ process.stdin.removeListener('keypress', onKey);
2217
+ if (canRaw) {
2218
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
2219
+ }
2220
+ };
2221
+
2222
+ renderPicker();
2223
+
2224
+ const onKey = (str, key) => {
2225
+ if (!key) return;
2226
+ const name = key.name || '';
2227
+ const seq = key.sequence || str || '';
2228
+
2229
+ // Ctrl-C / Ctrl-D → exit to main
2230
+ if (key.ctrl && (name === 'c' || name === 'd')) {
2231
+ cleanup();
2232
+ process.stdout.write('\n');
2233
+ resolve({ action: 'back' });
2234
+ return;
2235
+ }
2236
+
2237
+ // q or Escape → back
2238
+ if (name === 'escape' || (str && str.toLowerCase() === 'q')) {
2239
+ cleanup();
2240
+ process.stdout.write('\n');
2241
+ resolve({ action: 'back' });
2242
+ return;
2243
+ }
2244
+
2245
+ // Arrow up
2246
+ if (name === 'up') {
2247
+ cursor = Math.max(0, cursor - 1);
2248
+ renderPicker();
2249
+ return;
2250
+ }
2251
+
2252
+ // Arrow down
2253
+ if (name === 'down') {
2254
+ cursor = Math.min(candidates.length - 1, cursor + 1);
2255
+ renderPicker();
2256
+ return;
2257
+ }
2258
+
2259
+ // Space → toggle selection
2260
+ if (seq === ' ') {
2261
+ const id = candidates[cursor].id;
2262
+ if (selected.has(id)) selected.delete(id);
2263
+ else selected.add(id);
2264
+ renderPicker();
2265
+ return;
2266
+ }
2267
+
2268
+ // Enter → import
2269
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
2270
+ cleanup();
2271
+ process.stdout.write('\n');
2272
+ resolve({ action: 'import', ids: [...selected] });
2273
+ return;
2274
+ }
2275
+ };
2276
+
2277
+ process.stdin.on('keypress', onKey);
2278
+ });
2279
+
2280
+ if (result.action === 'back' || result.ids.length === 0) {
2281
+ return { next: 'main' };
2282
+ }
2283
+
2284
+ // Persist imported sessions to sessions.json
2285
+ const updatedMeta = getSessionMeta(cwd);
2286
+ const now = new Date().toISOString();
2287
+ let importCount = 0;
2288
+ for (const id of result.ids) {
2289
+ const sess = candidates.find(s => s.id === id);
2290
+ if (!sess) continue;
2291
+ updatedMeta[id] = {
2292
+ ...updatedMeta[id],
2293
+ source: 'data-tools',
2294
+ importedAt: now,
2295
+ createdAt: updatedMeta[id]?.createdAt ?? now,
2296
+ };
2297
+ importCount++;
2298
+ }
2299
+ saveSessionMeta(updatedMeta, cwd);
2300
+
2301
+ process.stdout.write(`✓ Imported ${importCount} session${importCount !== 1 ? 's' : ''} from data-tools\n\n`);
1355
2302
 
1356
2303
  return { next: 'main' };
1357
2304
  }
1358
2305
 
1359
- // ─── Screen: newSessionScreen ─────────────────────────────────────────────────
2306
+ // ─── Screen: prTriageScreen ───────────────────────────────────────────────────
1360
2307
 
1361
- async function newSessionScreen(rl, ask) {
1362
- const cwd = process.cwd();
1363
- const input = (await ask('\n What do you want to do? ')).trim();
1364
- if (!input) { return { next: 'main' }; }
2308
+ /**
2309
+ * PR Triage screen. Lists open PRs, lets the user select one, checkout + fetch
2310
+ * comments, then dispatch fixes through the dual-brain pipeline.
2311
+ *
2312
+ * ctx.openPRs is the pre-fetched array from detectOpenPRs().
2313
+ */
2314
+ async function prTriageScreen(rl, ask, ctx = {}) {
2315
+ const cwd = process.cwd();
2316
+ const prs = ctx.openPRs || [];
2317
+
2318
+ const termW = process.stdout.columns || 60;
2319
+ const boxW = Math.min(termW - 2, 60);
2320
+ const W = boxW - 4;
2321
+
2322
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2323
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2324
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2325
+ const row = (content) => makeBoxRow(content, W);
2326
+
2327
+ if (prs.length === 0) {
2328
+ process.stdout.write('\n');
2329
+ process.stdout.write(top + '\n');
2330
+ process.stdout.write(row('PR Triage') + '\n');
2331
+ process.stdout.write(sep + '\n');
2332
+ process.stdout.write(row('No open PRs found.') + '\n');
2333
+ process.stdout.write(sep + '\n');
2334
+ process.stdout.write(row('[q] Back') + '\n');
2335
+ process.stdout.write(bot + '\n\n');
2336
+ await ask(' Press Enter to go back...');
2337
+ return { next: 'main' };
2338
+ }
1365
2339
 
1366
- const profile = loadProfile(cwd);
1367
- const detection = detectTask({ prompt: input });
1368
- const decision = decideRoute({ profile, detection, cwd });
2340
+ // Helper: get review decision label
2341
+ function reviewLabel(rd) {
2342
+ if (!rd) return 'pending review';
2343
+ const map = {
2344
+ APPROVED: 'approved',
2345
+ CHANGES_REQUESTED: 'changes_requested',
2346
+ REVIEW_REQUIRED: 'review_required',
2347
+ };
2348
+ return map[rd] || rd.toLowerCase();
2349
+ }
2350
+
2351
+ // ── Render PR list ─────────────────────────────────────────────────────────
2352
+ process.stdout.write('\n');
2353
+ process.stdout.write(top + '\n');
2354
+ process.stdout.write(row('PR Triage') + '\n');
2355
+ process.stdout.write(sep + '\n');
2356
+
2357
+ prs.forEach((pr, i) => {
2358
+ const title = String(pr.title || '').slice(0, W - 6);
2359
+ const decision = reviewLabel(pr.reviewDecision);
2360
+ const diff = `+${pr.additions || 0} -${pr.deletions || 0}`;
2361
+ const files = pr.changedFiles ? `${pr.changedFiles} file${pr.changedFiles === 1 ? '' : 's'}` : '';
2362
+ const numStr = `#${pr.number}`;
2363
+
2364
+ process.stdout.write(row(`[${i + 1}] ${numStr} ${title}`) + '\n');
2365
+ process.stdout.write(row(` ${decision} · ${diff}${files ? ' · ' + files : ''}`) + '\n');
2366
+ if (pr.headRefName) {
2367
+ process.stdout.write(row(` Branch: ${pr.headRefName}`) + '\n');
2368
+ }
2369
+ if (i < prs.length - 1) {
2370
+ process.stdout.write(row('') + '\n');
2371
+ }
2372
+ });
2373
+
2374
+ process.stdout.write(sep + '\n');
2375
+ process.stdout.write(row('[1-9] Select PR [q] Back') + '\n');
2376
+ process.stdout.write(bot + '\n\n');
2377
+
2378
+ const pick = (await ask(' Choice: ')).trim().toLowerCase();
2379
+
2380
+ if (pick === 'q' || pick === 'b' || pick === '') return { next: 'main' };
2381
+
2382
+ const idx = parseInt(pick, 10) - 1;
2383
+ if (isNaN(idx) || idx < 0 || idx >= prs.length) return { next: 'pr-triage', openPRs: prs };
2384
+
2385
+ const selectedPR = prs[idx];
2386
+
2387
+ // ── PR detail: checkout + fetch comments ──────────────────────────────────
2388
+ process.stdout.write(`\n Checking out PR #${selectedPR.number}...\n`);
1369
2389
 
1370
- console.log(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})`);
1371
- console.log(` Reason: ${decision.explanation}\n`);
2390
+ const checkoutResult = _spawnSyncTop('gh', ['pr', 'checkout', String(selectedPR.number)], {
2391
+ cwd,
2392
+ encoding: 'utf8',
2393
+ stdio: ['pipe', 'pipe', 'pipe'],
2394
+ timeout: 15000,
2395
+ });
2396
+
2397
+ if (checkoutResult.status !== 0) {
2398
+ process.stdout.write(` Could not checkout PR: ${(checkoutResult.stderr || '').slice(0, 100)}\n`);
2399
+ await ask(' Press Enter to continue...');
2400
+ return { next: 'pr-triage', openPRs: prs };
2401
+ }
2402
+
2403
+ process.stdout.write(` Fetching comments...\n`);
2404
+
2405
+ let comments = [];
2406
+ try {
2407
+ const commentsResult = _spawnSyncTop('gh', [
2408
+ 'pr', 'view', String(selectedPR.number),
2409
+ '--comments',
2410
+ '--json', 'comments',
2411
+ ], {
2412
+ cwd,
2413
+ encoding: 'utf8',
2414
+ stdio: ['pipe', 'pipe', 'pipe'],
2415
+ timeout: 5000,
2416
+ });
2417
+
2418
+ if (commentsResult.status === 0 && commentsResult.stdout) {
2419
+ const parsed = JSON.parse(commentsResult.stdout.trim());
2420
+ comments = parsed?.comments || [];
2421
+ }
2422
+ } catch {}
1372
2423
 
1373
- const { spawnSync } = await import('node:child_process');
1374
- const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
1375
- if (launchTool === 'codex') {
1376
- spawnSync('codex', [input], { stdio: 'inherit' });
2424
+ // ── Show PR detail: comments grouped by file ──────────────────────────────
2425
+ process.stdout.write('\n');
2426
+ process.stdout.write(top + '\n');
2427
+ process.stdout.write(row(`#${selectedPR.number} ${String(selectedPR.title).slice(0, W - 6)}`) + '\n');
2428
+ process.stdout.write(sep + '\n');
2429
+
2430
+ if (comments.length === 0) {
2431
+ process.stdout.write(row('No review comments.') + '\n');
1377
2432
  } else {
1378
- spawnSync('claude', ['-p', input], { stdio: 'inherit' });
2433
+ // Group comments by their file path (body comments have no path)
2434
+ const grouped = {};
2435
+ for (const c of comments) {
2436
+ const file = c.path || '(general)';
2437
+ if (!grouped[file]) grouped[file] = [];
2438
+ grouped[file].push(c);
2439
+ }
2440
+ for (const [file, fileCmts] of Object.entries(grouped)) {
2441
+ const fileLabel = file.length > W - 4 ? '...' + file.slice(-(W - 7)) : file;
2442
+ process.stdout.write(row(` ${fileLabel}`) + '\n');
2443
+ for (const c of fileCmts.slice(0, 3)) {
2444
+ const body = String(c.body || '').replace(/\s+/g, ' ').slice(0, W - 6);
2445
+ process.stdout.write(row(` → ${body}`) + '\n');
2446
+ }
2447
+ if (fileCmts.length > 3) {
2448
+ process.stdout.write(row(` ... +${fileCmts.length - 3} more`) + '\n');
2449
+ }
2450
+ }
1379
2451
  }
1380
2452
 
1381
- // After session ends, capture the most-recent session ID so [c] can resume it
1382
- const freshSessions = importReplitSessions(cwd);
1383
- if (freshSessions.length > 0) {
1384
- saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
2453
+ process.stdout.write(sep + '\n');
2454
+ process.stdout.write(row('[f] Dispatch fixes [v] View full diff [b] Back') + '\n');
2455
+ process.stdout.write(bot + '\n\n');
2456
+
2457
+ const action = (await ask(' Action: ')).trim().toLowerCase();
2458
+
2459
+ if (action === 'v') {
2460
+ // Show full diff via gh pr diff
2461
+ process.stdout.write('\n');
2462
+ const diffResult = _spawnSyncTop('gh', ['pr', 'diff', String(selectedPR.number)], {
2463
+ cwd,
2464
+ encoding: 'utf8',
2465
+ stdio: ['ignore', 'pipe', 'pipe'],
2466
+ timeout: 10000,
2467
+ });
2468
+ const diffOut = (diffResult.stdout || '').slice(0, 3000);
2469
+ process.stdout.write(diffOut || ' (no diff output)\n');
2470
+ process.stdout.write('\n');
2471
+ await ask(' Press Enter to continue...');
2472
+ return { next: 'pr-triage', openPRs: prs };
1385
2473
  }
1386
2474
 
1387
- return { next: 'main' };
2475
+ if (action === 'f') {
2476
+ // Dispatch each comment as a fix task through detect→decide→dispatch
2477
+ if (comments.length === 0) {
2478
+ process.stdout.write(' No comments to fix.\n\n');
2479
+ await ask(' Press Enter to continue...');
2480
+ return { next: 'pr-triage', openPRs: prs };
2481
+ }
2482
+
2483
+ process.stdout.write(`\n Dispatching ${comments.length} comment fix${comments.length === 1 ? '' : 's'} through dual-brain...\n\n`);
2484
+
2485
+ // Collect the PR files for context
2486
+ const prFiles = [];
2487
+ try {
2488
+ const filesResult = _spawnSyncTop('gh', [
2489
+ 'pr', 'view', String(selectedPR.number),
2490
+ '--json', 'files',
2491
+ ], {
2492
+ cwd,
2493
+ encoding: 'utf8',
2494
+ stdio: ['pipe', 'pipe', 'pipe'],
2495
+ timeout: 5000,
2496
+ });
2497
+ if (filesResult.status === 0) {
2498
+ const pf = JSON.parse(filesResult.stdout || '{}');
2499
+ (pf.files || []).forEach(f => prFiles.push(f.path));
2500
+ }
2501
+ } catch {}
2502
+
2503
+ const profile = loadProfile(cwd);
2504
+
2505
+ for (let ci = 0; ci < comments.length; ci++) {
2506
+ const c = comments[ci];
2507
+ const taskPrompt = c.path
2508
+ ? `Fix review comment in ${c.path}: ${c.body}`
2509
+ : `Fix PR review comment: ${c.body}`;
2510
+
2511
+ process.stdout.write(` [${ci + 1}/${comments.length}] ${taskPrompt.slice(0, 60)}...\n`);
2512
+
2513
+ try {
2514
+ const detection = detectTask({ prompt: taskPrompt, files: prFiles });
2515
+ const decision = decideRoute({ profile, detection, cwd });
2516
+ const result = await dispatch({ decision, prompt: taskPrompt, files: prFiles, cwd });
2517
+ const status = result.status === 'completed' ? '✓' : '✗';
2518
+ process.stdout.write(` ${status} ${result.status} (${(result.durationMs / 1000).toFixed(1)}s)\n`);
2519
+ if (result.summary) process.stdout.write(` ${result.summary.slice(0, 80)}\n`);
2520
+ } catch (e) {
2521
+ process.stdout.write(` ✗ Error: ${e.message.slice(0, 80)}\n`);
2522
+ }
2523
+ }
2524
+
2525
+ process.stdout.write('\n All fixes dispatched.\n\n');
2526
+ await ask(' Press Enter to continue...');
2527
+ return { next: 'pr-triage', openPRs: prs };
2528
+ }
2529
+
2530
+ // 'b' or anything else → back to PR list
2531
+ return { next: 'pr-triage', openPRs: prs };
1388
2532
  }
1389
2533
 
1390
2534
  // ─── Screen: settingsScreen ───────────────────────────────────────────────────
1391
2535
 
1392
2536
  async function settingsScreen(rl, ask) {
1393
2537
  const cwd = process.cwd();
1394
- const profile = loadProfile(cwd);
1395
- const auth = await detectAuth();
1396
2538
 
1397
- let guardCount = 0;
1398
- try {
1399
- const settingsFile = join(cwd, '.claude', 'settings.json');
1400
- if (existsSync(settingsFile)) {
1401
- const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
1402
- const preToolUse = settings?.hooks?.PreToolUse ?? [];
1403
- const guardCmd = 'node .claude/hooks/head-guard.mjs';
1404
- const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
1405
- const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
1406
- const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
1407
- const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
1408
- const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
1409
- guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
1410
- }
1411
- } catch { /* ignore */ }
2539
+ // Box layout matching dashboard
2540
+ const termW = process.stdout.columns || 60;
2541
+ const boxW = Math.min(termW - 2, 60);
2542
+ const W = boxW - 4;
1412
2543
 
1413
- const modeLabel = (m) => m === profile.mode ? `${m} (active)` : m;
2544
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2545
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2546
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2547
+ const row = (content) => makeBoxRow(content, W);
1414
2548
 
1415
- const claudeSub = profile?.providers?.claude;
1416
- const openaiSub = profile?.providers?.openai;
1417
- const claudePlanLabel = claudeSub?.enabled
1418
- ? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
1419
- : 'disabled';
1420
- const openaiPlanLabel = openaiSub?.enabled
1421
- ? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
1422
- : 'disabled';
2549
+ // Detect if gh is available + has PRs for the PR triage option
2550
+ const settingsPRs = await detectOpenPRs(cwd);
1423
2551
 
1424
- const settingsLines = [
1425
- `Mode:`,
1426
- ` [1] ${modeLabel('cost-saver')}`,
1427
- ` [2] ${modeLabel('balanced')}`,
1428
- ` [3] ${modeLabel('quality-first')}`,
1429
- '',
1430
- `Subscriptions:`,
1431
- ` Claude: ${auth.claude.found ? 'logged in' : 'not logged in'} — ${claudePlanLabel}${claudeSub?.label ? ` [${claudeSub.label}]` : ''}`,
1432
- ` OpenAI: ${auth.openai.found ? 'logged in' : 'not logged in'} — ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
1433
- '',
1434
- `Enforcement: ${guardCount}/4 guards active`,
2552
+ // Load current work style
2553
+ const profile = loadProfile(cwd);
2554
+ const currentBias = profile?.bias || profile?.mode || 'balanced';
2555
+ const WORK_STYLE_DISPLAY = {
2556
+ 'cost-saver': '⚡ Fast',
2557
+ 'auto': '⚡ Fast',
2558
+ 'solo-claude': '⚡ Fast',
2559
+ 'solo-openai': ' Fast',
2560
+ 'balanced': '⚖️ Balanced',
2561
+ 'quality-first': '🔥 Full Power',
2562
+ };
2563
+ const workStyleLabel = WORK_STYLE_DISPLAY[currentBias] || '⚖️ Balanced';
2564
+
2565
+ const lines = [
2566
+ top,
2567
+ row('Settings'),
2568
+ sep,
2569
+ row(`[w] Work Style: ${workStyleLabel}`),
2570
+ row('[m] Manage subscriptions'),
2571
+ row('[e] Manage sessions'),
2572
+ row('[i] Import from replit-tools'),
2573
+ row('[d] Switch to data-tools'),
2574
+ row('[?] Help & shortcuts'),
2575
+ row('[x] Diagnostics'),
2576
+ ...(settingsPRs.length > 0 ? [row(`[p] PR triage (${settingsPRs.length} open)`)] : []),
2577
+ row(''),
2578
+ row('[Esc/b] Back to dashboard'),
2579
+ bot,
1435
2580
  ];
2581
+ process.stdout.write('\n' + lines.join('\n') + '\n\n');
2582
+
2583
+ const raw = (await ask(' Choice: ')).trim();
2584
+ const choice = raw.toLowerCase();
2585
+
2586
+ if (choice === 'w') {
2587
+ // Work style picker
2588
+ const wsTop = ` ┌${'─'.repeat(51)}┐`;
2589
+ const wsSep = ` ├${'─'.repeat(51)}┤`;
2590
+ const wsBot = ` └${'─'.repeat(51)}┘`;
2591
+ const wsPad = (s) => {
2592
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
2593
+ let vlen = 0;
2594
+ for (const ch of plain) {
2595
+ const cp = ch.codePointAt(0);
2596
+ if (
2597
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
2598
+ (cp >= 0x2600 && cp <= 0x27bf) ||
2599
+ cp === 0xfe0f || cp === 0x20e3
2600
+ ) { vlen += 2; } else { vlen += 1; }
2601
+ }
2602
+ return s + ' '.repeat(Math.max(0, 51 - vlen));
2603
+ };
2604
+ const wsRow = (s) => ` │ ${wsPad(s)}│`;
1436
2605
 
1437
- console.log('');
1438
- console.log(box('Settings', settingsLines));
1439
- console.log('');
1440
- console.log(menu([
1441
- { key: '1', label: 'Switch to cost-saver', section: 'Mode' },
1442
- { key: '2', label: 'Switch to balanced', section: 'Mode' },
1443
- { key: '3', label: 'Switch to quality-first', section: 'Mode' },
1444
- { key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
1445
- { key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
1446
- { key: 'b', label: 'Back', section: '' },
1447
- ]));
1448
- console.log('');
2606
+ const isFast = currentBias === 'cost-saver' || currentBias === 'auto' || currentBias === 'solo-claude' || currentBias === 'solo-openai';
2607
+ const isBal = currentBias === 'balanced';
2608
+ const isFull = currentBias === 'quality-first';
1449
2609
 
1450
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
2610
+ console.log('');
2611
+ console.log(wsTop);
2612
+ console.log(wsRow('Work Style'));
2613
+ console.log(wsSep);
2614
+ console.log(wsRow(` 1. ⚡ Fast — quick, single model${isFast ? ' ← current' : ''}`));
2615
+ console.log(wsRow(` 2. ⚖️ Balanced — smart routing${isBal ? ' ← current' : ''}`));
2616
+ console.log(wsRow(` 3. 🔥 Full Power — dual-brain everything${isFull ? ' ← current' : ''}`));
2617
+ console.log(wsSep);
2618
+ console.log(wsRow('[Enter] Keep current'));
2619
+ console.log(wsBot);
2620
+ console.log('');
1451
2621
 
1452
- if (choice === '1' || choice === '2' || choice === '3') {
1453
- const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
1454
- profile.mode = modeMap[choice];
1455
- saveProfile(profile, { cwd });
1456
- console.log(` Mode set to: ${profile.mode}`);
2622
+ const wsChoice = (await ask(' Choice [1/2/3/Enter]: ')).trim();
2623
+ const wsMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
2624
+ const newBias = wsMap[wsChoice];
2625
+ if (newBias && newBias !== currentBias) {
2626
+ profile.bias = newBias;
2627
+ const enabledCount = [
2628
+ profile.providers?.claude?.enabled,
2629
+ profile.providers?.openai?.enabled,
2630
+ ].filter(Boolean).length;
2631
+ if (enabledCount >= 2) profile.mode = newBias;
2632
+ saveProfile(profile, { cwd });
2633
+ const newLabel = WORK_STYLE_DISPLAY[newBias] || newBias;
2634
+ console.log(`\n ✓ Work style set to ${newLabel}\n`);
2635
+ await ask(' Press Enter to continue...');
2636
+ }
1457
2637
  return { next: 'settings' };
1458
2638
  }
1459
2639
 
1460
- if (choice === 'a') {
1461
- return { next: 'subscriptions' };
1462
- }
2640
+ if (choice === 'm') { return { next: 'subscriptions' }; }
2641
+
2642
+ if (choice === 'e') { return { next: 'sessions' }; }
1463
2643
 
1464
2644
  if (choice === 'i') {
1465
- await cmdInstall();
2645
+ return { next: 'import-picker' };
2646
+ }
2647
+
2648
+ if (choice === 'p' && settingsPRs.length > 0) {
2649
+ return { next: 'pr-triage', openPRs: settingsPRs };
2650
+ }
2651
+
2652
+ if (choice === 'd') {
2653
+ const { spawnSync } = await import('node:child_process');
2654
+ const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
2655
+ if (which.status === 0) {
2656
+ spawnSync('claude-menu', { stdio: 'inherit' });
2657
+ } else {
2658
+ process.stdout.write('\n data-tools not found — install with: npm i -g replit-tools\n\n');
2659
+ await ask(' Press Enter to continue...');
2660
+ }
1466
2661
  return { next: 'settings' };
1467
2662
  }
1468
2663
 
1469
- if (choice === 'b' || choice === 'back') { return { next: 'main' }; }
2664
+ if (choice === '?') {
2665
+ const W2 = 37;
2666
+ const helpTop = ` ┌${'─'.repeat(W2)}┐`;
2667
+ const helpSep = ` ├${'─'.repeat(W2)}┤`;
2668
+ const helpBottom = ` └${'─'.repeat(W2)}┘`;
2669
+ const helpPad = (s) => s + ' '.repeat(Math.max(0, W2 - s.length));
2670
+ process.stdout.write('\n');
2671
+ process.stdout.write(helpTop + '\n');
2672
+ process.stdout.write(` │ ${helpPad('At ~/workspace$ prompt:')}│\n`);
2673
+ process.stdout.write(` │ ${helpPad('db = show this menu')}│\n`);
2674
+ process.stdout.write(` │ ${helpPad('j = login to claude')}│\n`);
2675
+ process.stdout.write(` │ ${helpPad('k = login to codex')}│\n`);
2676
+ process.stdout.write(helpSep + '\n');
2677
+ process.stdout.write(` │ ${helpPad('In Claude:')}│\n`);
2678
+ process.stdout.write(` │ ${helpPad('Ctrl+C x2 = back to menu')}│\n`);
2679
+ process.stdout.write(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│\n`);
2680
+ process.stdout.write(helpBottom + '\n\n');
2681
+ await ask(' Press Enter to continue...');
2682
+ return { next: 'settings' };
2683
+ }
2684
+
2685
+ if (choice === 'x') { return { next: 'diagnostics' }; }
1470
2686
 
1471
- return { next: 'settings' };
2687
+ if (choice === 'b' || choice === 'back' || raw === '\x1b') { return { next: 'main' }; }
2688
+
2689
+ return { next: 'main' };
1472
2690
  }
1473
2691
 
1474
- // ─── Helper: aggregatePlans ───────────────────────────────────────────────────
1475
2692
 
1476
- const PLAN_PRICES = {
1477
- pro: '$20', max5: '$100', max20: '$200',
1478
- plus: '$20', pro100: '$100', pro200: '$200',
1479
- };
2693
+ // ─── Helper: aggregatePlans ───────────────────────────────────────────────────
1480
2694
 
1481
2695
  function aggregatePlans(subs) {
1482
2696
  if (!subs || subs.length === 0) return '';
1483
2697
  const counts = {};
1484
2698
  for (const s of subs) {
1485
- const price = PLAN_PRICES[s.plan] || s.plan;
1486
- counts[price] = (counts[price] || 0) + 1;
2699
+ const label = s.plan || 'unknown';
2700
+ counts[label] = (counts[label] || 0) + 1;
1487
2701
  }
1488
2702
  return Object.entries(counts)
1489
- .sort((a, b) => parseInt(b[0].slice(1)) - parseInt(a[0].slice(1)))
1490
- .map(([price, count]) => `${price}×${count}`)
2703
+ .map(([label, count]) => count > 1 ? `${label}×${count}` : label)
1491
2704
  .join(' ');
1492
2705
  }
1493
2706
 
@@ -1549,13 +2762,13 @@ async function subscriptionsScreen(rl, ask) {
1549
2762
  const choice = (await ask(' Choice: ')).trim().toLowerCase();
1550
2763
 
1551
2764
  if (choice === '1') {
1552
- console.log('\n Linking Claude subscription...');
2765
+ console.log('\n Linking Claude account...');
1553
2766
  console.log(' A browser window will open — paste the code below when prompted.\n');
1554
2767
  const { spawnSync } = await import('node:child_process');
1555
2768
  const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 60000 });
1556
2769
  if (r.status === 0) {
1557
2770
  console.log('\n ✅ Claude linked successfully!\n');
1558
- const label = (await ask(" Label (e.g. \"Josh's $100 sub\", or Enter to skip): ")).trim();
2771
+ const label = (await ask(" Label (e.g. \"Josh's work account\", or Enter to skip): ")).trim();
1559
2772
  const expiry = await askExpiry(ask, 'Claude');
1560
2773
  const newPlans = detectPlans();
1561
2774
  const plan = newPlans.claude?.plan || 'pro';
@@ -1577,7 +2790,7 @@ async function subscriptionsScreen(rl, ask) {
1577
2790
  }
1578
2791
 
1579
2792
  if (choice === '2') {
1580
- console.log('\n Linking Codex subscription...');
2793
+ console.log('\n Linking Codex account...');
1581
2794
  console.log(' A browser window will open — paste the code below when prompted.\n');
1582
2795
  const { spawnSync } = await import('node:child_process');
1583
2796
  const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 60000 });
@@ -1615,12 +2828,12 @@ async function subscriptionsScreen(rl, ask) {
1615
2828
  }
1616
2829
 
1617
2830
  if (allSubs.length === 0) {
1618
- console.log('\n No subscriptions to remove.\n');
2831
+ console.log('\n No linked accounts to remove.\n');
1619
2832
  await ask(' Press Enter to continue...');
1620
2833
  return { next: 'subscriptions' };
1621
2834
  }
1622
2835
 
1623
- console.log('\n Remove a subscription:\n');
2836
+ console.log('\n Remove a linked account:\n');
1624
2837
  allSubs.forEach(({ displayName, sub }, i) => {
1625
2838
  const planLabels = displayName === 'Claude' ? CLAUDE_PLAN_LABELS : OPENAI_PLAN_LABELS;
1626
2839
  const planLabel = planLabels[sub.plan] ?? sub.plan ?? 'unknown';
@@ -1660,21 +2873,20 @@ async function subscriptionsScreen(rl, ask) {
1660
2873
  // ─── Onboarding Wizard ───────────────────────────────────────────────────────
1661
2874
 
1662
2875
  /**
1663
- * 5-step onboarding wizard shown on first run (no .dualbrain/profile.json).
1664
- * Matches the rounded ┌─┐ box style used in mainScreen / renderHeader.
2876
+ * Streamlined onboarding: auto-detect capabilities, ask ONE question (work style).
2877
+ * Replaces the old 5-step wizard with a ~5-second, one-choice flow.
1665
2878
  * @param {{ auth, plans, existingSessions }} detection
1666
2879
  * @param {string} cwd
1667
2880
  * @param {object} rl readline interface
1668
2881
  * @returns {object|null} profile object to save, or null if cancelled/skipped
1669
2882
  */
1670
- async function runOnboardingWizard(detection, cwd, rl) {
2883
+ async function runOnboardingWizard(_detection, cwd, rl) {
1671
2884
  const ask = (q) => new Promise(res => rl.question(q, res));
1672
2885
  const version = readVersion();
1673
2886
 
1674
2887
  // ── Rounded box helpers (matching mainScreen style) ────────────────────────
1675
2888
  const W = 51;
1676
2889
  const wTop = ` ┌${'─'.repeat(W)}┐`;
1677
- const wSep = ` ├${'─'.repeat(W)}┤`;
1678
2890
  const wBottom = ` └${'─'.repeat(W)}┘`;
1679
2891
  const wPad = (s) => {
1680
2892
  const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
@@ -1691,244 +2903,95 @@ async function runOnboardingWizard(detection, cwd, rl) {
1691
2903
  };
1692
2904
  const wRow = (s) => ` │ ${wPad(s)}│`;
1693
2905
 
1694
- // ── Collected wizard state ─────────────────────────────────────────────────
1695
- const state = {
1696
- claudePlan: null,
1697
- openaiPlan: null,
1698
- headModel: null,
1699
- importSessions: false,
1700
- profile: 'auto',
1701
- };
1702
-
1703
- const { auth, plans, existingSessions } = detection;
1704
- const claudeReady = auth.claude.found;
1705
- const openaiReady = auth.openai.found;
1706
-
1707
- // ══════════════════════════════════════════════════════════════════════════
1708
- // Step 1 — Welcome & provider detection
1709
- // ══════════════════════════════════════════════════════════════════════════
1710
- console.log('');
1711
- console.log(wTop);
1712
- console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1713
- console.log(wSep);
1714
- console.log(wRow(`Step 1 of 5: Detected providers`));
1715
- console.log(wSep);
1716
-
1717
- // Plan tier is inferred from auth config signals — not the actual plan name.
1718
- // Show the tier ($20/$100/$200) with "configured" suffix to be honest.
1719
- const claudePlanSuffix = claudeReady && plans.claude ? ` · ${plans.claude} configured` : '';
1720
- const openaiPlanSuffix = openaiReady && plans.openai ? ` · ${plans.openai} configured` : '';
1721
-
1722
- console.log(wRow(claudeReady
1723
- ? `✓ Claude CLI${claudePlanSuffix}`
1724
- : `✗ Claude CLI not logged in`));
1725
- console.log(wRow(openaiReady
1726
- ? `✓ Codex CLI${openaiPlanSuffix}`
1727
- : `✗ Codex CLI not logged in`));
1728
- if (existingSessions.length > 0) {
1729
- console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
1730
- }
1731
- console.log(wSep);
1732
- console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
1733
- console.log(wBottom);
1734
- console.log('');
1735
-
1736
- if (!claudeReady && !openaiReady) {
1737
- console.log(' No AI provider found. Log in first:');
1738
- console.log(' claude auth login — for Claude');
1739
- console.log(' codex login — for OpenAI/Codex');
1740
- console.log(' Then re-run: dual-brain init\n');
1741
- return null;
1742
- }
1743
-
1744
- const step1 = (await ask(' > ')).trim().toLowerCase();
1745
- if (step1 === 's') {
1746
- // Skip: auto-save detected plans and proceed directly
1747
- const skippedProfile = loadProfile(cwd);
1748
- if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
1749
- if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
1750
- const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
1751
- skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
1752
- return skippedProfile;
1753
- }
2906
+ // ── Use detectCapabilities for broad detection (env vars, ~/.claude, CLI) ──
2907
+ const caps = await detectCapabilities(cwd);
2908
+ const claudeReady = caps.claude.available;
2909
+ const openaiReady = caps.openai.available;
2910
+ const codexAvailable = caps.codex.available;
1754
2911
 
1755
- // ══════════════════════════════════════════════════════════════════════════
1756
- // Step 2 — Budget / plan selection
1757
- // ══════════════════════════════════════════════════════════════════════════
1758
- console.log('');
1759
- console.log(wTop);
1760
- console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1761
- console.log(wSep);
1762
- console.log(wRow(`Step 2 of 5: Subscription plans`));
1763
- console.log(wSep);
1764
-
1765
- if (claudeReady) {
1766
- // Plan tier is inferred from auth config (rate-limit signal), not the actual plan name.
1767
- const configuredClaudePlan = plans.claude || '$20';
1768
- const configuredClaudeDesc = configuredClaudePlan + ' configured';
1769
- console.log(wRow(`Claude — ${configuredClaudeDesc}`));
1770
- console.log(wRow(` [1] Pro ($20/mo)`));
1771
- console.log(wRow(` [2] Max x5 ($100/mo)`));
1772
- console.log(wRow(` [3] Max x20 ($200/mo)`));
1773
- console.log(wRow(` [Enter] Keep configured (${configuredClaudePlan})`));
1774
- console.log(wSep);
1775
- const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
1776
- const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
1777
- state.claudePlan = claudePlanMap[claudeChoice] || configuredClaudePlan;
1778
- }
1779
-
1780
- if (openaiReady) {
1781
- // Plan tier is inferred from JWT claim in auth config, not the actual plan name.
1782
- const configuredOpenaiPlan = plans.openai || '$20';
1783
- const configuredOpenaiDesc = configuredOpenaiPlan + ' configured';
1784
- console.log(wRow(`OpenAI — ${configuredOpenaiDesc}`));
1785
- console.log(wRow(` [1] Plus ($20/mo)`));
1786
- console.log(wRow(` [2] Pro ($100/mo)`));
1787
- console.log(wRow(` [3] Pro ($200/mo higher limits)`));
1788
- console.log(wRow(` [Enter] Keep configured (${configuredOpenaiPlan})`));
1789
- console.log(wSep);
1790
- const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
1791
- const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
1792
- state.openaiPlan = openaiPlanMap[openaiChoice] || configuredOpenaiPlan;
1793
- }
1794
-
1795
- console.log(wBottom);
1796
-
1797
- // ══════════════════════════════════════════════════════════════════════════
1798
- // Step 3 — HEAD model selection
1799
- // ══════════════════════════════════════════════════════════════════════════
1800
- const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
1801
- const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
1802
- const recommendedLabel = hasBigPlan
1803
- ? 'Opus (Max plan — best quality)'
1804
- : 'Sonnet (Pro plan — balanced speed/quality)';
1805
-
1806
- console.log('');
1807
- console.log(wTop);
1808
- console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1809
- console.log(wSep);
1810
- console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
1811
- console.log(wSep);
1812
- console.log(wRow(`Recommended: ${recommendedLabel}`));
1813
- console.log(wSep);
1814
- console.log(wRow(` [1] Haiku — fastest, lowest cost`));
1815
- console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
1816
- console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
1817
- console.log(wRow(` [Enter] Use recommended`));
1818
- console.log(wBottom);
1819
- console.log('');
2912
+ // ── Detect replit-tools ────────────────────────────────────────────────────
2913
+ const rt = detectReplitTools(cwd);
1820
2914
 
1821
- const step3 = (await ask(' HEAD model [1/2/3/Enter]: ')).trim();
1822
- const modelMap = {
1823
- '1': 'claude-haiku-4-5',
1824
- '2': 'claude-sonnet-4-5',
1825
- '3': 'claude-opus-4-5',
1826
- };
1827
- state.headModel = modelMap[step3] || recommendedModel;
2915
+ const GREEN = '\x1b[32m✓\x1b[0m';
2916
+ const RED = '\x1b[31m✗\x1b[0m';
2917
+ const DIM = '\x1b[2m';
2918
+ const RESET = '\x1b[0m';
1828
2919
 
1829
2920
  // ══════════════════════════════════════════════════════════════════════════
1830
- // Step 4Import sessions + profile selection
2921
+ // Step 1Auto-detect capabilities (instant, no spinner)
1831
2922
  // ══════════════════════════════════════════════════════════════════════════
1832
2923
  console.log('');
1833
2924
  console.log(wTop);
1834
2925
  console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1835
- console.log(wSep);
1836
- console.log(wRow(`Step 4 of 5: Sessions & routing profile`));
1837
- console.log(wSep);
2926
+ console.log(wRow(claudeReady
2927
+ ? `${GREEN} Claude Code`
2928
+ : `${RED} Claude Code — not found`));
2929
+ console.log(wRow(openaiReady
2930
+ ? `${GREEN} OpenAI API`
2931
+ : codexAvailable
2932
+ ? `${GREEN} OpenAI / Codex CLI`
2933
+ : `${DIM}○ OpenAI — not configured${RESET}`));
2934
+ console.log(wRow(rt.installed
2935
+ ? `${GREEN} replit-tools`
2936
+ : `${DIM}○ replit-tools — not found${RESET}`));
2937
+ console.log(wBottom);
1838
2938
 
1839
- if (existingSessions.length > 0) {
1840
- console.log(wRow(`Import ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''}?`));
1841
- console.log(wRow(` [y] Yes [Enter/n] Skip`));
1842
- console.log(wSep);
1843
- const importChoice = (await ask(' Import sessions [y/Enter]: ')).trim().toLowerCase();
1844
- state.importSessions = importChoice === 'y';
1845
- if (state.importSessions) {
1846
- console.log('');
1847
- console.log(` Importing ${existingSessions.length} sessions...`);
1848
- const recent = existingSessions.slice(0, 5);
1849
- for (const sess of recent) {
1850
- console.log(` ${sess.age.padEnd(6)} ${sess.name}`);
1851
- }
1852
- if (existingSessions.length > 5) {
1853
- console.log(` ... and ${existingSessions.length - 5} more`);
1854
- }
1855
- }
1856
- console.log(wSep);
2939
+ // ── Edge cases: communicate honestly, but always let them proceed ──────────
2940
+ console.log('');
2941
+ if (!claudeReady && !openaiReady && !codexAvailable) {
2942
+ console.log(' No AI providers detected — configure OPENAI_API_KEY or use');
2943
+ console.log(' within Claude Code. You can still continue and set up later.');
2944
+ console.log('');
2945
+ } else if (claudeReady && !openaiReady && !codexAvailable) {
2946
+ console.log(` ${DIM}Tip: Add OPENAI_API_KEY for dual-brain collaboration${RESET}`);
2947
+ console.log('');
2948
+ } else if (!claudeReady && (openaiReady || codexAvailable)) {
2949
+ console.log(` ${DIM}Note: Use within Claude Code for full dual-brain${RESET}`);
2950
+ console.log('');
1857
2951
  }
1858
2952
 
1859
- console.log(wRow(`Routing profile:`));
1860
- console.log(wRow(` [1] auto — adapts based on task risk & outcomes`));
1861
- console.log(wRow(` [2] balanced — best model per tier, normal budgets`));
1862
- console.log(wRow(` [3] cost-saver — prefer cheaper models, skip GPT`));
1863
- console.log(wRow(` [4] quality-first dual-brain for medium+ risk`));
1864
- console.log(wRow(` [Enter] auto (recommended)`));
2953
+ // ══════════════════════════════════════════════════════════════════════════
2954
+ // Step 2 ONE question: work style
2955
+ // ══════════════════════════════════════════════════════════════════════════
2956
+ console.log(wTop);
2957
+ console.log(wRow('How do you want to work?'));
2958
+ console.log(wRow(''));
2959
+ console.log(wRow(' 1 ⚡ Fast — single model, quick tasks, skip reviews'));
2960
+ console.log(wRow(' 2 ⚖️ Balanced — smart routing, reviews on important changes'));
2961
+ console.log(wRow(' 3 🔥 Full Power — deep reasoning, dual-brain when it matters'));
1865
2962
  console.log(wBottom);
1866
2963
  console.log('');
1867
2964
 
1868
- const step4 = (await ask(' Profile [1/2/3/4/Enter]: ')).trim();
1869
- const profileMap = { '1': 'auto', '2': 'balanced', '3': 'cost-saver', '4': 'quality-first' };
1870
- state.profile = profileMap[step4] || 'auto';
2965
+ const styleChoice = (await ask(' Choice [2]: ')).trim();
2966
+ const styleMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
2967
+ const styleNames = { 'cost-saver': 'Fast', 'balanced': 'Balanced', 'quality-first': 'Full Power' };
2968
+ const chosenBias = styleMap[styleChoice] || 'balanced';
2969
+ const chosenName = styleNames[chosenBias];
1871
2970
 
1872
- // ══════════════════════════════════════════════════════════════════════════
1873
- // Step 5 Summary & confirm
1874
- // ══════════════════════════════════════════════════════════════════════════
1875
- const claudeSummary = state.claudePlan
1876
- ? `Claude: ${CLAUDE_PLAN_LABELS[state.claudePlan] ?? state.claudePlan}`
1877
- : `Claude: not configured`;
1878
- const openaiSummary = state.openaiPlan
1879
- ? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
1880
- : `OpenAI: not configured`;
1881
- const modelSummary = `HEAD model: ${state.headModel}`;
1882
- const profileSummary = `Profile: ${state.profile}`;
1883
- const sessionSummary = existingSessions.length > 0
1884
- ? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
1885
- : null;
2971
+ // ── Non-blocking note if metered API detected ──────────────────────────────
2972
+ if (openaiReady && caps.openai.metered) {
2973
+ console.log(` ${DIM}OpenAI API key detected — usage is metered, guardrails enabled${RESET}`);
2974
+ console.log('');
2975
+ }
1886
2976
 
1887
- console.log('');
2977
+ // ── Done ───────────────────────────────────────────────────────────────────
1888
2978
  console.log(wTop);
1889
- console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
1890
- console.log(wSep);
1891
- console.log(wRow(`Step 5 of 5: Summary`));
1892
- console.log(wSep);
1893
- console.log(wRow(claudeSummary));
1894
- console.log(wRow(openaiSummary));
1895
- console.log(wRow(modelSummary));
1896
- console.log(wRow(profileSummary));
1897
- if (sessionSummary) console.log(wRow(sessionSummary));
1898
- console.log(wSep);
1899
- console.log(wRow(`[Enter] Save and start [q] Quit without saving`));
2979
+ console.log(wRow(`${GREEN} Ready ${chosenName} mode`));
2980
+ console.log(wRow(` Type a task to start, or press Enter for dashboard`));
1900
2981
  console.log(wBottom);
1901
2982
  console.log('');
1902
2983
 
1903
- const step5 = (await ask(' > ')).trim().toLowerCase();
1904
- if (step5 === 'q') {
1905
- console.log('\n Setup cancelled.\n');
1906
- return null;
1907
- }
1908
-
1909
2984
  // ── Build and return the profile object ────────────────────────────────────
1910
2985
  const finalProfile = loadProfile(cwd);
1911
2986
 
1912
- if (state.claudePlan) {
1913
- finalProfile.providers.claude = { enabled: true, plan: state.claudePlan };
1914
- } else if (claudeReady) {
1915
- finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
1916
- }
1917
-
1918
- if (state.openaiPlan) {
1919
- finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
1920
- } else if (openaiReady) {
1921
- finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
1922
- }
1923
-
1924
- const enabledCount = [
1925
- finalProfile.providers?.claude?.enabled,
1926
- finalProfile.providers?.openai?.enabled,
1927
- ].filter(Boolean).length;
2987
+ finalProfile.providers.claude = { enabled: claudeReady };
2988
+ finalProfile.providers.openai = { enabled: openaiReady || codexAvailable };
2989
+ finalProfile.apiGuardrail = caps.openai.metered;
1928
2990
 
1929
- finalProfile.mode = enabledCount >= 2 ? state.profile : claudeReady ? 'solo-claude' : 'solo-openai';
1930
- finalProfile.headModel = state.headModel;
1931
- finalProfile.bias = state.profile;
2991
+ const enabledCount = [claudeReady, openaiReady || codexAvailable].filter(Boolean).length;
2992
+ finalProfile.mode = enabledCount >= 2 ? 'dual' : claudeReady ? 'solo-claude' : 'solo-openai';
2993
+ finalProfile.bias = chosenBias;
2994
+ finalProfile.workStyle = chosenBias;
1932
2995
 
1933
2996
  return finalProfile;
1934
2997
  }
@@ -1969,11 +3032,11 @@ async function authScreen(rl, ask) {
1969
3032
  ` plan: ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
1970
3033
  ];
1971
3034
 
1972
- console.log(box('Subscription Status', authLines));
3035
+ console.log(box('Provider Status', authLines));
1973
3036
  console.log('');
1974
3037
  console.log(menu([
1975
- { key: 'a', label: 'Manage subscriptions', section: '' },
1976
- { key: 'b', label: 'Back to dashboard', section: '' },
3038
+ { key: 'a', label: 'Manage linked accounts', section: '' },
3039
+ { key: 'b', label: 'Back to dashboard', section: '' },
1977
3040
  ]));
1978
3041
  console.log('');
1979
3042
 
@@ -2412,45 +3475,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
2412
3475
  // ─── Screen: sessionsScreen ───────────────────────────────────────────────────
2413
3476
 
2414
3477
  const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
3478
+ const STALE_DAYS = 7;
3479
+
3480
+ /**
3481
+ * Return a compact status badge string for a session row (plain text, no ANSI).
3482
+ */
3483
+ function sessionBadge(sess) {
3484
+ if (sess.isActive) return '[active]';
3485
+ const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
3486
+ if (ageMs >= STALE_DAYS * 86400000) return '[stale]';
3487
+ if (sess.tool === 'codex') return '[dt]';
3488
+ return '';
3489
+ }
2415
3490
 
3491
+ /**
3492
+ * Interactive full session list with arrow-key navigation.
3493
+ * Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
3494
+ */
2416
3495
  async function sessionsScreen(rl, ask) {
2417
3496
  const cwd = process.cwd();
2418
- const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
2419
3497
 
2420
- console.log('');
2421
- console.log(separator('Session Manager'));
2422
- console.log('');
3498
+ // Load all active sessions (no slice limit)
3499
+ let sessions = enrichSessions(importReplitSessions(cwd), cwd);
3500
+
3501
+ // ── Box geometry ────────────────────────────────────────────────────────────
3502
+ const termW = process.stdout.columns || 60;
3503
+ const boxW = Math.min(termW - 2, 52);
3504
+ const W = boxW - 4;
3505
+
3506
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
3507
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
3508
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2423
3509
 
2424
3510
  if (sessions.length === 0) {
2425
- console.log(' No sessions found.\n');
2426
- console.log(' [b] Back\n');
2427
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
2428
- if (choice === 'b' || choice === 'back') return { next: 'main' };
2429
- return { next: 'sessions' };
3511
+ process.stdout.write('\n' + top + '\n');
3512
+ process.stdout.write(makeBoxRow('Sessions', W) + '\n');
3513
+ process.stdout.write(sep + '\n');
3514
+ process.stdout.write(makeBoxRow('No sessions found.', W) + '\n');
3515
+ process.stdout.write(sep + '\n');
3516
+ process.stdout.write(makeBoxRow('q Back', W) + '\n');
3517
+ process.stdout.write(bot + '\n\n');
3518
+ await ask(' Press Enter to continue...');
3519
+ return { next: 'main' };
2430
3520
  }
2431
3521
 
2432
- sessions.forEach((sess, i) => {
2433
- const pin = sess.pinned ? '📌 ' : ' ';
2434
- const active = sess.isActive ? ' ●' : '';
2435
- const cat = sess.category ? ` [${sess.category}]` : '';
2436
- console.log(` [${i + 1}] ${pin}${sess.age.padEnd(6)} ${sess.name}${active}${cat}`);
2437
- });
3522
+ /**
3523
+ * Format one session row.
3524
+ * Right side: badge(9) + age(4) + space + count(4) = 18 chars total.
3525
+ */
3526
+ function formatRow(sess, selected) {
3527
+ const arrow = selected ? '▸ ' : ' ';
3528
+ const badge = sessionBadge(sess);
3529
+ const badgeStr = badge ? badge.padEnd(9) : ' ';
3530
+ const age = (sess.age || '').replace(/ ago$/, '').padStart(4);
3531
+ const count = `(${sess.promptCount ?? 0})`.padStart(4);
3532
+ const right = `${badgeStr}${age} ${count}`;
3533
+ const nameMax = W - 2 - right.length;
3534
+ let name = sess.name || sess.id.slice(0, 8);
3535
+ if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
3536
+ else name = name.padEnd(nameMax);
3537
+ return makeBoxRow(`${arrow}${name}${right}`, W);
3538
+ }
2438
3539
 
2439
- console.log('');
2440
- console.log(' [1-9] Select a session to manage');
2441
- console.log(' [b] Back');
2442
- console.log('');
3540
+ let cursor = 0;
2443
3541
 
2444
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
3542
+ function render() {
3543
+ process.stdout.write('\x1b[2J\x1b[H');
3544
+ process.stdout.write(top + '\n');
3545
+ process.stdout.write(makeBoxRow('Sessions', W) + '\n');
3546
+ process.stdout.write(sep + '\n');
3547
+ for (let i = 0; i < sessions.length; i++) {
3548
+ process.stdout.write(formatRow(sessions[i], i === cursor) + '\n');
3549
+ }
3550
+ process.stdout.write(sep + '\n');
3551
+ process.stdout.write(makeBoxRow('↑↓ Navigate Enter Resume x Archive r Rename', W) + '\n');
3552
+ process.stdout.write(makeBoxRow('q Back', W) + '\n');
3553
+ process.stdout.write(bot + '\n');
3554
+ }
2445
3555
 
2446
- if (choice === 'b' || choice === 'back') return { next: 'main' };
3556
+ render();
2447
3557
 
2448
- const numChoice = parseInt(choice, 10);
2449
- if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= sessions.length) {
2450
- return { next: 'session-manage', session: sessions[numChoice - 1] };
2451
- }
3558
+ const readline = await import('node:readline');
3559
+ readline.emitKeypressEvents(process.stdin, rl);
3560
+
3561
+ const result = await new Promise((resolve) => {
3562
+ const wasRaw = process.stdin.isRaw;
3563
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
3564
+ if (canRaw) process.stdin.setRawMode(true);
3565
+
3566
+ const cleanup = () => {
3567
+ process.stdin.removeListener('keypress', onKey);
3568
+ if (canRaw) {
3569
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
3570
+ }
3571
+ };
3572
+
3573
+ const onKey = async (str, key) => {
3574
+ if (!key) return;
3575
+ const kname = key.name || '';
3576
+
3577
+ // Ctrl-C / Ctrl-D → exit
3578
+ if (key.ctrl && (kname === 'c' || kname === 'd')) {
3579
+ cleanup();
3580
+ process.stdout.write('\n');
3581
+ resolve({ next: 'main' });
3582
+ return;
3583
+ }
3584
+
3585
+ // q / Escape → back
3586
+ if (kname === 'q' || kname === 'escape' || str === 'q') {
3587
+ cleanup();
3588
+ process.stdout.write('\n');
3589
+ resolve({ next: 'main' });
3590
+ return;
3591
+ }
3592
+
3593
+ // Arrow up
3594
+ if (kname === 'up') {
3595
+ cursor = Math.max(0, cursor - 1);
3596
+ render();
3597
+ return;
3598
+ }
3599
+
3600
+ // Arrow down
3601
+ if (kname === 'down') {
3602
+ cursor = Math.min(sessions.length - 1, cursor + 1);
3603
+ render();
3604
+ return;
3605
+ }
3606
+
3607
+ // Enter → resume highlighted session
3608
+ if (kname === 'return' || kname === 'enter') {
3609
+ const sess = sessions[cursor];
3610
+ cleanup();
3611
+ process.stdout.write('\n');
3612
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
3613
+ const { spawnSync } = await import('node:child_process');
3614
+ spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
3615
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
3616
+ resolve({ next: 'main' });
3617
+ return;
3618
+ }
3619
+
3620
+ // x → archive highlighted session (non-destructive)
3621
+ if (str === 'x' || str === 'X') {
3622
+ const sess = sessions[cursor];
3623
+ archiveSession(sess.id, cwd);
3624
+ sessions = sessions.filter(s => s.id !== sess.id);
3625
+ if (sessions.length === 0) {
3626
+ cleanup();
3627
+ process.stdout.write('\n');
3628
+ resolve({ next: 'main' });
3629
+ return;
3630
+ }
3631
+ cursor = Math.min(cursor, sessions.length - 1);
3632
+ render();
3633
+ return;
3634
+ }
2452
3635
 
2453
- return { next: 'sessions' };
3636
+ // r rename highlighted session
3637
+ if (str === 'r' || str === 'R') {
3638
+ const sess = sessions[cursor];
3639
+ cleanup();
3640
+
3641
+ // Briefly collect a line of text
3642
+ process.stdout.write('\n New name: ');
3643
+ const newName = await new Promise(res2 => {
3644
+ let buf = '';
3645
+ const onData = (chunk) => {
3646
+ const s = chunk.toString();
3647
+ for (const ch of s) {
3648
+ if (ch === '\n' || ch === '\r') {
3649
+ process.stdin.removeListener('data', onData);
3650
+ process.stdout.write('\n');
3651
+ res2(buf.trim());
3652
+ return;
3653
+ }
3654
+ if (ch === '\x7f' || ch === '\b') {
3655
+ if (buf.length > 0) {
3656
+ buf = buf.slice(0, -1);
3657
+ process.stdout.write('\b \b');
3658
+ }
3659
+ } else {
3660
+ buf += ch;
3661
+ process.stdout.write(ch);
3662
+ }
3663
+ }
3664
+ };
3665
+ process.stdin.on('data', onData);
3666
+ });
3667
+
3668
+ if (newName) {
3669
+ renameSession(sess.id, newName, cwd);
3670
+ sessions[cursor] = { ...sess, name: newName };
3671
+ }
3672
+
3673
+ // Re-enable raw mode and re-attach listener
3674
+ if (canRaw) {
3675
+ try { process.stdin.setRawMode(true); } catch {}
3676
+ }
3677
+ readline.emitKeypressEvents(process.stdin, rl);
3678
+ process.stdin.on('keypress', onKey);
3679
+ render();
3680
+ return;
3681
+ }
3682
+ };
3683
+
3684
+ process.stdin.on('keypress', onKey);
3685
+ });
3686
+
3687
+ return result;
2454
3688
  }
2455
3689
 
2456
3690
  async function sessionManageScreen(rl, ask, ctx = {}) {
@@ -2534,6 +3768,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
2534
3768
  return { next: 'session-manage', session: sess };
2535
3769
  }
2536
3770
 
3771
+
3772
+ // ─── Auto-commit drafting ─────────────────────────────────────────────────────
3773
+
3774
+ /**
3775
+ * Detect uncommitted changes in cwd.
3776
+ * Returns { hasChanges, files, statOutput, diffSnippet } or null.
3777
+ */
3778
+ function detectUncommittedChanges(cwd) {
3779
+ try {
3780
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
3781
+ } catch { return null; }
3782
+
3783
+ let statOutput = '';
3784
+ try {
3785
+ statOutput = execSync('git diff --stat HEAD', { cwd, encoding: 'utf8', timeout: 3000, stdio: 'pipe' }).trim();
3786
+ } catch { return null; }
3787
+
3788
+ let statusOutput = '';
3789
+ try {
3790
+ statusOutput = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
3791
+ } catch {}
3792
+
3793
+ if (!statOutput && !statusOutput) return null;
3794
+
3795
+ const statFiles = statOutput
3796
+ .split('\n')
3797
+ .filter(l => l.includes('|'))
3798
+ .map(l => l.split('|')[0].trim())
3799
+ .filter(Boolean);
3800
+
3801
+ const statusFiles = statusOutput
3802
+ .split('\n')
3803
+ .filter(Boolean)
3804
+ .map(l => l.slice(3).trim())
3805
+ .filter(f => f && !statFiles.includes(f));
3806
+
3807
+ const files = [...new Set([...statFiles, ...statusFiles])];
3808
+
3809
+ let diffSnippet = '';
3810
+ try {
3811
+ const full = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
3812
+ diffSnippet = full.slice(0, 2000);
3813
+ } catch {}
3814
+
3815
+ return { hasChanges: true, files, statOutput, diffSnippet };
3816
+ }
3817
+
3818
+ /**
3819
+ * Build a conventional commit message from file list + diff snippet.
3820
+ * Deterministic — no AI calls.
3821
+ */
3822
+ function generateCommitMessage(files, diffSnippet) {
3823
+ if (!files || files.length === 0) return 'chore: update files';
3824
+
3825
+ const testFiles = files.filter(f =>
3826
+ /\.(test|spec)\.[jt]sx?$/.test(f) || /\/(test|tests|__tests__)\//i.test(f)
3827
+ );
3828
+ const docFiles = files.filter(f => /\.(md|txt|rst|adoc)$/i.test(f) || /docs?\//i.test(f));
3829
+ const configFiles = files.filter(f =>
3830
+ /\.(json|yaml|yml|toml|ini)$/i.test(f) ||
3831
+ /^\.?(eslint|prettier|babel|jest|tsconfig|package)/i.test(f.replace(/.*\//, ''))
3832
+ );
3833
+ const srcFiles = files.filter(f =>
3834
+ !testFiles.includes(f) && !docFiles.includes(f) && !configFiles.includes(f)
3835
+ );
3836
+
3837
+ let type = 'feat';
3838
+ if (diffSnippet) {
3839
+ const lower = diffSnippet.toLowerCase();
3840
+ if (['fix', 'bug', 'error', 'issue', 'resolve', 'patch', 'correct', 'repair'].some(w => lower.includes(w))) {
3841
+ type = 'fix';
3842
+ } else if (['refactor', 'cleanup', 'simplify', 'reorganize'].some(w => lower.includes(w))) {
3843
+ type = 'refactor';
3844
+ }
3845
+ }
3846
+
3847
+ const dominantFile = files[0].replace(/.*\//, '');
3848
+
3849
+ if (testFiles.length === files.length) {
3850
+ const mod = testFiles[0].replace(/\.(test|spec)\.[jt]sx?$/, '').replace(/.*\//, '');
3851
+ return `test: add/fix tests for ${mod}`;
3852
+ }
3853
+ if (docFiles.length === files.length) {
3854
+ return `docs: update ${docFiles[0].replace(/.*\//, '')}`;
3855
+ }
3856
+ if (configFiles.length === files.length) {
3857
+ return `chore: update ${configFiles[0].replace(/.*\//, '')}`;
3858
+ }
3859
+ if (srcFiles.length > 0 && testFiles.length > 0) {
3860
+ const dom = srcFiles[0].replace(/.*\//, '').replace(/\.[jt]sx?$/, '');
3861
+ return `${type}: ${dom} with tests`;
3862
+ }
3863
+ if (files.length === 1) {
3864
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3865
+ }
3866
+
3867
+ const dirs = files.map(f => (f.includes('/') ? f.split('/').slice(-2, -1)[0] : ''));
3868
+ const commonDir = dirs[0] && dirs.every(d => d === dirs[0]) ? dirs[0] : null;
3869
+ if (commonDir) return `${type}: update ${commonDir}`;
3870
+
3871
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3872
+ }
3873
+
3874
+ /**
3875
+ * Show a commit card after task completion and handle user action.
3876
+ * Enter -> git add -A && git commit -m "message"
3877
+ * e -> prompt for custom message, then commit
3878
+ * d -> show full diff, then return to card
3879
+ * s -> skip
3880
+ *
3881
+ * Only shown on TTY. Never auto-commits — the card is the offer.
3882
+ * Returns true if a commit was made.
3883
+ */
3884
+ async function offerAutoCommit(cwd) {
3885
+ if (!process.stdout.isTTY) return false;
3886
+
3887
+ try {
3888
+ const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3889
+ const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3890
+ if (claude > 0 || codex > 0) return false;
3891
+ } catch {}
3892
+
3893
+ try {
3894
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
3895
+ if (existsSync(sessionPath)) {
3896
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
3897
+ if (sess?.lastResult?.status === 'failure') return false;
3898
+ }
3899
+ } catch {}
3900
+
3901
+ const changes = detectUncommittedChanges(cwd);
3902
+ if (!changes) return false;
3903
+
3904
+ let finalMsg = generateCommitMessage(changes.files, changes.diffSnippet);
3905
+
3906
+ const termW = process.stdout.columns || 60;
3907
+ const boxW = Math.min(termW - 2, 54);
3908
+ const W = boxW - 4;
3909
+
3910
+ const top = `\u250c${'\u2500'.repeat(boxW - 2)}\u2510`;
3911
+ const sep = `\u251c${'\u2500'.repeat(boxW - 2)}\u2524`;
3912
+ const bot = `\u2514${'\u2500'.repeat(boxW - 2)}\u2518`;
3913
+
3914
+ const padLine = (s) => {
3915
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
3916
+ return `\u2502 ${s}${ ' '.repeat(Math.max(0, W - plain.length))} \u2502`;
3917
+ };
3918
+
3919
+ const filesLabel = changes.files.length <= 3
3920
+ ? changes.files.join(', ')
3921
+ : `${changes.files.slice(0, 3).join(', ')} +${changes.files.length - 3} more`;
3922
+ const fileCountLabel = `${changes.files.length} file${changes.files.length === 1 ? '' : 's'} changed: ${filesLabel}`;
3923
+ const fileLineTrunc = fileCountLabel.length > W ? fileCountLabel.slice(0, W - 3) + '...' : fileCountLabel;
3924
+
3925
+ const actLine1 = '[Enter] Commit [e] Edit message [d] Full diff';
3926
+ const actLine2 = '[s] Skip';
3927
+
3928
+ const printCard = (msg) => {
3929
+ const msgLine = msg.length > W ? msg.slice(0, W - 3) + '...' : msg;
3930
+ process.stdout.write(top + '\n');
3931
+ process.stdout.write(padLine('\x1b[33m\u{1F4DD} Ready to commit?\x1b[0m') + '\n');
3932
+ process.stdout.write(sep + '\n');
3933
+ process.stdout.write(padLine(msgLine) + '\n');
3934
+ process.stdout.write(padLine('') + '\n');
3935
+ process.stdout.write(padLine(fileLineTrunc) + '\n');
3936
+ process.stdout.write(padLine('') + '\n');
3937
+ process.stdout.write(padLine(actLine1) + '\n');
3938
+ process.stdout.write(padLine(actLine2) + '\n');
3939
+ process.stdout.write(bot + '\n');
3940
+ };
3941
+
3942
+ const readlinemod = await import('node:readline');
3943
+ readlinemod.emitKeypressEvents(process.stdin);
3944
+
3945
+ const waitKey = () => new Promise((resolve) => {
3946
+ const wasRaw = process.stdin.isRaw;
3947
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
3948
+ if (canRaw) process.stdin.setRawMode(true);
3949
+
3950
+ const cleanup = () => {
3951
+ process.stdin.removeListener('keypress', onKey);
3952
+ if (canRaw) { try { process.stdin.setRawMode(wasRaw || false); } catch {} }
3953
+ };
3954
+
3955
+ const onKey = (str, key) => {
3956
+ if (!key) return;
3957
+ const name = key.name || '';
3958
+ const seq = key.sequence || str || '';
3959
+
3960
+ if (key.ctrl && (name === 'c' || name === 'd')) {
3961
+ cleanup(); process.stdout.write('\n'); resolve('s'); return;
3962
+ }
3963
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
3964
+ cleanup(); process.stdout.write('\n'); resolve('commit'); return;
3965
+ }
3966
+ if (!str || str.length === 0) return;
3967
+ const lower = str.toLowerCase();
3968
+ if (lower === 'e' || lower === 'd' || lower === 's') {
3969
+ cleanup(); process.stdout.write('\n'); resolve(lower); return;
3970
+ }
3971
+ };
3972
+
3973
+ process.stdin.on('keypress', onKey);
3974
+ });
3975
+
3976
+ process.stdout.write('\n');
3977
+ printCard(finalMsg);
3978
+
3979
+ let committed = false;
3980
+ let done = false;
3981
+
3982
+ while (!done) {
3983
+ const choice = await waitKey();
3984
+
3985
+ if (choice === 'commit') {
3986
+ try {
3987
+ execSync('git add -A', { cwd, stdio: 'pipe' });
3988
+ execSync(`git commit -m ${JSON.stringify(finalMsg)}`, { cwd, stdio: 'pipe' });
3989
+ process.stdout.write(`\n \x1b[32m\u2713 Committed:\x1b[0m ${finalMsg}\n\n`);
3990
+ committed = true;
3991
+ } catch (e) {
3992
+ process.stderr.write(` Commit failed: ${e.message}\n`);
3993
+ }
3994
+ done = true;
3995
+
3996
+ } else if (choice === 'e') {
3997
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
3998
+ const edited = await new Promise(res => rl2.question('\n Commit message: ', res));
3999
+ rl2.close();
4000
+ if (edited.trim()) finalMsg = edited.trim();
4001
+ process.stdout.write('\n');
4002
+ printCard(finalMsg);
4003
+
4004
+ } else if (choice === 'd') {
4005
+ process.stdout.write('\n');
4006
+ try {
4007
+ const fullDiff = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
4008
+ process.stdout.write(fullDiff || '(no diff output)\n');
4009
+ } catch { process.stdout.write('(could not read diff)\n'); }
4010
+ process.stdout.write('\n');
4011
+ printCard(finalMsg);
4012
+
4013
+ } else {
4014
+ process.stdout.write(' Skipped.\n\n');
4015
+ done = true;
4016
+ }
4017
+ }
4018
+
4019
+ return committed;
4020
+ }
4021
+
2537
4022
  // ─── Screen state machine ─────────────────────────────────────────────────────
2538
4023
 
2539
4024
  const SCREENS = {
@@ -2541,6 +4026,8 @@ const SCREENS = {
2541
4026
  main: mainScreen,
2542
4027
  'new-session': newSessionScreen,
2543
4028
  settings: settingsScreen,
4029
+ 'import-picker': importPickerScreen,
4030
+ 'pr-triage': prTriageScreen,
2544
4031
  subscriptions: subscriptionsScreen,
2545
4032
  dashboard: dashboardScreen,
2546
4033
  auth: authScreen,
@@ -2559,13 +4046,26 @@ async function runScreens(startScreen = 'dashboard') {
2559
4046
  let current = startScreen;
2560
4047
  let ctx = {};
2561
4048
  while (current && current !== 'exit') {
4049
+ // Handle type-to-start dispatch from mainScreen — all work routes through pipeline.
4050
+ if (current === 'go' && ctx.prompt) {
4051
+ const prompt = ctx.prompt;
4052
+ const dryRun = ctx.dryRun || false;
4053
+ await cmdGo([prompt], { dryRun });
4054
+ current = 'main';
4055
+ ctx = {};
4056
+ continue;
4057
+ }
4058
+
2562
4059
  const screen = SCREENS[current];
2563
4060
  if (!screen) break;
2564
4061
  try {
2565
4062
  const result = await screen(rl, ask, ctx);
2566
4063
  current = result?.next || 'exit';
2567
- // Pass through context (e.g. selected session) to next screen
2568
- ctx = result?.session ? { session: result.session } : {};
4064
+ // Pass through context (e.g. selected session, typed prompt, openPRs) to next screen
4065
+ ctx = result?.session ? { session: result.session }
4066
+ : result?.prompt ? { prompt: result.prompt }
4067
+ : result?.openPRs ? { openPRs: result.openPRs }
4068
+ : {};
2569
4069
  } catch (e) {
2570
4070
  console.error(`Error: ${e.message}`);
2571
4071
  current = 'main';
@@ -2575,6 +4075,309 @@ async function runScreens(startScreen = 'dashboard') {
2575
4075
  rl.close();
2576
4076
  }
2577
4077
 
4078
+
4079
+ // ─── Watch mode ──────────────────────────────────────────────────────────────
4080
+
4081
+ /**
4082
+ * Suggest an action for a batch of changed files.
4083
+ * Returns { label, cmd, safe } or null (no suggestion needed).
4084
+ * Deterministic — no AI calls.
4085
+ */
4086
+ function suggestAction(changedFiles, cwd) {
4087
+ // .env changes — highest priority warning
4088
+ const envChanged = changedFiles.some(f => {
4089
+ const b = basename(f);
4090
+ return b === '.env' || b.startsWith('.env.');
4091
+ });
4092
+ if (envChanged) {
4093
+ return { label: '⚠ Environment changed — restart services', cmd: null, safe: false };
4094
+ }
4095
+
4096
+ // package.json → npm install
4097
+ if (changedFiles.some(f => basename(f) === 'package.json')) {
4098
+ return { label: 'npm install (dependencies may have changed)', cmd: 'npm install', safe: true };
4099
+ }
4100
+
4101
+ // Config files → restart dev server
4102
+ const configChanged = changedFiles.some(f => {
4103
+ const b = basename(f);
4104
+ return /\.config\.(m?js|ts|cjs|json)$/.test(b)
4105
+ || b === 'tsconfig.json'
4106
+ || b === '.eslintrc'
4107
+ || b === '.babelrc'
4108
+ || b === 'vite.config.js'
4109
+ || b === 'webpack.config.js';
4110
+ });
4111
+ if (configChanged) {
4112
+ return { label: 'Restart dev server (config changed)', cmd: null, safe: false };
4113
+ }
4114
+
4115
+ // Test/spec files themselves changed → run them
4116
+ const testChanged = changedFiles.filter(f => /\.(test|spec)\.(m?js|ts|cjs)$/.test(f));
4117
+ if (testChanged.length > 0) {
4118
+ let testCmd = 'npm test';
4119
+ try {
4120
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4121
+ if (!pkg.scripts?.test) testCmd = null;
4122
+ } catch {}
4123
+ const fileList = testChanged.map(f => basename(f)).join(', ');
4124
+ return testCmd ? { label: `Run tests: ${fileList}`, cmd: testCmd, safe: true } : null;
4125
+ }
4126
+
4127
+ // Markdown → no suggestion
4128
+ if (changedFiles.every(f => extname(f) === '.md')) {
4129
+ return null;
4130
+ }
4131
+
4132
+ // Source file changed → look for related test file
4133
+ const sourceChanged = changedFiles.filter(f =>
4134
+ /\.(m?js|ts|cjs|py|rb|go|rs)$/.test(f) && !/\.(test|spec)\./.test(f)
4135
+ );
4136
+ if (sourceChanged.length > 0) {
4137
+ const testDirs = ['test', 'tests', '__tests__', 'spec', 'src'];
4138
+ for (const srcFile of sourceChanged) {
4139
+ const srcBase = basename(srcFile);
4140
+ const srcExt = extname(srcFile);
4141
+ const srcStem = srcBase.slice(0, -srcExt.length);
4142
+ const testExts = [...new Set([srcExt, '.js', '.ts', '.mjs'])];
4143
+ const srcDirAbs = join(cwd, dirname(srcFile));
4144
+
4145
+ for (const dir of testDirs) {
4146
+ for (const ext of testExts) {
4147
+ const candidates = [
4148
+ join(cwd, dir, `${srcStem}.test${ext}`),
4149
+ join(cwd, dir, `${srcStem}.spec${ext}`),
4150
+ join(srcDirAbs, `${srcStem}.test${ext}`),
4151
+ join(srcDirAbs, `${srcStem}.spec${ext}`),
4152
+ ];
4153
+ for (const c of candidates) {
4154
+ if (existsSync(c)) {
4155
+ const rel = c.replace(cwd + '/', '');
4156
+ let testCmd = 'npm test';
4157
+ try {
4158
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4159
+ const scripts = pkg.scripts?.test ?? '';
4160
+ const dev = { ...pkg.devDependencies, ...pkg.dependencies };
4161
+ if (scripts.includes('jest') || dev.jest) testCmd = `npx jest ${rel}`;
4162
+ else if (scripts.includes('vitest') || dev.vitest) testCmd = `npx vitest run ${rel}`;
4163
+ else if (scripts.includes('mocha') || dev.mocha) testCmd = `npx mocha ${rel}`;
4164
+ } catch {}
4165
+ return { label: `Run related tests: ${rel}`, cmd: testCmd, safe: true };
4166
+ }
4167
+ }
4168
+ }
4169
+ }
4170
+ }
4171
+
4172
+ // No test file found — suggest generic test run
4173
+ let testCmd = 'npm test';
4174
+ try {
4175
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4176
+ if (!pkg.scripts?.test) testCmd = null;
4177
+ } catch { testCmd = null; }
4178
+
4179
+ if (testCmd) {
4180
+ const fileList = sourceChanged.map(f => basename(f)).join(', ');
4181
+ return { label: `Run tests (${fileList} changed)`, cmd: testCmd, safe: true };
4182
+ }
4183
+ }
4184
+
4185
+ return null;
4186
+ }
4187
+
4188
+ const W_RESET = '\x1b[0m';
4189
+ const W_BOLD = '\x1b[1m';
4190
+ const W_DIM = '\x1b[2m';
4191
+ const W_YELLOW = '\x1b[33m';
4192
+ const W_CYAN = '\x1b[36m';
4193
+ const W_GREEN = '\x1b[32m';
4194
+ const W_RED = '\x1b[31m';
4195
+
4196
+ function watchRedraw(header, logLines, prompt) {
4197
+ process.stdout.write('\x1b[2J\x1b[H');
4198
+ process.stdout.write(header + '\n\n');
4199
+ const visible = logLines.slice(-8);
4200
+ for (let i = 0; i < visible.length; i++) {
4201
+ const dim = i < visible.length - 4;
4202
+ if (dim) process.stdout.write(W_DIM);
4203
+ process.stdout.write(visible[i] + '\n');
4204
+ if (dim) process.stdout.write(W_RESET);
4205
+ }
4206
+ if (prompt) process.stdout.write('\n' + prompt);
4207
+ }
4208
+
4209
+ async function cmdWatch(rawArgs) {
4210
+ const cwd = process.cwd();
4211
+ const auto = rawArgs.includes('--auto');
4212
+ const dirArg = rawArgs.find(a => !a.startsWith('-')) ?? '.';
4213
+ const watchDir = join(cwd, dirArg);
4214
+
4215
+ if (!existsSync(watchDir)) {
4216
+ process.stderr.write(`Error: Directory not found: ${watchDir}\n`);
4217
+ process.exit(1);
4218
+ }
4219
+
4220
+ const relDir = watchDir === cwd ? '.' : watchDir.replace(cwd + '/', '');
4221
+ const modeStr = auto ? `${W_YELLOW}--auto${W_RESET}` : 'interactive';
4222
+ const header = `${W_BOLD}${W_CYAN}Watching${W_RESET} ${relDir} ${W_DIM}(${modeStr}${W_DIM}, q or Ctrl+C to exit)${W_RESET}`;
4223
+
4224
+ const logLines = [];
4225
+ function addLog(line) {
4226
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
4227
+ logLines.push(`${W_DIM}${ts}${W_RESET} ${line}`);
4228
+ }
4229
+
4230
+ addLog(`${W_DIM}Ready — waiting for file changes...${W_RESET}`);
4231
+ watchRedraw(header, logLines);
4232
+
4233
+ let resolvePending = null;
4234
+ let watcherRef = null;
4235
+
4236
+ function cleanup() {
4237
+ try { if (watcherRef) watcherRef.close(); } catch {}
4238
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
4239
+ try { watchRl.close(); } catch {}
4240
+ process.stdout.write('\n');
4241
+ process.exit(0);
4242
+ }
4243
+
4244
+ const watchRl = createInterface({ input: process.stdin, output: process.stdout });
4245
+ if (process.stdin.isTTY) {
4246
+ process.stdin.setRawMode(true);
4247
+ process.stdin.resume();
4248
+ process.stdin.setEncoding('utf8');
4249
+ }
4250
+
4251
+ process.stdin.on('data', (key) => {
4252
+ if (key === 'q' || key === '') { cleanup(); return; }
4253
+ if (resolvePending) { resolvePending(key); resolvePending = null; }
4254
+ });
4255
+
4256
+ process.on('SIGINT', cleanup);
4257
+ process.on('SIGTERM', cleanup);
4258
+
4259
+ function waitForKey() {
4260
+ return new Promise(resolve => { resolvePending = resolve; });
4261
+ }
4262
+
4263
+ let processing = false;
4264
+ async function processBatch(files) {
4265
+ if (processing) return;
4266
+ processing = true;
4267
+ try {
4268
+ const fileList = [...files];
4269
+ files.clear();
4270
+ const relFiles = fileList.map(f =>
4271
+ f.replace(cwd + '/', '').replace(cwd + '\\', '')
4272
+ );
4273
+
4274
+ for (const f of relFiles) addLog(` ${W_CYAN}${f}${W_RESET} saved`);
4275
+
4276
+ const suggestion = suggestAction(relFiles, cwd);
4277
+
4278
+ if (!suggestion) {
4279
+ addLog(` ${W_DIM}(no action suggested)${W_RESET}`);
4280
+ watchRedraw(header, logLines);
4281
+ return;
4282
+ }
4283
+
4284
+ addLog(` ${W_YELLOW}Suggestion:${W_RESET} ${suggestion.label}`);
4285
+
4286
+ if (auto) {
4287
+ if (!suggestion.safe || !suggestion.cmd) {
4288
+ addLog(` ${W_DIM}[auto] Skipping — not auto-safe${W_RESET}`);
4289
+ watchRedraw(header, logLines);
4290
+ return;
4291
+ }
4292
+ addLog(` ${W_GREEN}[auto] Running:${W_RESET} ${suggestion.cmd}`);
4293
+ watchRedraw(header, logLines);
4294
+ try {
4295
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4296
+ for (const l of out.trim().split('\n').slice(-5)) addLog(` ${W_DIM}${l}${W_RESET}`);
4297
+ addLog(` ${W_GREEN}done${W_RESET}`);
4298
+ } catch (e) {
4299
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4300
+ for (const l of msg.split('\n').slice(-3)) addLog(` ${W_RED}${l}${W_RESET}`);
4301
+ addLog(` ${W_RED}command failed${W_RESET}`);
4302
+ }
4303
+ watchRedraw(header, logLines);
4304
+ return;
4305
+ }
4306
+
4307
+ // Interactive prompt
4308
+ const promptLine = suggestion.cmd
4309
+ ? ` ${W_BOLD}[Enter]${W_RESET} Run ${W_BOLD}[s]${W_RESET} Skip ${W_BOLD}[q]${W_RESET} Quit\n > `
4310
+ : ` ${W_BOLD}[s]${W_RESET} Dismiss ${W_BOLD}[q]${W_RESET} Quit\n > `;
4311
+ watchRedraw(header, logLines, promptLine);
4312
+
4313
+ const key = await waitForKey();
4314
+
4315
+ if (key === 'q' || key === '') { cleanup(); return; }
4316
+
4317
+ if ((key === '\r' || key === '\n' || key === ' ') && suggestion.cmd) {
4318
+ addLog(` ${W_GREEN}Running:${W_RESET} ${suggestion.cmd}`);
4319
+ watchRedraw(header, logLines);
4320
+ try {
4321
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4322
+ for (const l of out.trim().split('\n').slice(-8)) addLog(` ${W_DIM}${l}${W_RESET}`);
4323
+ addLog(` ${W_GREEN}done${W_RESET}`);
4324
+ } catch (e) {
4325
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4326
+ for (const l of msg.split('\n').slice(-5)) addLog(` ${W_RED}${l}${W_RESET}`);
4327
+ addLog(` ${W_RED}command failed${W_RESET}`);
4328
+ }
4329
+ } else {
4330
+ addLog(` ${W_DIM}skipped${W_RESET}`);
4331
+ }
4332
+ watchRedraw(header, logLines);
4333
+ } finally {
4334
+ processing = false;
4335
+ }
4336
+ }
4337
+
4338
+ let debounceTimer = null;
4339
+ const pendingFiles = new Set();
4340
+
4341
+ try {
4342
+ watcherRef = fsWatch(watchDir, { recursive: true }, (_eventType, filename) => {
4343
+ if (!filename) return;
4344
+ if (
4345
+ filename.includes('node_modules') ||
4346
+ filename.includes('.git') ||
4347
+ filename.includes('.dualbrain') ||
4348
+ /package-lock\.json$/.test(filename) ||
4349
+ /yarn\.lock$/.test(filename) ||
4350
+ /pnpm-lock\.yaml$/.test(filename)
4351
+ ) return;
4352
+
4353
+ pendingFiles.add(join(watchDir, filename));
4354
+
4355
+ if (debounceTimer) clearTimeout(debounceTimer);
4356
+ debounceTimer = setTimeout(() => {
4357
+ debounceTimer = null;
4358
+ processBatch(pendingFiles).catch(e => {
4359
+ addLog(` ${W_RED}Watch error: ${e.message}${W_RESET}`);
4360
+ watchRedraw(header, logLines);
4361
+ });
4362
+ }, 2000);
4363
+ });
4364
+ } catch (e) {
4365
+ if (e.code === 'ENOSPC') {
4366
+ process.stderr.write(
4367
+ '\nError: Too many file watchers (ENOSPC).\n' +
4368
+ 'Increase the limit:\n' +
4369
+ ' echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n'
4370
+ );
4371
+ process.exit(1);
4372
+ }
4373
+ throw e;
4374
+ }
4375
+
4376
+ // Keep alive — stdin events drive everything, cleanup() calls process.exit
4377
+ await new Promise(() => {});
4378
+ }
4379
+
4380
+
2578
4381
  // ─── Specialist commands ──────────────────────────────────────────────────────
2579
4382
 
2580
4383
  const SPECIALIST_DEFAULTS = {
@@ -2670,22 +4473,25 @@ async function cmdSpecialistGo(specialist, args) {
2670
4473
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
2671
4474
  }
2672
4475
 
2673
- console.log(` specialist : ${specialist}`);
2674
- console.log(` provider : ${decision.provider}`);
2675
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
2676
- console.log(` tier : ${decision.tier}`);
2677
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
2678
- console.log(` reason : ${decision.explanation}`);
4476
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
4477
+ if (dryRun || verbose) {
4478
+ console.log(` specialist : ${specialist}`);
4479
+ console.log(` provider : ${decision.provider}`);
4480
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
4481
+ console.log(` tier : ${decision.tier}`);
4482
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
4483
+ console.log(` reason : ${decision.explanation}`);
4484
+ }
2679
4485
 
2680
4486
  if (dryRun) {
2681
4487
  console.log('\n(dry-run — not executing)');
2682
4488
  return;
2683
4489
  }
2684
4490
 
2685
- console.log('\nDispatching...');
4491
+ if (verbose) console.log('\nDispatching...');
2686
4492
  let result;
2687
4493
  if (decision.dualBrain) {
2688
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
4494
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
2689
4495
  console.log(`\nConsensus: ${result.consensus}`);
2690
4496
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
2691
4497
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -2699,7 +4505,7 @@ async function cmdSpecialistGo(specialist, args) {
2699
4505
  nextAction: null,
2700
4506
  }, cwd);
2701
4507
  } else {
2702
- result = await dispatch({ decision, prompt, files, cwd });
4508
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
2703
4509
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
2704
4510
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
2705
4511
  if (result.summary) console.log(result.summary);
@@ -2743,8 +4549,8 @@ async function main() {
2743
4549
  if (profileExists(cwd)) {
2744
4550
  await runScreens('main');
2745
4551
  } else {
2746
- // First run: run the 5-step onboarding wizard, then go to main.
2747
- process.stdout.write(`\ndual-brain v${readVersion()} Setup\n\nDetecting your setup...\n`);
4552
+ // First run: run the onboarding wizard, then go to main.
4553
+ // (wizard handles detection display)
2748
4554
  const auth = await detectAuth();
2749
4555
  const plans = detectPlans();
2750
4556
  const existingSessions = importReplitSessions(cwd);
@@ -2759,22 +4565,35 @@ async function main() {
2759
4565
  await runScreens('main');
2760
4566
  }
2761
4567
  } else {
2762
- // Non-TTY: print status card and exit
2763
- const cwd = process.cwd();
2764
- const repo = loadRepoCache(cwd);
2765
- const session = loadSession(cwd);
2766
- const health = getHealth(cwd);
2767
- const card = formatSessionCard(session, repo, health);
2768
- console.log(card);
4568
+ // Non-TTY with no args: read stdin as a task and run one-shot
4569
+ const stdinTask = await new Promise((resolve) => {
4570
+ let data = '';
4571
+ process.stdin.setEncoding('utf8');
4572
+ process.stdin.on('data', chunk => { data += chunk; });
4573
+ process.stdin.on('end', () => resolve(data.trim()));
4574
+ // If stdin has no data within 200ms (not truly piped), fall back to status card
4575
+ setTimeout(() => resolve(null), 200);
4576
+ });
4577
+ if (stdinTask) {
4578
+ process.stderr.write('🧠 routing...\n');
4579
+ await cmdGo([stdinTask]);
4580
+ } else {
4581
+ const cwd = process.cwd();
4582
+ const repo = loadRepoCache(cwd);
4583
+ const session = loadSession(cwd);
4584
+ const health = getHealth(cwd);
4585
+ const card = formatSessionCard(session, repo, health);
4586
+ console.log(card);
4587
+ }
2769
4588
  }
2770
4589
  return;
2771
4590
  }
2772
4591
 
2773
4592
  if (cmd === 'init') {
2774
4593
  if (isInteractive) {
2775
- // Run 5-step onboarding wizard then main screen
4594
+ // Run onboarding wizard then main screen
2776
4595
  const cwd = process.cwd();
2777
- process.stdout.write(`\ndual-brain v${readVersion()} Setup\n\nDetecting your setup...\n`);
4596
+ // (wizard handles detection display)
2778
4597
  const auth = await detectAuth();
2779
4598
  const plans = detectPlans();
2780
4599
  const existingSessions = importReplitSessions(cwd);
@@ -2799,7 +4618,12 @@ async function main() {
2799
4618
  await cmdAuth(args.slice(1));
2800
4619
  return;
2801
4620
  }
4621
+ if (cmd === 'plan') { await cmdGo(args.slice(1), { dryRun: true }); return; }
4622
+ if (cmd === 'do') { await cmdGo(args.slice(1)); return; }
2802
4623
  if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
4624
+ if (cmd === 'think') { await cmdThink(args.slice(1)); return; }
4625
+ if (cmd === 'review') { await cmdReview(args.slice(1)); return; }
4626
+ if (cmd === 'ship') { await cmdShip(); return; }
2803
4627
  if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
2804
4628
  if (cmd === 'hot') { cmdHot(args[1]); return; }
2805
4629
  if (cmd === 'cool') { cmdCool(args[1]); return; }
@@ -2843,6 +4667,8 @@ async function main() {
2843
4667
  process.exit(0);
2844
4668
  }
2845
4669
 
4670
+ if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
4671
+
2846
4672
  if (cmd === 'shell-hook') {
2847
4673
  // Output a bash snippet users can add to their .bashrc or source directly.
2848
4674
  const hook = `
@@ -2858,6 +4684,43 @@ fi
2858
4684
  return;
2859
4685
  }
2860
4686
 
4687
+ // ─── One-shot mode ────────────────────────────────────────────────────────────
4688
+ // If cmd is not a recognized subcommand, treat the entire arg list as a task.
4689
+ // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
4690
+ const KNOWN_COMMANDS = new Set([
4691
+ 'init', 'install', 'auth', 'go', 'do', 'plan', 'ship', 'think', 'review', 'status', 'hot', 'cool',
4692
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
4693
+ '--help', '-h', '--version', '-v',
4694
+ ...Object.keys(loadSpecialistRegistry()),
4695
+ ]);
4696
+
4697
+ if (!KNOWN_COMMANDS.has(cmd)) {
4698
+ // All of args are part of the task description (plus any flags like --dry-run/--files).
4699
+ // Join non-flag words into a single prompt string so cmdGo's args.find() picks it up.
4700
+ // We strip out flag values (e.g. the value after --files) before collecting prompt words.
4701
+ process.stderr.write('🧠 routing...\n');
4702
+ const flagValuesToSkip = new Set();
4703
+ const pairedFlags = ['--files'];
4704
+ for (const f of pairedFlags) {
4705
+ const idx = args.indexOf(f);
4706
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
4707
+ flagValuesToSkip.add(args[idx + 1]);
4708
+ }
4709
+ }
4710
+ const passedFlags = [];
4711
+ for (let i = 0; i < args.length; i++) {
4712
+ if (args[i].startsWith('--') || args[i].startsWith('-')) {
4713
+ passedFlags.push(args[i]);
4714
+ if (pairedFlags.includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
4715
+ passedFlags.push(args[++i]);
4716
+ }
4717
+ }
4718
+ }
4719
+ const promptWords = args.filter(a => !a.startsWith('--') && !a.startsWith('-') && !flagValuesToSkip.has(a));
4720
+ await cmdGo([promptWords.join(' '), ...passedFlags]);
4721
+ return;
4722
+ }
4723
+
2861
4724
  process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
2862
4725
  process.exit(1);
2863
4726
  }