dual-brain 0.1.22 → 0.2.0

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/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
+ }