dual-brain 7.1.23 → 7.1.24

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.
@@ -1483,9 +1483,9 @@ function detectInterruptedWork(sessions, cwd) {
1483
1483
  * Shows: "● Claude ● OpenAI ⚖️ Balanced"
1484
1484
  * Uses ANSI color codes for the dots — no dollar amounts or usage bars.
1485
1485
  */
1486
- function buildProviderStatusLine(profile, auth, maxWidth = 54) {
1487
- const GREEN = '●';
1488
- const RED = '●';
1486
+ function buildProviderStatusLine(profile, auth) {
1487
+ const GREEN = '\x1b[32m●\x1b[0m';
1488
+ const RED = '\x1b[31m●\x1b[0m';
1489
1489
 
1490
1490
  const claudeDot = auth.claude.found ? GREEN : RED;
1491
1491
  const openaiDot = auth.openai.found ? GREEN : RED;
@@ -1498,30 +1498,11 @@ function buildProviderStatusLine(profile, auth, maxWidth = 54) {
1498
1498
  'solo-claude': '⚡ Fast',
1499
1499
  'solo-openai': '⚡ Fast',
1500
1500
  };
1501
- const WORK_STYLE_TIPS = {
1502
- 'auto': 'adapts routing by task risk',
1503
- 'cost-saver': 'single model, minimal reviews',
1504
- 'balanced': 'smart routing, reviews when needed',
1505
- 'quality-first': 'dual-brain on everything important',
1506
- 'solo-claude': 'Claude only, no GPT dispatch',
1507
- 'solo-openai': 'OpenAI only, no Claude dispatch',
1508
- };
1509
1501
  const bias = profile?.bias || profile?.mode || 'balanced';
1510
1502
  const label = WORK_STYLE_LABELS[bias] || '⚖️ Balanced';
1511
- const fullTip = WORK_STYLE_TIPS[bias] || 'smart routing, reviews when needed';
1512
-
1513
- // Trim tip to fit within box width (measure visible chars: strip ANSI + variation selectors)
1514
- const labelPlain = label.replace(/[︀-️]/g, '').replace(/[[0-9;]*m/g, '');
1515
- const prefixLen = ('● Claude ● OpenAI ' + labelPlain + ' — ').length;
1516
- const tipMax = maxWidth - prefixLen;
1517
- const tip = tipMax >= 6
1518
- ? (fullTip.length > tipMax ? fullTip.slice(0, tipMax - 1) + '…' : fullTip)
1519
- : '';
1520
1503
 
1521
- const suffix = tip ? ` — ${tip}` : '';
1522
- return `${claudeDot} Claude ${openaiDot} OpenAI ${label}${suffix}`;
1504
+ return `${claudeDot} Claude ${openaiDot} OpenAI ${label}`;
1523
1505
  }
1524
-
1525
1506
  /**
1526
1507
  * Render a box row padded to inner width W (stripping ANSI for length calculation).
1527
1508
  * Returns a string like: "│ content padded to W │"
@@ -1609,25 +1590,6 @@ async function mainScreen(rl, ask) {
1609
1590
 
1610
1591
  const row = (content) => makeBoxRow(content, W);
1611
1592
 
1612
- // ── Header: one line above the box ────────────────────────────────────────
1613
- process.stdout.write(`\n🧠 dual-brain v${version}\n`);
1614
- {
1615
- let gitName = '';
1616
- try {
1617
- const { execSync } = await import('node:child_process');
1618
- gitName = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1619
- } catch { /* ignore */ }
1620
- if (gitName) {
1621
- const hour = new Date().getHours();
1622
- let greet;
1623
- if (hour >= 5 && hour <= 11) greet = 'Good morning';
1624
- else if (hour >= 12 && hour <= 16) greet = 'Good afternoon';
1625
- else if (hour >= 17 && hour <= 21) greet = 'Good evening';
1626
- else greet = 'Late night';
1627
- process.stdout.write(`\x1b[2m${greet}, ${gitName}\x1b[0m\n`);
1628
- }
1629
- }
1630
-
1631
1593
  // ── Continuation card (interrupted work) ─────────────────────────────────
1632
1594
  if (interrupted) {
1633
1595
  const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
@@ -1706,15 +1668,74 @@ async function mainScreen(rl, ask) {
1706
1668
  // 's' → fall through to normal dashboard
1707
1669
  }
1708
1670
 
1709
- // ── Status section ────────────────────────────────────────────────────────
1710
- const providerLine = buildProviderStatusLine(profile, auth, W);
1671
+ // ── Box 1 — Header row data ─────────────────────────────────────────────
1672
+ const providerLine = buildProviderStatusLine(profile, auth);
1711
1673
 
1712
- const statusRows = [row(providerLine)];
1713
- if (dtVersion) {
1714
- statusRows.push(row(`\x1b[2m📦 replit-tools v${dtVersion}\x1b[0m`));
1674
+ // ── Box 2 — Workspace: gather git data ───────────────────────────────────
1675
+ let gitBranch = 'unknown';
1676
+ let gitUncommitted = 0;
1677
+ let gitAheadCount = 0;
1678
+ let gitLastMsg = '';
1679
+ let gitLastAgo = '';
1680
+
1681
+ try {
1682
+ gitBranch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
1683
+ cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe',
1684
+ }).trim() || 'unknown';
1685
+ } catch {}
1686
+
1687
+ try {
1688
+ const status = execSync('git status --porcelain 2>/dev/null', {
1689
+ cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe',
1690
+ });
1691
+ gitUncommitted = status.trim().split('\n').filter(Boolean).length;
1692
+ } catch {}
1693
+
1694
+ try {
1695
+ const aheadOut = execSync('git rev-list @{u}..HEAD 2>/dev/null | wc -l', {
1696
+ cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe',
1697
+ });
1698
+ gitAheadCount = parseInt(aheadOut.trim(), 10) || 0;
1699
+ } catch {}
1700
+
1701
+ try {
1702
+ const logOut = execSync('git log -1 --format="%s|%ct" 2>/dev/null', {
1703
+ cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe',
1704
+ }).trim();
1705
+ if (logOut) {
1706
+ const [msg, ts] = logOut.split('|');
1707
+ gitLastMsg = (msg || '').slice(0, 38);
1708
+ const ageMs = Date.now() - (parseInt(ts, 10) * 1000);
1709
+ const ageMin = Math.floor(ageMs / 60000);
1710
+ if (ageMin < 60) gitLastAgo = `${ageMin}m ago`;
1711
+ else if (ageMin < 1440) gitLastAgo = `${Math.floor(ageMin / 60)}h ago`;
1712
+ else gitLastAgo = `${Math.floor(ageMin / 1440)}d ago`;
1713
+ }
1714
+ } catch {}
1715
+
1716
+ // ── Box 2 rows ────────────────────────────────────────────────────────────
1717
+ const uncommittedPart = gitUncommitted > 0 ? ` · ${gitUncommitted} uncommitted` : '';
1718
+ const aheadPart = gitAheadCount > 0 ? ` · ${gitAheadCount} ahead` : '';
1719
+ const workspaceLine1 = `${gitBranch}${uncommittedPart}${aheadPart}`;
1720
+ const workspaceLine2 = gitLastMsg
1721
+ ? `Last: ${gitLastMsg} (${gitLastAgo})`
1722
+ : '';
1723
+
1724
+ // Open PRs
1725
+ const repoState = detectRepoState(cwd);
1726
+ const openPRs = await detectOpenPRs(cwd);
1727
+
1728
+ const workspaceRows = [row(workspaceLine1)];
1729
+ if (workspaceLine2) workspaceRows.push(row(workspaceLine2));
1730
+ if (openPRs.length > 0) {
1731
+ workspaceRows.push(row(`${openPRs.length} open PR${openPRs.length === 1 ? '' : 's'}`));
1715
1732
  }
1716
1733
 
1717
- // ── Observer observations (top 2, high priority first) ───────────────────
1734
+ // ── Box 3 Awareness: observer + roadmap + risk ──────────────────────────
1735
+ let awarenessLine1 = '\x1b[2m💡\x1b[0m Ready to work';
1736
+ let awarenessLine2 = '\x1b[2m📋 No roadmap yet\x1b[0m';
1737
+ let awarenessLine3 = '\x1b[32m✓\x1b[0m No risk flags';
1738
+
1718
1739
  let quickObservations = [];
1719
1740
  try {
1720
1741
  const observerMod = await import('../src/observer.mjs');
@@ -1724,64 +1745,39 @@ async function mainScreen(rl, ask) {
1724
1745
  const sorted = [...quickState.observations].sort(
1725
1746
  (a, b) => (PRIO[a.priority] ?? 2) - (PRIO[b.priority] ?? 2)
1726
1747
  );
1727
- quickObservations = sorted.slice(0, 2);
1728
- for (const obs of quickObservations) {
1729
- let prefix;
1730
- if (obs.priority === 'high') prefix = '🔴';
1731
- else if (obs.priority === 'medium') prefix = '🟡';
1732
- else prefix = '\x1b[2m💡\x1b[0m';
1733
- statusRows.push(row(`${prefix} ${obs.message}`));
1748
+ quickObservations = sorted.slice(0, 3);
1749
+ const top = quickObservations[0];
1750
+ if (top) {
1751
+ const prefix = top.priority === 'high' ? '🔴' : top.priority === 'medium' ? '🟡' : '\x1b[2m💡\x1b[0m';
1752
+ awarenessLine1 = `${prefix} ${top.message}`;
1753
+ }
1754
+ const hasHighRisk = quickObservations.some(o => o.priority === 'high');
1755
+ if (hasHighRisk) {
1756
+ awarenessLine3 = '\x1b[31m⚠\x1b[0m Risk flags detected — run: dual-brain review';
1734
1757
  }
1735
1758
  }
1736
- } catch { /* non-fatal — module may not exist yet */ }
1737
-
1738
- // ── Action cards (git state + open PRs) ──────────────────────────────────
1739
- const repoState = detectRepoState(cwd);
1740
- const openPRs = await detectOpenPRs(cwd);
1741
- const actionRows = buildActionRows(repoState, row, openPRs);
1742
-
1743
- // ── High-priority observer action cards ───────────────────────────────────
1744
- if (quickObservations.some(o => o.priority === 'high')) {
1745
- const DIM = '\x1b[2m';
1746
- const RESET = '\x1b[0m';
1747
- actionRows.push(row(`${DIM}[r] Security review [t] Run tests [c] Commit${RESET}`));
1748
- }
1759
+ } catch { /* non-fatal — observer may not exist */ }
1749
1760
 
1750
- // ── Related sessions hint (only when no continuation card is showing) ─────
1751
- if (!interrupted && recentSessions.length > 0) {
1752
- try {
1753
- const { findRelatedSessions } = await import('../src/session.mjs');
1754
- const mostRecent = recentSessions[0];
1755
- // Build a pseudo-prompt from the most recent session's name/objective
1756
- const recentPrompt = mostRecent.name || '';
1757
- // Load session index to get files for the most recent session
1758
- const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1759
- let recentFiles = [];
1760
- try {
1761
- const idx = JSON.parse(readFileSync(indexPath, 'utf8'));
1762
- recentFiles = idx[mostRecent.id]?.files || [];
1763
- } catch {}
1764
- const related = findRelatedSessions(recentPrompt, recentFiles, cwd);
1765
- if (related.length > 0) {
1766
- const relAgeLabel = (isoDate) => {
1767
- if (!isoDate) return '';
1768
- const diff = Date.now() - Date.parse(isoDate);
1769
- const days = Math.floor(diff / 86400000);
1770
- const hours = Math.floor(diff / 3600000);
1771
- if (days >= 1) return `${days}d`;
1772
- return `${hours}h ago`;
1773
- };
1774
- const relatedParts = related.slice(0, 2).map(r => {
1775
- const age = relAgeLabel(r.date);
1776
- return age ? `${r.smartName} (${age})` : r.smartName;
1777
- });
1778
- const DIM = '\x1b[2m';
1779
- const RESET = '\x1b[0m';
1780
- actionRows.push(row(`${DIM}📎 Related: ${relatedParts.join(', ')}${RESET}`));
1761
+ // Try roadmap file
1762
+ try {
1763
+ const roadmapPath = join(cwd, '.dual-brain', 'roadmap.md');
1764
+ if (existsSync(roadmapPath)) {
1765
+ const roadmapText = readFileSync(roadmapPath, 'utf8');
1766
+ const lines = roadmapText.split('\n').filter(Boolean);
1767
+ // Skip heading lines, grab first non-heading line
1768
+ const firstItem = lines.find(l => !l.startsWith('#') && l.trim().length > 0);
1769
+ if (firstItem) {
1770
+ const clean = firstItem.replace(/^[-*>]+\s*/, '').trim().slice(0, 45);
1771
+ awarenessLine2 = `\x1b[2m📋\x1b[0m ${clean}`;
1781
1772
  }
1782
- } catch { /* non-fatal */ }
1783
- }
1784
- // ── End related sessions hint ─────────────────────────────────────────────
1773
+ }
1774
+ } catch { /* non-fatal */ }
1775
+
1776
+ const awarenessRows = [
1777
+ row(awarenessLine1),
1778
+ row(awarenessLine2),
1779
+ row(awarenessLine3),
1780
+ ];
1785
1781
 
1786
1782
  // ── Sessions section ──────────────────────────────────────────────────────
1787
1783
  const sessionRows = [];
@@ -1837,23 +1833,28 @@ async function mainScreen(rl, ask) {
1837
1833
  });
1838
1834
  }
