dual-brain 0.2.2 → 0.2.3
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 +343 -13
- package/package.json +1 -1
- package/src/dispatch.mjs +14 -0
- package/src/pipeline.mjs +6 -0
- package/src/profile.mjs +276 -0
- package/src/receipt.mjs +213 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -1668,6 +1668,141 @@ function makeBoxRow(content, W) {
|
|
|
1668
1668
|
return `│ ${content}${' '.repeat(padding)} │`;
|
|
1669
1669
|
}
|
|
1670
1670
|
|
|
1671
|
+
// ─── Command palette: input classifier ───────────────────────────────────────
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Classify user input into one of three tiers:
|
|
1675
|
+
* { tier: 'free', command, args } — deterministic, zero tokens
|
|
1676
|
+
* { tier: 'cheap' } — question → haiku
|
|
1677
|
+
* { tier: 'full' } — work task → confirm then dispatch
|
|
1678
|
+
*/
|
|
1679
|
+
function classifyInput(input) {
|
|
1680
|
+
const trimmed = input.trim();
|
|
1681
|
+
const lower = trimmed.toLowerCase();
|
|
1682
|
+
const parts = trimmed.split(/\s+/);
|
|
1683
|
+
const cmd = parts[0].toLowerCase();
|
|
1684
|
+
const args = parts.slice(1);
|
|
1685
|
+
|
|
1686
|
+
// Tier 1: FREE — exact command matches
|
|
1687
|
+
const FREE_COMMANDS = new Map([
|
|
1688
|
+
['resume', 'resume'],
|
|
1689
|
+
['r', 'resume'],
|
|
1690
|
+
['status', 'status'],
|
|
1691
|
+
['sessions', 'sessions'],
|
|
1692
|
+
['ss', 'sessions'],
|
|
1693
|
+
['settings', 'settings'],
|
|
1694
|
+
['s', 'settings'],
|
|
1695
|
+
['team', 'team'],
|
|
1696
|
+
['t', 'team'],
|
|
1697
|
+
['doctor', 'doctor'],
|
|
1698
|
+
['d', 'doctor'],
|
|
1699
|
+
['health', 'health'],
|
|
1700
|
+
['h', 'health'],
|
|
1701
|
+
['projects', 'projects'],
|
|
1702
|
+
['p', 'projects'],
|
|
1703
|
+
['help', 'help'],
|
|
1704
|
+
['?', 'help'],
|
|
1705
|
+
['quit', 'quit'],
|
|
1706
|
+
['q', 'quit'],
|
|
1707
|
+
['exit', 'quit'],
|
|
1708
|
+
['budget', 'budget'],
|
|
1709
|
+
['b', 'budget'],
|
|
1710
|
+
]);
|
|
1711
|
+
|
|
1712
|
+
if (FREE_COMMANDS.has(cmd)) {
|
|
1713
|
+
return { tier: 'free', command: FREE_COMMANDS.get(cmd), args };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Multi-word free commands
|
|
1717
|
+
if (lower.startsWith('search ')) {
|
|
1718
|
+
return { tier: 'free', command: 'search', args: parts.slice(1) };
|
|
1719
|
+
}
|
|
1720
|
+
if (lower === 'init --replit') {
|
|
1721
|
+
return { tier: 'free', command: 'init --replit', args: [] };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Tier 2: CHEAP — question / diagnostic patterns → haiku
|
|
1725
|
+
const QUESTION_WORDS = /^(why|what|how|where|when|who|is my|check|show me|explain|tell me|list|am i|are there|does|did|can i|will|should i)/i;
|
|
1726
|
+
const QUESTION_CONTAINS = /\b(why|what|how is|how are|where is|where are|explain|tell me|show me)\b/i;
|
|
1727
|
+
if (QUESTION_WORDS.test(lower) || QUESTION_CONTAINS.test(lower)) {
|
|
1728
|
+
return { tier: 'cheap' };
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Tier 3: FULL — everything else is a work task
|
|
1732
|
+
return { tier: 'full' };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// ─── Dashboard: resume state detection ───────────────────────────────────────
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Detect resumable state for dashboard contextual hint.
|
|
1739
|
+
* Returns an object with type ('resumable' | 'fresh' | 'none') and detail fields.
|
|
1740
|
+
* All checks are best-effort and fail silent.
|
|
1741
|
+
*/
|
|
1742
|
+
async function detectResumeState(cwd) {
|
|
1743
|
+
const result = { type: 'none', label: null, ageLabel: null, nextAction: null };
|
|
1744
|
+
|
|
1745
|
+
// Check for recent receipt (< 24h)
|
|
1746
|
+
try {
|
|
1747
|
+
const { getLatestReceipt } = await import('../src/receipt.mjs');
|
|
1748
|
+
const receipt = getLatestReceipt(cwd);
|
|
1749
|
+
if (receipt) {
|
|
1750
|
+
const ageMs = Date.now() - Date.parse(receipt.timestamp);
|
|
1751
|
+
if (ageMs < 24 * 60 * 60 * 1000) {
|
|
1752
|
+
const mins = Math.round(ageMs / 60000);
|
|
1753
|
+
const age = mins < 60
|
|
1754
|
+
? `${mins}m ago`
|
|
1755
|
+
: mins < 1440
|
|
1756
|
+
? `${Math.round(mins / 60)}h ago`
|
|
1757
|
+
: `${Math.round(mins / 1440)}d ago`;
|
|
1758
|
+
const fileCount = (receipt.filesChanged || []).length;
|
|
1759
|
+
const filePart = fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : '';
|
|
1760
|
+
result.type = 'resumable';
|
|
1761
|
+
result.label = (receipt.goal || 'last session').slice(0, 40);
|
|
1762
|
+
result.ageLabel = age;
|
|
1763
|
+
result.filePart = filePart;
|
|
1764
|
+
result.nextAction = (receipt.nextAction || '').slice(0, 35);
|
|
1765
|
+
return result;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
} catch { /* non-fatal */ }
|
|
1769
|
+
|
|
1770
|
+
// Check for open tasks in ledger
|
|
1771
|
+
try {
|
|
1772
|
+
const { getOpenTasks } = await import('../src/ledger.mjs');
|
|
1773
|
+
const open = getOpenTasks(cwd);
|
|
1774
|
+
if (open.length > 0) {
|
|
1775
|
+
result.type = 'resumable';
|
|
1776
|
+
result.label = (open[0].intent || 'open task').slice(0, 40);
|
|
1777
|
+
result.ageLabel = null;
|
|
1778
|
+
result.filePart = '';
|
|
1779
|
+
result.nextAction = `${open.length} open task${open.length !== 1 ? 's' : ''}`;
|
|
1780
|
+
return result;
|
|
1781
|
+
}
|
|
1782
|
+
} catch { /* non-fatal */ }
|
|
1783
|
+
|
|
1784
|
+
// Check if this is a fresh project (package.json but no dual-brain history)
|
|
1785
|
+
try {
|
|
1786
|
+
const { existsSync: exists } = await import('node:fs');
|
|
1787
|
+
const { join: pjoin } = await import('node:path');
|
|
1788
|
+
const hasPkg = exists(pjoin(cwd, 'package.json'));
|
|
1789
|
+
const hasHistory = exists(pjoin(cwd, '.dual-brain', 'receipts'));
|
|
1790
|
+
if (hasPkg && !hasHistory) {
|
|
1791
|
+
let pkgName = 'this project';
|
|
1792
|
+
try {
|
|
1793
|
+
const { readFileSync: rfs } = await import('node:fs');
|
|
1794
|
+
const pkg = JSON.parse(rfs(pjoin(cwd, 'package.json'), 'utf8'));
|
|
1795
|
+
if (pkg.name) pkgName = pkg.name;
|
|
1796
|
+
} catch {}
|
|
1797
|
+
result.type = 'fresh';
|
|
1798
|
+
result.label = pkgName;
|
|
1799
|
+
return result;
|
|
1800
|
+
}
|
|
1801
|
+
} catch { /* non-fatal */ }
|
|
1802
|
+
|
|
1803
|
+
return result;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1671
1806
|
// ─── Screen: mainScreen ───────────────────────────────────────────────────────
|
|
1672
1807
|
|
|
1673
1808
|
async function mainScreen(rl, ask) {
|
|
@@ -1987,11 +2122,12 @@ async function mainScreen(rl, ask) {
|
|
|
1987
2122
|
const rtInfo = replitMod.inspectReplitTools(cwd);
|
|
1988
2123
|
const authInfo = replitMod.getAuthStatus(cwd);
|
|
1989
2124
|
const archive = replitMod.getSessionArchive(cwd);
|
|
1990
|
-
const archCount = Array.isArray(archive) ? archive.length : (archive?.count ?? 0);
|
|
2125
|
+
const archCount = Array.isArray(archive) ? archive.length : (archive?.totalSessions ?? archive?.count ?? 0);
|
|
1991
2126
|
const secretNames = replitMod.listSecretNames();
|
|
1992
2127
|
const secretCount = Array.isArray(secretNames) ? secretNames.length : 0;
|
|
1993
2128
|
const verStr = rtInfo.version ? `v${rtInfo.version}` : (rtInfo.installed ? 'installed' : 'not installed');
|
|
1994
|
-
const
|
|
2129
|
+
const isAuthenticated = authInfo.authenticated ?? (authInfo.available && authInfo.tokenStatus === 'valid');
|
|
2130
|
+
const authStr = isAuthenticated ? '\x1b[32m✓\x1b[0m auth' : '\x1b[2mno auth\x1b[0m';
|
|
1995
2131
|
replitAwarenessRows.push(row(`\x1b[2m🔧\x1b[0m Replit replit-tools ${verStr} ${authStr}`));
|
|
1996
2132
|
replitAwarenessRows.push(row(`\x1b[2m \x1b[0m ${archCount} archived session${archCount !== 1 ? 's' : ''} ${secretCount} secret${secretCount !== 1 ? 's' : ''}`));
|
|
1997
2133
|
}
|
|
@@ -2058,16 +2194,41 @@ async function mainScreen(rl, ask) {
|
|
|
2058
2194
|
});
|
|
2059
2195
|
}
|
|
2060
2196
|
|
|
2197
|
+
// ── Resume state detection ────────────────────────────────────────────────
|
|
2198
|
+
let resumeStateRows = [];
|
|
2199
|
+
const resumeState = await detectResumeState(cwd);
|
|
2200
|
+
if (resumeState.type === 'resumable') {
|
|
2201
|
+
const DIM = '\x1b[2m';
|
|
2202
|
+
const RESET = '\x1b[0m';
|
|
2203
|
+
const CYAN = '\x1b[36m';
|
|
2204
|
+
const labelTrunc = resumeState.label || 'last session';
|
|
2205
|
+
const agePart = resumeState.ageLabel ? ` (${resumeState.ageLabel})` : '';
|
|
2206
|
+
const filePart = resumeState.filePart || '';
|
|
2207
|
+
const nextPart = resumeState.nextAction ? ` · next: ${resumeState.nextAction}` : '';
|
|
2208
|
+
resumeStateRows = [
|
|
2209
|
+
row(`${CYAN}Last:${RESET} "${labelTrunc}"${agePart}${filePart}${nextPart}`),
|
|
2210
|
+
row(`${DIM}[Enter] resume [n] new [?] help${RESET}`),
|
|
2211
|
+
];
|
|
2212
|
+
} else if (resumeState.type === 'fresh' && recentSessions.length === 0) {
|
|
2213
|
+
const DIM = '\x1b[2m';
|
|
2214
|
+
const RESET = '\x1b[0m';
|
|
2215
|
+
resumeStateRows = [
|
|
2216
|
+
row(`New project: ${resumeState.label}`),
|
|
2217
|
+
row(`${DIM}Type what you want to do [?] help${RESET}`),
|
|
2218
|
+
];
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2061
2221
|
// ── Box 5 — Input bar ──────────────────────────────────────────────────
|
|
2062
|
-
const actionsContent = '>
|
|
2222
|
+
const actionsContent = '> task or command... [?] help [q] quit';
|
|
2063
2223
|
const actionsRow = row(actionsContent);
|
|
2064
2224
|
|
|
2065
2225
|
// ── Print the full 5-box layout ───────────────────────────────────────────
|
|
2066
2226
|
// Box 1: header (title + provider dots + work style)
|
|
2067
2227
|
// Box 2: workspace (branch · uncommitted · ahead, last commit, open PRs)
|
|
2068
2228
|
// Box 3: awareness (observer, roadmap, risk)
|
|
2069
|
-
// Box 4: sessions
|
|
2229
|
+
// Box 4: sessions (+ resume state hint if applicable)
|
|
2070
2230
|
// Box 5: input bar
|
|
2231
|
+
const hasResumeHint = resumeStateRows.length > 0;
|
|
2071
2232
|
const lines = [
|
|
2072
2233
|
top,
|
|
2073
2234
|
row(`🧠 dual-brain v${version}`),
|
|
@@ -2078,6 +2239,7 @@ async function mainScreen(rl, ask) {
|
|
|
2078
2239
|
...awarenessRows,
|
|
2079
2240
|
sep,
|
|
2080
2241
|
...sessionRows,
|
|
2242
|
+
...(hasResumeHint ? [sep, ...resumeStateRows] : []),
|
|
2081
2243
|
sep,
|
|
2082
2244
|
actionsRow,
|
|
2083
2245
|
bot,
|
|
@@ -2177,7 +2339,7 @@ async function mainScreen(rl, ask) {
|
|
|
2177
2339
|
// Single-key commands only fire when buffer is empty
|
|
2178
2340
|
if (taskBuffer.length === 0) {
|
|
2179
2341
|
const lower = str.toLowerCase();
|
|
2180
|
-
const singleKeySet = new Set(['n', 's', 't', 'q', '/', 'i']);
|
|
2342
|
+
const singleKeySet = new Set(['n', 's', 't', 'q', '/', 'i', '?', 'h', 'd']);
|
|
2181
2343
|
if (singleKeySet.has(lower)) {
|
|
2182
2344
|
cleanup();
|
|
2183
2345
|
process.stdout.write('\n');
|
|
@@ -2203,15 +2365,124 @@ async function mainScreen(rl, ask) {
|
|
|
2203
2365
|
|
|
2204
2366
|
const choice = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
2205
2367
|
|
|
2206
|
-
// Typed
|
|
2368
|
+
// ── Typed input — run through command palette ─────────────────────────────
|
|
2207
2369
|
if (raw.startsWith('__task__:')) {
|
|
2208
|
-
const
|
|
2209
|
-
if (
|
|
2210
|
-
|
|
2370
|
+
const input = raw.slice('__task__:'.length).trim();
|
|
2371
|
+
if (!input) return { next: 'main' };
|
|
2372
|
+
|
|
2373
|
+
const classified = classifyInput(input);
|
|
2374
|
+
|
|
2375
|
+
// Tier 1: FREE — deterministic, zero tokens
|
|
2376
|
+
if (classified.tier === 'free') {
|
|
2377
|
+
const cmd = classified.command;
|
|
2378
|
+
const args = classified.args;
|
|
2379
|
+
|
|
2380
|
+
if (cmd === 'resume' || cmd === 'r') {
|
|
2381
|
+
if (recentSessions.length === 0) return { next: 'new-session' };
|
|
2382
|
+
return { next: 'sessions' };
|
|
2383
|
+
}
|
|
2384
|
+
if (cmd === 'status' || cmd === 's') {
|
|
2385
|
+
await cmdStatus([]);
|
|
2386
|
+
await ask('\n Press Enter to continue...');
|
|
2387
|
+
return { next: 'main' };
|
|
2388
|
+
}
|
|
2389
|
+
if (cmd === 'sessions' || cmd === 'ss') {
|
|
2390
|
+
return { next: 'sessions' };
|
|
2391
|
+
}
|
|
2392
|
+
if (cmd === 'settings') {
|
|
2393
|
+
return { next: 'settings' };
|
|
2394
|
+
}
|
|
2395
|
+
if (cmd === 'team' || cmd === 't') {
|
|
2396
|
+
return { next: 'team' };
|
|
2397
|
+
}
|
|
2398
|
+
if (cmd === 'doctor' || cmd === 'd') {
|
|
2399
|
+
return { next: 'diagnostics' };
|
|
2400
|
+
}
|
|
2401
|
+
if (cmd === 'health' || cmd === 'h') {
|
|
2402
|
+
const hooksDir = join(cwd, '.claude', 'hooks');
|
|
2403
|
+
const healthScript = join(hooksDir, 'health-check.mjs');
|
|
2404
|
+
const { spawnSync: sp } = await import('node:child_process');
|
|
2405
|
+
if (existsSync(healthScript)) {
|
|
2406
|
+
sp('node', [healthScript], { stdio: 'inherit', cwd });
|
|
2407
|
+
} else {
|
|
2408
|
+
process.stdout.write('\n health-check.mjs not found — run: dual-brain install\n');
|
|
2409
|
+
}
|
|
2410
|
+
await ask('\n Press Enter to continue...');
|
|
2411
|
+
return { next: 'main' };
|
|
2412
|
+
}
|
|
2413
|
+
if (cmd === 'help' || cmd === '?') {
|
|
2414
|
+
return { next: 'palette-help' };
|
|
2415
|
+
}
|
|
2416
|
+
if (cmd === 'quit' || cmd === 'q') {
|
|
2417
|
+
return { next: 'exit' };
|
|
2418
|
+
}
|
|
2419
|
+
if (cmd === 'search') {
|
|
2420
|
+
const query = args.join(' ');
|
|
2421
|
+
if (!query) {
|
|
2422
|
+
const q2 = (await ask(' Search: ')).trim();
|
|
2423
|
+
if (!q2) return { next: 'main' };
|
|
2424
|
+
args.push(q2);
|
|
2425
|
+
}
|
|
2426
|
+
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
2427
|
+
try { buildSessionIndex(cwd); } catch {}
|
|
2428
|
+
const results = searchSessions(args.join(' '), cwd);
|
|
2429
|
+
if (results.length === 0) {
|
|
2430
|
+
process.stdout.write(`\n No sessions matching "${args.join(' ')}"\n\n`);
|
|
2431
|
+
await ask(' Press Enter to continue...');
|
|
2432
|
+
return { next: 'main' };
|
|
2433
|
+
}
|
|
2434
|
+
process.stdout.write(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
|
|
2435
|
+
results.slice(0, 9).forEach((sess, i) => {
|
|
2436
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
2437
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
2438
|
+
process.stdout.write(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}\n`);
|
|
2439
|
+
});
|
|
2440
|
+
process.stdout.write('\n');
|
|
2441
|
+
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
2442
|
+
const num = parseInt(pick, 10);
|
|
2443
|
+
if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
|
|
2444
|
+
const sess = results[num - 1];
|
|
2445
|
+
const { spawnSync: sp2 } = await import('node:child_process');
|
|
2446
|
+
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
2447
|
+
process.stdout.write(`\n Launching: ${tool} --resume ${sess.id}\n\n`);
|
|
2448
|
+
sp2(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
2449
|
+
}
|
|
2450
|
+
return { next: 'main' };
|
|
2451
|
+
}
|
|
2452
|
+
if (cmd === 'budget') {
|
|
2453
|
+
await cmdStatus([]);
|
|
2454
|
+
await ask('\n Press Enter to continue...');
|
|
2455
|
+
return { next: 'main' };
|
|
2456
|
+
}
|
|
2457
|
+
if (cmd === 'init --replit') {
|
|
2458
|
+
await cmdInit(rl);
|
|
2459
|
+
return { next: 'main' };
|
|
2460
|
+
}
|
|
2461
|
+
// fallthrough: unknown free command → treat as full task
|
|
2211
2462
|
}
|
|
2212
|
-
|
|
2463
|
+
|
|
2464
|
+
// Tier 2: CHEAP — question/diagnostic, route to haiku
|
|
2465
|
+
if (classified.tier === 'cheap') {
|
|
2466
|
+
process.stdout.write(`\n Routing to haiku for quick answer...\n`);
|
|
2467
|
+
return { next: 'go', prompt: input, model: 'haiku' };
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// Tier 3: FULL — work task, confirm before dispatching
|
|
2471
|
+
if (classified.tier === 'full') {
|
|
2472
|
+
const summary = input.length > 60 ? input.slice(0, 57) + '...' : input;
|
|
2473
|
+
process.stdout.write(`\n Launch coding session: ${summary}\n`);
|
|
2474
|
+
process.stdout.write(` Model: sonnet [Enter] to proceed, [n] to cancel\n\n`);
|
|
2475
|
+
const confirm = (await ask(' > ')).trim().toLowerCase();
|
|
2476
|
+
if (confirm === 'n' || confirm === 'no') return { next: 'main' };
|
|
2477
|
+
return { next: 'go', prompt: input };
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// Default fallback
|
|
2481
|
+
return { next: 'go', prompt: input };
|
|
2213
2482
|
}
|
|
2214
2483
|
|
|
2484
|
+
// ── Single-key shortcuts ───────────────────────────────────────────────────
|
|
2485
|
+
|
|
2215
2486
|
// Enter (empty) → resume most recent session
|
|
2216
2487
|
if (raw === '' || choice === '\r') {
|
|
2217
2488
|
if (recentSessions.length === 0) {
|
|
@@ -2225,7 +2496,7 @@ async function mainScreen(rl, ask) {
|
|
|
2225
2496
|
return { next: 'main' };
|
|
2226
2497
|
}
|
|
2227
2498
|
|
|
2228
|
-
// Number 1-
|
|
2499
|
+
// Number 1-9 → resume that session
|
|
2229
2500
|
const numChoice = parseInt(raw, 10);
|
|
2230
2501
|
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
|
|
2231
2502
|
const sess = recentSessions[numChoice - 1];
|
|
@@ -2245,6 +2516,8 @@ async function mainScreen(rl, ask) {
|
|
|
2245
2516
|
}
|
|
2246
2517
|
|
|
2247
2518
|
if (choice === 'n') { return { next: 'new-session' }; }
|
|
2519
|
+
if (choice === '?' || choice === 'h') { return { next: 'palette-help' }; }
|
|
2520
|
+
if (choice === 'd') { return { next: 'diagnostics' }; }
|
|
2248
2521
|
|
|
2249
2522
|
if (choice === '/') {
|
|
2250
2523
|
const query = (await ask(' Search: ')).trim();
|
|
@@ -2303,6 +2576,48 @@ async function newSessionScreen(rl, ask) {
|
|
|
2303
2576
|
return { next: 'main' };
|
|
2304
2577
|
}
|
|
2305
2578
|
|
|
2579
|
+
// ─── Screen: paletteHelpScreen ───────────────────────────────────────────────
|
|
2580
|
+
|
|
2581
|
+
async function paletteHelpScreen(rl, ask) {
|
|
2582
|
+
const termW = process.stdout.columns || 60;
|
|
2583
|
+
const boxW = Math.min(termW - 2, 60);
|
|
2584
|
+
const W = boxW - 4;
|
|
2585
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2586
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2587
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2588
|
+
const row = (content) => makeBoxRow(content, W);
|
|
2589
|
+
const DIM = '\x1b[2m';
|
|
2590
|
+
const RESET = '\x1b[0m';
|
|
2591
|
+
|
|
2592
|
+
const lines = [
|
|
2593
|
+
top,
|
|
2594
|
+
row('Command Palette'),
|
|
2595
|
+
sep,
|
|
2596
|
+
row(`${DIM}resume r${RESET} Resume last session`),
|
|
2597
|
+
row(`${DIM}status${RESET} Provider health + budget`),
|
|
2598
|
+
row(`${DIM}sessions ss${RESET} List recent sessions`),
|
|
2599
|
+
row(`${DIM}search <query>${RESET} Search session history`),
|
|
2600
|
+
row(`${DIM}budget b${RESET} Token usage + routing`),
|
|
2601
|
+
row(`${DIM}health h${RESET} System health check`),
|
|
2602
|
+
row(`${DIM}doctor d${RESET} Repo diagnostics`),
|
|
2603
|
+
row(`${DIM}settings s${RESET} Settings screen`),
|
|
2604
|
+
row(`${DIM}team t${RESET} Team screen`),
|
|
2605
|
+
row(`${DIM}help ?${RESET} Show this help`),
|
|
2606
|
+
row(`${DIM}quit q${RESET} Exit`),
|
|
2607
|
+
sep,
|
|
2608
|
+
row('Or type any task to launch a coding session'),
|
|
2609
|
+
row(`${DIM}Questions (why/how/what) → haiku (cheap)${RESET}`),
|
|
2610
|
+
row(`${DIM}Work tasks → confirm then dispatch${RESET}`),
|
|
2611
|
+
sep,
|
|
2612
|
+
row(`${DIM}[Enter] go back${RESET}`),
|
|
2613
|
+
bot,
|
|
2614
|
+
];
|
|
2615
|
+
|
|
2616
|
+
process.stdout.write('\n' + lines.join('\n') + '\n\n');
|
|
2617
|
+
await ask('');
|
|
2618
|
+
return { next: 'main' };
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2306
2621
|
// ─── Screen: importPickerScreen ──────────────────────────────────────────────
|
|
2307
2622
|
|
|
2308
2623
|
async function importPickerScreen() {
|
|
@@ -4476,6 +4791,7 @@ const SCREENS = {
|
|
|
4476
4791
|
welcome: welcomeScreen,
|
|
4477
4792
|
main: mainScreen,
|
|
4478
4793
|
'new-session': newSessionScreen,
|
|
4794
|
+
'palette-help': paletteHelpScreen,
|
|
4479
4795
|
settings: settingsScreen,
|
|
4480
4796
|
team: teamScreen,
|
|
4481
4797
|
'import-picker': importPickerScreen,
|
|
@@ -4502,7 +4818,21 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
4502
4818
|
if (current === 'go' && ctx.prompt) {
|
|
4503
4819
|
const prompt = ctx.prompt;
|
|
4504
4820
|
const dryRun = ctx.dryRun || false;
|
|
4505
|
-
|
|
4821
|
+
// Haiku tier: dispatch with model override for cheap question answers
|
|
4822
|
+
if (ctx.model === 'haiku') {
|
|
4823
|
+
process.stdout.write('\n');
|
|
4824
|
+
try {
|
|
4825
|
+
const { runPipeline: rp } = await import('../src/pipeline.mjs');
|
|
4826
|
+
const { result } = await rp('go', prompt, { cwd: process.cwd(), dryRun, forceDepth: 'shallow' });
|
|
4827
|
+
if (result?.output) process.stdout.write('\n' + String(result.output).trim() + '\n\n');
|
|
4828
|
+
else process.stdout.write(' (no output)\n\n');
|
|
4829
|
+
} catch (e) {
|
|
4830
|
+
// Fall back to normal dispatch on error
|
|
4831
|
+
await cmdGo([prompt], { dryRun });
|
|
4832
|
+
}
|
|
4833
|
+
} else {
|
|
4834
|
+
await cmdGo([prompt], { dryRun });
|
|
4835
|
+
}
|
|
4506
4836
|
current = 'main';
|
|
4507
4837
|
ctx = {};
|
|
4508
4838
|
continue;
|
|
@@ -4515,7 +4845,7 @@ async function runScreens(startScreen = 'dashboard') {
|
|
|
4515
4845
|
current = result?.next || 'exit';
|
|
4516
4846
|
// Pass through context (e.g. selected session, typed prompt, openPRs) to next screen
|
|
4517
4847
|
ctx = result?.session ? { session: result.session }
|
|
4518
|
-
: result?.prompt ? { prompt: result.prompt }
|
|
4848
|
+
: result?.prompt ? { prompt: result.prompt, model: result.model }
|
|
4519
4849
|
: result?.openPRs ? { openPRs: result.openPRs }
|
|
4520
4850
|
: {};
|
|
4521
4851
|
} catch (e) {
|
package/package.json
CHANGED
package/src/dispatch.mjs
CHANGED
|
@@ -672,6 +672,20 @@ async function dispatch(input = {}) {
|
|
|
672
672
|
// Safety gate: redact secrets before anything reaches a subprocess or log
|
|
673
673
|
prompt = redact(prompt);
|
|
674
674
|
|
|
675
|
+
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
676
|
+
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
677
|
+
// This closes the receipt → brief → next session loop automatically.
|
|
678
|
+
if (!input.situationBrief) {
|
|
679
|
+
try {
|
|
680
|
+
const { buildResumeBrief } = await import('./receipt.mjs');
|
|
681
|
+
const brief = buildResumeBrief(cwd);
|
|
682
|
+
if (brief) {
|
|
683
|
+
input = { ...input, situationBrief: brief };
|
|
684
|
+
}
|
|
685
|
+
} catch { /* non-blocking */ }
|
|
686
|
+
}
|
|
687
|
+
// ── End resume brief injection ───────────────────────────────────────────────
|
|
688
|
+
|
|
675
689
|
// ── Related session context injection ────────────────────────────────────────
|
|
676
690
|
// Find past sessions related to this task and prepend a context block.
|
|
677
691
|
// Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
|
package/src/pipeline.mjs
CHANGED
|
@@ -1134,6 +1134,12 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1134
1134
|
return { success: false, gateFailure: 'outcome', reason: run.gates.outcome.reason, run };
|
|
1135
1135
|
}
|
|
1136
1136
|
|
|
1137
|
+
// Post-session receipt — capture what happened and seed next session's context
|
|
1138
|
+
try {
|
|
1139
|
+
const { generateReceipt } = await import('./receipt.mjs');
|
|
1140
|
+
generateReceipt(run, cwd);
|
|
1141
|
+
} catch { /* non-blocking */ }
|
|
1142
|
+
|
|
1137
1143
|
// Persist decision for future recall
|
|
1138
1144
|
if (run.result && !run.result?.error) {
|
|
1139
1145
|
try {
|
package/src/profile.mjs
CHANGED
|
@@ -818,6 +818,282 @@ function listSubscriptions(cwd) {
|
|
|
818
818
|
// CLI
|
|
819
819
|
// ---------------------------------------------------------------------------
|
|
820
820
|
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
// Capability Manifest — single runtime view of all provider/subscription state
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
/** 60-second in-process cache for the manifest. */
|
|
826
|
+
let _manifestCache = null;
|
|
827
|
+
let _manifestCachedAt = 0;
|
|
828
|
+
const MANIFEST_TTL_MS = 60_000;
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Build a normalized capability manifest that consolidates provider health,
|
|
832
|
+
* subscription config, user preferences, policy, and learning data.
|
|
833
|
+
*
|
|
834
|
+
* @param {string} [cwd]
|
|
835
|
+
* @returns {Promise<object>}
|
|
836
|
+
*/
|
|
837
|
+
export async function getCapabilityManifest(cwd = process.cwd()) {
|
|
838
|
+
const now = Date.now();
|
|
839
|
+
if (_manifestCache && now - _manifestCachedAt < MANIFEST_TTL_MS) {
|
|
840
|
+
return _manifestCache;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ── Read orchestrator.json for subscription config ─────────────────────
|
|
844
|
+
let orchConfig = {};
|
|
845
|
+
try {
|
|
846
|
+
const orchPath = join(cwd, 'orchestrator.json');
|
|
847
|
+
orchConfig = JSON.parse(readFileSync(orchPath, 'utf8'));
|
|
848
|
+
} catch { /* missing or malformed — fall through */ }
|
|
849
|
+
|
|
850
|
+
const orchSubs = orchConfig.subscriptions ?? {};
|
|
851
|
+
const orchProv = orchConfig.providers ?? {};
|
|
852
|
+
|
|
853
|
+
// ── Plan normalizer (orchestrator.json uses "$100", "max-5x", "pro" etc) ─
|
|
854
|
+
function normalizePlan(raw) {
|
|
855
|
+
if (!raw) return 'unknown';
|
|
856
|
+
const s = String(raw).toLowerCase();
|
|
857
|
+
if (s.includes('max') && s.includes('20')) return 'max20';
|
|
858
|
+
if (s.includes('max') && (s.includes('5') || s.includes('5x'))) return 'max5';
|
|
859
|
+
if (s.includes('pro')) return 'pro';
|
|
860
|
+
if (s.includes('plus')) return 'plus';
|
|
861
|
+
if (s === '$20' || s === '20') return 'pro';
|
|
862
|
+
if (s === '$100' || s === '100') return 'max5';
|
|
863
|
+
if (s === '$200' || s === '200') return 'max20';
|
|
864
|
+
return 'unknown';
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ── Health states ──────────────────────────────────────────────────────
|
|
868
|
+
let healthStates = {};
|
|
869
|
+
try {
|
|
870
|
+
const { getHealth } = await import('./health.mjs');
|
|
871
|
+
healthStates = getHealth(cwd).states ?? {};
|
|
872
|
+
} catch { /* health.mjs unavailable */ }
|
|
873
|
+
|
|
874
|
+
function deriveHealth(providerKey) {
|
|
875
|
+
// Aggregate across all model classes for the provider
|
|
876
|
+
const entries = Object.entries(healthStates)
|
|
877
|
+
.filter(([k]) => k.startsWith(providerKey + ':'))
|
|
878
|
+
.map(([, v]) => v?.status ?? 'healthy');
|
|
879
|
+
if (entries.length === 0) return 'healthy';
|
|
880
|
+
if (entries.some(s => s === 'hot')) return 'rate-limited';
|
|
881
|
+
if (entries.some(s => s === 'degraded')) return 'degraded';
|
|
882
|
+
if (entries.some(s => s === 'probing')) return 'degraded';
|
|
883
|
+
return 'healthy';
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ── Budget pressure from health file (simple proxy) ────────────────────
|
|
887
|
+
function deriveBudget(providerKey) {
|
|
888
|
+
const hotEntries = Object.entries(healthStates)
|
|
889
|
+
.filter(([k]) => k.startsWith(providerKey + ':'))
|
|
890
|
+
.filter(([, v]) => v?.status === 'hot');
|
|
891
|
+
if (hotEntries.length === 0) return { pressure5h: 0, pressure7d: 0 };
|
|
892
|
+
// Clamp to 0.9 when hot — we don't have real token data here
|
|
893
|
+
const pressure = Math.min(0.9, 0.5 + hotEntries.length * 0.15);
|
|
894
|
+
return { pressure5h: pressure, pressure7d: pressure * 0.6 };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ── Claude provider ────────────────────────────────────────────────────
|
|
898
|
+
const claudeProvider = { available: false, authenticated: false, plan: 'unknown',
|
|
899
|
+
models: ['opus', 'sonnet', 'haiku'], health: 'healthy',
|
|
900
|
+
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
// available: claude CLI or CLAUDE_CODE env or replit-tools claude dir
|
|
904
|
+
const claudeDir = join(homedir(), '.claude');
|
|
905
|
+
const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
|
|
906
|
+
if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) {
|
|
907
|
+
claudeProvider.available = true;
|
|
908
|
+
claudeProvider.source = process.env.ANTHROPIC_API_KEY ? 'env' : 'credentials';
|
|
909
|
+
} else if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
|
|
910
|
+
claudeProvider.available = true;
|
|
911
|
+
claudeProvider.source = existsSync(replitClaudeDir) ? 'replit-tools' : 'credentials';
|
|
912
|
+
} else {
|
|
913
|
+
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); claudeProvider.available = true; claudeProvider.source = 'credentials'; } catch { /* not found */ }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// authenticated: use getAuthHealthStatus
|
|
917
|
+
const { getAuthHealthStatus } = await import('./health.mjs');
|
|
918
|
+
const authStatus = await getAuthHealthStatus(cwd);
|
|
919
|
+
claudeProvider.authenticated = authStatus.ok;
|
|
920
|
+
if (authStatus.source === 'replit-tools') claudeProvider.source = 'replit-tools';
|
|
921
|
+
} catch { /* getAuthHealthStatus unavailable */ }
|
|
922
|
+
|
|
923
|
+
claudeProvider.plan = normalizePlan(orchProv.claude?.subscription ?? orchSubs.claude?.plan);
|
|
924
|
+
claudeProvider.health = claudeProvider.authenticated ? deriveHealth('claude') : 'down';
|
|
925
|
+
claudeProvider.budget = deriveBudget('claude');
|
|
926
|
+
|
|
927
|
+
// ── OpenAI provider ────────────────────────────────────────────────────
|
|
928
|
+
const openaiProvider = { available: false, authenticated: false, plan: 'unknown',
|
|
929
|
+
models: ['gpt-5.5', 'o3', 'gpt-4o', 'gpt-4o-mini'], health: 'healthy',
|
|
930
|
+
budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
let hasSecret = false;
|
|
934
|
+
try { const { hasSecret: hs } = await import('./replit.mjs'); hasSecret = hs('OPENAI_API_KEY'); } catch { hasSecret = !!(process.env.OPENAI_API_KEY); }
|
|
935
|
+
|
|
936
|
+
let codexAvailable = false;
|
|
937
|
+
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); codexAvailable = true; } catch { /* not in PATH */ }
|
|
938
|
+
|
|
939
|
+
openaiProvider.available = hasSecret || codexAvailable;
|
|
940
|
+
openaiProvider.authenticated = hasSecret;
|
|
941
|
+
openaiProvider.source = hasSecret ? 'env' : codexAvailable ? 'codex-config' : 'none';
|
|
942
|
+
} catch { /* detection failed */ }
|
|
943
|
+
|
|
944
|
+
openaiProvider.plan = normalizePlan(orchProv.openai?.subscription ?? orchSubs.openai?.plan);
|
|
945
|
+
openaiProvider.health = openaiProvider.authenticated ? deriveHealth('openai') : 'down';
|
|
946
|
+
openaiProvider.budget = deriveBudget('openai');
|
|
947
|
+
|
|
948
|
+
// ── Preferences ────────────────────────────────────────────────────────
|
|
949
|
+
let preferences = { bias: 'auto', forbiddenModels: [], preferredModels: [],
|
|
950
|
+
costBias: 0.5, confirmBeforeExpensive: false };
|
|
951
|
+
try {
|
|
952
|
+
const profile = loadProfile(cwd);
|
|
953
|
+
const bias = profile.bias ?? profile.workStyle ?? 'auto';
|
|
954
|
+
preferences.bias = ['auto','balanced','cost-saver','quality-first'].includes(bias) ? bias : 'auto';
|
|
955
|
+
preferences.forbiddenModels = profile.forbiddenModels ?? [];
|
|
956
|
+
preferences.preferredModels = profile.preferredModels ?? [];
|
|
957
|
+
preferences.costBias = profile.costBias ?? (bias === 'cost-saver' ? 0.8 : bias === 'quality-first' ? 0.1 : 0.5);
|
|
958
|
+
preferences.confirmBeforeExpensive = profile.apiGuardrail ?? false;
|
|
959
|
+
} catch { /* profile unavailable */ }
|
|
960
|
+
|
|
961
|
+
// ── Policy ─────────────────────────────────────────────────────────────
|
|
962
|
+
const policy = {
|
|
963
|
+
highRiskRequiresBestAvailable: true,
|
|
964
|
+
failoverMode: 'tell',
|
|
965
|
+
dualBrainThreshold: 'high',
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
// ── Learning ───────────────────────────────────────────────────────────
|
|
969
|
+
let learning = {};
|
|
970
|
+
try {
|
|
971
|
+
const { getModelSuccessRates } = await import('./doctor.mjs');
|
|
972
|
+
learning = getModelSuccessRates(cwd);
|
|
973
|
+
} catch { /* doctor.mjs unavailable */ }
|
|
974
|
+
|
|
975
|
+
// ── Setup summary ──────────────────────────────────────────────────────
|
|
976
|
+
const hasAnyProvider = (claudeProvider.available && claudeProvider.authenticated) ||
|
|
977
|
+
(openaiProvider.available && openaiProvider.authenticated);
|
|
978
|
+
|
|
979
|
+
let recommendedAction = null;
|
|
980
|
+
if (!claudeProvider.available && !openaiProvider.available) {
|
|
981
|
+
recommendedAction = 'connect-claude';
|
|
982
|
+
} else if (!claudeProvider.authenticated && !openaiProvider.authenticated) {
|
|
983
|
+
recommendedAction = 'refresh-auth';
|
|
984
|
+
} else if (!openaiProvider.available) {
|
|
985
|
+
recommendedAction = 'connect-openai';
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const manifest = {
|
|
989
|
+
providers: { claude: claudeProvider, openai: openaiProvider },
|
|
990
|
+
preferences,
|
|
991
|
+
policy,
|
|
992
|
+
learning,
|
|
993
|
+
setup: {
|
|
994
|
+
hasAnyProvider,
|
|
995
|
+
recommendedAction,
|
|
996
|
+
zeroProviderMode: !hasAnyProvider,
|
|
997
|
+
},
|
|
998
|
+
timestamp: new Date().toISOString(),
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
_manifestCache = manifest;
|
|
1002
|
+
_manifestCachedAt = now;
|
|
1003
|
+
return manifest;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Compute the effective routing policy for a specific task, applying rules in order:
|
|
1008
|
+
* 1. Safety constraints (high-risk → best available model)
|
|
1009
|
+
* 2. Provider availability
|
|
1010
|
+
* 3. Task tier fit (search→haiku, execute→sonnet, think→opus)
|
|
1011
|
+
* 4. User preferences (cost bias, forbidden models)
|
|
1012
|
+
* 5. Learning (prefer models with ≥90% success rate for this task type)
|
|
1013
|
+
*
|
|
1014
|
+
* @param {object} manifest — from getCapabilityManifest()
|
|
1015
|
+
* @param {{ tier?: string, risk?: string, taskType?: string }} taskContext
|
|
1016
|
+
* @returns {{ provider: string, model: string, tier: string, reason: string, overrides: string[] }}
|
|
1017
|
+
*/
|
|
1018
|
+
export function getEffectivePolicy(manifest, taskContext = {}) {
|
|
1019
|
+
const { providers, preferences, policy, learning } = manifest;
|
|
1020
|
+
const tier = taskContext.tier ?? 'execute';
|
|
1021
|
+
const risk = taskContext.risk ?? 'medium';
|
|
1022
|
+
const taskType = taskContext.taskType ?? 'general';
|
|
1023
|
+
const overrides = [];
|
|
1024
|
+
|
|
1025
|
+
// Tier → default model mapping
|
|
1026
|
+
const tierModelMap = { search: 'haiku', execute: 'sonnet', think: 'opus' };
|
|
1027
|
+
let preferredModel = tierModelMap[tier] ?? 'sonnet';
|
|
1028
|
+
let preferredProvider = 'claude';
|
|
1029
|
+
|
|
1030
|
+
// 1. Safety: high/critical risk → best available model
|
|
1031
|
+
if (policy.highRiskRequiresBestAvailable && (risk === 'high' || risk === 'critical')) {
|
|
1032
|
+
preferredModel = 'opus';
|
|
1033
|
+
overrides.push(`risk=${risk} → upgraded to opus`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// 2. Provider availability — fall back to openai if claude is down
|
|
1037
|
+
const claudeOk = providers.claude.available && providers.claude.authenticated &&
|
|
1038
|
+
providers.claude.health !== 'down';
|
|
1039
|
+
const openaiOk = providers.openai.available && providers.openai.authenticated &&
|
|
1040
|
+
providers.openai.health !== 'down';
|
|
1041
|
+
|
|
1042
|
+
if (!claudeOk && openaiOk) {
|
|
1043
|
+
preferredProvider = 'openai';
|
|
1044
|
+
// Remap model names for openai
|
|
1045
|
+
const openaiTierMap = { search: 'gpt-4o-mini', execute: 'gpt-4o', think: 'gpt-5.5' };
|
|
1046
|
+
preferredModel = risk === 'high' || risk === 'critical' ? 'gpt-5.5' : (openaiTierMap[tier] ?? 'gpt-4o');
|
|
1047
|
+
overrides.push('claude unavailable → routed to openai');
|
|
1048
|
+
} else if (!claudeOk && !openaiOk) {
|
|
1049
|
+
return { provider: 'none', model: 'none', tier, reason: 'no providers available', overrides };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// 3. Task fit already applied via tierModelMap above
|
|
1053
|
+
|
|
1054
|
+
// 4. User preferences: forbidden models, cost bias
|
|
1055
|
+
const forbidden = preferences.forbiddenModels ?? [];
|
|
1056
|
+
if (forbidden.includes(preferredModel)) {
|
|
1057
|
+
// Downgrade one step
|
|
1058
|
+
const fallback = preferredProvider === 'claude'
|
|
1059
|
+
? (preferredModel === 'opus' ? 'sonnet' : 'haiku')
|
|
1060
|
+
: (preferredModel === 'gpt-5.5' ? 'gpt-4o' : 'gpt-4o-mini');
|
|
1061
|
+
overrides.push(`${preferredModel} forbidden → downgraded to ${fallback}`);
|
|
1062
|
+
preferredModel = fallback;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (preferences.costBias > 0.7 && preferredModel === 'opus' && risk !== 'high' && risk !== 'critical') {
|
|
1066
|
+
preferredModel = 'sonnet';
|
|
1067
|
+
overrides.push('cost-bias > 0.7 → downgraded from opus to sonnet');
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// 5. Learning: if another model has ≥90% success for this task type, prefer it
|
|
1071
|
+
const successRates = learning ?? {};
|
|
1072
|
+
let bestLearnedModel = null;
|
|
1073
|
+
let bestRate = 0.9; // threshold
|
|
1074
|
+
for (const [model, stats] of Object.entries(successRates)) {
|
|
1075
|
+
if (stats.rate >= bestRate && stats.total >= 5 && !forbidden.includes(model)) {
|
|
1076
|
+
// Only prefer if it's on the right provider
|
|
1077
|
+
const isClaudeModel = ['opus', 'sonnet', 'haiku'].includes(model);
|
|
1078
|
+
if ((preferredProvider === 'claude' && isClaudeModel) ||
|
|
1079
|
+
(preferredProvider === 'openai' && !isClaudeModel)) {
|
|
1080
|
+
bestLearnedModel = model;
|
|
1081
|
+
bestRate = stats.rate;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (bestLearnedModel && bestLearnedModel !== preferredModel) {
|
|
1086
|
+
overrides.push(`learning: ${bestLearnedModel} has ${Math.round(bestRate * 100)}% success → preferred`);
|
|
1087
|
+
preferredModel = bestLearnedModel;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const reason = overrides.length > 0
|
|
1091
|
+
? overrides[0]
|
|
1092
|
+
: `tier=${tier} → ${preferredProvider}/${preferredModel}`;
|
|
1093
|
+
|
|
1094
|
+
return { provider: preferredProvider, model: preferredModel, tier, reason, overrides };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
821
1097
|
async function main() {
|
|
822
1098
|
const args = process.argv.slice(2);
|
|
823
1099
|
const cwd = process.cwd();
|
package/src/receipt.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
|
|
1
5
|
const DIM = '\x1b[2m';
|
|
2
6
|
const GREEN = '\x1b[32m';
|
|
3
7
|
const YELLOW= '\x1b[33m';
|
|
@@ -105,6 +109,215 @@ export function formatFailureReceipt(receipt, failureContext) {
|
|
|
105
109
|
return lines.join('\n');
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
// ─── Persistent session receipt ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const RECEIPTS_DIR = '.dual-brain/receipts';
|
|
115
|
+
|
|
116
|
+
function receiptsDir(cwd) {
|
|
117
|
+
return join(cwd, RECEIPTS_DIR);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gitChangedFiles(cwd) {
|
|
121
|
+
try {
|
|
122
|
+
const out = execSync('git diff --name-only HEAD', { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
123
|
+
.toString().trim();
|
|
124
|
+
if (!out) return [];
|
|
125
|
+
return out.split('\n').filter(Boolean);
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readDecisionsRecent(cwd, limit = 5) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = readFileSync(join(cwd, '.dual-brain', 'decisions.jsonl'), 'utf8');
|
|
134
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
135
|
+
return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ageLabel(ms) {
|
|
142
|
+
const mins = Math.round(ms / 60000);
|
|
143
|
+
if (mins < 60) return `${mins}m ago`;
|
|
144
|
+
const hours = Math.round(mins / 60);
|
|
145
|
+
if (hours < 24) return `${hours}h ago`;
|
|
146
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate a persistent session receipt and append it to the receipts store.
|
|
151
|
+
* @param {object} run PipelineRun object (or any outcome object with compatible fields)
|
|
152
|
+
* @param {string} cwd Working directory
|
|
153
|
+
* @returns {object} The receipt object
|
|
154
|
+
*/
|
|
155
|
+
export function generateReceipt(run = {}, cwd = process.cwd()) {
|
|
156
|
+
const now = new Date();
|
|
157
|
+
const ts = now.toISOString();
|
|
158
|
+
|
|
159
|
+
// Derive files changed — prefer run.result, fall back to git diff
|
|
160
|
+
const filesChanged = (run.result?.filesChanged?.length > 0)
|
|
161
|
+
? run.result.filesChanged
|
|
162
|
+
: gitChangedFiles(cwd);
|
|
163
|
+
|
|
164
|
+
// Recent decisions from living docs
|
|
165
|
+
const decisionEntries = readDecisionsRecent(cwd, 5);
|
|
166
|
+
const decisions = decisionEntries.map(d => d.question || d.decision || '').filter(Boolean).slice(0, 3);
|
|
167
|
+
|
|
168
|
+
// Test results
|
|
169
|
+
const testsRun = run.verification?.ok !== undefined
|
|
170
|
+
? (run.verification.ok ? 'passed' : 'failed')
|
|
171
|
+
: null;
|
|
172
|
+
|
|
173
|
+
// Unresolved risks from plan
|
|
174
|
+
const risksUnresolved = [];
|
|
175
|
+
if (run.plan?.approvalRequired && !run.outcome?.approved) {
|
|
176
|
+
risksUnresolved.push('approval required but not obtained');
|
|
177
|
+
}
|
|
178
|
+
if (run.verification && !run.verification.ok) {
|
|
179
|
+
risksUnresolved.push('verification failed');
|
|
180
|
+
}
|
|
181
|
+
const verNotes = run.verification?.notes ?? [];
|
|
182
|
+
for (const note of verNotes) {
|
|
183
|
+
if (/warn|risk|unverif|no file changes/i.test(note)) risksUnresolved.push(note.slice(0, 80));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Blockers — gates that failed
|
|
187
|
+
const blockers = [];
|
|
188
|
+
for (const [name, g] of Object.entries(run.gates ?? {})) {
|
|
189
|
+
if (g && !g.passed) blockers.push(`${name}: ${g.reason?.slice(0, 80)}`);
|
|
190
|
+
}
|
|
191
|
+
if (run.result?.error) blockers.push(run.result.error.slice(0, 80));
|
|
192
|
+
|
|
193
|
+
// Derive status
|
|
194
|
+
const success = run.result && !run.result.error && (run.verification?.ok !== false);
|
|
195
|
+
const status = !run.result ? 'incomplete'
|
|
196
|
+
: blockers.length > 0 ? 'failed'
|
|
197
|
+
: success ? 'success'
|
|
198
|
+
: 'partial';
|
|
199
|
+
|
|
200
|
+
// Next action (reuse existing logic)
|
|
201
|
+
let nextAction = 'review the output';
|
|
202
|
+
if (status === 'success' && filesChanged.length > 0) {
|
|
203
|
+
nextAction = run.verification?.testsRun ? 'commit changes' : 'run tests, then commit';
|
|
204
|
+
} else if (status === 'failed') {
|
|
205
|
+
nextAction = 'investigate failure, retry with adjusted approach';
|
|
206
|
+
} else if (status === 'partial') {
|
|
207
|
+
nextAction = 'check partial output, verify manually';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const duration = (run.completedAt && run.startedAt)
|
|
211
|
+
? Math.round((run.completedAt - run.startedAt) / 1000)
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
const receipt = {
|
|
215
|
+
timestamp: ts,
|
|
216
|
+
goal: (run.prompt ?? '').slice(0, 200),
|
|
217
|
+
filesChanged,
|
|
218
|
+
decisions,
|
|
219
|
+
testsRun,
|
|
220
|
+
risksUnresolved,
|
|
221
|
+
blockers,
|
|
222
|
+
nextAction,
|
|
223
|
+
provider: run.plan?.primaryProvider ?? run.result?.provider ?? null,
|
|
224
|
+
model: run.plan?.primaryModel ?? run.result?.model ?? null,
|
|
225
|
+
duration,
|
|
226
|
+
status,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Store receipt
|
|
230
|
+
try {
|
|
231
|
+
const dir = receiptsDir(cwd);
|
|
232
|
+
mkdirSync(dir, { recursive: true });
|
|
233
|
+
|
|
234
|
+
const filename = ts.replace(/[:.]/g, '-').slice(0, 19) + '.json';
|
|
235
|
+
writeFileSync(join(dir, filename), JSON.stringify(receipt, null, 2));
|
|
236
|
+
|
|
237
|
+
// One-line summary for fast scanning
|
|
238
|
+
const summary = {
|
|
239
|
+
ts,
|
|
240
|
+
goal: receipt.goal.slice(0, 80),
|
|
241
|
+
status,
|
|
242
|
+
files: filesChanged.length,
|
|
243
|
+
next: nextAction.slice(0, 60),
|
|
244
|
+
};
|
|
245
|
+
appendFileSync(join(dir, 'index.jsonl'), JSON.stringify(summary) + '\n');
|
|
246
|
+
} catch {
|
|
247
|
+
// Storage failure is non-blocking
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return receipt;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read the most recent receipt(s) and build a compact resume brief (max 500 chars).
|
|
255
|
+
* @param {string} cwd
|
|
256
|
+
* @returns {string|null}
|
|
257
|
+
*/
|
|
258
|
+
export function buildResumeBrief(cwd = process.cwd()) {
|
|
259
|
+
try {
|
|
260
|
+
const dir = receiptsDir(cwd);
|
|
261
|
+
if (!existsSync(dir)) return null;
|
|
262
|
+
|
|
263
|
+
// Find the most recent receipt JSON file
|
|
264
|
+
const files = readdirSync(dir)
|
|
265
|
+
.filter(f => f.endsWith('.json') && f !== 'index.json')
|
|
266
|
+
.sort()
|
|
267
|
+
.reverse();
|
|
268
|
+
|
|
269
|
+
if (files.length === 0) return null;
|
|
270
|
+
|
|
271
|
+
const raw = readFileSync(join(dir, files[0]), 'utf8');
|
|
272
|
+
const r = JSON.parse(raw);
|
|
273
|
+
|
|
274
|
+
const age = ageLabel(Date.now() - Date.parse(r.timestamp));
|
|
275
|
+
const filesSummary = r.filesChanged?.length > 0
|
|
276
|
+
? r.filesChanged.slice(0, 3).map(f => f.split('/').pop()).join(', ')
|
|
277
|
+
+ (r.filesChanged.length > 3 ? ` +${r.filesChanged.length - 3}` : '')
|
|
278
|
+
: 'no files changed';
|
|
279
|
+
const riskLine = r.risksUnresolved?.length > 0
|
|
280
|
+
? `Risk: ${r.risksUnresolved[0].slice(0, 60)}`
|
|
281
|
+
: null;
|
|
282
|
+
|
|
283
|
+
const lines = [
|
|
284
|
+
'RESUME CONTEXT:',
|
|
285
|
+
`Last session: ${age}`,
|
|
286
|
+
`Goal: ${(r.goal || 'unknown').slice(0, 80)}`,
|
|
287
|
+
`Done: ${filesSummary}`,
|
|
288
|
+
`Status: ${r.status}${r.testsRun ? ', tests ' + r.testsRun : ''}`,
|
|
289
|
+
];
|
|
290
|
+
if (riskLine) lines.push(riskLine);
|
|
291
|
+
lines.push(`Next: ${(r.nextAction || '').slice(0, 80)}`);
|
|
292
|
+
|
|
293
|
+
const brief = lines.join('\n');
|
|
294
|
+
return brief.length > 500 ? brief.slice(0, 497) + '...' : brief;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Return the most recent receipt object, or null if none exists or the store is empty.
|
|
302
|
+
* @param {string} cwd
|
|
303
|
+
* @returns {object|null}
|
|
304
|
+
*/
|
|
305
|
+
export function getLatestReceipt(cwd = process.cwd()) {
|
|
306
|
+
try {
|
|
307
|
+
const dir = receiptsDir(cwd);
|
|
308
|
+
if (!existsSync(dir)) return null;
|
|
309
|
+
const files = readdirSync(dir)
|
|
310
|
+
.filter(f => f.endsWith('.json') && f !== 'index.json')
|
|
311
|
+
.sort()
|
|
312
|
+
.reverse();
|
|
313
|
+
if (files.length === 0) return null;
|
|
314
|
+
const raw = readFileSync(join(dir, files[0]), 'utf8');
|
|
315
|
+
return JSON.parse(raw);
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
108
321
|
export function buildReceiptFromOutcome(outcome = {}) {
|
|
109
322
|
const result = {
|
|
110
323
|
success: outcome.success ?? outcome.result?.success ?? false,
|