dual-brain 0.2.2 → 0.2.4
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 +971 -411
- package/package.json +1 -1
- package/src/dispatch.mjs +14 -0
- package/src/pipeline.mjs +6 -0
- package/src/profile.mjs +535 -10
- package/src/receipt.mjs +213 -0
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,
|