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/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,