dual-brain 0.2.1 → 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.
@@ -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 authStr = authInfo.authenticated ? '\x1b[32m✓\x1b[0m auth' : '\x1b[2mno auth\x1b[0m';
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 = '> type anything... [s] settings [t] team [q] quit';
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 task dispatch as "dual-brain go"
2368
+ // ── Typed input run through command palette ─────────────────────────────
2207
2369
  if (raw.startsWith('__task__:')) {
2208
- const prompt = raw.slice('__task__:'.length).trim();
2209
- if (prompt) {
2210
- return { next: 'go', prompt };
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
- return { next: 'main' };
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-3 → resume that session
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
- await cmdGo([prompt], { dryRun });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
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.
@@ -709,7 +723,7 @@ async function dispatch(input = {}) {
709
723
  ? input.situationBrief.trim()
710
724
  : null;
711
725
  if (situationBrief) {
712
- prompt = `--- SITUATION BRIEF ---\n${situationBrief}\n--- END BRIEF ---\n\n${prompt}`;
726
+ prompt = `[SITUATION BRIEF]\n${situationBrief}\n[END BRIEF]\n\n${prompt}`;
713
727
  }
714
728
  // ── End situation brief ──────────────────────────────────────────────────────
715
729
 
@@ -1071,7 +1085,7 @@ async function dispatchDualBrain(input = {}) {
1071
1085
  ? input.situationBrief.trim()
1072
1086
  : null;
1073
1087
  if (_dualBrainBrief) {
1074
- prompt = `--- SITUATION BRIEF ---\n${_dualBrainBrief}\n--- END BRIEF ---\n\n${prompt}`;
1088
+ prompt = `[SITUATION BRIEF]\n${_dualBrainBrief}\n[END BRIEF]\n\n${prompt}`;
1075
1089
  }
1076
1090
  // ── End situation brief ──────────────────────────────────────────────────────
1077
1091
 
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,