dual-brain 0.1.6 → 0.1.8

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
2
  // dual-brain — CLI entry point. Commands: init, go, 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';
@@ -177,6 +177,8 @@ Commands:
177
177
  security "task" Force Security specialist for the task
178
178
  --dry-run (specialist commands) Show routing without executing
179
179
  --files a,b (specialist commands) Provide file context
180
+ watch [dir] Monitor file changes and suggest actions
181
+ --auto Auto-execute safe suggestions (tests, install)
180
182
  shell-hook Output bash snippet to add dual-brain to your shell
181
183
  Usage: dual-brain shell-hook >> ~/.bashrc
182
184
 
@@ -368,22 +370,24 @@ async function cmdGo(args) {
368
370
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
369
371
  }
370
372
 
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}`);
373
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
374
+ if (dryRun || verbose) {
375
+ console.log(` provider : ${decision.provider}`);
376
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
377
+ console.log(` tier : ${decision.tier}`);
378
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
379
+ console.log(` reason : ${decision.explanation}`);
380
+ }
377
381
 
378
382
  if (dryRun) {
379
383
  console.log('\n(dry-run — not executing)');
380
384
  return;
381
385
  }
382
386
 
383
- console.log('\nDispatching...');
387
+ if (verbose) console.log('\nDispatching...');
384
388
  let result;
385
389
  if (decision.dualBrain) {
386
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
390
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
387
391
  console.log(`\nConsensus: ${result.consensus}`);
388
392
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
389
393
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -398,7 +402,7 @@ async function cmdGo(args) {
398
402
  nextAction: null,
399
403
  }, cwd);
400
404
  } else {
401
- result = await dispatch({ decision, prompt, files, cwd });
405
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
402
406
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
403
407
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
404
408
  if (result.summary) console.log(result.summary);
@@ -417,6 +421,7 @@ async function cmdGo(args) {
417
421
  nextAction: null,
418
422
  }, cwd);
419
423
  if (result.status !== 'completed') process.exit(1);
424
+ await offerAutoCommit(cwd);
420
425
  }
421
426
  }
422
427
 
@@ -998,17 +1003,345 @@ function loadTerminalState(cwd, terminalId) {
998
1003
  } catch { return null; }
999
1004
  }
1000
1005
 
1006
+ // ─── PR Detection ─────────────────────────────────────────────────────────────
1007
+
1008
+ /**
1009
+ * Detect open PRs using the gh CLI.
1010
+ * Gracefully returns [] if gh is not installed, no remote, no auth, or no PRs.
1011
+ *
1012
+ * @param {string} cwd
1013
+ * @returns {Promise<Array>}
1014
+ */
1015
+ async function detectOpenPRs(cwd) {
1016
+ try {
1017
+ // 1. Check if gh CLI exists (1s timeout)
1018
+ const ghCheck = _spawnSyncTop('which', ['gh'], {
1019
+ encoding: 'utf8',
1020
+ stdio: ['pipe', 'pipe', 'pipe'],
1021
+ timeout: 1000,
1022
+ });
1023
+ if (ghCheck.status !== 0) return [];
1024
+
1025
+ // 2. Check if repo has a GitHub remote
1026
+ const remoteCheck = _spawnSyncTop('git', ['remote', 'get-url', 'origin'], {
1027
+ cwd,
1028
+ encoding: 'utf8',
1029
+ stdio: ['pipe', 'pipe', 'pipe'],
1030
+ timeout: 1000,
1031
+ });
1032
+ if (remoteCheck.status !== 0) return [];
1033
+ const remoteUrl = (remoteCheck.stdout || '').trim();
1034
+ if (!remoteUrl.includes('github.com')) return [];
1035
+
1036
+ // 3. Fetch open PRs (3s timeout)
1037
+ const prResult = _spawnSyncTop('gh', [
1038
+ 'pr', 'list',
1039
+ '--state', 'open',
1040
+ '--json', 'number,title,reviewDecision,reviewRequests,additions,deletions,changedFiles,headRefName',
1041
+ '--limit', '5',
1042
+ ], {
1043
+ cwd,
1044
+ encoding: 'utf8',
1045
+ stdio: ['pipe', 'pipe', 'pipe'],
1046
+ timeout: 3000,
1047
+ });
1048
+
1049
+ if (prResult.status !== 0) return [];
1050
+ const raw = (prResult.stdout || '').trim();
1051
+ if (!raw) return [];
1052
+
1053
+ const prs = JSON.parse(raw);
1054
+ if (!Array.isArray(prs)) return [];
1055
+ return prs;
1056
+ } catch {
1057
+ return [];
1058
+ }
1059
+ }
1060
+
1001
1061
  // ─── Dashboard box helpers ────────────────────────────────────────────────────
1002
1062
 
1063
+ /**
1064
+ * Detect repo state for action cards. All checks run with tight timeouts —
1065
+ * best-effort only, never blocks startup.
1066
+ *
1067
+ * Returns: { dirtyCount, lastCommitAgeDays, lastFailure, isGitRepo }
1068
+ */
1069
+ function detectRepoState(cwd) {
1070
+ const result = { dirtyCount: 0, lastCommitAgeDays: 0, lastFailure: null, isGitRepo: false };
1071
+ try {
1072
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1073
+ result.isGitRepo = true;
1074
+ } catch { return result; }
1075
+
1076
+ try {
1077
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1078
+ result.dirtyCount = status.trim().split('\n').filter(Boolean).length;
1079
+ } catch {}
1080
+
1081
+ try {
1082
+ const logOut = execSync('git log --format="%ct" -1', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
1083
+ if (logOut) {
1084
+ const commitTs = parseInt(logOut, 10) * 1000;
1085
+ result.lastCommitAgeDays = Math.floor((Date.now() - commitTs) / 86400000);
1086
+ }
1087
+ } catch {}
1088
+
1089
+ try {
1090
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
1091
+ if (existsSync(sessionPath)) {
1092
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
1093
+ const lastResult = sess?.lastResult;
1094
+ if (lastResult?.status === 'failure') {
1095
+ const summary = lastResult.task
1096
+ ? String(lastResult.task).slice(0, 40)
1097
+ : 'last task';
1098
+ result.lastFailure = summary;
1099
+ }
1100
+ }
1101
+ } catch {}
1102
+
1103
+ return result;
1104
+ }
1105
+
1106
+ /**
1107
+ * Build action card rows for the dashboard based on repo state.
1108
+ * Returns an array of box row strings (may be empty).
1109
+ * openPRs is optional — if provided, a PR card is included.
1110
+ */
1111
+ function buildActionRows(repoState, rowFn, openPRs = []) {
1112
+ if (!repoState.isGitRepo) return [];
1113
+
1114
+ const YELLOW = '\x1b[33m';
1115
+ const RED = '\x1b[31m';
1116
+ const GREEN = '\x1b[32m';
1117
+ const CYAN = '\x1b[36m';
1118
+ const DIM = '\x1b[2m';
1119
+ const RESET = '\x1b[0m';
1120
+
1121
+ const cards = [];
1122
+
1123
+ if (repoState.dirtyCount > 0) {
1124
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.dirtyCount} uncommitted file${repoState.dirtyCount === 1 ? '' : 's'}`);
1125
+ }
1126
+
1127
+ if (repoState.lastFailure !== null) {
1128
+ cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
1129
+ }
1130
+
1131
+ if (repoState.lastCommitAgeDays >= 3) {
1132
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
1133
+ }
1134
+
1135
+ // PR card — show a summary of open PRs when gh is available
1136
+ if (openPRs.length > 0) {
1137
+ const prSummary = openPRs.slice(0, 2)
1138
+ .map(pr => `#${pr.number} ${String(pr.title).slice(0, 22)}`)
1139
+ .join(', ');
1140
+ const trunc = openPRs.length > 2 ? ` +${openPRs.length - 2}` : '';
1141
+ cards.push(`${CYAN}⇅${RESET} ${openPRs.length} open PR${openPRs.length === 1 ? '' : 's'}: ${prSummary}${trunc}`);
1142
+ }
1143
+
1144
+ if (cards.length === 0) {
1145
+ return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
1146
+ }
1147
+
1148
+ return cards.map(c => rowFn(c));
1149
+ }
1150
+
1151
+ /**
1152
+ * Detect interrupted work from the most recent session.
1153
+ * Returns a continuation hint if confidence is high enough, or null to skip.
1154
+ *
1155
+ * Signals that indicate interrupted work:
1156
+ * - Session < 4 hours old with no clean exit
1157
+ * - Last result was a failure
1158
+ * - Uncommitted git changes exist
1159
+ * - Session has high message count (user was deep in work)
1160
+ *
1161
+ * Minimum thresholds: messageCount > 5 OR filesChanged > 0
1162
+ *
1163
+ * @param {Array} sessions — from importReplitSessions / enrichSessions
1164
+ * @param {string} cwd
1165
+ * @returns {{ shouldContinue: boolean, reason: string, sessionId: string, sessionName: string, lastState: string|null, ageLabel: string }|null}
1166
+ */
1167
+ function detectInterruptedWork(sessions, cwd) {
1168
+ if (!sessions || sessions.length === 0) return null;
1169
+
1170
+ const most = sessions[0]; // already sorted most-recent first
1171
+ if (!most || !most.lastActive) return null;
1172
+
1173
+ const ageMs = Date.now() - new Date(most.lastActive).getTime();
1174
+ const fourH = 4 * 60 * 60 * 1000;
1175
+
1176
+ // Must be within 4 hours
1177
+ if (ageMs >= fourH) return null;
1178
+
1179
+ // Load session.json for deeper signal
1180
+ const session = loadSession(cwd);
1181
+
1182
+ // Minimum thresholds: must have real work depth
1183
+ const msgCount = most.messageCount ?? most.promptCount ?? 0;
1184
+ const filesChanged = session?.filesChanged?.length ?? 0;
1185
+ if (msgCount <= 5 && filesChanged === 0) return null;
1186
+
1187
+ const lastResultStatus = session?.lastResult?.status ?? null;
1188
+
1189
+ // Build confidence signals
1190
+ const signals = [];
1191
+ if (lastResultStatus === 'failure') signals.push('last run failed');
1192
+ if (filesChanged > 0) signals.push(`${filesChanged} file${filesChanged !== 1 ? 's' : ''} changed`);
1193
+ if (msgCount > 10) signals.push('deep session');
1194
+
1195
+ // Check for uncommitted git changes
1196
+ try {
1197
+ const gitResult = _spawnSyncTop('git', ['status', '--porcelain'], {
1198
+ cwd,
1199
+ encoding: 'utf8',
1200
+ stdio: ['pipe', 'pipe', 'pipe'],
1201
+ timeout: 3000,
1202
+ });
1203
+ if (gitResult.status === 0 && gitResult.stdout.trim().length > 0) {
1204
+ signals.push('uncommitted changes');
1205
+ }
1206
+ } catch { /* non-fatal */ }
1207
+
1208
+ // Need at least one signal beyond base thresholds to avoid annoying low-signal cards
1209
+ if (signals.length === 0 && msgCount <= 10) return null;
1210
+
1211
+ // Build a human-readable "last state" from available data
1212
+ let lastState = null;
1213
+ if (session?.lastResult?.summary) {
1214
+ lastState = session.lastResult.summary;
1215
+ } else if (session?.objective) {
1216
+ lastState = session.objective;
1217
+ } else if (most.name && !/^Session [0-9a-f]{8}/i.test(most.name)) {
1218
+ lastState = most.name;
1219
+ }
1220
+
1221
+ // Trim lastState to fit on one line
1222
+ if (lastState && lastState.length > 45) lastState = lastState.slice(0, 42) + '...';
1223
+
1224
+ // Build reason label
1225
+ const reason = signals.length > 0 ? signals.join(', ') : `${msgCount} messages`;
1226
+
1227
+ // Age label
1228
+ const mins = Math.floor(ageMs / 60000);
1229
+ let ageLabel;
1230
+ if (mins < 1) ageLabel = 'just now';
1231
+ else if (mins < 60) ageLabel = `${mins}m ago`;
1232
+ else ageLabel = `${Math.floor(mins / 60)}h ago`;
1233
+
1234
+ return {
1235
+ shouldContinue: true,
1236
+ reason,
1237
+ sessionId: most.id,
1238
+ sessionName: most.name || most.id.slice(0, 8),
1239
+ lastState,
1240
+ ageLabel,
1241
+ };
1242
+ }
1243
+
1244
+ // ─── Budget sparkline helpers ─────────────────────────────────────────────────
1245
+
1246
+ /** Token quotas per plan (5-hour window aggregate). Mirrors src/decide.mjs SUB_QUOTAS. */
1247
+ const _SPARKLINE_QUOTAS = {
1248
+ claude: { '$20': 402_500, '$100': 1_638_000, '$200': 4_120_000 },
1249
+ openai: { '$20': 400_000, '$100': 1_050_000, '$200': 1_900_000 },
1250
+ };
1251
+
1252
+ const _PLAN_PRICE_MAP = {
1253
+ pro: '$20', max5: '$100', max20: '$200',
1254
+ plus: '$20', pro100: '$100', pro200: '$200',
1255
+ };
1256
+
1257
+ /**
1258
+ * Read 5-hour usage entries from .dualbrain/usage/ logs.
1259
+ * @param {string} cwd
1260
+ * @returns {Array<object>}
1261
+ */
1262
+ function _readFiveHrUsage(cwd) {
1263
+ const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
1264
+ const now = Date.now();
1265
+ const cutoff = now - FIVE_HRS_MS;
1266
+ const usageDir = join(cwd, '.dualbrain', 'usage');
1267
+ const entries = [];
1268
+ for (let i = 0; i <= 1; i++) {
1269
+ const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
1270
+ const file = join(usageDir, `usage-${date}.jsonl`);
1271
+ if (!existsSync(file)) continue;
1272
+ let raw;
1273
+ try { raw = readFileSync(file, 'utf8'); } catch { continue; }
1274
+ for (const line of raw.split('\n')) {
1275
+ if (!line.trim()) continue;
1276
+ let rec;
1277
+ try { rec = JSON.parse(line); } catch { continue; }
1278
+ const ts = Date.parse(rec.timestamp);
1279
+ if (!isNaN(ts) && ts >= cutoff) entries.push(rec);
1280
+ }
1281
+ }
1282
+ return entries;
1283
+ }
1284
+
1285
+ /**
1286
+ * Build a 5-char sparkline bar: \u2593\u2593\u2593\u2591\u2591 where \u2593 = used quota.
1287
+ * @param {number} used tokens used
1288
+ * @param {number} quota total token quota
1289
+ * @returns {string}
1290
+ */
1291
+ function _sparkBar(used, quota) {
1292
+ if (!quota || quota <= 0) return '\u2591\u2591\u2591\u2591\u2591';
1293
+ const filled = Math.min(5, Math.round((used / quota) * 5));
1294
+ return '\u2593'.repeat(filled) + '\u2591'.repeat(5 - filled);
1295
+ }
1296
+
1297
+ /**
1298
+ * Return per-sub usage bar strings for a provider.
1299
+ * @param {string} provKey 'claude' | 'openai'
1300
+ * @param {object} profile
1301
+ * @param {Array<object>} fiveHrEntries
1302
+ * @returns {string} e.g. "$100 \u2593\u2593\u2591\u2591\u2591 $100 \u2593\u2591\u2591\u2591\u2591"
1303
+ */
1304
+ function _buildSubBars(provKey, profile, fiveHrEntries) {
1305
+ const providerCfg = profile?.providers?.[provKey];
1306
+ if (!providerCfg) return '';
1307
+
1308
+ const rawSubs = providerCfg.subs?.length
1309
+ ? providerCfg.subs
1310
+ : providerCfg.plan
1311
+ ? [{ plan: providerCfg.plan }]
1312
+ : [];
1313
+ if (rawSubs.length === 0) return '';
1314
+
1315
+ let totalUsed = 0;
1316
+ for (const e of fiveHrEntries) {
1317
+ if (e.provider !== provKey) continue;
1318
+ const inp = e.input_tokens ?? 0;
1319
+ const out = e.output_tokens ?? 0;
1320
+ totalUsed += inp + out > 0 ? inp + out : 8_000;
1321
+ }
1322
+ const perSub = rawSubs.length > 1 ? Math.round(totalUsed / rawSubs.length) : totalUsed;
1323
+
1324
+ return rawSubs.map(s => {
1325
+ const planKey = _PLAN_PRICE_MAP[s.plan] || s.plan || '$100';
1326
+ const quota = _SPARKLINE_QUOTAS[provKey]?.[planKey] ?? 1_000_000;
1327
+ const sparkline = _sparkBar(perSub, quota);
1328
+ return `${planKey} ${sparkline}`;
1329
+ }).join(' ');
1330
+ }
1331
+
1003
1332
  /**
1004
1333
  * Build a provider status string for the dashboard status line.
1005
- * Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
1334
+ * Shows per-sub usage sparkline bars: "\u25cf Claude $100 \u2593\u2593\u2591\u2591\u2591 \u25cf OpenAI $100 \u2593\u2591\u2591\u2591\u2591"
1006
1335
  * Uses ANSI color codes for the dots (no emoji width issues).
1007
1336
  */
