dual-brain 0.1.5 → 0.1.7

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.
@@ -29,7 +29,7 @@ import {
29
29
  import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
30
30
 
31
31
  import { loadRepoCache } from '../src/repo.mjs';
32
- import { loadSession, saveSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions } from '../src/session.mjs';
32
+ import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
33
33
 
34
34
  import { box, bar, badge, menu, separator } from '../src/tui.mjs';
35
35
 
@@ -368,22 +368,24 @@ async function cmdGo(args) {
368
368
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
369
369
  }
370
370
 
371
- // Print routing table
372
- console.log(` provider : ${decision.provider}`);
373
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
374
- console.log(` tier : ${decision.tier}`);
375
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
376
- console.log(` reason : ${decision.explanation}`);
371
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
372
+ if (dryRun || verbose) {
373
+ console.log(` provider : ${decision.provider}`);
374
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
375
+ console.log(` tier : ${decision.tier}`);
376
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
377
+ console.log(` reason : ${decision.explanation}`);
378
+ }
377
379
 
378
380
  if (dryRun) {
379
381
  console.log('\n(dry-run — not executing)');
380
382
  return;
381
383
  }
382
384
 
383
- console.log('\nDispatching...');
385
+ if (verbose) console.log('\nDispatching...');
384
386
  let result;
385
387
  if (decision.dualBrain) {
386
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
388
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
387
389
  console.log(`\nConsensus: ${result.consensus}`);
388
390
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
389
391
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -398,7 +400,7 @@ async function cmdGo(args) {
398
400
  nextAction: null,
399
401
  }, cwd);
400
402
  } else {
401
- result = await dispatch({ decision, prompt, files, cwd });
403
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
402
404
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
403
405
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
404
406
  if (result.summary) console.log(result.summary);
@@ -1000,6 +1002,176 @@ function loadTerminalState(cwd, terminalId) {
1000
1002
 
1001
1003
  // ─── Dashboard box helpers ────────────────────────────────────────────────────
1002
1004
 
1005
+ /**
1006
+ * Detect repo state for action cards. All checks run with tight timeouts —
1007
+ * best-effort only, never blocks startup.
1008
+ *
1009
+ * Returns: { dirtyCount, lastCommitAgeDays, lastFailure, isGitRepo }
1010
+ */
1011
+ function detectRepoState(cwd) {
1012
+ const result = { dirtyCount: 0, lastCommitAgeDays: 0, lastFailure: null, isGitRepo: false };
1013
+ try {
1014
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1015
+ result.isGitRepo = true;
1016
+ } catch { return result; }
1017
+
1018
+ try {
1019
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1020
+ result.dirtyCount = status.trim().split('\n').filter(Boolean).length;
1021
+ } catch {}
1022
+
1023
+ try {
1024
+ const logOut = execSync('git log --format="%ct" -1', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
1025
+ if (logOut) {
1026
+ const commitTs = parseInt(logOut, 10) * 1000;
1027
+ result.lastCommitAgeDays = Math.floor((Date.now() - commitTs) / 86400000);
1028
+ }
1029
+ } catch {}
1030
+
1031
+ try {
1032
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
1033
+ if (existsSync(sessionPath)) {
1034
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
1035
+ const lastResult = sess?.lastResult;
1036
+ if (lastResult?.status === 'failure') {
1037
+ const summary = lastResult.task
1038
+ ? String(lastResult.task).slice(0, 40)
1039
+ : 'last task';
1040
+ result.lastFailure = summary;
1041
+ }
1042
+ }
1043
+ } catch {}
1044
+
1045
+ return result;
1046
+ }
1047
+
1048
+ /**
1049
+ * Build action card rows for the dashboard based on repo state.
1050
+ * Returns an array of box row strings (may be empty).
1051
+ */
1052
+ function buildActionRows(repoState, rowFn) {
1053
+ if (!repoState.isGitRepo) return [];
1054
+
1055
+ const YELLOW = '\x1b[33m';
1056
+ const RED = '\x1b[31m';
1057
+ const GREEN = '\x1b[32m';
1058
+ const DIM = '\x1b[2m';
1059
+ const RESET = '\x1b[0m';
1060
+
1061
+ const cards = [];
1062
+
1063
+ if (repoState.dirtyCount > 0) {
1064
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.dirtyCount} uncommitted file${repoState.dirtyCount === 1 ? '' : 's'}`);
1065
+ }
1066
+
1067
+ if (repoState.lastFailure !== null) {
1068
+ cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
1069
+ }
1070
+
1071
+ if (repoState.lastCommitAgeDays >= 3) {
1072
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
1073
+ }
1074
+
1075
+ if (cards.length === 0) {
1076
+ return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
1077
+ }
1078
+
1079
+ return cards.map(c => rowFn(c));
1080
+ }
1081
+
1082
+ /**
1083
+ * Detect interrupted work from the most recent session.
1084
+ * Returns a continuation hint if confidence is high enough, or null to skip.
1085
+ *
1086
+ * Signals that indicate interrupted work:
1087
+ * - Session < 4 hours old with no clean exit
1088
+ * - Last result was a failure
1089
+ * - Uncommitted git changes exist
1090
+ * - Session has high message count (user was deep in work)
1091
+ *
1092
+ * Minimum thresholds: messageCount > 5 OR filesChanged > 0
1093
+ *
1094
+ * @param {Array} sessions — from importReplitSessions / enrichSessions
1095
+ * @param {string} cwd
1096
+ * @returns {{ shouldContinue: boolean, reason: string, sessionId: string, sessionName: string, lastState: string|null, ageLabel: string }|null}
1097
+ */
1098
+ function detectInterruptedWork(sessions, cwd) {
1099
+ if (!sessions || sessions.length === 0) return null;
1100
+
1101
+ const most = sessions[0]; // already sorted most-recent first
1102
+ if (!most || !most.lastActive) return null;
1103
+
1104
+ const ageMs = Date.now() - new Date(most.lastActive).getTime();
1105
+ const fourH = 4 * 60 * 60 * 1000;
1106
+
1107
+ // Must be within 4 hours
1108
+ if (ageMs >= fourH) return null;
1109
+
1110
+ // Load session.json for deeper signal
1111
+ const session = loadSession(cwd);
1112
+
1113
+ // Minimum thresholds: must have real work depth
1114
+ const msgCount = most.messageCount ?? most.promptCount ?? 0;
1115
+ const filesChanged = session?.filesChanged?.length ?? 0;
1116
+ if (msgCount <= 5 && filesChanged === 0) return null;
1117
+
1118
+ const lastResultStatus = session?.lastResult?.status ?? null;
1119
+
1120
+ // Build confidence signals
1121
+ const signals = [];
1122
+ if (lastResultStatus === 'failure') signals.push('last run failed');
1123
+ if (filesChanged > 0) signals.push(`${filesChanged} file${filesChanged !== 1 ? 's' : ''} changed`);
1124
+ if (msgCount > 10) signals.push('deep session');
1125
+
1126
+ // Check for uncommitted git changes
1127
+ try {
1128
+ const gitResult = _spawnSyncTop('git', ['status', '--porcelain'], {
1129
+ cwd,
1130
+ encoding: 'utf8',
1131
+ stdio: ['pipe', 'pipe', 'pipe'],
1132
+ timeout: 3000,
1133
+ });
1134
+ if (gitResult.status === 0 && gitResult.stdout.trim().length > 0) {
1135
+ signals.push('uncommitted changes');
1136
+ }
1137
+ } catch { /* non-fatal */ }
1138
+
1139
+ // Need at least one signal beyond base thresholds to avoid annoying low-signal cards
1140
+ if (signals.length === 0 && msgCount <= 10) return null;
1141
+
1142
+ // Build a human-readable "last state" from available data
1143
+ let lastState = null;
1144
+ if (session?.lastResult?.summary) {
1145
+ lastState = session.lastResult.summary;
1146
+ } else if (session?.objective) {
1147
+ lastState = session.objective;
1148
+ } else if (most.name && !/^Session [0-9a-f]{8}/i.test(most.name)) {
1149
+ lastState = most.name;
1150
+ }
1151
+
1152
+ // Trim lastState to fit on one line
1153
+ if (lastState && lastState.length > 45) lastState = lastState.slice(0, 42) + '...';
1154
+
1155
+ // Build reason label
1156
+ const reason = signals.length > 0 ? signals.join(', ') : `${msgCount} messages`;
1157
+
1158
+ // Age label
1159
+ const mins = Math.floor(ageMs / 60000);
1160
+ let ageLabel;
1161
+ if (mins < 1) ageLabel = 'just now';
1162
+ else if (mins < 60) ageLabel = `${mins}m ago`;
1163
+ else ageLabel = `${Math.floor(mins / 60)}h ago`;
1164
+
1165
+ return {
1166
+ shouldContinue: true,
1167
+ reason,
1168
+ sessionId: most.id,
1169
+ sessionName: most.name || most.id.slice(0, 8),
1170
+ lastState,
1171
+ ageLabel,
1172
+ };
1173
+ }
1174
+
1003
1175
  /**
1004
1176
  * Build a provider status string for the dashboard status line.
1005
1177
  * Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
@@ -1094,12 +1266,20 @@ async function mainScreen(rl, ask) {
1094
1266
  } catch {}
1095
1267
 
1096
1268
  // Gather recent sessions
1097
- const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 3);
1269
+ const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1270
+ const recentSessions = allSessions.slice(0, 3);
1271
+ const staleCount = allSessions.filter(s => {
1272
+ const ageMs = s.lastActive ? Date.now() - new Date(s.lastActive).getTime() : 0;
1273
+ return ageMs >= 7 * 86400000;
1274
+ }).length;
1098
1275
 
1099
1276
  // Detect data-tools version
1100
1277
  const rtMain = detectReplitTools(cwd);
1101
1278
  const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1102
1279
 
1280
+ // ── Interrupted work detection ────────────────────────────────────────────
1281
+ const interrupted = detectInterruptedWork(allSessions, cwd);
1282
+
1103
1283
  // ── Box layout ────────────────────────────────────────────────────────────
1104
1284
  const termW = process.stdout.columns || 60;
1105
1285
  const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
@@ -1130,6 +1310,84 @@ async function mainScreen(rl, ask) {
1130
1310
  }
1131
1311
  }
1132
1312
 
1313
+ // ── Continuation card (interrupted work) ─────────────────────────────────
1314
+ if (interrupted) {
1315
+ const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
1316
+ const csep = `├${'─'.repeat(boxW - 2)}┤`;
1317
+ const cbot = `└${'─'.repeat(boxW - 2)}┘`;
1318
+ const crow = (content) => makeBoxRow(content, W);
1319
+
1320
+ const titleLine = `\x1b[33m💡\x1b[0m Continue: ${interrupted.sessionName}`;
1321
+ const lastLine = interrupted.lastState
1322
+ ? ` Last: ${interrupted.lastState} · ${interrupted.ageLabel}`
1323
+ : ` ${interrupted.reason} · ${interrupted.ageLabel}`;
1324
+ const actLine = ' [Enter] Resume [n] New session [s] Skip';
1325
+
1326
+ process.stdout.write([ctop, crow(titleLine), csep, crow(lastLine), crow(actLine), cbot].join('\n') + '\n\n');
1327
+
1328
+ // Wait for a keypress to decide what to do with the card
1329
+ const readline2 = await import('node:readline');
1330
+ readline2.emitKeypressEvents(process.stdin, rl);
1331
+
1332
+ const cardChoice = await new Promise((resolve) => {
1333
+ const wasRaw2 = process.stdin.isRaw;
1334
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1335
+ if (canRaw2) process.stdin.setRawMode(true);
1336
+
1337
+ const cleanup2 = () => {
1338
+ process.stdin.removeListener('keypress', onCardKey);
1339
+ if (canRaw2) {
1340
+ try { process.stdin.setRawMode(wasRaw2 || false); } catch {}
1341
+ }
1342
+ };
1343
+
1344
+ const onCardKey = (str, key) => {
1345
+ if (!key) return;
1346
+ const name = key.name || '';
1347
+ const seq = key.sequence || str || '';
1348
+
1349
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1350
+ cleanup2();
1351
+ process.stdout.write('\n');
1352
+ resolve('q');
1353
+ return;
1354
+ }
1355
+
1356
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1357
+ cleanup2();
1358
+ process.stdout.write('\n');
1359
+ resolve('resume');
1360
+ return;
1361
+ }
1362
+
1363
+ if (!str || str.length === 0) return;
1364
+ const lower = str.toLowerCase();
1365
+ if (lower === 'n' || lower === 's' || lower === 'q') {
1366
+ cleanup2();
1367
+ process.stdout.write('\n');
1368
+ resolve(lower);
1369
+ return;
1370
+ }
1371
+ };
1372
+
1373
+ process.stdin.on('keypress', onCardKey);
1374
+ });
1375
+
1376
+ if (cardChoice === 'q') return { next: 'exit' };
1377
+
1378
+ if (cardChoice === 'resume') {
1379
+ const { spawnSync } = await import('node:child_process');
1380
+ process.stdout.write(` Launching: claude --resume ${interrupted.sessionId}\n\n`);
1381
+ spawnSync('claude', ['--resume', interrupted.sessionId], { stdio: 'inherit' });
1382
+ saveTerminalState(cwd, getTerminalId(), interrupted.sessionId, 'claude');
1383
+ return { next: 'main' };
1384
+ }
1385
+
1386
+ if (cardChoice === 'n') return { next: 'new-session' };
1387
+
1388
+ // 's' → fall through to normal dashboard
1389
+ }
1390
+
1133
1391
  // ── Status section ────────────────────────────────────────────────────────
1134
1392
  const providerLine = buildProviderStatusLine(profile, auth);
1135
1393
 
@@ -1138,6 +1396,10 @@ async function mainScreen(rl, ask) {
1138
1396
  statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1139
1397
  }
1140
1398
 
1399
+ // ── Action cards (git state) ──────────────────────────────────────────────
1400
+ const repoState = detectRepoState(cwd);
1401
+ const actionRows = buildActionRows(repoState, row);
1402
+
1141
1403
  // ── Sessions section ──────────────────────────────────────────────────────
1142
1404
  const sessionRows = [];
1143
1405
  if (recentSessions.length === 0) {
@@ -1152,33 +1414,64 @@ async function mainScreen(rl, ask) {
1152
1414
  ? sess.project.replace(/^-/, '/').replace(/-/g, '/')
1153
1415
  : sess.id.slice(0, 8);
1154
1416
  }
1155
- // Layout: "{num} {name...} {age}"
1417
+
1418
+ // Build badges (ANSI color; track visible width separately)
1419
+ const badges = [];
1420
+ const badgeVisible = [];
1421
+ if (sess.isActive) {
1422
+ badges.push('\x1b[32m[active]\x1b[0m');
1423
+ badgeVisible.push('[active]'.length);
1424
+ }
1425
+ if (sess.source === 'replit-tools' || sess.source === 'data-tools') {
1426
+ badges.push('\x1b[36m[dt]\x1b[0m');
1427
+ badgeVisible.push('[dt]'.length);
1428
+ }
1429
+ const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
1430
+ if (ageMs > 7 * 24 * 3600 * 1000) {
1431
+ badges.push('\x1b[2m[stale]\x1b[0m');
1432
+ badgeVisible.push('[stale]'.length);
1433
+ }
1434
+ const msgCount = sess.messageCount ?? sess.promptCount ?? 0;
1435
+ const msgBadge = `\x1b[2m(${msgCount})\x1b[0m`;
1436
+ const msgBadgeW = `(${msgCount})`.length;
1437
+
1438
+ const badgeStr = badges.join('');
1439
+ const badgesW = badgeVisible.reduce((s, n) => s + n, 0);
1440
+
1441
+ // Layout: "{num} {name...}{badges} {age} {msg}"
1156
1442
  const numStr = String(i + 1);
1157
1443
  const ageStr = sess.age || '';
1158
- // Available for name: W - numStr.length - 2 spaces - 2 spaces before age - ageStr.length
1159
- const nameMax = W - numStr.length - 2 - 2 - ageStr.length;
1160
- const name = rawName.length > nameMax
1161
- ? rawName.slice(0, nameMax - 3) + '...'
1444
+ // Available for name: W minus fixed chrome, badge widths, and msg badge
1445
+ const nameMax = W - numStr.length - 2 - badgesW - 2 - ageStr.length - 2 - msgBadgeW;
1446
+ const truncName = rawName.length > nameMax
1447
+ ? rawName.slice(0, Math.max(0, nameMax - 3)) + '...'
1162
1448
  : rawName.padEnd(nameMax);
1163
- const content = `${numStr} ${name} ${ageStr}`;
1449
+ const content = `${numStr} ${truncName}${badgeStr} ${ageStr} ${msgBadge}`;
1164
1450
  sessionRows.push(row(content));
1165
1451
  });
1166
1452
  }
1167
1453
 
1168
1454
  // ── Actions bar ───────────────────────────────────────────────────────────
1169
- const actionsContent = '↵ Resume n New / Search s Settings q Quit';
1455
+ const actionsContent = '↵ Resume n New / Search i Import s Settings q Quit';
1170
1456
  const actionsRow = row(actionsContent);
1171
1457
 
1172
1458
  // ── Print the full box ────────────────────────────────────────────────────
1459
+ // Include action cards between status and sessions (with separators only when non-empty)
1173
1460
  const lines = [
1174
1461
  top,
1175
1462
  ...statusRows,
1463
+ ...(actionRows.length > 0 ? [sep, ...actionRows] : []),
1176
1464
  sep,
1177
1465
  ...sessionRows,
1178
1466
  sep,
1179
1467
  actionsRow,
1180
1468
  bot,
1181
1469
  ];
1470
+ // ── Stale session hint ──────────────────────────────────────────────────
1471
+ if (staleCount >= 3) {
1472
+ process.stdout.write(`\x1b[2m${staleCount} stale sessions (>7d) — press s → archive to clean up\x1b[0m\n`);
1473
+ }
1474
+
1182
1475
  process.stdout.write(lines.join('\n') + '\n');
1183
1476
  process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
1184
1477
 
@@ -1267,7 +1560,7 @@ async function mainScreen(rl, ask) {
1267
1560
  // Single-key commands only fire when buffer is empty
1268
1561
  if (taskBuffer.length === 0) {
1269
1562
  const lower = str.toLowerCase();
1270
- if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
1563
+ if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/' || lower === 'i') {
1271
1564
  cleanup();
1272
1565
  process.stdout.write('\n');
1273
1566
  resolve(lower);
@@ -1372,6 +1665,7 @@ async function mainScreen(rl, ask) {
1372
1665
  }
1373
1666
 
1374
1667
  if (choice === 's') { return { next: 'settings' }; }
1668
+ if (choice === 'i') { return { next: 'import-picker' }; }
1375
1669
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
1376
1670
 
1377
1671
  return { next: 'main' };
@@ -1408,6 +1702,236 @@ async function newSessionScreen(rl, ask) {
1408
1702
  return { next: 'main' };
1409
1703
  }
1410
1704
 
1705
+ // ─── Screen: importPickerScreen ──────────────────────────────────────────────
1706
+
1707
+ async function importPickerScreen() {
1708
+ const cwd = process.cwd();
1709
+
1710
+ // Load all available sessions from data-tools
1711
+ const allSessions = importReplitSessions(cwd);
1712
+
1713
+ // Load existing session meta to filter already-imported ones
1714
+ const meta = getSessionMeta(cwd);
1715
+ const alreadyImported = new Set(
1716
+ Object.entries(meta)
1717
+ .filter(([, v]) => v.source === 'data-tools')
1718
+ .map(([id]) => id)
1719
+ );
1720
+
1721
+ // Filter out already-imported sessions
1722
+ const candidates = allSessions.filter(s => !alreadyImported.has(s.id));
1723
+
1724
+ // ── Box layout ────────────────────────────────────────────────────────────
1725
+ const termW = process.stdout.columns || 60;
1726
+ const boxW = Math.min(termW - 2, 60);
1727
+ const W = boxW - 4;
1728
+
1729
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
1730
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
1731
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
1732
+
1733
+ const row = (content) => makeBoxRow(content, W);
1734
+
1735
+ // Helper: wait for any keypress (used in edge-case screens)
1736
+ const waitKey = async () => {
1737
+ const rl2 = await import('node:readline');
1738
+ rl2.emitKeypressEvents(process.stdin);
1739
+ await new Promise(resolve => {
1740
+ const wasRaw2 = process.stdin.isRaw;
1741
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1742
+ if (canRaw2) process.stdin.setRawMode(true);
1743
+ const onKey2 = () => {
1744
+ process.stdin.removeListener('keypress', onKey2);
1745
+ if (canRaw2) { try { process.stdin.setRawMode(wasRaw2 || false); } catch {} }
1746
+ resolve();
1747
+ };
1748
+ process.stdin.once('keypress', onKey2);
1749
+ });
1750
+ };
1751
+
1752
+ // Handle edge cases
1753
+ if (allSessions.length === 0) {
1754
+ process.stdout.write('\n');
1755
+ process.stdout.write(top + '\n');
1756
+ process.stdout.write(row('Import from data-tools') + '\n');
1757
+ process.stdout.write(sep + '\n');
1758
+ process.stdout.write(row('No data-tools sessions found.') + '\n');
1759
+ process.stdout.write(row('Install replit-tools: npm i -g replit-tools') + '\n');
1760
+ process.stdout.write(sep + '\n');
1761
+ process.stdout.write(row('Press any key to go back...') + '\n');
1762
+ process.stdout.write(bot + '\n\n');
1763
+ await waitKey();
1764
+ return { next: 'main' };
1765
+ }
1766
+
1767
+ if (candidates.length === 0) {
1768
+ process.stdout.write('\n');
1769
+ process.stdout.write(top + '\n');
1770
+ process.stdout.write(row('Import from data-tools') + '\n');
1771
+ process.stdout.write(sep + '\n');
1772
+ process.stdout.write(row(`All ${allSessions.length} sessions already imported.`) + '\n');
1773
+ process.stdout.write(sep + '\n');
1774
+ process.stdout.write(row('Press any key to go back...') + '\n');
1775
+ process.stdout.write(bot + '\n\n');
1776
+ await waitKey();
1777
+ return { next: 'main' };
1778
+ }
1779
+
1780
+ // Pre-select sessions < 3 days old
1781
+ const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
1782
+ const selected = new Set(
1783
+ candidates
1784
+ .filter(s => s.lastActive && (Date.now() - new Date(s.lastActive).getTime()) < threeDaysMs)
1785
+ .map(s => s.id)
1786
+ );
1787
+
1788
+ let cursor = 0;
1789
+
1790
+ const renderPicker = () => {
1791
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1792
+
1793
+ const headerTitle = 'Import from data-tools';
1794
+ const footerLine = '↑↓ Navigate Space Toggle Enter Import q Back';
1795
+
1796
+ process.stdout.write('\n');
1797
+ process.stdout.write(top + '\n');
1798
+ process.stdout.write(row(headerTitle) + '\n');
1799
+ process.stdout.write(sep + '\n');
1800
+
1801
+ candidates.forEach((sess, i) => {
1802
+ const isCursor = i === cursor;
1803
+ const isSelected = selected.has(sess.id);
1804
+ const check = isSelected ? '☑' : '☐';
1805
+ const cursor_ch = isCursor ? '▸ ' : ' ';
1806
+
1807
+ // Format age compactly
1808
+ const ageStr = sess.age || '';
1809
+ // Message count
1810
+ const msgCount = sess.promptCount ?? sess.messageCount ?? 0;
1811
+ const msgStr = `${msgCount} msgs`;
1812
+
1813
+ // Name: truncate to fit
1814
+ // Layout: "cursor_ch(2) check(1) space(1) name age msgs"
1815
+ // chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length = 8 + ageStr.length + msgStr.length
1816
+ const chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length;
1817
+ const nameMax = Math.max(0, W - chrome);
1818
+ let name = sess.name || sess.id.slice(0, 8);
1819
+ if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
1820
+ else name = name.padEnd(nameMax);
1821
+
1822
+ const line = `${cursor_ch}${check} ${name} ${ageStr} ${msgStr}`;
1823
+ // Highlight cursor row with dim inverse
1824
+ const renderedLine = isCursor
1825
+ ? `\x1b[7m${cursor_ch}${check} ${name} ${ageStr} ${msgStr}\x1b[0m`
1826
+ : line;
1827
+ process.stdout.write(row(renderedLine) + '\n');
1828
+ });
1829
+
1830
+ process.stdout.write(sep + '\n');
1831
+ process.stdout.write(row(footerLine) + '\n');
1832
+ process.stdout.write(bot + '\n\n');
1833
+ };
1834
+
1835
+ // Run the interactive picker
1836
+ const readline = await import('node:readline');
1837
+ readline.emitKeypressEvents(process.stdin);
1838
+
1839
+ const result = await new Promise((resolve) => {
1840
+ const wasRaw = process.stdin.isRaw;
1841
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1842
+ if (canRaw) process.stdin.setRawMode(true);
1843
+
1844
+ const cleanup = () => {
1845
+ process.stdin.removeListener('keypress', onKey);
1846
+ if (canRaw) {
1847
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
1848
+ }
1849
+ };
1850
+
1851
+ renderPicker();
1852
+
1853
+ const onKey = (str, key) => {
1854
+ if (!key) return;
1855
+ const name = key.name || '';
1856
+ const seq = key.sequence || str || '';
1857
+
1858
+ // Ctrl-C / Ctrl-D → exit to main
1859
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1860
+ cleanup();
1861
+ process.stdout.write('\n');
1862
+ resolve({ action: 'back' });
1863
+ return;
1864
+ }
1865
+
1866
+ // q or Escape → back
1867
+ if (name === 'escape' || (str && str.toLowerCase() === 'q')) {
1868
+ cleanup();
1869
+ process.stdout.write('\n');
1870
+ resolve({ action: 'back' });
1871
+ return;
1872
+ }
1873
+
1874
+ // Arrow up
1875
+ if (name === 'up') {
1876
+ cursor = Math.max(0, cursor - 1);
1877
+ renderPicker();
1878
+ return;
1879
+ }
1880
+
1881
+ // Arrow down
1882
+ if (name === 'down') {
1883
+ cursor = Math.min(candidates.length - 1, cursor + 1);
1884
+ renderPicker();
1885
+ return;
1886
+ }
1887
+
1888
+ // Space → toggle selection
1889
+ if (seq === ' ') {
1890
+ const id = candidates[cursor].id;
1891
+ if (selected.has(id)) selected.delete(id);
1892
+ else selected.add(id);
1893
+ renderPicker();
1894
+ return;
1895
+ }
1896
+
1897
+ // Enter → import
1898
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1899
+ cleanup();
1900
+ process.stdout.write('\n');
1901
+ resolve({ action: 'import', ids: [...selected] });
1902
+ return;
1903
+ }
1904
+ };
1905
+
1906
+ process.stdin.on('keypress', onKey);
1907
+ });
1908
+
1909
+ if (result.action === 'back' || result.ids.length === 0) {
1910
+ return { next: 'main' };
1911
+ }
1912
+
1913
+ // Persist imported sessions to sessions.json
1914
+ const updatedMeta = getSessionMeta(cwd);
1915
+ const now = new Date().toISOString();
1916
+ let importCount = 0;
1917
+ for (const id of result.ids) {
1918
+ const sess = candidates.find(s => s.id === id);
1919
+ if (!sess) continue;
1920
+ updatedMeta[id] = {
1921
+ ...updatedMeta[id],
1922
+ source: 'data-tools',
1923
+ importedAt: now,
1924
+ createdAt: updatedMeta[id]?.createdAt ?? now,
1925
+ };
1926
+ importCount++;
1927
+ }
1928
+ saveSessionMeta(updatedMeta, cwd);
1929
+
1930
+ process.stdout.write(`✓ Imported ${importCount} session${importCount !== 1 ? 's' : ''} from data-tools\n\n`);
1931
+
1932
+ return { next: 'main' };
1933
+ }
1934
+
1411
1935
  // ─── Screen: settingsScreen ───────────────────────────────────────────────────
1412
1936
 
1413
1937
  async function settingsScreen(rl, ask) {
@@ -1447,15 +1971,7 @@ async function settingsScreen(rl, ask) {
1447
1971
  if (choice === 'e') { return { next: 'sessions' }; }
1448
1972
 
1449
1973
  if (choice === 'i') {
1450
- const sessions = importReplitSessions(cwd);
1451
- if (sessions.length === 0) {
1452
- process.stdout.write('\n No replit-tools sessions found to import.\n\n');
1453
- } else {
1454
- process.stdout.write(`\n Found ${sessions.length} sessions from replit-tools.\n`);
1455
- process.stdout.write(' Sessions are automatically available in the Recent list.\n\n');
1456
- }
1457
- await ask(' Press Enter to continue...');
1458
- return { next: 'settings' };
1974
+ return { next: 'import-picker' };
1459
1975
  }
1460
1976
 
1461
1977
  if (choice === 'd') {
@@ -2439,45 +2955,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
2439
2955
  // ─── Screen: sessionsScreen ───────────────────────────────────────────────────
2440
2956
 
2441
2957
  const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
2958
+ const STALE_DAYS = 7;
2442
2959
 
2960
+ /**
2961
+ * Return a compact status badge string for a session row (plain text, no ANSI).
2962
+ */
2963
+ function sessionBadge(sess) {
2964
+ if (sess.isActive) return '[active]';
2965
+ const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
2966
+ if (ageMs >= STALE_DAYS * 86400000) return '[stale]';
2967
+ if (sess.tool === 'codex') return '[dt]';
2968
+ return '';
2969
+ }
2970
+
2971
+ /**
2972
+ * Interactive full session list with arrow-key navigation.
2973
+ * Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
2974
+ */
2443
2975
  async function sessionsScreen(rl, ask) {
2444
2976
  const cwd = process.cwd();
2445
- const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
2446
2977
 
2447
- console.log('');
2448
- console.log(separator('Session Manager'));
2449
- console.log('');
2978
+ // Load all active sessions (no slice limit)
2979
+ let sessions = enrichSessions(importReplitSessions(cwd), cwd);
2980
+
2981
+ // ── Box geometry ────────────────────────────────────────────────────────────
2982
+ const termW = process.stdout.columns || 60;
2983
+ const boxW = Math.min(termW - 2, 52);
2984
+ const W = boxW - 4;
2985
+
2986
+ const top = `┌${'─'.repeat(boxW - 2)}┐`;
2987
+ const sep = `├${'─'.repeat(boxW - 2)}┤`;
2988
+ const bot = `└${'─'.repeat(boxW - 2)}┘`;
2450
2989
 
2451
2990
  if (sessions.length === 0) {
2452
- console.log(' No sessions found.\n');
2453
- console.log(' [b] Back\n');
2454
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
2455
- if (choice === 'b' || choice === 'back') return { next: 'main' };
2456
- return { next: 'sessions' };
2991
+ process.stdout.write('\n' + top + '\n');
2992
+ process.stdout.write(makeBoxRow('Sessions', W) + '\n');
2993
+ process.stdout.write(sep + '\n');
2994
+ process.stdout.write(makeBoxRow('No sessions found.', W) + '\n');
2995
+ process.stdout.write(sep + '\n');
2996
+ process.stdout.write(makeBoxRow('q Back', W) + '\n');
2997
+ process.stdout.write(bot + '\n\n');
2998
+ await ask(' Press Enter to continue...');
2999
+ return { next: 'main' };
2457
3000
  }
2458
3001
 
2459
- sessions.forEach((sess, i) => {
2460
- const pin = sess.pinned ? '📌 ' : ' ';
2461
- const active = sess.isActive ? ' ●' : '';
2462
- const cat = sess.category ? ` [${sess.category}]` : '';
2463
- console.log(` [${i + 1}] ${pin}${sess.age.padEnd(6)} ${sess.name}${active}${cat}`);
2464
- });
3002
+ /**
3003
+ * Format one session row.
3004
+ * Right side: badge(9) + age(4) + space + count(4) = 18 chars total.
3005
+ */
3006
+ function formatRow(sess, selected) {
3007
+ const arrow = selected ? '▸ ' : ' ';
3008
+ const badge = sessionBadge(sess);
3009
+ const badgeStr = badge ? badge.padEnd(9) : ' ';
3010
+ const age = (sess.age || '').replace(/ ago$/, '').padStart(4);
3011
+ const count = `(${sess.promptCount ?? 0})`.padStart(4);
3012
+ const right = `${badgeStr}${age} ${count}`;
3013
+ const nameMax = W - 2 - right.length;
3014
+ let name = sess.name || sess.id.slice(0, 8);
3015
+ if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
3016
+ else name = name.padEnd(nameMax);
3017
+ return makeBoxRow(`${arrow}${name}${right}`, W);
3018
+ }
3019
+
3020
+ let cursor = 0;
3021
+
3022
+ function render() {
3023
+ process.stdout.write('\x1b[2J\x1b[H');
3024
+ process.stdout.write(top + '\n');
3025
+ process.stdout.write(makeBoxRow('Sessions', W) + '\n');
3026
+ process.stdout.write(sep + '\n');
3027
+ for (let i = 0; i < sessions.length; i++) {
3028
+ process.stdout.write(formatRow(sessions[i], i === cursor) + '\n');
3029
+ }
3030
+ process.stdout.write(sep + '\n');
3031
+ process.stdout.write(makeBoxRow('↑↓ Navigate Enter Resume x Archive r Rename', W) + '\n');
3032
+ process.stdout.write(makeBoxRow('q Back', W) + '\n');
3033
+ process.stdout.write(bot + '\n');
3034
+ }
2465
3035
 
2466
- console.log('');
2467
- console.log(' [1-9] Select a session to manage');
2468
- console.log(' [b] Back');
2469
- console.log('');
3036
+ render();
2470
3037
 
2471
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
3038
+ const readline = await import('node:readline');
3039
+ readline.emitKeypressEvents(process.stdin, rl);
2472
3040
 
2473
- if (choice === 'b' || choice === 'back') return { next: 'main' };
3041
+ const result = await new Promise((resolve) => {
3042
+ const wasRaw = process.stdin.isRaw;
3043
+ const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
3044
+ if (canRaw) process.stdin.setRawMode(true);
2474
3045
 
2475
- const numChoice = parseInt(choice, 10);
2476
- if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= sessions.length) {
2477
- return { next: 'session-manage', session: sessions[numChoice - 1] };
2478
- }
3046
+ const cleanup = () => {
3047
+ process.stdin.removeListener('keypress', onKey);
3048
+ if (canRaw) {
3049
+ try { process.stdin.setRawMode(wasRaw || false); } catch {}
3050
+ }
3051
+ };
3052
+
3053
+ const onKey = async (str, key) => {
3054
+ if (!key) return;
3055
+ const kname = key.name || '';
3056
+
3057
+ // Ctrl-C / Ctrl-D → exit
3058
+ if (key.ctrl && (kname === 'c' || kname === 'd')) {
3059
+ cleanup();
3060
+ process.stdout.write('\n');
3061
+ resolve({ next: 'main' });
3062
+ return;
3063
+ }
2479
3064
 
2480
- return { next: 'sessions' };
3065
+ // q / Escape → back
3066
+ if (kname === 'q' || kname === 'escape' || str === 'q') {
3067
+ cleanup();
3068
+ process.stdout.write('\n');
3069
+ resolve({ next: 'main' });
3070
+ return;
3071
+ }
3072
+
3073
+ // Arrow up
3074
+ if (kname === 'up') {
3075
+ cursor = Math.max(0, cursor - 1);
3076
+ render();
3077
+ return;
3078
+ }
3079
+
3080
+ // Arrow down
3081
+ if (kname === 'down') {
3082
+ cursor = Math.min(sessions.length - 1, cursor + 1);
3083
+ render();
3084
+ return;
3085
+ }
3086
+
3087
+ // Enter → resume highlighted session
3088
+ if (kname === 'return' || kname === 'enter') {
3089
+ const sess = sessions[cursor];
3090
+ cleanup();
3091
+ process.stdout.write('\n');
3092
+ process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
3093
+ const { spawnSync } = await import('node:child_process');
3094
+ spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
3095
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
3096
+ resolve({ next: 'main' });
3097
+ return;
3098
+ }
3099
+
3100
+ // x → archive highlighted session (non-destructive)
3101
+ if (str === 'x' || str === 'X') {
3102
+ const sess = sessions[cursor];
3103
+ archiveSession(sess.id, cwd);
3104
+ sessions = sessions.filter(s => s.id !== sess.id);
3105
+ if (sessions.length === 0) {
3106
+ cleanup();
3107
+ process.stdout.write('\n');
3108
+ resolve({ next: 'main' });
3109
+ return;
3110
+ }
3111
+ cursor = Math.min(cursor, sessions.length - 1);
3112
+ render();
3113
+ return;
3114
+ }
3115
+
3116
+ // r → rename highlighted session
3117
+ if (str === 'r' || str === 'R') {
3118
+ const sess = sessions[cursor];
3119
+ cleanup();
3120
+
3121
+ // Briefly collect a line of text
3122
+ process.stdout.write('\n New name: ');
3123
+ const newName = await new Promise(res2 => {
3124
+ let buf = '';
3125
+ const onData = (chunk) => {
3126
+ const s = chunk.toString();
3127
+ for (const ch of s) {
3128
+ if (ch === '\n' || ch === '\r') {
3129
+ process.stdin.removeListener('data', onData);
3130
+ process.stdout.write('\n');
3131
+ res2(buf.trim());
3132
+ return;
3133
+ }
3134
+ if (ch === '\x7f' || ch === '\b') {
3135
+ if (buf.length > 0) {
3136
+ buf = buf.slice(0, -1);
3137
+ process.stdout.write('\b \b');
3138
+ }
3139
+ } else {
3140
+ buf += ch;
3141
+ process.stdout.write(ch);
3142
+ }
3143
+ }
3144
+ };
3145
+ process.stdin.on('data', onData);
3146
+ });
3147
+
3148
+ if (newName) {
3149
+ renameSession(sess.id, newName, cwd);
3150
+ sessions[cursor] = { ...sess, name: newName };
3151
+ }
3152
+
3153
+ // Re-enable raw mode and re-attach listener
3154
+ if (canRaw) {
3155
+ try { process.stdin.setRawMode(true); } catch {}
3156
+ }
3157
+ readline.emitKeypressEvents(process.stdin, rl);
3158
+ process.stdin.on('keypress', onKey);
3159
+ render();
3160
+ return;
3161
+ }
3162
+ };
3163
+
3164
+ process.stdin.on('keypress', onKey);
3165
+ });
3166
+
3167
+ return result;
2481
3168
  }
2482
3169
 
2483
3170
  async function sessionManageScreen(rl, ask, ctx = {}) {
@@ -2568,6 +3255,7 @@ const SCREENS = {
2568
3255
  main: mainScreen,
2569
3256
  'new-session': newSessionScreen,
2570
3257
  settings: settingsScreen,
3258
+ 'import-picker': importPickerScreen,
2571
3259
  subscriptions: subscriptionsScreen,
2572
3260
  dashboard: dashboardScreen,
2573
3261
  auth: authScreen,
@@ -2724,22 +3412,25 @@ async function cmdSpecialistGo(specialist, args) {
2724
3412
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
2725
3413
  }
2726
3414
 
2727
- console.log(` specialist : ${specialist}`);
2728
- console.log(` provider : ${decision.provider}`);
2729
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
2730
- console.log(` tier : ${decision.tier}`);
2731
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
2732
- console.log(` reason : ${decision.explanation}`);
3415
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
3416
+ if (dryRun || verbose) {
3417
+ console.log(` specialist : ${specialist}`);
3418
+ console.log(` provider : ${decision.provider}`);
3419
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
3420
+ console.log(` tier : ${decision.tier}`);
3421
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
3422
+ console.log(` reason : ${decision.explanation}`);
3423
+ }
2733
3424
 
2734
3425
  if (dryRun) {
2735
3426
  console.log('\n(dry-run — not executing)');
2736
3427
  return;
2737
3428
  }
2738
3429
 
2739
- console.log('\nDispatching...');
3430
+ if (verbose) console.log('\nDispatching...');
2740
3431
  let result;
2741
3432
  if (decision.dualBrain) {
2742
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
3433
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
2743
3434
  console.log(`\nConsensus: ${result.consensus}`);
2744
3435
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
2745
3436
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -2753,7 +3444,7 @@ async function cmdSpecialistGo(specialist, args) {
2753
3444
  nextAction: null,
2754
3445
  }, cwd);
2755
3446
  } else {
2756
- result = await dispatch({ decision, prompt, files, cwd });
3447
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
2757
3448
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
2758
3449
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
2759
3450
  if (result.summary) console.log(result.summary);
@@ -2813,13 +3504,26 @@ async function main() {
2813
3504
  await runScreens('main');
2814
3505
  }
2815
3506
  } else {
2816
- // Non-TTY: print status card and exit
2817
- const cwd = process.cwd();
2818
- const repo = loadRepoCache(cwd);
2819
- const session = loadSession(cwd);
2820
- const health = getHealth(cwd);
2821
- const card = formatSessionCard(session, repo, health);
2822
- console.log(card);
3507
+ // Non-TTY with no args: read stdin as a task and run one-shot
3508
+ const stdinTask = await new Promise((resolve) => {
3509
+ let data = '';
3510
+ process.stdin.setEncoding('utf8');
3511
+ process.stdin.on('data', chunk => { data += chunk; });
3512
+ process.stdin.on('end', () => resolve(data.trim()));
3513
+ // If stdin has no data within 200ms (not truly piped), fall back to status card
3514
+ setTimeout(() => resolve(null), 200);
3515
+ });
3516
+ if (stdinTask) {
3517
+ process.stderr.write('🧠 routing...\n');
3518
+ await cmdGo([stdinTask]);
3519
+ } else {
3520
+ const cwd = process.cwd();
3521
+ const repo = loadRepoCache(cwd);
3522
+ const session = loadSession(cwd);
3523
+ const health = getHealth(cwd);
3524
+ const card = formatSessionCard(session, repo, health);
3525
+ console.log(card);
3526
+ }
2823
3527
  }
2824
3528
  return;
2825
3529
  }
@@ -2912,6 +3616,43 @@ fi
2912
3616
  return;
2913
3617
  }
2914
3618
 
3619
+ // ─── One-shot mode ────────────────────────────────────────────────────────────
3620
+ // If cmd is not a recognized subcommand, treat the entire arg list as a task.
3621
+ // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
3622
+ const KNOWN_COMMANDS = new Set([
3623
+ 'init', 'install', 'auth', 'go', 'status', 'hot', 'cool',
3624
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook',
3625
+ '--help', '-h', '--version', '-v',
3626
+ ...Object.keys(loadSpecialistRegistry()),
3627
+ ]);
3628
+
3629
+ if (!KNOWN_COMMANDS.has(cmd)) {
3630
+ // All of args are part of the task description (plus any flags like --dry-run/--files).
3631
+ // Join non-flag words into a single prompt string so cmdGo's args.find() picks it up.
3632
+ // We strip out flag values (e.g. the value after --files) before collecting prompt words.
3633
+ process.stderr.write('🧠 routing...\n');
3634
+ const flagValuesToSkip = new Set();
3635
+ const pairedFlags = ['--files'];
3636
+ for (const f of pairedFlags) {
3637
+ const idx = args.indexOf(f);
3638
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
3639
+ flagValuesToSkip.add(args[idx + 1]);
3640
+ }
3641
+ }
3642
+ const passedFlags = [];
3643
+ for (let i = 0; i < args.length; i++) {
3644
+ if (args[i].startsWith('--') || args[i].startsWith('-')) {
3645
+ passedFlags.push(args[i]);
3646
+ if (pairedFlags.includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
3647
+ passedFlags.push(args[++i]);
3648
+ }
3649
+ }
3650
+ }
3651
+ const promptWords = args.filter(a => !a.startsWith('--') && !a.startsWith('-') && !flagValuesToSkip.has(a));
3652
+ await cmdGo([promptWords.join(' '), ...passedFlags]);
3653
+ return;
3654
+ }
3655
+
2915
3656
  process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
2916
3657
  process.exit(1);
2917
3658
  }