1839
1835
 
1840
- // ── Actions barnavigation only (pipeline verbs are internal stages, not menu items) ─
1841
- const actionsContent = 'n New session / Search q Quit';
1836
+ // ── Box 5Input bar ──────────────────────────────────────────────────
1837
+ const actionsContent = '> type anything... [s] settings [/] search [q] quit';
1842
1838
  const actionsRow = row(actionsContent);
1843
1839
 
1844
- // ── Print the full box ────────────────────────────────────────────────────
1845
- // Include action cards between status and sessions (with separators only when non-empty)
1846
- const poweredByRow = row('\x1b[2mPowered by dual-brain\x1b[0m');
1840
+ // ── Print the full 5-box layout ───────────────────────────────────────────
1841
+ // Box 1: header (title + provider dots + work style)
1842
+ // Box 2: workspace (branch · uncommitted · ahead, last commit, open PRs)
1843
+ // Box 3: awareness (observer, roadmap, risk)
1844
+ // Box 4: sessions
1845
+ // Box 5: input bar
1847
1846
  const lines = [
1848
1847
  top,
1849
- ...statusRows,
1850
- ...(actionRows.length > 0 ? [sep, ...actionRows] : []),
1848
+ row(`🧠 dual-brain v${version}`),
1849
+ row(providerLine),
1850
+ sep,
1851
+ ...workspaceRows,
1852
+ sep,
1853
+ ...awarenessRows,
1851
1854
  sep,
1852
1855
  ...sessionRows,
1853
1856
  sep,
1854
1857
  actionsRow,
1855
- sep,
1856
- poweredByRow,
1857
1858
  bot,
1858
1859
  ];