1008
1337
  function buildProviderStatusLine(profile, auth) {
1009
- const GREEN = '\x1b[32m●\x1b[0m';
1010
- const RED = '\x1b[31m●\x1b[0m';
1338
+ const GREEN = '\x1b[32m\u25cf\x1b[0m';
1339
+ const RED = '\x1b[31m\u25cf\x1b[0m';
1011
1340
  const now = Date.now();
1341
+ const cwd = process.cwd();
1342
+
1343
+ let fiveHrEntries = [];
1344
+ try { fiveHrEntries = _readFiveHrUsage(cwd); } catch {}
1012
1345
 
1013
1346
  function providerSegment(provKey, displayName) {
1014
1347
  const sub = profile?.providers?.[provKey];
@@ -1018,16 +1351,11 @@ function buildProviderStatusLine(profile, auth) {
1018
1351
  const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
1019
1352
  if (expired) return `${RED} ${displayName}: expired`;
1020
1353
 
1021
- const dot = GREEN;
1022
- // Multi-sub: show aggregated plan amounts
1023
- const subs = sub?.subs;
1024
- if (subs && subs.length > 0) {
1025
- const agg = aggregatePlans(subs);
1026
- return `${dot} ${displayName} ${agg}`;
1027
- }
1028
- // Single plan
1029
- const planPrice = PLAN_PRICES[sub?.plan] || sub?.plan || 'connected';
1030
- return `${dot} ${displayName} ${planPrice}`;
1354
+ const dot = GREEN;
1355
+ const bars = _buildSubBars(provKey, profile, fiveHrEntries);
1356
+ return bars
1357
+ ? `${dot} ${displayName} ${bars}`
1358
+ : `${dot} ${displayName}: connected`;
1031
1359
  }
1032
1360
 
1033
1361
  const parts = [];
@@ -1105,6 +1433,9 @@ async function mainScreen(rl, ask) {
1105
1433
  const rtMain = detectReplitTools(cwd);
1106
1434
  const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1107
1435
 
1436
+ // ── Interrupted work detection ────────────────────────────────────────────
1437
+ const interrupted = detectInterruptedWork(allSessions, cwd);
1438
+
1108
1439
  // ── Box layout ────────────────────────────────────────────────────────────
1109
1440
  const termW = process.stdout.columns || 60;
1110
1441
  const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
@@ -1135,6 +1466,84 @@ async function mainScreen(rl, ask) {
1135
1466
  }
1136
1467
  }
1137
1468
 
1469
+ // ── Continuation card (interrupted work) ─────────────────────────────────
1470
+ if (interrupted) {
1471
+ const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
1472
+ const csep = `├${'─'.repeat(boxW - 2)}┤`;
1473
+ const cbot = `└${'─'.repeat(boxW - 2)}┘`;
1474
+ const crow = (content) => makeBoxRow(content, W);
1475
+
1476
+ const titleLine = `\x1b[33m💡\x1b[0m Continue: ${interrupted.sessionName}`;
1477
+ const lastLine = interrupted.lastState
1478
+ ? ` Last: ${interrupted.lastState} · ${interrupted.ageLabel}`
1479
+ : ` ${interrupted.reason} · ${interrupted.ageLabel}`;
1480
+ const actLine = ' [Enter] Resume [n] New session [s] Skip';
1481
+
1482
+ process.stdout.write([ctop, crow(titleLine), csep, crow(lastLine), crow(actLine), cbot].join('\n') + '\n\n');
1483
+
1484
+ // Wait for a keypress to decide what to do with the card
1485
+ const readline2 = await import('node:readline');
1486
+ readline2.emitKeypressEvents(process.stdin, rl);
1487
+
1488
+ const cardChoice = await new Promise((resolve) => {
1489
+ const wasRaw2 = process.stdin.isRaw;
1490
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1491
+ if (canRaw2) process.stdin.setRawMode(true);
1492
+
1493
+ const cleanup2 = () => {
1494
+ process.stdin.removeListener('keypress', onCardKey);
1495
+ if (canRaw2) {
1496
+ try { process.stdin.setRawMode(wasRaw2 || false); } catch {}
1497
+ }
1498
+ };
1499
+
1500
+ const onCardKey = (str, key) => {
1501
+ if (!key) return;
1502
+ const name = key.name || '';
1503
+ const seq = key.sequence || str || '';
1504
+
1505
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1506
+ cleanup2();
1507
+ process.stdout.write('\n');
1508
+ resolve('q');
1509
+ return;
1510
+ }
1511
+
1512
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1513
+ cleanup2();
1514
+ process.stdout.write('\n');
1515
+ resolve('resume');
1516
+ return;
1517
+ }
1518
+
1519
+ if (!str || str.length === 0) return;
1520
+ const lower = str.toLowerCase();
1521
+ if (lower === 'n' || lower === 's' || lower === 'q') {
1522
+ cleanup2();
1523
+ process.stdout.write('\n');
1524
+ resolve(lower);
1525
+ return;
1526
+ }
1527
+ };
1528
+
1529
+ process.stdin.on('keypress', onCardKey);
1530
+ });
1531
+
1532
+ if (cardChoice === 'q') return { next: 'exit' };
1533
+
1534
+ if (cardChoice === 'resume') {
1535
+ const { spawnSync } = await import('node:child_process');
1536
+ process.stdout.write(` Launching: claude --resume ${interrupted.sessionId}\n\n`);
1537
+ spawnSync('claude', ['--resume', interrupted.sessionId], { stdio: 'inherit' });
1538
+ saveTerminalState(cwd, getTerminalId(), interrupted.sessionId, 'claude');
1539
+ return { next: 'main' };
1540
+ }
1541
+
1542
+ if (cardChoice === 'n') return { next: 'new-session' };
1543
+
1544
+ // 's' → fall through to normal dashboard
1545
+ }
1546
+
1138
1547
  // ── Status section ────────────────────────────────────────────────────────
