dual-brain 0.1.7 → 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
 
@@ -419,6 +421,7 @@ async function cmdGo(args) {
419
421
  nextAction: null,
420
422
  }, cwd);
421
423
  if (result.status !== 'completed') process.exit(1);
424
+ await offerAutoCommit(cwd);
422
425
  }
423
426
  }
424
427
 
@@ -1000,6 +1003,61 @@ function loadTerminalState(cwd, terminalId) {
1000
1003
  } catch { return null; }
1001
1004
  }
1002
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
+
1003
1061
  // ─── Dashboard box helpers ────────────────────────────────────────────────────
1004
1062
 
1005
1063
  /**
@@ -1048,13 +1106,15 @@ function detectRepoState(cwd) {
1048
1106
  /**
1049
1107
  * Build action card rows for the dashboard based on repo state.
1050
1108
  * Returns an array of box row strings (may be empty).
1109
+ * openPRs is optional — if provided, a PR card is included.
1051
1110
  */
1052
- function buildActionRows(repoState, rowFn) {
1111
+ function buildActionRows(repoState, rowFn, openPRs = []) {
1053
1112
  if (!repoState.isGitRepo) return [];
1054
1113
 
1055
1114
  const YELLOW = '\x1b[33m';
1056
1115
  const RED = '\x1b[31m';
1057
1116
  const GREEN = '\x1b[32m';
1117
+ const CYAN = '\x1b[36m';
1058
1118
  const DIM = '\x1b[2m';
1059
1119
  const RESET = '\x1b[0m';
1060
1120
 
@@ -1072,6 +1132,15 @@ function buildActionRows(repoState, rowFn) {
1072
1132
  cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
1073
1133
  }
1074
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
+
1075
1144
  if (cards.length === 0) {
1076
1145
  return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
1077
1146
  }
@@ -1172,15 +1241,107 @@ function detectInterruptedWork(sessions, cwd) {
1172
1241
  };
1173
1242
  }
1174
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
+
1175
1332
  /**
1176
1333
  * Build a provider status string for the dashboard status line.
1177
- * 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"
1178
1335
  * Uses ANSI color codes for the dots (no emoji width issues).
1179
1336
  */
1180
1337
  function buildProviderStatusLine(profile, auth) {
1181
- const GREEN = '\x1b[32m●\x1b[0m';
1182
- const RED = '\x1b[31m●\x1b[0m';
1338
+ const GREEN = '\x1b[32m\u25cf\x1b[0m';
1339
+ const RED = '\x1b[31m\u25cf\x1b[0m';
1183
1340
  const now = Date.now();
1341
+ const cwd = process.cwd();
1342
+
1343
+ let fiveHrEntries = [];
1344
+ try { fiveHrEntries = _readFiveHrUsage(cwd); } catch {}
1184
1345
 
1185
1346
  function providerSegment(provKey, displayName) {
1186
1347
  const sub = profile?.providers?.[provKey];
@@ -1190,16 +1351,11 @@ function buildProviderStatusLine(profile, auth) {
1190
1351
  const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
1191
1352
  if (expired) return `${RED} ${displayName}: expired`;
1192
1353
 
1193
- const dot = GREEN;
1194
- // Multi-sub: show aggregated plan amounts
1195
- const subs = sub?.subs;
1196
- if (subs && subs.length > 0) {
1197
- const agg = aggregatePlans(subs);
1198
- return `${dot} ${displayName} ${agg}`;
1199
- }
1200
- // Single plan
1201
- const planPrice = PLAN_PRICES[sub?.plan] || sub?.plan || 'connected';
1202
- 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`;
1203
1359
  }
1204
1360
 
1205
1361
  const parts = [];
@@ -1396,9 +1552,46 @@ async function mainScreen(rl, ask) {
1396
1552
  statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1397
1553
  }
1398
1554
 
1399
- // ── Action cards (git state) ──────────────────────────────────────────────
1555
+ // ── Action cards (git state + open PRs) ──────────────────────────────────
1400
1556
  const repoState = detectRepoState(cwd);
1401
- const actionRows = buildActionRows(repoState, row);
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 ─────────────────────────────────────────────
1402
1595
 
1403
1596
  // ── Sessions section ──────────────────────────────────────────────────────
1404
1597
  const sessionRows = [];
@@ -1452,7 +1645,8 @@ async function mainScreen(rl, ask) {
1452
1645
  }
1453
1646
 
1454
1647
  // ── Actions bar ───────────────────────────────────────────────────────────
1455
- 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;
1456
1650
  const actionsRow = row(actionsContent);
1457
1651
 
1458
1652
  // ── Print the full box ────────────────────────────────────────────────────
@@ -1560,7 +1754,9 @@ async function mainScreen(rl, ask) {
1560
1754
  // Single-key commands only fire when buffer is empty
1561
1755
  if (taskBuffer.length === 0) {
1562
1756
  const lower = str.toLowerCase();
1563
- 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)) {
1564
1760
  cleanup();
1565
1761
  process.stdout.write('\n');
1566
1762
  resolve(lower);
@@ -1666,6 +1862,7 @@ async function mainScreen(rl, ask) {
1666
1862
 
1667
1863
  if (choice === 's') { return { next: 'settings' }; }
1668
1864
  if (choice === 'i') { return { next: 'import-picker' }; }
1865
+ if (choice === 'p' && openPRs.length > 0) { return { next: 'pr-triage', openPRs }; }
1669
1866
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1670
1867
 
1671
1868
  return { next: 'main' };
@@ -1932,6 +2129,234 @@ async function importPickerScreen() {
1932
2129
  return { next: 'main' };
1933
2130
  }
1934
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
+
1935
2360
  // ─── Screen: settingsScreen ───────────────────────────────────────────────────
1936
2361
 
1937
2362
  async function settingsScreen(rl, ask) {
@@ -1947,6 +2372,9 @@ async function settingsScreen(rl, ask) {
1947
2372
  const bot = `└${'─'.repeat(boxW - 2)}┘`;
1948
2373
  const row = (content) => makeBoxRow(content, W);
1949
2374
 
2375
+ // Detect if gh is available + has PRs for the PR triage option
2376
+ const settingsPRs = await detectOpenPRs(cwd);
2377
+
1950
2378
  const lines = [
1951
2379
  top,
1952
2380
  row('Settings'),
@@ -1957,6 +2385,7 @@ async function settingsScreen(rl, ask) {
1957
2385
  row('[d] Switch to data-tools'),
1958
2386
  row('[?] Help & shortcuts'),
1959
2387
  row('[x] Diagnostics'),
2388
+ ...(settingsPRs.length > 0 ? [row(`[p] PR triage (${settingsPRs.length} open)`)] : []),
1960
2389
  row(''),
1961
2390
  row('[Esc/b] Back to dashboard'),
1962
2391
  bot,
@@ -1974,6 +2403,10 @@ async function settingsScreen(rl, ask) {
1974
2403
  return { next: 'import-picker' };
1975
2404
  }
1976
2405
 
2406
+ if (choice === 'p' && settingsPRs.length > 0) {
2407
+ return { next: 'pr-triage', openPRs: settingsPRs };
2408
+ }
2409
+
1977
2410
  if (choice === 'd') {
1978
2411
  const { spawnSync } = await import('node:child_process');
1979
2412
  const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
@@ -3248,6 +3681,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
3248
3681
  return { next: 'session-manage', session: sess };
3249
3682
  }
3250
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
+
3251
3935
  // ─── Screen state machine ─────────────────────────────────────────────────────
3252
3936
 
3253
3937
  const SCREENS = {
@@ -3256,6 +3940,7 @@ const SCREENS = {
3256
3940
  'new-session': newSessionScreen,
3257
3941
  settings: settingsScreen,
3258
3942
  'import-picker': importPickerScreen,
3943
+ 'pr-triage': prTriageScreen,
3259
3944
  subscriptions: subscriptionsScreen,
3260
3945
  dashboard: dashboardScreen,
3261
3946
  auth: authScreen,
@@ -3294,6 +3979,7 @@ async function runScreens(startScreen = 'dashboard') {
3294
3979
  if (freshSessions.length > 0) {
3295
3980
  saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
3296
3981
  }
3982
+ await offerAutoCommit(cwd);
3297
3983
  current = 'main';
3298
3984
  ctx = {};
3299
3985
  continue;
@@ -3304,9 +3990,10 @@ async function runScreens(startScreen = 'dashboard') {
3304
3990
  try {
3305
3991
  const result = await screen(rl, ask, ctx);
3306
3992
  current = result?.next || 'exit';
3307
- // Pass through context (e.g. selected session, typed prompt) to next screen
3308
- ctx = result?.session ? { session: result.session }
3309
- : 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 }
3310
3997
  : {};
3311
3998
  } catch (e) {
3312
3999
  console.error(`Error: ${e.message}`);
@@ -3317,6 +4004,309 @@ async function runScreens(startScreen = 'dashboard') {
3317
4004
  rl.close();
3318
4005
  }
3319
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
+
3320
4310
  // ─── Specialist commands ──────────────────────────────────────────────────────
3321
4311
 
3322
4312
  const SPECIALIST_DEFAULTS = {
@@ -3601,6 +4591,8 @@ async function main() {
3601
4591
  process.exit(0);
3602
4592
  }
3603
4593
 
4594
+ if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
4595
+
3604
4596
  if (cmd === 'shell-hook') {
3605
4597
  // Output a bash snippet users can add to their .bashrc or source directly.
3606
4598
  const hook = `
@@ -3621,7 +4613,7 @@ fi
3621
4613
  // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
3622
4614
  const KNOWN_COMMANDS = new Set([
3623
4615
  'init', 'install', 'auth', 'go', 'status', 'hot', 'cool',
3624
- 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook',
4616
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
3625
4617
  '--help', '-h', '--version', '-v',
3626
4618
  ...Object.keys(loadSpecialistRegistry()),
3627
4619
  ]);