1859
1860
  // ── Stale session hint ──────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.23",
3
+ "version": "7.1.24",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "./session": "./src/session.mjs",
20
20
  "./decompose": "./src/decompose.mjs",
21
21
  "./brief": "./src/brief.mjs",
22
- "./redact": "./src/redact.mjs"
22
+ "./redact": "./src/redact.mjs",
23
+ "./calibration": "./src/calibration.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "claude-code",
@@ -58,6 +59,7 @@
58
59
  "src/decompose.mjs",
59
60
  "src/brief.mjs",
60
61
  "src/redact.mjs",
62
+ "src/calibration.mjs",
61
63
  "src/pipeline.mjs",
62
64
  "src/context.mjs",
63
65
  "src/outcome.mjs",
@@ -67,8 +69,10 @@
67
69
  "src/receipt.mjs",
68
70
  "src/failure-memory.mjs",
69
71
  "src/index.mjs",
72
+ "src/ledger.mjs",
70
73
  "src/intelligence.mjs",
71
74
  "src/tui.mjs",
75
+ "src/living-docs.mjs",
72
76
  "src/install-hooks.mjs",
73
77
  "src/update-check.mjs",
74
78
  "bin/*.mjs",
@@ -0,0 +1,148 @@
1
+ // User calibration module — tracks specificity, corrections, and autonomy signals
2
+ // to adapt dual-brain behavior to any user from vague vibe-coder to precise expert.
3
+
4
+ const FILE_REF_RE = /(?:src\/|\.mjs|\.tsx?|\.jsx?|\.json|\.ya?ml|\.sh|line\s+\d+|\bL\d+\b)/i;
5
+ const TECH_TERM_RE = /\b(?:regex|middleware|api|endpoint|refactor|migration|schema|auth(?:entication)?|jwt|token|hook|dispatch|pipeline|module|function|class|interface|type|import|export|async|await|promise|callback|handler|router|controller|service|repository|factory|singleton|decorator|mixin|proxy|guard|interceptor|serializer|validator|transformer|adapter|facade|strategy|observer|subscriber|emitter|stream|buffer|cache|queue|worker|thread|mutex|semaphore|socket|websocket|http|grpc|graphql|rest|orm|sql|nosql|index|query|transaction|migration|seed|fixture|mock|stub|spy|assertion|coverage|lint|typecheck|bundle|compile|transpile|minify|tree.shake|dead.code|chunk|lazy.load|ssr|csr|hydrat)\b/i;
6
+ const VAGUE_RE = /\b(?:idk|just|make\s+it|fix\s+it|whatever|vibes?|better|nicer|faster|cleaner|improve|help|do\s+it|yeah|sure|ok|okay|hmm|uh|er|um)\b/i;
7
+ const AUTONOMY_HIGH_RE = /\b(?:just\s+do\s+it|go(?:\s+ahead)?|build\s+it|ship\s+it|run\s+it|execute|do\s+it|proceed|continue|carry\s+on|handle\s+it|take\s+care|make\s+it\s+happen|yolo)\b/i;
8
+ const QUESTION_RE = /\?|^(?:how|what|why|when|where|which|should|can|could|would|is|are|do|does|did|will|won't|don't)\b/i;
9
+ const CORRECTION_RE = /^(?:no[,.]?|not\s|wrong|stop|don't|that'?s?\s+not|i\s+said|i\s+meant)\b|(?:\binstead\b|\brather\b|\bactually\b)/i;
10
+
11
+ export function analyzeInput(input) {
12
+ const text = (input || '').trim();
13
+ const lower = text.toLowerCase();
14
+ const words = text.split(/\s+/).filter(Boolean);
15
+ const wordCount = words.length;
16
+
17
+ const hasFileRefs = FILE_REF_RE.test(text);
18
+ const hasTechTerms = TECH_TERM_RE.test(text);
19
+ const hasExactInstructions = /\b(?:step\s+\d|first[,\s]|then[,\s]|finally[,\s]|specifically|exactly|must|should\s+(?:use|call|return|handle)|(?:use|call|return|throw|emit|dispatch)\s+\w)/i.test(text);
20
+ const isVague = VAGUE_RE.test(lower) || wordCount <= 3;
21
+
22
+ let specificity;
23
+ if (hasFileRefs || /\bline\s*\d+\b|\bL\d+\b/i.test(text) || (hasTechTerms && hasExactInstructions)) {
24
+ specificity = 5;
25
+ } else if (hasTechTerms && wordCount >= 6) {
26
+ specificity = 4;
27
+ } else if (!isVague && wordCount >= 8) {
28
+ specificity = 3;
29
+ } else if (wordCount >= 4 && !isVague) {
30
+ specificity = 2;
31
+ } else {
32
+ specificity = 1;
33
+ }
34
+
35
+ return {
36
+ specificity,
37
+ signals: {
38
+ hasFileRefs,
39
+ hasTechTerms,
40
+ hasExactInstructions,
41
+ isVague,
42
+ wordCount
43
+ }
44
+ };
45
+ }
46
+
47
+ export function detectCorrection(input) {
48
+ return CORRECTION_RE.test((input || '').trim());
49
+ }
50
+
51
+ function clamp(value, min = 1, max = 5) {
52
+ return Math.min(max, Math.max(min, value));
53
+ }
54
+
55
+ function round1(value) {
56
+ return Math.round(value * 10) / 10;
57
+ }
58
+
59
+ function detectAutonomySignal(input) {
60
+ const text = (input || '').trim();
61
+ if (AUTONOMY_HIGH_RE.test(text)) return 5;
62
+ if (QUESTION_RE.test(text)) return 2;
63
+ return null;
64
+ }
65
+
66
+ export function updateCalibration(calibration, input, correction = false) {
67
+ const { specificity: newSpec } = analyzeInput(input);
68
+ const autonomySignal = detectAutonomySignal(input);
69
+
70
+ const prev = {
71
+ specificity: calibration.specificity ?? 3,
72
+ corrections: calibration.corrections ?? 3,
73
+ autonomy: calibration.autonomy ?? 3,
74
+ interactions: calibration.interactions ?? 0
75
+ };
76
+
77
+ const specificity = round1(clamp(prev.specificity * 0.7 + newSpec * 0.3));
78
+
79
+ const corrections = round1(clamp(
80
+ correction ? prev.corrections - 0.3 : prev.corrections + 0.1
81
+ ));
82
+
83
+ let autonomy = prev.autonomy;
84
+ if (autonomySignal !== null) {
85
+ autonomy = round1(clamp(prev.autonomy * 0.7 + autonomySignal * 0.3));
86
+ }
87
+
88
+ return {
89
+ specificity,
90
+ corrections,
91
+ autonomy,
92
+ interactions: prev.interactions + 1,
93
+ lastUpdated: new Date().toISOString()
94
+ };
95
+ }
96
+
97
+ export function getAdaptation(calibration) {
98
+ const s = calibration.specificity ?? 3;
99
+ const c = calibration.corrections ?? 3;
100
+ const a = calibration.autonomy ?? 3;
101
+
102
+ const clarifyBeforeActing = s < 2.5 || c > 3;
103
+ const explainReasoning = a < 3;
104
+ const suggestNextSteps = a < 4;
105
+ const askForApproval = c < 2.5;
106
+ const autoExecute = a > 4 && c > 3.5;
107
+
108
+ let responseStyle;
109
+ const combined = (s + c + a) / 3;
110
+ if (combined >= 4) {
111
+ responseStyle = 'terse';
112
+ } else if (combined >= 2.5) {
113
+ responseStyle = 'normal';
114
+ } else {
115
+ responseStyle = 'detailed';
116
+ }
117
+
118
+ let userLevel;
119
+ if (s >= 4) {
120
+ userLevel = 'advanced';
121
+ } else if (s >= 2.5) {
122
+ userLevel = 'intermediate';
123
+ } else {
124
+ userLevel = 'beginner';
125
+ }
126
+
127
+ return {
128
+ clarifyBeforeActing,
129
+ explainReasoning,
130
+ suggestNextSteps,
131
+ askForApproval,
132
+ autoExecute,
133
+ responseStyle,
134
+ userLevel
135
+ };
136
+ }
137
+
138
+ export function formatCalibration(calibration) {
139
+ const { userLevel, responseStyle } = getAdaptation(calibration);
140
+ const s = calibration.specificity ?? 3;
141
+ const c = calibration.corrections ?? 3;
142
+ const a = calibration.autonomy ?? 3;
143
+
144
+ const autonomyLabel = a >= 4 ? 'high autonomy' : a >= 2.5 ? 'normal autonomy' : 'low autonomy';
145
+ const trustLabel = c >= 4 ? 'high trust' : c >= 2.5 ? 'good trust' : 'low trust';
146
+
147
+ return `User: ${userLevel} · ${autonomyLabel} · ${trustLabel}\n (specificity: ${s}, corrections: ${c}, autonomy: ${a})`;
148
+ }
package/src/ledger.mjs ADDED
@@ -0,0 +1,196 @@
1
+ // Task ledger: append-only accountability store for HEAD's promises. Every tracked task has a full snapshot per state change.
2
+
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ const LEDGER_PATH = '.dual-brain/ledger.jsonl';
7
+
8
+ function ledgerPath(cwd) {
9
+ return join(cwd || process.cwd(), LEDGER_PATH);
10
+ }
11
+
12
+ function readAllEntries(cwd) {
13
+ const p = ledgerPath(cwd);
14
+ if (!existsSync(p)) return [];
15
+ const raw = readFileSync(p, 'utf8').trim();
16
+ if (!raw) return [];
17
+ return raw.split('\n').map(line => JSON.parse(line));
18
+ }
19
+
20
+ function appendEntry(entry, cwd) {
21
+ const p = ledgerPath(cwd);
22
+ mkdirSync(join(cwd || process.cwd(), '.dual-brain'), { recursive: true });
23
+ writeFileSync(p, JSON.stringify(entry) + '\n', { flag: 'a' });
24
+ }
25
+
26
+ function getCurrentTasks(cwd) {
27
+ const entries = readAllEntries(cwd);
28
+ const map = new Map();
29
+ for (const entry of entries) {
30
+ map.set(entry.id, entry);
31
+ }
32
+ return Array.from(map.values());
33
+ }
34
+
35
+ export function createTask(task, cwd) {
36
+ const entry = {
37
+ id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
38
+ created: new Date().toISOString(),
39
+ updated: new Date().toISOString(),
40
+ intent: task.intent || '',
41
+ status: 'open',
42
+ owner: task.owner || 'head',
43
+ priority: task.priority || 'medium',
44
+ blockers: task.blockers || [],
45
+ proof: task.proof || null,
46
+ subtasks: task.subtasks || [],
47
+ parentTask: task.parentTask || null,
48
+ files: task.files || [],
49
+ result: task.result || null,
50
+ cost: task.cost || null,
51
+ };
52
+ appendEntry(entry, cwd);
53
+ return entry;
54
+ }
55
+
56
+ export function updateTask(taskId, updates, cwd) {
57
+ const current = getTask(taskId, cwd);
58
+ if (!current) throw new Error(`Task not found: ${taskId}`);
59
+
60
+ if (updates.status === 'done') {
61
+ const proof = updates.proof ?? current.proof;
62
+ const result = updates.result ?? current.result;
63
+ if (!proof) throw new Error(`Cannot mark task done without proof: ${taskId}`);
64
+ if (!result) throw new Error(`Cannot mark task done without result: ${taskId}`);
65
+ }
66
+
67
+ const updated = {
68
+ ...current,
69
+ ...updates,
70
+ id: taskId,
71
+ updated: new Date().toISOString(),
72
+ };
73
+ appendEntry(updated, cwd);
74
+ return updated;
75
+ }
76
+
77
+ export function failTask(taskId, reason, cwd) {
78
+ const current = getTask(taskId, cwd);
79
+ if (!current) throw new Error(`Task not found: ${taskId}`);
80
+ const updated = {
81
+ ...current,
82
+ status: 'failed',
83
+ result: reason || 'failed',
84
+ updated: new Date().toISOString(),
85
+ };
86
+ appendEntry(updated, cwd);
87
+ return updated;
88
+ }
89
+
90
+ export function blockTask(taskId, blocker, cwd) {
91
+ const current = getTask(taskId, cwd);
92
+ if (!current) throw new Error(`Task not found: ${taskId}`);
93
+ const updated = {
94
+ ...current,
95
+ status: 'blocked',
96
+ blockers: [...(current.blockers || []), blocker],
97
+ updated: new Date().toISOString(),
98
+ };
99
+ appendEntry(updated, cwd);
100
+ return updated;
101
+ }
102
+
103
+ export function getTask(taskId, cwd) {
104
+ const entries = readAllEntries(cwd);
105
+ let latest = null;
106
+ for (const entry of entries) {
107
+ if (entry.id === taskId) latest = entry;
108
+ }
109
+ return latest;
110
+ }
111
+
112
+ export function getOpenTasks(cwd) {
113
+ return getCurrentTasks(cwd).filter(t =>
114
+ t.status === 'open' || t.status === 'in_progress' || t.status === 'blocked'
115
+ );
116
+ }
117
+
118
+ export function getTaskHistory(taskId, cwd) {
119
+ return readAllEntries(cwd).filter(e => e.id === taskId);
120
+ }
121
+
122
+ export function getTaskSummary(cwd) {
123
+ const tasks = getCurrentTasks(cwd);
124
+ const summary = { open: 0, inProgress: 0, blocked: 0, done: 0, failed: 0, total: tasks.length };
125
+ for (const t of tasks) {
126
+ if (t.status === 'open') summary.open++;
127
+ else if (t.status === 'in_progress') summary.inProgress++;
128
+ else if (t.status === 'blocked') summary.blocked++;
129
+ else if (t.status === 'done') summary.done++;
130
+ else if (t.status === 'failed') summary.failed++;
131
+ }
132
+ return summary;
133
+ }
134
+
135
+ export function formatTaskList(tasks) {
136
+ const all = tasks;
137
+ const open = all.filter(t => t.status === 'open' || t.status === 'in_progress').length;
138
+ const blocked = all.filter(t => t.status === 'blocked').length;
139
+ const done = all.filter(t => t.status === 'done').length;
140
+
141
+ const lines = [`TASKS (${open} open, ${blocked} blocked, ${done} done)`];
142
+
143
+ for (const t of all) {
144
+ const pri = t.priority === 'critical' ? 'crit' : t.priority === 'high' ? 'high' : t.priority === 'low' ? 'low' : 'med';
145
+ const label = t.intent.length > 48 ? t.intent.slice(0, 45) + '...' : t.intent;
146
+
147
+ if (t.status === 'done') {
148
+ lines.push(` ✓ [${pri}] ${label} (done)`);
149
+ } else if (t.status === 'failed') {
150
+ lines.push(` ✗ [${pri}] ${label} (failed)`);
151
+ } else if (t.status === 'blocked') {
152
+ const blockerNote = t.blockers && t.blockers.length ? `: ${t.blockers[t.blockers.length - 1]}` : '';
153
+ lines.push(` ◌ [${pri}] ${label} (blocked${blockerNote})`);
154
+ } else {
155
+ lines.push(` ● [${pri}] ${label} (${t.status})`);
156
+ }
157
+ }
158
+
159
+ return lines.join('\n');
160
+ }
161
+
162
+ export function reconcile(cwd) {
163
+ const tasks = getCurrentTasks(cwd);
164
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
165
+ return tasks.filter(t =>
166
+ (t.status === 'open' || t.status === 'in_progress') &&
167
+ new Date(t.updated).getTime() < cutoff
168
+ );
169
+ }
170
+
171
+ export function decompose(taskId, subtasks, cwd) {
172
+ const parent = getTask(taskId, cwd);
173
+ if (!parent) throw new Error(`Task not found: ${taskId}`);
174
+
175
+ const created = subtasks.map(sub =>
176
+ createTask(
177
+ {
178
+ ...sub,
179
+ parentTask: taskId,
180
+ owner: sub.owner || parent.owner,
181
+ priority: sub.priority || parent.priority,
182
+ },
183
+ cwd
184
+ )
185
+ );
186
+
187
+ const subtaskIds = created.map(s => s.id);
188
+ const updatedParent = {
189
+ ...parent,
190
+ subtasks: [...(parent.subtasks || []), ...subtaskIds],
191
+ updated: new Date().toISOString(),
192
+ };
193
+ appendEntry(updatedParent, cwd);
194
+
195
+ return { parent: updatedParent, subtasks: created };
196
+ }
@@ -0,0 +1,210 @@
1
+ // living-docs.mjs — Living document system for .dual-brain/.
2
+ // Manages project.json, vision.md, roadmap.md, state.md, actions.jsonl, decisions.jsonl, checkpoints.jsonl.
3
+
4
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+
8
+ const DIR = '.dual-brain';
9
+
10
+ function docsDir(cwd = process.cwd()) {
11
+ return join(cwd, DIR);
12
+ }
13
+
14
+ function ensureDir(cwd) {
15
+ mkdirSync(docsDir(cwd), { recursive: true });
16
+ }
17
+
18
+ function filePath(name, cwd) {
19
+ return join(docsDir(cwd), name);
20
+ }
21
+
22
+ function readFileSafe(name, cwd, fallback = '') {
23
+ try {
24
+ return readFileSync(filePath(name, cwd), 'utf8');
25
+ } catch {
26
+ return fallback;
27
+ }
28
+ }
29
+
30
+ function readJsonSafe(name, cwd, fallback = {}) {
31
+ try {
32
+ return JSON.parse(readFileSync(filePath(name, cwd), 'utf8'));
33
+ } catch {
34
+ return fallback;
35
+ }
36
+ }
37
+
38
+ function readPackageJson(cwd) {
39
+ try {
40
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function gitExec(cmd, cwd) {
47
+ try {
48
+ return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export function initLivingDocs(cwd = process.cwd()) {
55
+ const dir = docsDir(cwd);
56
+ const existed = existsSync(dir);
57
+ ensureDir(cwd);
58
+
59
+ const pkg = readPackageJson(cwd);
60
+
61
+ if (!existsSync(filePath('project.json', cwd))) {
62
+ const project = {
63
+ name: pkg.name ?? '',
64
+ version: pkg.version ?? '0.0.1',
65
+ created: new Date().toISOString(),
66
+ workStyle: 'balanced',
67
+ userCalibration: { specificity: 3, corrections: 3, autonomy: 3 },
68
+ team: [],
69
+ providers: {},
70
+ };
71
+ writeFileSync(filePath('project.json', cwd), JSON.stringify(project, null, 2));
72
+ }
73
+
74
+ if (!existsSync(filePath('vision.md', cwd))) {
75
+ writeFileSync(filePath('vision.md', cwd), '# Vision\n\n_Not yet defined. Type your vision and HEAD will maintain this document._\n');
76
+ }
77
+
78
+ if (!existsSync(filePath('roadmap.md', cwd))) {
79
+ writeFileSync(filePath('roadmap.md', cwd), '# Roadmap\n\n_No roadmap yet. As you work, HEAD will build this from your actions._\n');
80
+ }
81
+
82
+ if (!existsSync(filePath('state.md', cwd))) {
83
+ writeFileSync(filePath('state.md', cwd), '# Current State\n\n_Fresh project. No history yet._\n');
84
+ }
85
+
86
+ for (const log of ['actions.jsonl', 'decisions.jsonl', 'checkpoints.jsonl']) {
87
+ if (!existsSync(filePath(log, cwd))) {
88
+ writeFileSync(filePath(log, cwd), '');
89
+ }
90
+ }
91
+
92
+ return { created: !existed, path: dir };
93
+ }
94
+
95
+ export function appendAction(action, cwd = process.cwd()) {
96
+ ensureDir(cwd);
97
+ const entry = {
98
+ id: `act_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
99
+ timestamp: new Date().toISOString(),
100
+ type: 'task',
101
+ intent: '',
102
+ status: 'started',
103
+ owner: 'head',
104
+ files: [],
105
+ proof: null,
106
+ cost: null,
107
+ result: null,
108
+ ...action,
109
+ };
110
+ appendFileSync(filePath('actions.jsonl', cwd), JSON.stringify(entry) + '\n');
111
+ return entry;
112
+ }
113
+
114
+ export function appendDecision(decision, cwd = process.cwd()) {
115
+ ensureDir(cwd);
116
+ const entry = {
117
+ id: `dec_${Date.now()}`,
118
+ timestamp: new Date().toISOString(),
119
+ question: '',
120
+ decision: '',
121
+ reasoning: '',
122
+ participants: [],
123
+ supersedes: null,
124
+ ...decision,
125
+ };
126
+ appendFileSync(filePath('decisions.jsonl', cwd), JSON.stringify(entry) + '\n');
127
+ return entry;
128
+ }
129
+
130
+ export function createCheckpoint(summary, cwd = process.cwd()) {
131
+ ensureDir(cwd);
132
+ const gitRef = gitExec('git rev-parse HEAD', cwd) ?? 'unknown';
133
+ const branch = gitExec('git rev-parse --abbrev-ref HEAD', cwd) ?? 'unknown';
134
+ const stateSnapshot = readFileSafe('state.md', cwd, '');
135
+ const entry = {
136
+ id: `cp_${Date.now()}`,
137
+ timestamp: new Date().toISOString(),
138
+ gitRef,
139
+ branch,
140
+ summary,
141
+ stateSnapshot,
142
+ };
143
+ appendFileSync(filePath('checkpoints.jsonl', cwd), JSON.stringify(entry) + '\n');
144
+ return entry;
145
+ }
146
+
147
+ export function updateState(newContent, cwd = process.cwd()) {
148
+ ensureDir(cwd);
149
+ writeFileSync(filePath('state.md', cwd), newContent);
150
+ }
151
+
152
+ export function updateRoadmap(newContent, cwd = process.cwd()) {
153
+ ensureDir(cwd);
154
+ writeFileSync(filePath('roadmap.md', cwd), newContent);
155
+ }
156
+
157
+ export function updateVision(newContent, cwd = process.cwd()) {
158
+ ensureDir(cwd);
159
+ const prev = readFileSafe('vision.md', cwd, '');
160
+ writeFileSync(filePath('vision.md', cwd), newContent);
161
+ if (prev !== newContent) {
162
+ appendDecision({
163
+ question: 'What is the project vision?',
164
+ decision: newContent.slice(0, 200),
165
+ reasoning: 'Vision document updated.',
166
+ participants: ['head'],
167
+ supersedes: null,
168
+ }, cwd);
169
+ }
170
+ }
171
+
172
+ export function getProjectState(cwd = process.cwd()) {
173
+ const project = readJsonSafe('project.json', cwd, {});
174
+ const state = readFileSafe('state.md', cwd, '');
175
+ const actions = getRecentActions(cwd, 20);
176
+ const decisions = readLastLines('decisions.jsonl', cwd, 5);
177
+ const checkpoints = readLastLines('checkpoints.jsonl', cwd, 1);
178
+ return {
179
+ project,
180
+ state,
181
+ recentActions: actions,
182
+ recentDecisions: decisions,
183
+ lastCheckpoint: checkpoints[0] ?? null,
184
+ };
185
+ }
186
+
187
+ function readLastLines(name, cwd, n) {
188
+ const raw = readFileSafe(name, cwd, '');
189
+ const lines = raw.split('\n').filter(l => l.trim());
190
+ return lines.slice(-n).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
191
+ }
192
+
193
+ export function getRecentActions(cwd = process.cwd(), limit = 20) {
194
+ return readLastLines('actions.jsonl', cwd, limit);
195
+ }
196
+
197
+ export function getOpenTasks(cwd = process.cwd()) {
198
+ const raw = readFileSafe('actions.jsonl', cwd, '');
199
+ const lines = raw.split('\n').filter(l => l.trim());
200
+ const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
201
+ return entries.filter(e => e.status === 'started' || e.status === 'blocked');
202
+ }
203
+
204
+ export function updateProject(updates, cwd = process.cwd()) {
205
+ ensureDir(cwd);
206
+ const current = readJsonSafe('project.json', cwd, {});
207
+ const merged = { ...current, ...updates };
208
+ writeFileSync(filePath('project.json', cwd), JSON.stringify(merged, null, 2));
209
+ return merged;
210
+ }