1139
1548
  const providerLine = buildProviderStatusLine(profile, auth);
1140
1549
 
@@ -1143,6 +1552,47 @@ async function mainScreen(rl, ask) {
1143
1552
  statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1144
1553
  }
1145
1554
 
1555
+ // ── Action cards (git state + open PRs) ──────────────────────────────────
1556
+ const repoState = detectRepoState(cwd);
1557
+ const openPRs = await detectOpenPRs(cwd);
1558
+ const actionRows = buildActionRows(repoState, row, openPRs);
1559
+
1560
+ // ── Related sessions hint (only when no continuation card is showing) ─────
1561
+ if (!interrupted && recentSessions.length > 0) {
1562
+ try {
1563
+ const { findRelatedSessions } = await import('../src/session.mjs');
1564
+ const mostRecent = recentSessions[0];
1565
+ // Build a pseudo-prompt from the most recent session's name/objective
1566
+ const recentPrompt = mostRecent.name || '';
1567
+ // Load session index to get files for the most recent session
1568
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1569
+ let recentFiles = [];
1570
+ try {
1571
+ const idx = JSON.parse(readFileSync(indexPath, 'utf8'));
1572
+ recentFiles = idx[mostRecent.id]?.files || [];
1573
+ } catch {}
1574
+ const related = findRelatedSessions(recentPrompt, recentFiles, cwd);
1575
+ if (related.length > 0) {
1576
+ const relAgeLabel = (isoDate) => {
1577
+ if (!isoDate) return '';
1578
+ const diff = Date.now() - Date.parse(isoDate);
1579
+ const days = Math.floor(diff / 86400000);
1580
+ const hours = Math.floor(diff / 3600000);
1581
+ if (days >= 1) return `${days}d`;
1582
+ return `${hours}h ago`;
1583
+ };
1584
+ const relatedParts = related.slice(0, 2).map(r => {
1585
+ const age = relAgeLabel(r.date);
1586
+ return age ? `${r.smartName} (${age})` : r.smartName;
1587
+ });
1588
+ const DIM = '\x1b[2m';
1589
+ const RESET = '\x1b[0m';
1590
+ actionRows.push(row(`${DIM}📎 Related: ${relatedParts.join(', ')}${RESET}`));
1591
+ }
1592
+ } catch { /* non-fatal */ }
1593
+ }
1594
+ // ── End related sessions hint ─────────────────────────────────────────────
1595
+
1146
1596
  // ── Sessions section ──────────────────────────────────────────────────────
1147
1597
  const sessionRows = [];
