dual-brain 0.1.7 → 0.1.9

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,14 +1241,21 @@ function detectInterruptedWork(sessions, cwd) {
1172
1241
  };
1173
1242
  }
1174
1243
 
1244
+ // ─── Provider status helpers ───────────────────────────────────────────────────
1245
+
1246
+ const _PLAN_PRICE_MAP = {
1247
+ pro: '$20', max5: '$100', max20: '$200',
1248
+ plus: '$20', pro100: '$100', pro200: '$200',
1249
+ };
1250
+
1175
1251
  /**
1176
1252
  * Build a provider status string for the dashboard status line.
1177
- * Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
1253
+ * Shows: " Claude $100 OpenAI $100"
1178
1254
  * Uses ANSI color codes for the dots (no emoji width issues).
1179
1255
  */
1180
1256
  function buildProviderStatusLine(profile, auth) {
1181
- const GREEN = '\x1b[32m●\x1b[0m';
1182
- const RED = '\x1b[31m●\x1b[0m';
1257
+ const GREEN = '●';
1258
+ const RED = '●';
1183
1259
  const now = Date.now();
1184
1260
 
1185
1261
  function providerSegment(provKey, displayName) {
@@ -1190,16 +1266,18 @@ function buildProviderStatusLine(profile, auth) {
1190
1266
  const expired = sub?.expiresAt && Date.parse(sub.expiresAt) < now;
1191
1267
  if (expired) return `${RED} ${displayName}: expired`;
1192
1268
 
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}`;
1269
+ const rawSubs = sub?.subs?.length
1270
+ ? sub.subs
1271
+ : sub?.plan
1272
+ ? [{ plan: sub.plan }]
1273
+ : [];
1274
+ const planLabel = rawSubs.length > 0
1275
+ ? rawSubs.map(s => _PLAN_PRICE_MAP[s.plan] || s.plan || '$100').join(' + ')
1276
+ : null;
1277
+
1278
+ return planLabel
1279
+ ? `${GREEN} ${displayName} ${planLabel}`
1280
+ : `${GREEN} ${displayName}: connected`;
1203
1281
  }
1204
1282
 
1205
1283
  const parts = [];
@@ -1396,9 +1474,46 @@ async function mainScreen(rl, ask) {
1396
1474
  statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1397
1475
  }
1398
1476
 
1399
- // ── Action cards (git state) ──────────────────────────────────────────────
1477
+ // ── Action cards (git state + open PRs) ──────────────────────────────────
1400
1478
  const repoState = detectRepoState(cwd);
1401
- const actionRows = buildActionRows(repoState, row);
1479
+ const openPRs = await detectOpenPRs(cwd);
1480
+ const actionRows = buildActionRows(repoState, row, openPRs);
1481
+
1482
+ // ── Related sessions hint (only when no continuation card is showing) ─────
1483
+ if (!interrupted && recentSessions.length > 0) {
1484
+ try {
1485
+ const { findRelatedSessions } = await import('../src/session.mjs');
1486
+ const mostRecent = recentSessions[0];
1487
+ // Build a pseudo-prompt from the most recent session's name/objective
1488
+ const recentPrompt = mostRecent.name || '';
1489
+ // Load session index to get files for the most recent session
1490
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1491
+ let recentFiles = [];
1492
+ try {
1493
+ const idx = JSON.parse(readFileSync(indexPath, 'utf8'));
1494
+ recentFiles = idx[mostRecent.id]?.files || [];
1495
+ } catch {}
1496
+ const related = findRelatedSessions(recentPrompt, recentFiles, cwd);
1497
+ if (related.length > 0) {
1498
+ const relAgeLabel = (isoDate) => {
1499
+ if (!isoDate) return '';
1500
+ const diff = Date.now() - Date.parse(isoDate);
1501
+ const days = Math.floor(diff / 86400000);
1502
+ const hours = Math.floor(diff / 3600000);
1503
+ if (days >= 1) return `${days}d`;
1504
+ return `${hours}h ago`;
1505
+ };
1506
+ const relatedParts = related.slice(0, 2).map(r => {
1507
+ const age = relAgeLabel(r.date);
1508
+ return age ? `${r.smartName} (${age})` : r.smartName;
1509
+ });
1510
+ const DIM = '\x1b[2m';
1511
+ const RESET = '\x1b[0m';
1512
+ actionRows.push(row(`${DIM}📎 Related: ${relatedParts.join(', ')}${RESET}`));
1513
+ }
1514
+ } catch { /* non-fatal */ }
1515
+ }
1516
+ // ── End related sessions hint ─────────────────────────────────────────────
1402
1517
 
1403
1518
  // ── Sessions section ──────────────────────────────────────────────────────
1404
1519
  const sessionRows = [];
@@ -1452,7 +1567,8 @@ async function mainScreen(rl, ask) {
1452
1567
  }
1453
1568
 
1454
1569
  // ── Actions bar ───────────────────────────────────────────────────────────
1455
- const actionsContent = '↵ Resume n New / Search i Import s Settings q Quit';
1570
+ const actionsBase = '↵ Resume n New / Search i Import s Settings q Quit';
1571
+ const actionsContent = openPRs.length > 0 ? `${actionsBase} p PRs` : actionsBase;
1456
1572
  const actionsRow = row(actionsContent);
1457
1573
 
1458
1574
  // ── Print the full box ────────────────────────────────────────────────────
@@ -1560,7 +1676,9 @@ async function mainScreen(rl, ask) {
1560
1676
  // Single-key commands only fire when buffer is empty
1561
1677
  if (taskBuffer.length === 0) {
1562
1678
  const lower = str.toLowerCase();
1563
- if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/' || lower === 'i') {
1679
+ const singleKeySet = new Set(['n', 's', 'q', '/', 'i']);
1680
+ if (lower === 'p' && openPRs.length > 0) singleKeySet.add('p');
1681
+ if (singleKeySet.has(lower)) {
1564
1682
  cleanup();
1565
1683
  process.stdout.write('\n');
1566
1684
  resolve(lower);
@@ -1666,6 +1784,7 @@ async function mainScreen(rl, ask) {
1666
1784
 
1667
1785
  if (choice === 's') { return { next: 'settings' }; }
1668
1786
  if (choice === 'i') { return { next: 'import-picker' }; }
1787
+ if (choice === 'p' && openPRs.length > 0) { return { next: 'pr-triage', openPRs }; }
1669
1788
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1670
1789
 
1671
1790
  return { next: 'main' };
@@ -1932,6 +2051,234 @@ async function importPickerScreen() {
1932
2051
  return { next: 'main' };
1933
2052
  }
1934
2053
 
2054
+ // ─── Screen: prTriageScreen ───────────────────────────────────────────────────
2055
+
2056
+ /**
2057
+ * PR Triage screen. Lists open PRs, lets the user select one, checkout + fetch
2058
+ * comments, then dispatch fixes through the dual-brain pipeline.
2059
+ *
2060
+ * ctx.openPRs is the pre-fetched array from detectOpenPRs().
2061
+ */
2062
+ async function prTriageScreen(rl, ask, ctx = {}) {
2063
+ const cwd = process.cwd();
2064
+ const prs = ctx.openPRs || [];
2065
+
2066
+ const termW = process.stdout.columns || 60;
2067
+ const boxW = Math.min(termW - 2, 60);
2068
+ const W = boxW - 4;
2069
+
2070
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2071
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2072
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2073
+ const row = (content) => makeBoxRow(content, W);
2074
+
2075
+ if (prs.length === 0) {
2076
+ process.stdout.write('\n');
2077
+ process.stdout.write(top + '\n');
2078
+ process.stdout.write(row('PR Triage') + '\n');
2079
+ process.stdout.write(sep + '\n');
2080
+ process.stdout.write(row('No open PRs found.') + '\n');
2081
+ process.stdout.write(sep + '\n');
2082
+ process.stdout.write(row('[q] Back') + '\n');
2083
+ process.stdout.write(bot + '\n\n');
2084
+ await ask(' Press Enter to go back...');
2085
+ return { next: 'main' };
2086
+ }
2087
+
2088
+ // Helper: get review decision label
2089
+ function reviewLabel(rd) {
2090
+ if (!rd) return 'pending review';
2091
+ const map = {
2092
+ APPROVED: 'approved',
2093
+ CHANGES_REQUESTED: 'changes_requested',
2094
+ REVIEW_REQUIRED: 'review_required',
2095
+ };
2096
+ return map[rd] || rd.toLowerCase();
2097
+ }
2098
+
2099
+ // ── Render PR list ─────────────────────────────────────────────────────────
2100
+ process.stdout.write('\n');
2101
+ process.stdout.write(top + '\n');
2102
+ process.stdout.write(row('PR Triage') + '\n');
2103
+ process.stdout.write(sep + '\n');
2104
+
2105
+ prs.forEach((pr, i) => {
2106
+ const title = String(pr.title || '').slice(0, W - 6);
2107
+ const decision = reviewLabel(pr.reviewDecision);
2108
+ const diff = `+${pr.additions || 0} -${pr.deletions || 0}`;
2109
+ const files = pr.changedFiles ? `${pr.changedFiles} file${pr.changedFiles === 1 ? '' : 's'}` : '';
2110
+ const numStr = `#${pr.number}`;
2111
+
2112
+ process.stdout.write(row(`[${i + 1}] ${numStr} ${title}`) + '\n');
2113
+ process.stdout.write(row(` ${decision} · ${diff}${files ? ' · ' + files : ''}`) + '\n');
2114
+ if (pr.headRefName) {
2115
+ process.stdout.write(row(` Branch: ${pr.headRefName}`) + '\n');
2116
+ }
2117
+ if (i < prs.length - 1) {
2118
+ process.stdout.write(row('') + '\n');
2119
+ }
2120
+ });
2121
+
2122
+ process.stdout.write(sep + '\n');
2123
+ process.stdout.write(row('[1-9] Select PR [q] Back') + '\n');
2124
+ process.stdout.write(bot + '\n\n');
2125
+
2126
+ const pick = (await ask(' Choice: ')).trim().toLowerCase();
2127
+
2128
+ if (pick === 'q' || pick === 'b' || pick === '') return { next: 'main' };
2129
+
2130
+ const idx = parseInt(pick, 10) - 1;
2131
+ if (isNaN(idx) || idx < 0 || idx >= prs.length) return { next: 'pr-triage', openPRs: prs };
2132
+
2133
+ const selectedPR = prs[idx];
2134
+
2135
+ // ── PR detail: checkout + fetch comments ──────────────────────────────────
2136
+ process.stdout.write(`\n Checking out PR #${selectedPR.number}...\n`);
2137
+
2138
+ const checkoutResult = _spawnSyncTop('gh', ['pr', 'checkout', String(selectedPR.number)], {
2139
+ cwd,
2140
+ encoding: 'utf8',
2141
+ stdio: ['pipe', 'pipe', 'pipe'],
2142
+ timeout: 15000,
2143
+ });
2144
+
2145
+ if (checkoutResult.status !== 0) {
2146
+ process.stdout.write(` Could not checkout PR: ${(checkoutResult.stderr || '').slice(0, 100)}\n`);
2147
+ await ask(' Press Enter to continue...');
2148
+ return { next: 'pr-triage', openPRs: prs };
2149
+ }
2150
+
2151
+ process.stdout.write(` Fetching comments...\n`);
2152
+
2153
+ let comments = [];
2154
+ try {
2155
+ const commentsResult = _spawnSyncTop('gh', [
2156
+ 'pr', 'view', String(selectedPR.number),
2157
+ '--comments',
2158
+ '--json', 'comments',
2159
+ ], {
2160
+ cwd,
2161
+ encoding: 'utf8',
2162
+ stdio: ['pipe', 'pipe', 'pipe'],
2163
+ timeout: 5000,
2164
+ });
2165
+
2166
+ if (commentsResult.status === 0 && commentsResult.stdout) {
2167
+ const parsed = JSON.parse(commentsResult.stdout.trim());
2168
+ comments = parsed?.comments || [];
2169
+ }
2170
+ } catch {}
2171
+
2172
+ // ── Show PR detail: comments grouped by file ──────────────────────────────
2173
+ process.stdout.write('\n');
2174
+ process.stdout.write(top + '\n');
2175
+ process.stdout.write(row(`#${selectedPR.number} ${String(selectedPR.title).slice(0, W - 6)}`) + '\n');
2176
+ process.stdout.write(sep + '\n');
2177
+
2178
+ if (comments.length === 0) {
2179
+ process.stdout.write(row('No review comments.') + '\n');
2180
+ } else {
2181
+ // Group comments by their file path (body comments have no path)
2182
+ const grouped = {};
2183
+ for (const c of comments) {
2184
+ const file = c.path || '(general)';
2185
+ if (!grouped[file]) grouped[file] = [];
2186
+ grouped[file].push(c);
2187
+ }
2188
+ for (const [file, fileCmts] of Object.entries(grouped)) {
2189
+ const fileLabel = file.length > W - 4 ? '...' + file.slice(-(W - 7)) : file;
2190
+ process.stdout.write(row(` ${fileLabel}`) + '\n');
2191
+ for (const c of fileCmts.slice(0, 3)) {
2192
+ const body = String(c.body || '').replace(/\s+/g, ' ').slice(0, W - 6);
2193
+ process.stdout.write(row(` → ${body}`) + '\n');
2194
+ }
2195
+ if (fileCmts.length > 3) {
2196
+ process.stdout.write(row(` ... +${fileCmts.length - 3} more`) + '\n');
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ process.stdout.write(sep + '\n');
2202
+ process.stdout.write(row('[f] Dispatch fixes [v] View full diff [b] Back') + '\n');
2203
+ process.stdout.write(bot + '\n\n');
2204
+
2205
+ const action = (await ask(' Action: ')).trim().toLowerCase();
2206
+
2207
+ if (action === 'v') {
2208
+ // Show full diff via gh pr diff
2209
+ process.stdout.write('\n');
2210
+ const diffResult = _spawnSyncTop('gh', ['pr', 'diff', String(selectedPR.number)], {
2211
+ cwd,
2212
+ encoding: 'utf8',
2213
+ stdio: ['ignore', 'pipe', 'pipe'],
2214
+ timeout: 10000,
2215
+ });
2216
+ const diffOut = (diffResult.stdout || '').slice(0, 3000);
2217
+ process.stdout.write(diffOut || ' (no diff output)\n');
2218
+ process.stdout.write('\n');
2219
+ await ask(' Press Enter to continue...');
2220
+ return { next: 'pr-triage', openPRs: prs };
2221
+ }
2222
+
2223
+ if (action === 'f') {
2224
+ // Dispatch each comment as a fix task through detect→decide→dispatch
2225
+ if (comments.length === 0) {
2226
+ process.stdout.write(' No comments to fix.\n\n');
2227
+ await ask(' Press Enter to continue...');
2228
+ return { next: 'pr-triage', openPRs: prs };
2229
+ }
2230
+
2231
+ process.stdout.write(`\n Dispatching ${comments.length} comment fix${comments.length === 1 ? '' : 's'} through dual-brain...\n\n`);
2232
+
2233
+ // Collect the PR files for context
2234
+ const prFiles = [];
2235
+ try {
2236
+ const filesResult = _spawnSyncTop('gh', [
2237
+ 'pr', 'view', String(selectedPR.number),
2238
+ '--json', 'files',
2239
+ ], {
2240
+ cwd,
2241
+ encoding: 'utf8',
2242
+ stdio: ['pipe', 'pipe', 'pipe'],
2243
+ timeout: 5000,
2244
+ });
2245
+ if (filesResult.status === 0) {
2246
+ const pf = JSON.parse(filesResult.stdout || '{}');
2247
+ (pf.files || []).forEach(f => prFiles.push(f.path));
2248
+ }
2249
+ } catch {}
2250
+
2251
+ const profile = loadProfile(cwd);
2252
+
2253
+ for (let ci = 0; ci < comments.length; ci++) {
2254
+ const c = comments[ci];
2255
+ const taskPrompt = c.path
2256
+ ? `Fix review comment in ${c.path}: ${c.body}`
2257
+ : `Fix PR review comment: ${c.body}`;
2258
+
2259
+ process.stdout.write(` [${ci + 1}/${comments.length}] ${taskPrompt.slice(0, 60)}...\n`);
2260
+
2261
+ try {
2262
+ const detection = detectTask({ prompt: taskPrompt, files: prFiles });
2263
+ const decision = decideRoute({ profile, detection, cwd });
2264
+ const result = await dispatch({ decision, prompt: taskPrompt, files: prFiles, cwd });
2265
+ const status = result.status === 'completed' ? '✓' : '✗';
2266
+ process.stdout.write(` ${status} ${result.status} (${(result.durationMs / 1000).toFixed(1)}s)\n`);
2267
+ if (result.summary) process.stdout.write(` ${result.summary.slice(0, 80)}\n`);
2268
+ } catch (e) {
2269
+ process.stdout.write(` ✗ Error: ${e.message.slice(0, 80)}\n`);
2270
+ }
2271
+ }
2272
+
2273
+ process.stdout.write('\n All fixes dispatched.\n\n');
2274
+ await ask(' Press Enter to continue...');
2275
+ return { next: 'pr-triage', openPRs: prs };
2276
+ }
2277
+
2278
+ // 'b' or anything else → back to PR list
2279
+ return { next: 'pr-triage', openPRs: prs };
2280
+ }
2281
+
1935
2282
  // ─── Screen: settingsScreen ───────────────────────────────────────────────────
1936
2283
 
1937
2284
  async function settingsScreen(rl, ask) {
@@ -1947,6 +2294,9 @@ async function settingsScreen(rl, ask) {
1947
2294
  const bot = `└${'─'.repeat(boxW - 2)}┘`;
1948
2295
  const row = (content) => makeBoxRow(content, W);
1949
2296
 
2297
+ // Detect if gh is available + has PRs for the PR triage option
2298
+ const settingsPRs = await detectOpenPRs(cwd);
2299
+
1950
2300
  const lines = [
1951
2301
  top,
1952
2302
  row('Settings'),
@@ -1957,6 +2307,7 @@ async function settingsScreen(rl, ask) {
1957
2307
  row('[d] Switch to data-tools'),
1958
2308
  row('[?] Help & shortcuts'),
1959
2309
  row('[x] Diagnostics'),
2310
+ ...(settingsPRs.length > 0 ? [row(`[p] PR triage (${settingsPRs.length} open)`)] : []),
1960
2311
  row(''),
1961
2312
  row('[Esc/b] Back to dashboard'),
1962
2313
  bot,
@@ -1974,6 +2325,10 @@ async function settingsScreen(rl, ask) {
1974
2325
  return { next: 'import-picker' };
1975
2326
  }
1976
2327
 
2328
+ if (choice === 'p' && settingsPRs.length > 0) {
2329
+ return { next: 'pr-triage', openPRs: settingsPRs };
2330
+ }
2331
+
1977
2332
  if (choice === 'd') {
1978
2333
  const { spawnSync } = await import('node:child_process');
1979
2334
  const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
@@ -3248,6 +3603,257 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
3248
3603
  return { next: 'session-manage', session: sess };
3249
3604
  }
3250
3605
 
3606
+
3607
+ // ─── Auto-commit drafting ─────────────────────────────────────────────────────
3608
+
3609
+ /**
3610
+ * Detect uncommitted changes in cwd.
3611
+ * Returns { hasChanges, files, statOutput, diffSnippet } or null.
3612
+ */
3613
+ function detectUncommittedChanges(cwd) {
3614
+ try {
3615
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
3616
+ } catch { return null; }
3617
+
3618
+ let statOutput = '';
3619
+ try {
3620
+ statOutput = execSync('git diff --stat HEAD', { cwd, encoding: 'utf8', timeout: 3000, stdio: 'pipe' }).trim();
3621
+ } catch { return null; }
3622
+
3623
+ let statusOutput = '';
3624
+ try {
3625
+ statusOutput = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
3626
+ } catch {}
3627
+
3628
+ if (!statOutput && !statusOutput) return null;
3629
+
3630
+ const statFiles = statOutput
3631
+ .split('\n')
3632
+ .filter(l => l.includes('|'))
3633
+ .map(l => l.split('|')[0].trim())
3634
+ .filter(Boolean);
3635
+
3636
+ const statusFiles = statusOutput
3637
+ .split('\n')
3638
+ .filter(Boolean)
3639
+ .map(l => l.slice(3).trim())
3640
+ .filter(f => f && !statFiles.includes(f));
3641
+
3642
+ const files = [...new Set([...statFiles, ...statusFiles])];
3643
+
3644
+ let diffSnippet = '';
3645
+ try {
3646
+ const full = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
3647
+ diffSnippet = full.slice(0, 2000);
3648
+ } catch {}
3649
+
3650
+ return { hasChanges: true, files, statOutput, diffSnippet };
3651
+ }
3652
+
3653
+ /**
3654
+ * Build a conventional commit message from file list + diff snippet.
3655
+ * Deterministic — no AI calls.
3656
+ */
3657
+ function generateCommitMessage(files, diffSnippet) {
3658
+ if (!files || files.length === 0) return 'chore: update files';
3659
+
3660
+ const testFiles = files.filter(f =>
3661
+ /\.(test|spec)\.[jt]sx?$/.test(f) || /\/(test|tests|__tests__)\//i.test(f)
3662
+ );
3663
+ const docFiles = files.filter(f => /\.(md|txt|rst|adoc)$/i.test(f) || /docs?\//i.test(f));
3664
+ const configFiles = files.filter(f =>
3665
+ /\.(json|yaml|yml|toml|ini)$/i.test(f) ||
3666
+ /^\.?(eslint|prettier|babel|jest|tsconfig|package)/i.test(f.replace(/.*\//, ''))
3667
+ );
3668
+ const srcFiles = files.filter(f =>
3669
+ !testFiles.includes(f) && !docFiles.includes(f) && !configFiles.includes(f)
3670
+ );
3671
+
3672
+ let type = 'feat';
3673
+ if (diffSnippet) {
3674
+ const lower = diffSnippet.toLowerCase();
3675
+ if (['fix', 'bug', 'error', 'issue', 'resolve', 'patch', 'correct', 'repair'].some(w => lower.includes(w))) {
3676
+ type = 'fix';
3677
+ } else if (['refactor', 'cleanup', 'simplify', 'reorganize'].some(w => lower.includes(w))) {
3678
+ type = 'refactor';
3679
+ }
3680
+ }
3681
+
3682
+ const dominantFile = files[0].replace(/.*\//, '');
3683
+
3684
+ if (testFiles.length === files.length) {
3685
+ const mod = testFiles[0].replace(/\.(test|spec)\.[jt]sx?$/, '').replace(/.*\//, '');
3686
+ return `test: add/fix tests for ${mod}`;
3687
+ }
3688
+ if (docFiles.length === files.length) {
3689
+ return `docs: update ${docFiles[0].replace(/.*\//, '')}`;
3690
+ }
3691
+ if (configFiles.length === files.length) {
3692
+ return `chore: update ${configFiles[0].replace(/.*\//, '')}`;
3693
+ }
3694
+ if (srcFiles.length > 0 && testFiles.length > 0) {
3695
+ const dom = srcFiles[0].replace(/.*\//, '').replace(/\.[jt]sx?$/, '');
3696
+ return `${type}: ${dom} with tests`;
3697
+ }
3698
+ if (files.length === 1) {
3699
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3700
+ }
3701
+
3702
+ const dirs = files.map(f => (f.includes('/') ? f.split('/').slice(-2, -1)[0] : ''));
3703
+ const commonDir = dirs[0] && dirs.every(d => d === dirs[0]) ? dirs[0] : null;
3704
+ if (commonDir) return `${type}: update ${commonDir}`;
3705
+
3706
+ return `${type}: update ${dominantFile.replace(/\.[jt]sx?$/, '')}`;
3707
+ }
3708
+
3709
+ /**
3710
+ * Show a commit card after task completion and handle user action.
3711
+ * Enter -> git add -A && git commit -m "message"
3712
+ * e -> prompt for custom message, then commit
3713
+ * d -> show full diff, then return to card
3714
+ * s -> skip
3715
+ *
3716
+ * Only shown on TTY. Never auto-commits — the card is the offer.
3717
+ * Returns true if a commit was made.
3718
+ */
3719
+ async function offerAutoCommit(cwd) {
3720
+ if (!process.stdout.isTTY) return false;
3721
+
3722
+ try {
3723
+ const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3724
+ const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
3725
+ if (claude > 0 || codex > 0) return false;
3726
+ } catch {}
3727
+
3728
+ try {
3729
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
3730
+ if (existsSync(sessionPath)) {
3731
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
3732
+ if (sess?.lastResult?.status === 'failure') return false;
3733
+ }
3734
+ } catch {}
3735
+
3736
+ const changes = detectUncommittedChanges(cwd);
3737
+ if (!changes) return false;
3738
+
3739
+ let finalMsg = generateCommitMessage(changes.files, changes.diffSnippet);
3740
+
3741
+ const termW = process.stdout.columns || 60;
3742
+ const boxW = Math.min(termW - 2, 54);
3743
+ const W = boxW - 4;
3744
+
3745
+ const top = `\u250c${'\u2500'.repeat(boxW - 2)}\u2510`;
3746
+ const sep = `\u251c${'\u2500'.repeat(boxW - 2)}\u2524`;
3747
+ const bot = `\u2514${'\u2500'.repeat(boxW - 2)}\u2518`;
3748
+
3749
+ const padLine = (s) => {
3750
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
3751
+ return `\u2502 ${s}${ ' '.repeat(Math.max(0, W - plain.length))} \u2502`;
3752
+ };
3753
+
3754
+ const filesLabel = changes.files.length <= 3
3755
+ ? changes.files.join(', ')
3756
+ : `${changes.files.slice(0, 3).join(', ')} +${changes.files.length - 3} more`;
3757
+ const fileCountLabel = `${changes.files.length} file${changes.files.length === 1 ? '' : 's'} changed: ${filesLabel}`;
3758
+ const fileLineTrunc = fileCountLabel.length > W ? fileCountLabel.slice(0, W - 3) + '...' : fileCountLabel;
3759
+
3760
+ const actLine1 = '[Enter] Commit [e] Edit message [d] Full diff';
3761
+ const actLine2 = '[s] Skip';
3762
+
3763
+ const printCard = (msg) => {
3764
+ const msgLine = msg.length > W ? msg.slice(0, W - 3) + '...' : msg;
3765
+ process.stdout.write(top + '\n');
3766
+ process.stdout.write(padLine('\x1b[33m\u{1F4DD} Ready to commit?\x1b[0m') + '\n');
3767
+ process.stdout.write(sep + '\n');
3768
+ process.stdout.write(padLine(msgLine) + '\n');
3769
+ process.stdout.write(padLine('') + '\n');
3770
+ process.stdout.write(padLine(fileLineTrunc) + '\n');
3771
+ process.stdout.write(padLine('') + '\n');
3772
+ process.stdout.write(padLine(actLine1) + '\n');
3773
+ process.stdout.write(padLine(actLine2) + '\n');
3774
+ process.stdout.write(bot + '\n');
3775
+ };
3776
+
3777
+ const readlinemod = await import('node:readline');
3778
+ readlinemod.emitKeypressEvents(process.stdin);
3779
+
3780
+ const waitKey = () => new Promise((resolve) => {
3781
+ const wasRaw = process.stdin.isRaw;
3782
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
3783
+ if (canRaw) process.stdin.setRawMode(true);
3784
+
3785
+ const cleanup = () => {
3786
+ process.stdin.removeListener('keypress', onKey);
3787
+ if (canRaw) { try { process.stdin.setRawMode(wasRaw || false); } catch {} }
3788
+ };
3789
+
3790
+ const onKey = (str, key) => {
3791
+ if (!key) return;
3792
+ const name = key.name || '';
3793
+ const seq = key.sequence || str || '';
3794
+
3795
+ if (key.ctrl && (name === 'c' || name === 'd')) {
3796
+ cleanup(); process.stdout.write('\n'); resolve('s'); return;
3797
+ }
3798
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
3799
+ cleanup(); process.stdout.write('\n'); resolve('commit'); return;
3800
+ }
3801
+ if (!str || str.length === 0) return;
3802
+ const lower = str.toLowerCase();
3803
+ if (lower === 'e' || lower === 'd' || lower === 's') {
3804
+ cleanup(); process.stdout.write('\n'); resolve(lower); return;
3805
+ }
3806
+ };
3807
+
3808
+ process.stdin.on('keypress', onKey);
3809
+ });
3810
+
3811
+ process.stdout.write('\n');
3812
+ printCard(finalMsg);
3813
+
3814
+ let committed = false;
3815
+ let done = false;
3816
+
3817
+ while (!done) {
3818
+ const choice = await waitKey();
3819
+
3820
+ if (choice === 'commit') {
3821
+ try {
3822
+ execSync('git add -A', { cwd, stdio: 'pipe' });
3823
+ execSync(`git commit -m ${JSON.stringify(finalMsg)}`, { cwd, stdio: 'pipe' });
3824
+ process.stdout.write(`\n \x1b[32m\u2713 Committed:\x1b[0m ${finalMsg}\n\n`);
3825
+ committed = true;
3826
+ } catch (e) {
3827
+ process.stderr.write(` Commit failed: ${e.message}\n`);
3828
+ }
3829
+ done = true;
3830
+
3831
+ } else if (choice === 'e') {
3832
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
3833
+ const edited = await new Promise(res => rl2.question('\n Commit message: ', res));
3834
+ rl2.close();
3835
+ if (edited.trim()) finalMsg = edited.trim();
3836
+ process.stdout.write('\n');
3837
+ printCard(finalMsg);
3838
+
3839
+ } else if (choice === 'd') {
3840
+ process.stdout.write('\n');
3841
+ try {
3842
+ const fullDiff = execSync('git diff HEAD', { cwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
3843
+ process.stdout.write(fullDiff || '(no diff output)\n');
3844
+ } catch { process.stdout.write('(could not read diff)\n'); }
3845
+ process.stdout.write('\n');
3846
+ printCard(finalMsg);
3847
+
3848
+ } else {
3849
+ process.stdout.write(' Skipped.\n\n');
3850
+ done = true;
3851
+ }
3852
+ }
3853
+
3854
+ return committed;
3855
+ }
3856
+
3251
3857
  // ─── Screen state machine ─────────────────────────────────────────────────────
3252
3858
 
3253
3859
  const SCREENS = {
@@ -3256,6 +3862,7 @@ const SCREENS = {
3256
3862
  'new-session': newSessionScreen,
3257
3863
  settings: settingsScreen,
3258
3864
  'import-picker': importPickerScreen,
3865
+ 'pr-triage': prTriageScreen,
3259
3866
  subscriptions: subscriptionsScreen,
3260
3867
  dashboard: dashboardScreen,
3261
3868
  auth: authScreen,
@@ -3294,6 +3901,7 @@ async function runScreens(startScreen = 'dashboard') {
3294
3901
  if (freshSessions.length > 0) {
3295
3902
  saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
3296
3903
  }
3904
+ await offerAutoCommit(cwd);
3297
3905
  current = 'main';
3298
3906
  ctx = {};
3299
3907
  continue;
@@ -3304,9 +3912,10 @@ async function runScreens(startScreen = 'dashboard') {
3304
3912
  try {
3305
3913
  const result = await screen(rl, ask, ctx);
3306
3914
  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 }
3915
+ // Pass through context (e.g. selected session, typed prompt, openPRs) to next screen
3916
+ ctx = result?.session ? { session: result.session }
3917
+ : result?.prompt ? { prompt: result.prompt }
3918
+ : result?.openPRs ? { openPRs: result.openPRs }
3310
3919
  : {};
3311
3920
  } catch (e) {
3312
3921
  console.error(`Error: ${e.message}`);
@@ -3317,6 +3926,309 @@ async function runScreens(startScreen = 'dashboard') {
3317
3926
  rl.close();
3318
3927
  }
3319
3928
 
3929
+
3930
+ // ─── Watch mode ──────────────────────────────────────────────────────────────
3931
+
3932
+ /**
3933
+ * Suggest an action for a batch of changed files.
3934
+ * Returns { label, cmd, safe } or null (no suggestion needed).
3935
+ * Deterministic — no AI calls.
3936
+ */
3937
+ function suggestAction(changedFiles, cwd) {
3938
+ // .env changes — highest priority warning
3939
+ const envChanged = changedFiles.some(f => {
3940
+ const b = basename(f);
3941
+ return b === '.env' || b.startsWith('.env.');
3942
+ });
3943
+ if (envChanged) {
3944
+ return { label: '⚠ Environment changed — restart services', cmd: null, safe: false };
3945
+ }
3946
+
3947
+ // package.json → npm install
3948
+ if (changedFiles.some(f => basename(f) === 'package.json')) {
3949
+ return { label: 'npm install (dependencies may have changed)', cmd: 'npm install', safe: true };
3950
+ }
3951
+
3952
+ // Config files → restart dev server
3953
+ const configChanged = changedFiles.some(f => {
3954
+ const b = basename(f);
3955
+ return /\.config\.(m?js|ts|cjs|json)$/.test(b)
3956
+ || b === 'tsconfig.json'
3957
+ || b === '.eslintrc'
3958
+ || b === '.babelrc'
3959
+ || b === 'vite.config.js'
3960
+ || b === 'webpack.config.js';
3961
+ });
3962
+ if (configChanged) {
3963
+ return { label: 'Restart dev server (config changed)', cmd: null, safe: false };
3964
+ }
3965
+
3966
+ // Test/spec files themselves changed → run them
3967
+ const testChanged = changedFiles.filter(f => /\.(test|spec)\.(m?js|ts|cjs)$/.test(f));
3968
+ if (testChanged.length > 0) {
3969
+ let testCmd = 'npm test';
3970
+ try {
3971
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
3972
+ if (!pkg.scripts?.test) testCmd = null;
3973
+ } catch {}
3974
+ const fileList = testChanged.map(f => basename(f)).join(', ');
3975
+ return testCmd ? { label: `Run tests: ${fileList}`, cmd: testCmd, safe: true } : null;
3976
+ }
3977
+
3978
+ // Markdown → no suggestion
3979
+ if (changedFiles.every(f => extname(f) === '.md')) {
3980
+ return null;
3981
+ }
3982
+
3983
+ // Source file changed → look for related test file
3984
+ const sourceChanged = changedFiles.filter(f =>
3985
+ /\.(m?js|ts|cjs|py|rb|go|rs)$/.test(f) && !/\.(test|spec)\./.test(f)
3986
+ );
3987
+ if (sourceChanged.length > 0) {
3988
+ const testDirs = ['test', 'tests', '__tests__', 'spec', 'src'];
3989
+ for (const srcFile of sourceChanged) {
3990
+ const srcBase = basename(srcFile);
3991
+ const srcExt = extname(srcFile);
3992
+ const srcStem = srcBase.slice(0, -srcExt.length);
3993
+ const testExts = [...new Set([srcExt, '.js', '.ts', '.mjs'])];
3994
+ const srcDirAbs = join(cwd, dirname(srcFile));
3995
+
3996
+ for (const dir of testDirs) {
3997
+ for (const ext of testExts) {
3998
+ const candidates = [
3999
+ join(cwd, dir, `${srcStem}.test${ext}`),
4000
+ join(cwd, dir, `${srcStem}.spec${ext}`),
4001
+ join(srcDirAbs, `${srcStem}.test${ext}`),
4002
+ join(srcDirAbs, `${srcStem}.spec${ext}`),
4003
+ ];
4004
+ for (const c of candidates) {
4005
+ if (existsSync(c)) {
4006
+ const rel = c.replace(cwd + '/', '');
4007
+ let testCmd = 'npm test';
4008
+ try {
4009
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4010
+ const scripts = pkg.scripts?.test ?? '';
4011
+ const dev = { ...pkg.devDependencies, ...pkg.dependencies };
4012
+ if (scripts.includes('jest') || dev.jest) testCmd = `npx jest ${rel}`;
4013
+ else if (scripts.includes('vitest') || dev.vitest) testCmd = `npx vitest run ${rel}`;
4014
+ else if (scripts.includes('mocha') || dev.mocha) testCmd = `npx mocha ${rel}`;
4015
+ } catch {}
4016
+ return { label: `Run related tests: ${rel}`, cmd: testCmd, safe: true };
4017
+ }
4018
+ }
4019
+ }
4020
+ }
4021
+ }
4022
+
4023
+ // No test file found — suggest generic test run
4024
+ let testCmd = 'npm test';
4025
+ try {
4026
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
4027
+ if (!pkg.scripts?.test) testCmd = null;
4028
+ } catch { testCmd = null; }
4029
+
4030
+ if (testCmd) {
4031
+ const fileList = sourceChanged.map(f => basename(f)).join(', ');
4032
+ return { label: `Run tests (${fileList} changed)`, cmd: testCmd, safe: true };
4033
+ }
4034
+ }
4035
+
4036
+ return null;
4037
+ }
4038
+
4039
+ const W_RESET = '\x1b[0m';
4040
+ const W_BOLD = '\x1b[1m';
4041
+ const W_DIM = '\x1b[2m';
4042
+ const W_YELLOW = '\x1b[33m';
4043
+ const W_CYAN = '\x1b[36m';
4044
+ const W_GREEN = '\x1b[32m';
4045
+ const W_RED = '\x1b[31m';
4046
+
4047
+ function watchRedraw(header, logLines, prompt) {
4048
+ process.stdout.write('\x1b[2J\x1b[H');
4049
+ process.stdout.write(header + '\n\n');
4050
+ const visible = logLines.slice(-8);
4051
+ for (let i = 0; i < visible.length; i++) {
4052
+ const dim = i < visible.length - 4;
4053
+ if (dim) process.stdout.write(W_DIM);
4054
+ process.stdout.write(visible[i] + '\n');
4055
+ if (dim) process.stdout.write(W_RESET);
4056
+ }
4057
+ if (prompt) process.stdout.write('\n' + prompt);
4058
+ }
4059
+
4060
+ async function cmdWatch(rawArgs) {
4061
+ const cwd = process.cwd();
4062
+ const auto = rawArgs.includes('--auto');
4063
+ const dirArg = rawArgs.find(a => !a.startsWith('-')) ?? '.';
4064
+ const watchDir = join(cwd, dirArg);
4065
+
4066
+ if (!existsSync(watchDir)) {
4067
+ process.stderr.write(`Error: Directory not found: ${watchDir}\n`);
4068
+ process.exit(1);
4069
+ }
4070
+
4071
+ const relDir = watchDir === cwd ? '.' : watchDir.replace(cwd + '/', '');
4072
+ const modeStr = auto ? `${W_YELLOW}--auto${W_RESET}` : 'interactive';
4073
+ const header = `${W_BOLD}${W_CYAN}Watching${W_RESET} ${relDir} ${W_DIM}(${modeStr}${W_DIM}, q or Ctrl+C to exit)${W_RESET}`;
4074
+
4075
+ const logLines = [];
4076
+ function addLog(line) {
4077
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
4078
+ logLines.push(`${W_DIM}${ts}${W_RESET} ${line}`);
4079
+ }
4080
+
4081
+ addLog(`${W_DIM}Ready — waiting for file changes...${W_RESET}`);
4082
+ watchRedraw(header, logLines);
4083
+
4084
+ let resolvePending = null;
4085
+ let watcherRef = null;
4086
+
4087
+ function cleanup() {
4088
+ try { if (watcherRef) watcherRef.close(); } catch {}
4089
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
4090
+ try { watchRl.close(); } catch {}
4091
+ process.stdout.write('\n');
4092
+ process.exit(0);
4093
+ }
4094
+
4095
+ const watchRl = createInterface({ input: process.stdin, output: process.stdout });
4096
+ if (process.stdin.isTTY) {
4097
+ process.stdin.setRawMode(true);
4098
+ process.stdin.resume();
4099
+ process.stdin.setEncoding('utf8');
4100
+ }
4101
+
4102
+ process.stdin.on('data', (key) => {
4103
+ if (key === 'q' || key === '') { cleanup(); return; }
4104
+ if (resolvePending) { resolvePending(key); resolvePending = null; }
4105
+ });
4106
+
4107
+ process.on('SIGINT', cleanup);
4108
+ process.on('SIGTERM', cleanup);
4109
+
4110
+ function waitForKey() {
4111
+ return new Promise(resolve => { resolvePending = resolve; });
4112
+ }
4113
+
4114
+ let processing = false;
4115
+ async function processBatch(files) {
4116
+ if (processing) return;
4117
+ processing = true;
4118
+ try {
4119
+ const fileList = [...files];
4120
+ files.clear();
4121
+ const relFiles = fileList.map(f =>
4122
+ f.replace(cwd + '/', '').replace(cwd + '\\', '')
4123
+ );
4124
+
4125
+ for (const f of relFiles) addLog(` ${W_CYAN}${f}${W_RESET} saved`);
4126
+
4127
+ const suggestion = suggestAction(relFiles, cwd);
4128
+
4129
+ if (!suggestion) {
4130
+ addLog(` ${W_DIM}(no action suggested)${W_RESET}`);
4131
+ watchRedraw(header, logLines);
4132
+ return;
4133
+ }
4134
+
4135
+ addLog(` ${W_YELLOW}Suggestion:${W_RESET} ${suggestion.label}`);
4136
+
4137
+ if (auto) {
4138
+ if (!suggestion.safe || !suggestion.cmd) {
4139
+ addLog(` ${W_DIM}[auto] Skipping — not auto-safe${W_RESET}`);
4140
+ watchRedraw(header, logLines);
4141
+ return;
4142
+ }
4143
+ addLog(` ${W_GREEN}[auto] Running:${W_RESET} ${suggestion.cmd}`);
4144
+ watchRedraw(header, logLines);
4145
+ try {
4146
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4147
+ for (const l of out.trim().split('\n').slice(-5)) addLog(` ${W_DIM}${l}${W_RESET}`);
4148
+ addLog(` ${W_GREEN}done${W_RESET}`);
4149
+ } catch (e) {
4150
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4151
+ for (const l of msg.split('\n').slice(-3)) addLog(` ${W_RED}${l}${W_RESET}`);
4152
+ addLog(` ${W_RED}command failed${W_RESET}`);
4153
+ }
4154
+ watchRedraw(header, logLines);
4155
+ return;
4156
+ }
4157
+
4158
+ // Interactive prompt
4159
+ const promptLine = suggestion.cmd
4160
+ ? ` ${W_BOLD}[Enter]${W_RESET} Run ${W_BOLD}[s]${W_RESET} Skip ${W_BOLD}[q]${W_RESET} Quit\n > `
4161
+ : ` ${W_BOLD}[s]${W_RESET} Dismiss ${W_BOLD}[q]${W_RESET} Quit\n > `;
4162
+ watchRedraw(header, logLines, promptLine);
4163
+
4164
+ const key = await waitForKey();
4165
+
4166
+ if (key === 'q' || key === '') { cleanup(); return; }
4167
+
4168
+ if ((key === '\r' || key === '\n' || key === ' ') && suggestion.cmd) {
4169
+ addLog(` ${W_GREEN}Running:${W_RESET} ${suggestion.cmd}`);
4170
+ watchRedraw(header, logLines);
4171
+ try {
4172
+ const out = execSync(suggestion.cmd, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 60000 });
4173
+ for (const l of out.trim().split('\n').slice(-8)) addLog(` ${W_DIM}${l}${W_RESET}`);
4174
+ addLog(` ${W_GREEN}done${W_RESET}`);
4175
+ } catch (e) {
4176
+ const msg = (e.stderr || e.stdout || e.message || '').trim();
4177
+ for (const l of msg.split('\n').slice(-5)) addLog(` ${W_RED}${l}${W_RESET}`);
4178
+ addLog(` ${W_RED}command failed${W_RESET}`);
4179
+ }
4180
+ } else {
4181
+ addLog(` ${W_DIM}skipped${W_RESET}`);
4182
+ }
4183
+ watchRedraw(header, logLines);
4184
+ } finally {
4185
+ processing = false;
4186
+ }
4187
+ }
4188
+
4189
+ let debounceTimer = null;
4190
+ const pendingFiles = new Set();
4191
+
4192
+ try {
4193
+ watcherRef = fsWatch(watchDir, { recursive: true }, (_eventType, filename) => {
4194
+ if (!filename) return;
4195
+ if (
4196
+ filename.includes('node_modules') ||
4197
+ filename.includes('.git') ||
4198
+ filename.includes('.dualbrain') ||
4199
+ /package-lock\.json$/.test(filename) ||
4200
+ /yarn\.lock$/.test(filename) ||
4201
+ /pnpm-lock\.yaml$/.test(filename)
4202
+ ) return;
4203
+
4204
+ pendingFiles.add(join(watchDir, filename));
4205
+
4206
+ if (debounceTimer) clearTimeout(debounceTimer);
4207
+ debounceTimer = setTimeout(() => {
4208
+ debounceTimer = null;
4209
+ processBatch(pendingFiles).catch(e => {
4210
+ addLog(` ${W_RED}Watch error: ${e.message}${W_RESET}`);
4211
+ watchRedraw(header, logLines);
4212
+ });
4213
+ }, 2000);
4214
+ });
4215
+ } catch (e) {
4216
+ if (e.code === 'ENOSPC') {
4217
+ process.stderr.write(
4218
+ '\nError: Too many file watchers (ENOSPC).\n' +
4219
+ 'Increase the limit:\n' +
4220
+ ' echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n'
4221
+ );
4222
+ process.exit(1);
4223
+ }
4224
+ throw e;
4225
+ }
4226
+
4227
+ // Keep alive — stdin events drive everything, cleanup() calls process.exit
4228
+ await new Promise(() => {});
4229
+ }
4230
+
4231
+
3320
4232
  // ─── Specialist commands ──────────────────────────────────────────────────────
3321
4233
 
3322
4234
  const SPECIALIST_DEFAULTS = {
@@ -3601,6 +4513,8 @@ async function main() {
3601
4513
  process.exit(0);
3602
4514
  }
3603
4515
 
4516
+ if (cmd === 'watch') { await cmdWatch(args.slice(1)); return; }
4517
+
3604
4518
  if (cmd === 'shell-hook') {
3605
4519
  // Output a bash snippet users can add to their .bashrc or source directly.
3606
4520
  const hook = `
@@ -3621,7 +4535,7 @@ fi
3621
4535
  // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
3622
4536
  const KNOWN_COMMANDS = new Set([
3623
4537
  'init', 'install', 'auth', 'go', 'status', 'hot', 'cool',
3624
- 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook',
4538
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
3625
4539
  '--help', '-h', '--version', '-v',
3626
4540
  ...Object.keys(loadSpecialistRegistry()),
3627
4541
  ]);