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.
- package/bin/dual-brain.mjs +1016 -24
- package/package.json +1 -1
- package/src/decide.mjs +241 -6
- package/src/dispatch.mjs +43 -0
- package/src/session.mjs +106 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
1182
|
-
const RED = '\x1b[31m
|
|
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
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
3309
|
-
: 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
|
]);
|