1148
1598
  if (recentSessions.length === 0) {
@@ -1195,13 +1645,16 @@ async function mainScreen(rl, ask) {
1195
1645
  }
1196
1646
 
1197
1647
  // ── Actions bar ───────────────────────────────────────────────────────────
1198
- const actionsContent = '↵ Resume n New / Search i Import s Settings q Quit';
1648
+ const actionsBase = '↵ Resume n New / Search i Import s Settings q Quit';
1649
+ const actionsContent = openPRs.length > 0 ? `${actionsBase} p PRs` : actionsBase;
1199
1650
  const actionsRow = row(actionsContent);
1200
1651
 
1201
1652
  // ── Print the full box ────────────────────────────────────────────────────
1653
+ // Include action cards between status and sessions (with separators only when non-empty)
1202
1654
  const lines = [
1203
1655
  top,
1204
1656
  ...statusRows,
1657
+ ...(actionRows.length > 0 ? [sep, ...actionRows] : []),
1205
1658
  sep,
1206
1659
  ...sessionRows,
1207
1660
  sep,
@@ -1301,7 +1754,9 @@ async function mainScreen(rl, ask) {
1301
1754
  // Single-key commands only fire when buffer is empty
1302
1755
  if (taskBuffer.length === 0) {
1303
1756
  const lower = str.toLowerCase();
1304
- if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/' || lower === 'i') {
1757
+ const singleKeySet = new Set(['n', 's', 'q', '/', 'i']);
1758
+ if (lower === 'p' && openPRs.length > 0) singleKeySet.add('p');
1759
+ if (singleKeySet.has(lower)) {
1305
1760
  cleanup();
1306
1761
  process.stdout.write('\n');
1307
1762
  resolve(lower);
@@ -1407,6 +1862,7 @@ async function mainScreen(rl, ask) {
1407
1862
 
1408
1863
  if (choice === 's') { return { next: 'settings' }; }
1409
1864
  if (choice === 'i') { return { next: 'import-picker' }; }
1865
+ if (choice === 'p' && openPRs.length > 0) { return { next: 'pr-triage', openPRs }; }
1410
1866
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1411
1867
 
1412
1868
  return { next: 'main' };
@@ -1673,6 +2129,234 @@ async function importPickerScreen() {
1673
2129
  return { next: 'main' };
1674
2130
  }
1675
2131
 
2132
+ // ─── Screen: prTriageScreen ───────────────────────────────────────────────────
2133
+
2134
+ /**
2135
+ * PR Triage screen. Lists open PRs, lets the user select one, checkout + fetch
2136
+ * comments, then dispatch fixes through the dual-brain pipeline.
2137
+ *
2138
+ * ctx.openPRs is the pre-fetched array from detectOpenPRs().
2139
+ */
2140
+ async function prTriageScreen(rl, ask, ctx = {}) {
2141
+ const cwd = process.cwd();
2142
+ const prs = ctx.openPRs || [];
2143
+
2144
+ const termW = process.stdout.columns || 60;
2145
+ const boxW = Math.min(termW - 2, 60);
2146
+ const W = boxW - 4;
2147
+
2148
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2149
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2150
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2151
+ const row = (content) => makeBoxRow(content, W);
2152
+
2153
+ if (prs.length === 0) {
2154
+ process.stdout.write('\n');
2155
+ process.stdout.write(top + '\n');
2156
+ process.stdout.write(row('PR Triage') + '\n');
2157
+ process.stdout.write(sep + '\n');
2158
+ process.stdout.write(row('No open PRs found.') + '\n');
2159
+ process.stdout.write(sep + '\n');
2160
+ process.stdout.write(row('[q] Back') + '\n');
2161
+ process.stdout.write(bot + '\n\n');
2162
+ await ask(' Press Enter to go back...');
2163
+ return { next: 'main' };
2164
+ }
2165
+
2166
+ // Helper: get review decision label
2167
+ function reviewLabel(rd) {
2168
+ if (!rd) return 'pending review';
2169
+ const map = {
2170
+ APPROVED: 'approved',
2171
+ CHANGES_REQUESTED: 'changes_requested',
2172
+ REVIEW_REQUIRED: 'review_required',
2173
+ };
2174
+ return map[rd] || rd.toLowerCase();
2175
+ }
2176
+
2177
+ // ── Render PR list ─────────────────────────────────────────────────────────
2178
+ process.stdout.write('\n');
2179
+ process.stdout.write(top + '\n');
2180
+ process.stdout.write(row('PR Triage') + '\n');
2181
+ process.stdout.write(sep + '\n');
2182
+
2183
+ prs.forEach((pr, i) => {
2184
+ const title = String(pr.title || '').slice(0, W - 6);
2185
+ const decision = reviewLabel(pr.reviewDecision);
2186
+ const diff = `+${pr.additions || 0} -${pr.deletions || 0}`;
2187
+ const files = pr.changedFiles ? `${pr.changedFiles} file${pr.changedFiles === 1 ? '' : 's'}` : '';
2188
+ const numStr = `#${pr.number}`;
2189
+
2190
+ process.stdout.write(row(`[${i + 1}] ${numStr} ${title}`) + '\n');
2191
+ process.stdout.write(row(` ${decision} · ${diff}${files ? ' · ' + files : ''}`) + '\n');
2192
+ if (pr.headRefName) {
2193
+ process.stdout.write(row(` Branch: ${pr.headRefName}`) + '\n');
2194
+ }
2195
+ if (i < prs.length - 1) {
2196
+ process.stdout.write(row('') + '\n');
2197
+ }
2198
+ });
2199
+
2200
+ process.stdout.write(sep + '\n');
2201
+ process.stdout.write(row('[1-9] Select PR [q] Back') + '\n');
2202
+ process.stdout.write(bot + '\n\n');
2203
+
2204
+ const pick = (await ask(' Choice: ')).trim().toLowerCase();
2205
+
2206
+ if (pick === 'q' || pick === 'b' || pick === '') return { next: 'main' };
2207
+
2208
+ const idx = parseInt(pick, 10) - 1;
2209
+ if (isNaN(idx) || idx < 0 || idx >= prs.length) return { next: 'pr-triage', openPRs: prs };
2210
+
2211
+ const selectedPR = prs[idx];
2212
+
2213
+ // ── PR detail: checkout + fetch comments ──────────────────────────────────
2214
+ process.stdout.write(`\n Checking out PR #${selectedPR.number}...\n`);
2215
+
2216
+ const checkoutResult = _spawnSyncTop('gh', ['pr', 'checkout', String(selectedPR.number)], {
2217
+ cwd,
2218
+ encoding: 'utf8',
2219
+ stdio: ['pipe', 'pipe', 'pipe'],
2220
+ timeout: 15000,
2221
+ });
2222
+
2223
+ if (checkoutResult.status !== 0) {
2224
+ process.stdout.write(` Could not checkout PR: ${(checkoutResult.stderr || '').slice(0, 100)}\n`);
2225
+ await ask(' Press Enter to continue...');
2226
+ return { next: 'pr-triage', openPRs: prs };
2227
+ }
2228
+
2229
+ process.stdout.write(` Fetching comments...\n`);
2230
+
2231
+ let comments = [];
2232
+ try {
2233
+ const commentsResult = _spawnSyncTop('gh', [
2234
+ 'pr', 'view', String(selectedPR.number),
2235
+ '--comments',
2236
+ '--json', 'comments',
2237
+ ], {
2238
+ cwd,
2239
+ encoding: 'utf8',
2240
+ stdio: ['pipe', 'pipe', 'pipe'],
2241
+ timeout: 5000,
2242
+ });
2243
+
2244
+ if (commentsResult.status === 0 && commentsResult.stdout) {
2245
+ const parsed = JSON.parse(commentsResult.stdout.trim());
2246
+ comments = parsed?.comments || [];
2247
+ }
2248
+ } catch {}
2249
+
2250
+ // ── Show PR detail: comments grouped by file ──────────────────────────────
2251
+ process.stdout.write('\n');
2252
+ process.stdout.write(top + '\n');
2253
+ process.stdout.write(row(`#${selectedPR.number} ${String(selectedPR.title).slice(0, W - 6)}`) + '\n');
2254
+ process.stdout.write(sep + '\n');
2255
+
2256
+ if (comments.length === 0) {
2257
+ process.stdout.write(row('No review comments.') + '\n');
2258
+ } else {
2259
+ // Group comments by their file path (body comments have no path)
2260
+ const grouped = {};
2261
+ for (const c of comments) {
2262
+ const file = c.path || '(general)';
2263
+ if (!grouped[file]) grouped[file] = [];
2264
+ grouped[file].push(c);
2265
+ }
2266
+ for (const [file, fileCmts] of Object.entries(grouped)) {
2267
+ const fileLabel = file.length > W - 4 ? '...' + file.slice(-(W - 7)) : file;
2268
+ process.stdout.write(row(` ${fileLabel}`) + '\n');
2269
+ for (const c of fileCmts.slice(0, 3)) {
2270
+ const body = String(c.body || '').replace(/\s+/g, ' ').slice(0, W - 6);
2271
+ process.stdout.write(row(` → ${body}`) + '\n');
2272
+ }
2273
+ if (fileCmts.length > 3) {
2274
+ process.stdout.write(row(` ... +${fileCmts.length - 3} more`) + '\n');
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ process.stdout.write(sep + '\n');
2280
+ process.stdout.write(row('[f] Dispatch fixes [v] View full diff [b] Back') + '\n');
2281
+ process.stdout.write(bot + '\n\n');
2282
+
2283
+ const action = (await ask(' Action: ')).trim().toLowerCase();
2284
+
2285
+ if (action === 'v') {
2286
+ // Show full diff via gh pr diff
2287
+ process.stdout.write('\n');
2288
+ const diffResult = _spawnSyncTop('gh', ['pr', 'diff', String(selectedPR.number)], {
2289
+ cwd,
2290
+ encoding: 'utf8',
2291
+ stdio: ['ignore', 'pipe', 'pipe'],
2292
+ timeout: 10000,
2293
+ });
2294
+ const diffOut = (diffResult.stdout || '').slice(0, 3000);
2295
+ process.stdout.write(diffOut || ' (no diff output)\n');
2296
+ process.stdout.write('\n');
2297
+ await ask(' Press Enter to continue...');
2298
+ return { next: 'pr-triage', openPRs: prs };
2299
+ }
2300
+
2301
+ if (action === 'f') {
2302
+ // Dispatch each comment as a fix task through detect→decide→dispatch
2303
+ if (comments.length === 0) {
2304
+ process.stdout.write(' No comments to fix.\n\n');
2305
+ await ask(' Press Enter to continue...');
2306
+ return { next: 'pr-triage', openPRs: prs };
2307
+ }
2308
+
2309
+ process.stdout.write(`\n Dispatching ${comments.length} comment fix${comments.length === 1 ? '' : 's'} through dual-brain...\n\n`);
2310
+
2311
+ // Collect the PR files for context
2312
+ const prFiles = [];
2313
+ try {
2314
+ const filesResult = _spawnSyncTop('gh', [
2315
+ 'pr', 'view', String(selectedPR.number),
2316
+ '--json', 'files',
2317
+ ], {
2318
+ cwd,
2319
+ encoding: 'utf8',
2320
+ stdio: ['pipe', 'pipe', 'pipe'],
2321
+ timeout: 5000,
2322
+ });
2323
+ if (filesResult.status === 0) {
2324
+ const pf = JSON.parse(filesResult.stdout || '{}');
2325
+ (pf.files || []).forEach(f => prFiles.push(f.path));
2326
+ }
2327
+ } catch {}
2328
+
2329
+ const profile = loadProfile(cwd);
2330
+
2331
+ for (let ci = 0; ci < comments.length; ci++) {
2332
+ const c = comments[ci];
2333
+ const taskPrompt = c.path
2334
+ ? `Fix review comment in ${c.path}: ${c.body}`
2335
+ : `Fix PR review comment: ${c.body}`;
2336
+
2337
+ process.stdout.write(` [${ci + 1}/${comments.length}] ${taskPrompt.slice(0, 60)}...\n`);
2338
+
2339
+ try {
2340
+ const detection = detectTask({ prompt: taskPrompt, files: prFiles });
2341
+ const decision = decideRoute({ profile, detection, cwd });
2342
+ const result = await dispatch({ decision, prompt: taskPrompt, files: prFiles, cwd });
2343
+ const status = result.status === 'completed' ? '✓' : '✗';
2344
+ process.stdout.write(` ${status} ${result.status} (${(result.durationMs / 1000).toFixed(1)}s)\n`);
2345
+ if (result.summary) process.stdout.write(` ${result.summary.slice(0, 80)}\n`);
2346
+ } catch (e) {
2347
+ process.stdout.write(` ✗ Error: ${e.message.slice(0, 80)}\n`);
2348
+ }
2349
+ }
2350
+
2351
+ process.stdout.write('\n All fixes dispatched.\n\n');
2352
+ await ask(' Press Enter to continue...');
2353
+ return { next: 'pr-triage', openPRs: prs };
2354
+ }
2355
+
2356
+ // 'b' or anything else → back to PR list
2357
+ return { next: 'pr-triage', openPRs: prs };
2358
+ }
2359
+
1676
2360
  // ─── Screen: settingsScreen ───────────────────────────────────────────────────
1677
2361
 
1678
2362
  async function settingsScreen(rl, ask) {
@@ -1688,6 +2372,9 @@ async function settingsScreen(rl, ask) {
1688
2372
  const bot = `└${'─'.repeat(boxW - 2)}┘`;
1689
2373
  const row = (content) => makeBoxRow(content, W);
1690
2374
 
2375
+ // Detect if gh is available + has PRs for the PR triage option
2376
+ const settingsPRs = await detectOpenPRs(cwd);
2377
+
1691
2378
  const lines = [
1692
2379
  top,
1693
2380
  row('Settings'),
@@ -1698,6 +2385,7 @@ async function settingsScreen(rl, ask) {
1698
2385
  row('[d] Switch to data-tools'),
1699
2386
  row('[?] Help & shortcuts'),
1700
2387
  row('[x] Diagnostics'),
2388
+ ...(settingsPRs.length > 0 ? [row(`[p] PR triage (${settingsPRs.length} open)`)] : []),
1701
2389
  row(''),
1702
2390
  row('[Esc/b] Back to dashboard'),
1703
2391
  bot,
@@ -1715,6 +2403,10 @@ async function settingsScreen(rl, ask) {
1715
2403
  return { next: 'import-picker' };
1716
2404
  }
1717
2405
 
2406
+ if (choice === 'p' && settingsPRs.length > 0) {
2407
+ return { next: 'pr-triage', openPRs: settingsPRs };
2408
+ }
2409
+
1718
2410
  if (choice === 'd') {
1719
2411
  const { spawnSync } = await import('node:child_process');
1720
2412
  const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
@@ -2989,6 +3681,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
2989
3681
  return { next: 'session-manage', session: sess };
2990
3682
  }
2991
3683
 
3684
+
3685
+ // ─── Auto-commit drafting ─────────────────────────────────────────────────────
3686
+
3687
+ /**
3688
+ * Detect uncommitted changes in cwd.
3689
+ * Returns { hasChanges, files, statOutput, diffSnippet } or null.
3690
+ */
3691
+ function detectUncommittedChanges(cwd) {
3692
+ try {
3693
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
3694
+ } catch { return null; }
3695
+
3696
+ let statOutput = '';
3697
+ try {
3698
+ statOutput = execSync('git diff --stat HEAD', { cwd, encoding: 'utf8', timeout: 3000, stdio: 'pipe' }).trim();
3699
+ } catch { return null; }
3700
+
3701
+ let statusOutput = '';
3702
+ try {
3703
+ statusOutput = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
3704
+ } catch {}
3705
+
3706
+ if (!statOutput && !statusOutput) return null;
3707
+
3708
+ const statFiles = statOutput
3709
+ .split('\n')
3710
+ .filter(l => l.includes('|'))
3711
+ .map(l => l.split('|')[0].trim())
3712
+ .filter(Boolean);
3713
+
3714
+ const statusFiles = statusOutput
3715
+ .split('\n')
3716
+ .filter(Boolean)
3717
+ .map(l => l.slice(3).trim())
3718
+ .filter(f => f && !statFiles.includes(f));
3719
+
3720
+ const files = [...new Set([...statFiles, ...statusFiles])];
3721
+
3722
+ let diffSnippet = '';
3723
+ try {
3724
+ const full = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
3725
+ diffSnippet = full.slice(0, 2000);
3726
+ } catch {}
3727
+
3728
+ return { hasChanges: true, files, statOutput, diffSnippet };
3729
+ }
3730
+
3731
+ /**
3732
+ * Build a conventional commit message from file list + diff snippet.
3733
+ * Deterministic — no AI calls.
3734
+ */
3735
+ function generateCommitMessage(files, diffSnippet) {
3736
+ if (!files || files.length === 0) return 'chore: update files';
3737
+
3738
+ const testFiles = files.filter(f =>
3739
+ /\.(test|spec)\.[jt]sx?$/.test(f) || /\/(test|tests|__tests__)\//i.test(f)
3740
+ );
3741
+ const docFiles = files.filter(f => /\.(md|txt|rst|adoc)$/i.test(f) || /docs?\//i.test(f));
3742
+ const configFiles = files.filter(f =>
3743
+ /\.(json|yaml|yml|toml|ini)$/i.test(f) ||
3744
+ /^\.?(eslint|prettier|babel|jest|tsconfig|package)/i.test(f.replace(/.*\//, ''))
3745
+ );
3746
+ const srcFiles = files.filter(f =>
3747
+ !testFiles.includes(f) && !docFiles.includes(f) && !configFiles.includes(f)
3748
+ );
3749
+
3750
+ let type = 'feat';
3751
+ if (diffSnippet) {
3752
+ const lower = diffSnippet.toLowerCase();
3753
+ if (['fix', 'bug', 'error', 'issue', 'resolve', 'patch', 'correct', 'repair'].some(w => lower.includes(w))) {
3754
+ type = 'fix';
3755
+ } else if (['refactor', 'cleanup', 'simplify', 'reorganize'].some(w => lower.includes(w))) {
3756
+ type = 'refactor';
3757
+ }
3758
+ }
3759
+
3760
+ const dominantFile = files[0].replace(/.*\//, '');
3761
+
3762
+ if (testFiles.length === files.length) {
3763
+ const mod = testFiles[0].replace(/\.(test|spec)\.[jt]sx?$/, '').replace(/.*\//, '');
3764
+ return `test: add/fix tests for ${mod}`;
3765
+ }
3766
+ if (docFiles.length === files.length) {
3767
+ return `docs: update ${docFiles[0].replace(/.*\//, '')}`;
3768
+ }
3769
+ if (configFiles.length === files.length) {
3770
+ return `chore: update ${configFiles[0].replace(/.*\//, '')}`;
3771
+ }
3772
+ if (srcFiles.length > 0 && testFiles.length > 0) {
3773
+ const dom = srcFiles[0].replace(/.*\//, '').replace(/\.[jt]sx?$/, '');
3774
+ return `${type}: ${dom} with tests`;
3775
+ }
3776
+ if (files.length === 1) {
3777
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3778
+ }
3779
+
3780
+ const dirs = files.map(f => (f.includes('/') ? f.split('/').slice(-2, -1)[0] : ''));
3781
+ const commonDir = dirs[0] && dirs.every(d => d === dirs[0]) ? dirs[0] : null;
3782
+ if (commonDir) return `${type}: update ${commonDir}`;
3783
+
3784
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3785
+ }
3786
+
3787
+ /**
3788
+ * Show a commit card after task completion and handle user action.
3789
+ * Enter -> git add -A && git commit -m "message"
3790
+ * e -> prompt for custom message, then commit
3791
+ * d -> show full diff, then return to card
3792
+ * s -> skip
3793
+ *
3794
+ * Only shown on TTY. Never auto-commits — the card is the offer.
3795
+ * Returns true if a commit was made.
3796
+ */
3797
+ async function offerAutoCommit(cwd) {
3798
+ if (!process.stdout.isTTY) return false;
3799
+
3800
+ try {
3801
+ const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3802
+ const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3803
+ if (claude > 0 || codex > 0) return false;
3804
+ } catch {}
3805
+
3806
+ try {
3807
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
3808
+ if (existsSync(sessionPath)) {
3809
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
3810
+ if (sess?.lastResult?.status === 'failure') return false;
3811
+ }
3812
+ } catch {}
3813
+
3814
+ const changes = detectUncommittedChanges(cwd);
3815
+ if (!changes) return false;
3816
+
3817
+ let finalMsg = generateCommitMessage(changes.files, changes.diffSnippet);
3818
+
3819
+ const termW = process.stdout.columns || 60;
3820
+ const boxW = Math.min(termW - 2, 54);
3821
+ const W = boxW - 4;
3822
+
3823
+ const top = `\u250c${'\u2500'.repeat(boxW - 2)}\u2510`;
3824
+ const sep = `\u251c${'\u2500'.repeat(boxW - 2)}\u2524`;
3825
+ const bot = `\u2514${'\u2500'.repeat(boxW - 2)}\u2518`;
3826
+
3827
+ const padLine = (s) => {
3828
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
3829
+ return `\u2502 ${s}${ ' '.repeat(Math.max(0, W - plain.length))} \u2502`;
3830
+ };
3831
+
3832
+ const filesLabel = changes.files.length <= 3
3833
+ ? changes.files.join(', ')
3834
+ : `${changes.files.slice(0, 3).join(', ')} +${changes.files.length - 3} more`;
3835
+ const fileCountLabel = `${changes.files.length} file${changes.files.length === 1 ? '' : 's'} changed: ${filesLabel}`;
3836
+ const fileLineTrunc = fileCountLabel.length > W ? fileCountLabel.slice(0, W - 3) + '...' : fileCountLabel;
3837
+
3838
+ const actLine1 = '[Enter] Commit [e] Edit message [d] Full diff';
3839
+ const actLine2 = '[s] Skip';
3840
+
3841
+ const printCard = (msg) => {
3842
+ const msgLine = msg.length > W ? msg.slice(0, W - 3) + '...' : msg;
3843
+ process.stdout.write(top + '\n');
3844
+ process.stdout.write(padLine('\x1b[33m\u{1F4DD} Ready to commit?\x1b[0m') + '\n');
3845
+ process.stdout.write(sep + '\n');
3846
+ process.stdout.write(padLine(msgLine) + '\n');
3847
+ process.stdout.write(padLine('') + '\n');
3848
+ process.stdout.write(padLine(fileLineTrunc) + '\n');
3849
+ process.stdout.write(padLine('') + '\n');
3850
+ process.stdout.write(padLine(actLine1) + '\n');
3851
+ process.stdout.write(padLine(actLine2) + '\n');
3852
+ process.stdout.write(bot + '\n');
3853
+ };
3854
+
3855
+ const readlinemod = await import('node:readline');
3856
+ readlinemod.emitKeypressEvents(process.stdin);
3857
+
3858
+ const waitKey = () => new Promise((resolve) => {
3859
+ const wasRaw = process.stdin.isRaw;
3860
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
3861
+ if (canRaw) process.stdin.setRawMode(true);
3862
+
3863
+ const cleanup = () => {
3864
+ process.stdin.removeListener('keypress', onKey);
3865
+ if (canRaw) { try { process.stdin.setRawMode(wasRaw || false); } catch {} }
3866
+ };
3867
+
3868
+ const onKey = (str, key) => {
3869
+ if (!key) return;
3870
+ const name = key.name || '';
3871
+ const seq = key.sequence || str || '';
3872
+
3873
+ if (key.ctrl && (name === 'c' || name === 'd')) {
3874
+ cleanup(); process.stdout.write('\n'); resolve('s'); return;
3875
+ }
3876
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
3877
+ cleanup(); process.stdout.write('\n'); resolve('commit'); return;
3878
+ }
3879
+ if (!str || str.length === 0) return;
3880
+ const lower = str.toLowerCase();
3881
+ if (lower === 'e' || lower === 'd' || lower === 's') {
3882
+ cleanup(); process.stdout.write('\n'); resolve(lower); return;
3883
+ }
3884
+ };
3885
+
3886
+ process.stdin.on('keypress', onKey);
3887
+ });
3888
+
3889
+ process.stdout.write('\n');
3890
+ printCard(finalMsg);
3891
+
3892
+ let committed = false;
3893
+ let done = false;
3894
+
3895
+ while (!done) {
3896
+ const choice = await waitKey();
3897
+
3898
+ if (choice === 'commit') {
3899
+ try {
3900
+ execSync('git add -A', { cwd, stdio: 'pipe' });
3901
+ execSync(`git commit -m ${JSON.stringify(finalMsg)}`, { cwd, stdio: 'pipe' });
3902
+ process.stdout.write(`\n \x1b[32m\u2713 Committed:\x1b[0m ${finalMsg}\n\n`);
3903
+ committed = true;
3904
+ } catch (e) {
3905
+ process.stderr.write(` Commit failed: ${e.message}\n`);
3906
+ }
3907
+ done = true;
3908
+
3909
+ } else if (choice === 'e') {
3910
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
3911
+ const edited = await new Promise(res => rl2.question('\n Commit message: ', res));
3912
+ rl2.close();
3913
+ if (edited.trim()) finalMsg = edited.trim();
3914
+ process.stdout.write('\n');
3915
+ printCard(finalMsg);
3916
+
3917
+ } else if (choice === 'd') {
3918
+ process.stdout.write('\n');
3919
+ try {
3920
+ const fullDiff = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
3921
+ process.stdout.write(fullDiff || '(no diff output)\n');
3922
+ } catch { process.stdout.write('(could not read diff)\n'); }
3923
+ process.stdout.write('\n');
3924
+ printCard(finalMsg);
3925
+
3926
+ } else {
3927
+ process.stdout.write(' Skipped.\n\n');
3928
+ done = true;
3929
+ }
3930
+ }
3931
+
3932
+ return committed;
3933
+ }
3934
+
2992
3935
  // ─── Screen state machine ─────────────────────────────────────────────────────
2993
3936
 
2994
3937
  const SCREENS = {
@@ -2997,6 +3940,7 @@ const SCREENS = {
2997
3940
  'new-session': newSessionScreen,
2998
3941
  settings: settingsScreen,
2999
3942
  'import-picker': importPickerScreen,
3943
+ 'pr-triage': prTriageScreen,
3000
3944
  subscriptions: subscriptionsScreen,
3001
3945
  dashboard: dashboardScreen,
3002
3946
  auth: authScreen,
@@ -3035,6 +3979,7 @@ async function runScreens(startScreen = 'dashboard') {
3035
3979
  if (freshSessions.length > 0) {
3036
3980
  saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
3037
3981
  }
3982
+ await offerAutoCommit(cwd);
3038
3983
  current = 'main';
3039
3984
  ctx = {};
3040
3985
  continue;
@@ -3045,9 +3990,10 @@ async function runScreens(startScreen = 'dashboard') {
3045
3990
  try {
3046
3991
  const result = await screen(rl, ask, ctx);
3047
3992
  current = result?.next || 'exit';
3048
- // Pass through context (e.g. selected session, typed prompt) to next screen
3049
- ctx = result?.session ? { session: result.session }
3050
- : result?.prompt ? { prompt: result.prompt }
3993
+ // Pass through context (e.g. selected session, typed prompt, openPRs) to next screen
3994
+ ctx = result?.session ? { session: result.session }
3995
+ : result?.prompt ? { prompt: result.prompt }
3996
+ : result?.openPRs ? { openPRs: result.openPRs }
3051
3997
  : {};
3052
3998
  } catch (e) {
3053
3999
  console.error(`Error: ${e.message}`);
@@ -3058,6 +4004,309 @@ async function runScreens(startScreen = 'dashboard') {
3058
4004
  rl.close();
3059
4005
  }
3060
4006
 
4007
+
4008
+ // ─── Watch mode ──────────────────────────────────────────────────────────────
4009
+
4010
+ /**
4011
+ * Suggest an action for a batch of changed files.
4012
+ * Returns { label, cmd, safe } or null (no suggestion needed).
4013
+ * Deterministic — no AI calls.
4014
+ */
4015
+ function suggestAction(changedFiles, cwd) {
4016
+ // .env changes — highest priority warning
4017
+ const envChanged = changedFiles.some(f => {
4018
+ const b = basename(f);
4019
+ return b === '.env' || b.startsWith('.env.');
4020
+ });
4021
+ if (envChanged) {
4022
+ return { label: '⚠ Environment changed — restart services', cmd: null, safe: false };
4023
+ }
4024
+
4025
+ // package.json → npm install
4026
+ if (changedFiles.some(f => basename(f) === 'package.json')) {
4027
+ return { label: 'npm install (dependencies may have changed)', cmd: 'npm install', safe: true };
4028
+ }
4029
+
4030
+ // Config files → restart dev server
4031
+ const configChanged = changedFiles.some(f => {
4032
+ const b = basename(f);
4033
+ return /\.config\.(m?js|ts|cjs|json)$/.test(b)
4034
+ || b === 'tsconfig.json'
4035
+ || b === '.eslintrc'
4036
+ || b === '.babelrc'
4037
+ || b === 'vite.config.js'
4038
+ || b === 'webpack.config.js';
4039
+ });
4040
+ if (configChanged) {
4041
+ return { label: 'Restart dev server (config changed)', cmd: null, safe: false };
4042
+ }
4043
+
4044
+ // Test/spec files themselves changed → run them
4045
+ const testChanged = changedFiles.filter(f => /\.(test|spec)\.(m?js|ts|cjs)$/.test(f));
4046
+ if (testChanged.length > 0) {
4047
+ let testCmd = 'npm test';
4048
+ try {
4049
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4050
+ if (!pkg.scripts?.test) testCmd = null;
4051
+ } catch {}
4052
+ const fileList = testChanged.map(f => basename(f)).join(', ');
4053
+ return testCmd ? { label: `Run tests: ${fileList}`, cmd: testCmd, safe: true } : null;
4054
+ }
4055
+
4056
+ // Markdown → no suggestion
4057
+ if (changedFiles.every(f => extname(f) === '.md')) {
4058
+ return null;
4059
+ }
4060
+
4061
+ // Source file changed → look for related test file
4062
+ const sourceChanged = changedFiles.filter(f =>
4063
+ /\.(m?js|ts|cjs|py|rb|go|rs)$/.test(f) && !/\.(test|spec)\./.test(f)
4064
+ );
4065
+ if (sourceChanged.length > 0) {
4066
+ const testDirs = ['test', 'tests', '__tests__', 'spec', 'src'];
4067
+ for (const srcFile of sourceChanged) {
4068
+ const srcBase = basename(srcFile);
4069
+ const srcExt = extname(srcFile);
4070
+ const srcStem = srcBase.slice(0, -srcExt.length);
4071
+ const testExts = [...new Set([srcExt, '.js', '.ts', '.mjs'])];
4072
+ const srcDirAbs = join(cwd, dirname(srcFile));
4073
+
4074
+ for (const dir of testDirs) {
4075
+ for (const ext of testExts) {
4076
+ const candidates = [
4077
+ join(cwd, dir, `${srcStem}.test${ext}`),
4078
+ join(cwd, dir, `${srcStem}.spec${ext}`),
4079
+ join(srcDirAbs, `${srcStem}.test${ext}`),
4080
+ join(srcDirAbs, `${srcStem}.spec${ext}`),
4081
+ ];
4082
+ for (const c of candidates) {
4083
+ if (existsSync(c)) {
4084
+ const rel = c.replace(cwd + '/', '');
4085
+ let testCmd = 'npm test';
4086
+ try {
4087
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4088
+ const scripts = pkg.scripts?.test ?? '';
4089
+ const dev = { ...pkg.devDependencies, ...pkg.dependencies };
4090
+ if (scripts.includes('jest') || dev.jest) testCmd = `npx jest ${rel}`;
4091
+ else if (scripts.includes('vitest') || dev.vitest) testCmd = `npx vitest run ${rel}`;
4092
+ else if (scripts.includes('mocha') || dev.mocha) testCmd = `npx mocha ${rel}`;
4093
+ } catch {}
4094
+ return { label: `Run related tests: ${rel}`, cmd: testCmd, safe: true };
4095
+ }
4096
+ }
4097
+ }
4098
+ }
4099
+ }
4100
+
4101
+ // No test file found — suggest generic test run
4102
+ let testCmd = 'npm test';
4103
+ try {
4104
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4105
+ if (!pkg.scripts?.test) testCmd = null;
4106
+ } catch { testCmd = null; }
4107
+
4108
+ if (testCmd) {
4109
+ const fileList = sourceChanged.map(f => basename(f)).join(', ');
4110
+ return { label: `Run tests (${fileList} changed)`, cmd: testCmd, safe: true };
4111
+ }
4112
+ }
4113
+
4114
+ return null;
4115
+ }
4116
+
4117
+ const W_RESET = '\x1b[0m';
4118
+ const W_BOLD = '\x1b[1m';
4119
+ const W_DIM = '\x1b[2m';
4120
+ const W_YELLOW = '\x1b[33m';
4121
+ const W_CYAN = '\x1b[36m';
4122
+ const W_GREEN = '\x1b[32m';
4123
+ const W_RED = '\x1b[31m';
4124
+
4125
+ function watchRedraw(header, logLines, prompt) {
4126
+ process.stdout.write('\x1b[2J\x1b[H');
4127
+ process.stdout.write(header + '\n\n');
4128
+ const visible = logLines.slice(-8);
4129
+ for (let i = 0; i < visible.length; i++) {
4130
+ const dim = i < visible.length - 4;
4131
+ if (dim) process.stdout.write(W_DIM);
4132
+ process.stdout.write(visible[i] + '\n');
4133
+ if (dim) process.stdout.write(W_RESET);
4134
+ }
4135
+ if (prompt) process.stdout.write('\n' + prompt);
4136
+ }
4137
+
4138
+ async function cmdWatch(rawArgs) {
4139
+ const cwd = process.cwd();
4140
+ const auto = rawArgs.includes('--auto');
4141
+ const dirArg = rawArgs.find(a => !a.startsWith('-')) ?? '.';
4142
+ const watchDir = join(cwd, dirArg);
4143
+
4144
+ if (!existsSync(watchDir)) {
4145
+ process.stderr.write(`Error: Directory not found: ${watchDir}\n`);
4146
+ process.exit(1);
4147
+ }
4148
+
4149
+ const relDir = watchDir === cwd ? '.' : watchDir.replace(cwd + '/', '');
4150
+ const modeStr = auto ? `${W_YELLOW}--auto${W_RESET}` : 'interactive';
4151
+ const header = `${W_BOLD}${W_CYAN}Watching${W_RESET} ${relDir} ${W_DIM}(${modeStr}${W_DIM}, q or Ctrl+C to exit)${W_RESET}`;
4152
+
4153
+ const logLines = [];
4154
+ function addLog(line) {
4155
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
4156
+ logLines.push(`${W_DIM}${ts}${W_RESET} ${line}`);
4157
+ }
4158
+
4159
+ addLog(`${W_DIM}Ready — waiting for file changes...${W_RESET}`);
4160
+ watchRedraw(header, logLines);
4161
+
4162
+ let resolvePending = null;
4163
+ let watcherRef = null;
4164
+
4165
+ function cleanup() {
4166
+ try { if (watcherRef) watcherRef.close(); } catch {}
4167
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
4168
+ try { watchRl.close(); } catch {}
4169
+ process.stdout.write('\n');
4170
+ process.exit(0);
4171
+ }
4172
+
4173
+ const watchRl = createInterface({ input: process.stdin, output: process.stdout });
4174
+ if (process.stdin.isTTY) {
4175
+ process.stdin.setRawMode(true);
4176
+ process.stdin.resume();
4177
+ process.stdin.setEncoding('utf8');
4178
+ }
4179
+
4180
+ process.stdin.on('data', (key) => {
4181
+ if (key === 'q' || key === '') { cleanup(); return; }
4182
+ if (resolvePending) { resolvePending(key); resolvePending = null; }
4183
+ });
4184
+
4185
+ process.on('SIGINT', cleanup);
4186
+ process.on('SIGTERM', cleanup);
4187
+
4188
+ function waitForKey() {
4189
+ return new Promise(resolve => { resolvePending = resolve; });
4190
+ }
4191
+
4192
+ let processing = false;
4193
+ async function processBatch(files) {
4194
+ if (processing) return;
4195
+ processing = true;
4196
+ try {
4197
+ const fileList = [...files];
4198
+ files.clear();
4199
+ const relFiles = fileList.map(f =>
4200
+ f.replace(cwd + '/', '').replace(cwd + '\\', '')
4201
+ );
4202
+
4203
+ for (const f of relFiles) addLog(` ${W_CYAN}${f}${W_RESET} saved`);
4204
+
4205
+ const suggestion = suggestAction(relFiles, cwd);
4206
+
4207
+ if (!suggestion) {
4208
+ addLog(` ${W_DIM}(no action suggested)${W_RESET}`);
4209
+ watchRedraw(header, logLines);
4210
+ return;
4211
+ }
4212
+
4213
+ addLog(` ${W_YELLOW}Suggestion:${W_RESET} ${suggestion.label}`);
4214
+
4215
+ if (auto) {
4216
+ if (!suggestion.safe || !suggestion.cmd) {
4217
+ addLog(` ${W_DIM}[auto] Skipping — not auto-safe${W_RESET}`);
4218
+ watchRedraw(header, logLines);
4219
+ return;
4220
+ }
4221
+ addLog(` ${W_GREEN}[auto] Running:${W_RESET} ${suggestion.cmd}`);
4222
+ watchRedraw(header, logLines);
4223
+ try {
4224
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4225
+ for (const l of out.trim().split('\n').slice(-5)) addLog(` ${W_DIM}${l}${W_RESET}`);
4226
+ addLog(` ${W_GREEN}done${W_RESET}`);
4227
+ } catch (e) {
4228
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4229
+ for (const l of msg.split('\n').slice(-3)) addLog(` ${W_RED}${l}${W_RESET}`);
4230
+ addLog(` ${W_RED}command failed${W_RESET}`);
4231
+ }
4232
+ watchRedraw(header, logLines);
4233
+ return;
4234
+ }
4235
+
4236
+ // Interactive prompt
4237
+ const promptLine = suggestion.cmd
4238
+ ? ` ${W_BOLD}[Enter]${W_RESET} Run ${W_BOLD}[s]${W_RESET} Skip ${W_BOLD}[q]${W_RESET} Quit\n > `
4239
+ : ` ${W_BOLD}[s]${W_RESET} Dismiss ${W_BOLD}[q]${W_RESET} Quit\n > `;
4240
+ watchRedraw(header, logLines, promptLine);
4241
+
4242
+ const key = await waitForKey();
4243
+
4244
+ if (key === 'q' || key === '') { cleanup(); return; }
4245
+
4246
+ if ((key === '\r' || key === '\n' || key === ' ') && suggestion.cmd) {
4247
+ addLog(` ${W_GREEN}Running:${W_RESET} ${suggestion.cmd}`);
4248
+ watchRedraw(header, logLines);
4249
+ try {
4250
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4251
+ for (const l of out.trim().split('\n').slice(-8)) addLog(` ${W_DIM}${l}${W_RESET}`);
4252
+ addLog(` ${W_GREEN}done${W_RESET}`);
4253
+ } catch (e) {
4254
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4255
+ for (const l of msg.split('\n').slice(-5)) addLog(` ${W_RED}${l}${W_RESET}`);
4256
+ addLog(` ${W_RED}command failed${W_RESET}`);
4257
+ }
4258
+ } else {
4259
+ addLog(` ${W_DIM}skipped${W_RESET}`);
4260
+ }
4261
+ watchRedraw(header, logLines);
4262
+ } finally {
4263
+ processing = false;
4264
+ }
4265
+ }
4266
+
4267
+ let debounceTimer = null;
4268
+ const pendingFiles = new Set();
4269
+
4270
+ try {
4271
+ watcherRef = fsWatch(watchDir, { recursive: true }, (_eventType, filename) => {
4272
+ if (!filename) return;
4273
+ if (
4274
+ filename.includes('node_modules') ||
4275
+ filename.includes('.git') ||
4276
+ filename.includes('.dualbrain') ||
4277
+ /package-lock\.json$/.test(filename) ||
4278
+ /yarn\.lock$/.test(filename) ||
4279
+ /pnpm-lock\.yaml$/.test(filename)
4280
+ ) return;
4281
+
4282
+ pendingFiles.add(join(watchDir, filename));
4283
+
4284
+ if (debounceTimer) clearTimeout(debounceTimer);
4285
+ debounceTimer = setTimeout(() => {
4286
+ debounceTimer = null;
4287
+ processBatch(pendingFiles).catch(e => {
4288
+ addLog(` ${W_RED}Watch error: ${e.message}${W_RESET}`);
4289
+ watchRedraw(header, logLines);
4290
+ });
4291
+ }, 2000);
4292
+ });
4293
+ } catch (e) {
4294
+ if (e.code === 'ENOSPC') {
4295
+ process.stderr.write(
4296
+ '\nError: Too many file watchers (ENOSPC).\n' +
4297
+ 'Increase the limit:\n' +
4298
+ ' echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n'
4299
+ );
4300
+ process.exit(1);
4301
+ }
4302
+ throw e;
4303
+ }
4304
+
4305
+ // Keep alive — stdin events drive everything, cleanup() calls process.exit
4306
+ await new Promise(() => {});
4307
+ }
4308
+
4309
+
3061
4310
  // ─── Specialist commands ──────────────────────────────────────────────────────
3062
4311
 
3063
4312
  const SPECIALIST_DEFAULTS = {
@@ -3153,22 +4402,25 @@ async function cmdSpecialistGo(specialist, args) {
3153
4402
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
3154
4403
  }
3155
4404
 
3156
- console.log(` specialist : ${specialist}`);
3157
- console.log(` provider : ${decision.provider}`);
3158
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
3159
- console.log(` tier : ${decision.tier}`);
3160
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
3161
- console.log(` reason : ${decision.explanation}`);
4405
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
4406
+ if (dryRun || verbose) {
4407
+ console.log(` specialist : ${specialist}`);
4408
+ console.log(` provider : ${decision.provider}`);
4409
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
4410
+ console.log(` tier : ${decision.tier}`);
4411
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
4412
+ console.log(` reason : ${decision.explanation}`);
4413
+ }
3162
4414
 
3163
4415
  if (dryRun) {
3164
4416
  console.log('\n(dry-run — not executing)');
3165
4417
  return;
3166
4418
  }
3167
4419
 
3168
- console.log('\nDispatching...');
4420
+ if (verbose) console.log('\nDispatching...');
3169
4421
  let result;
3170
4422
  if (decision.dualBrain) {
3171
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
4423
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
3172
4424
  console.log(`\nConsensus: ${result.consensus}`);
3173
4425
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
3174
4426
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -3182,7 +4434,7 @@ async function cmdSpecialistGo(specialist, args) {
3182
4434
  nextAction: null,
3183
4435
  }, cwd);
3184
4436
  } else {
3185
- result = await dispatch({ decision, prompt, files, cwd });
4437
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
3186
4438
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
3187
4439
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
3188
4440
  if (result.summary) console.log(result.summary);
@@ -3242,13 +4494,26 @@ async function main() {
3242
4494
  await runScreens('main');
3243
4495
  }
3244
4496
  } else {
3245
- // Non-TTY: print status card and exit
3246
- const cwd = process.cwd();
3247
- const repo = loadRepoCache(cwd);
3248
- const session = loadSession(cwd);
3249
- const health = getHealth(cwd);
3250
- const card = formatSessionCard(session, repo, health);
3251
- console.log(card);
4497
+ // Non-TTY with no args: read stdin as a task and run one-shot
4498
+ const stdinTask = await new Promise((resolve) => {
4499
+ let data = '';
4500
+ process.stdin.setEncoding('utf8');
4501
+ process.stdin.on('data', chunk => { data += chunk; });
4502
+ process.stdin.on('end', () => resolve(data.trim()));
4503
+ // If stdin has no data within 200ms (not truly piped), fall back to status card
4504
+ setTimeout(() => resolve(null), 200);
4505
+ });
4506
+ if (stdinTask) {
4507
+ process.stderr.write('🧠 routing...\n');
4508
+ await cmdGo([stdinTask]);
4509
+ } else {
4510
+ const cwd = process.cwd();
4511
+ const repo = loadRepoCache(cwd);
4512
+ const session = loadSession(cwd);
4513
+ const health = getHealth(cwd);
4514
+ const card = formatSessionCard(session, repo, health);
4515
+ console.log(card);
4516
+ }
3252
4517
  }
3253
4518
  return;
3254
4519
  }
@@ -3326,6 +4591,8 @@ async function main() {
3326
4591
  process.exit(0);
3327
4592
  }
3328
4593
 
4594
+ if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
4595
+
3329
4596
  if (cmd === 'shell-hook') {
3330
4597
  // Output a bash snippet users can add to their .bashrc or source directly.
3331
4598
  const hook = `
@@ -3341,6 +4608,43 @@ fi
3341
4608
  return;
3342
4609
  }
3343
4610
 
4611
+ // ─── One-shot mode ────────────────────────────────────────────────────────────
4612
+ // If cmd is not a recognized subcommand, treat the entire arg list as a task.
4613
+ // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
4614
+ const KNOWN_COMMANDS = new Set([
4615
+ 'init', 'install', 'auth', 'go', 'status', 'hot', 'cool',
4616
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
4617
+ '--help', '-h', '--version', '-v',
4618
+ ...Object.keys(loadSpecialistRegistry()),
4619
+ ]);
4620
+
4621
+ if (!KNOWN_COMMANDS.has(cmd)) {
4622
+ // All of args are part of the task description (plus any flags like --dry-run/--files).
4623
+ // Join non-flag words into a single prompt string so cmdGo's args.find() picks it up.
4624
+ // We strip out flag values (e.g. the value after --files) before collecting prompt words.
4625
+ process.stderr.write('🧠 routing...\n');
4626
+ const flagValuesToSkip = new Set();
4627
+ const pairedFlags = ['--files'];
4628
+ for (const f of pairedFlags) {
4629
+ const idx = args.indexOf(f);
4630
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
4631
+ flagValuesToSkip.add(args[idx + 1]);
4632
+ }
4633
+ }
4634
+ const passedFlags = [];
4635
+ for (let i = 0; i < args.length; i++) {
4636
+ if (args[i].startsWith('--') || args[i].startsWith('-')) {
4637
+ passedFlags.push(args[i]);
4638
+ if (pairedFlags.includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
4639
+ passedFlags.push(args[++i]);
4640
+ }
4641
+ }
4642
+ }
4643
+ const promptWords = args.filter(a => !a.startsWith('--') && !a.startsWith('-') && !flagValuesToSkip.has(a));
4644
+ await cmdGo([promptWords.join(' '), ...passedFlags]);
4645
+ return;
4646
+ }
4647
+
3344
4648
  process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
3345
4649
  process.exit(1);
3346
4650
  }