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.
- package/bin/dual-brain.mjs +938 -24
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +1 -1
- package/src/decide.mjs +18 -8
- 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,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
|
-
*
|
|
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 = '
|
|
1182
|
-
const RED = '
|
|
1257
|
+
const GREEN = '[32m●[0m';
|
|
1258
|
+
const RED = '[31m●[0m';
|
|
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
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
3309
|
-
: 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
|
]);
|