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