dual-brain 0.1.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.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * summary-checkpoint.mjs — Fast derived state for the hot path.
4
+ *
5
+ * Maintains a summary file (usage-summary-YYYY-MM-DD.json) that hooks
6
+ * can read in O(1) instead of scanning the full JSONL log.
7
+ *
8
+ * The summary is rebuilt from JSONL truth if missing or corrupt.
9
+ *
10
+ * Exported API:
11
+ * readSummary(date?) → current summary object
12
+ * updateSummary(newEntry) → incrementally update summary with one entry
13
+ * rebuildSummary(date?) → full rebuild from JSONL
14
+ * getRecentPromptHashes() → last 10min of prompt hashes (for dupe detection)
15
+ * getPressureBuckets() → provider/tier call counts for rolling window
16
+ * getTokenAverages() → moving averages of actual tokens by tier
17
+ */
18
+
19
+ import { execSync as _execSync } from 'child_process';
20
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
21
+ import { dirname, join } from 'path';
22
+ import { fileURLToPath } from 'url';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+
26
+ function summaryPath(date) {
27
+ const d = date || new Date().toISOString().slice(0, 10);
28
+ return join(__dirname, `usage-summary-${d}.json`);
29
+ }
30
+
31
+ function usagePath(date) {
32
+ const d = date || new Date().toISOString().slice(0, 10);
33
+ return join(__dirname, `usage-${d}.jsonl`);
34
+ }
35
+
36
+ function emptySummary() {
37
+ return {
38
+ version: 1,
39
+ date: new Date().toISOString().slice(0, 10),
40
+ updated_at: new Date().toISOString(),
41
+ last_offset: 0,
42
+
43
+ totals: {
44
+ calls: 0,
45
+ cost_estimate: 0,
46
+ by_tier: {},
47
+ by_provider: {},
48
+ by_model: {},
49
+ },
50
+
51
+ pressure: {
52
+ claude: { think: [], execute: [], search: [] },
53
+ openai: { think: [], execute: [], search: [] },
54
+ },
55
+
56
+ recent_hashes: [],
57
+
58
+ token_averages: {},
59
+
60
+ codex_latencies: [],
61
+
62
+ session_insights: {
63
+ gpt_latency_status: 'normal',
64
+ provider_override_count: 0,
65
+ failure_domains: [],
66
+ dual_brain_useful: false,
67
+ balance_posture: 'no activity yet',
68
+ },
69
+
70
+ // Session handoff fields — enriched checkpoint for cross-session continuity
71
+ session_handoff: {
72
+ gate_passed: [], // completed milestones/tasks this session
73
+ evidence: [], // concrete evidence: commit hashes, file paths, PR URLs
74
+ pickup_prompt: 'none recorded', // one-sentence continuation prompt
75
+ friction: [], // problems encountered during the session
76
+ cross_workstream_patterns: [], // generalizable lessons beyond this task
77
+ },
78
+ };
79
+ }
80
+
81
+ const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
82
+
83
+ function atomicWrite(path, data) {
84
+ const tmp = path + '.tmp.' + process.pid;
85
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
86
+ renameSync(tmp, path);
87
+ }
88
+
89
+ function readSummary(date) {
90
+ const path = summaryPath(date);
91
+ try {
92
+ const data = JSON.parse(readFileSync(path, 'utf8'));
93
+ if (data.version === 1) return data;
94
+ } catch {}
95
+ return rebuildSummary(date);
96
+ }
97
+
98
+ function rebuildSummary(date) {
99
+ const d = date || new Date().toISOString().slice(0, 10);
100
+ const logPath = usagePath(d);
101
+ const summary = emptySummary();
102
+ summary.date = d;
103
+
104
+ if (!existsSync(logPath)) {
105
+ atomicWrite(summaryPath(d), summary);
106
+ return summary;
107
+ }
108
+
109
+ let raw;
110
+ try { raw = readFileSync(logPath, 'utf8'); } catch { return summary; }
111
+
112
+ const lines = raw.split('\n').filter(Boolean);
113
+ for (const line of lines) {
114
+ try {
115
+ const entry = JSON.parse(line);
116
+ applyEntry(summary, entry);
117
+ } catch {}
118
+ }
119
+
120
+ summary.last_offset = Buffer.byteLength(raw, 'utf8');
121
+ summary.updated_at = new Date().toISOString();
122
+ atomicWrite(summaryPath(d), summary);
123
+ return summary;
124
+ }
125
+
126
+ function applyEntry(summary, entry) {
127
+ const tier = entry.tier || 'execute';
128
+ const provider = entry.provider || 'claude';
129
+ const model = entry.model || 'unknown';
130
+ const cost = COST_PER_CALL[tier] || COST_PER_CALL.execute;
131
+
132
+ summary.totals.calls++;
133
+ summary.totals.cost_estimate += cost;
134
+
135
+ summary.totals.by_tier[tier] = (summary.totals.by_tier[tier] || 0) + 1;
136
+ summary.totals.by_provider[provider] = (summary.totals.by_provider[provider] || 0) + 1;
137
+ summary.totals.by_model[model] = (summary.totals.by_model[model] || 0) + 1;
138
+
139
+ // Pressure: store timestamps for rolling window lookups
140
+ const ts = entry.timestamp || new Date().toISOString();
141
+ if (summary.pressure[provider]?.[tier]) {
142
+ summary.pressure[provider][tier].push(ts);
143
+ // Keep only last 5 hours of timestamps to bound size
144
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
145
+ summary.pressure[provider][tier] = summary.pressure[provider][tier].filter(
146
+ t => Date.parse(t) >= cutoff
147
+ );
148
+ }
149
+
150
+ // Recent prompt hashes (for duplicate detection)
151
+ if (entry.type === 'tier_recommendation' && entry.prompt_hash) {
152
+ summary.recent_hashes.push({ hash: entry.prompt_hash, ts });
153
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
154
+ summary.recent_hashes = summary.recent_hashes.filter(
155
+ h => Date.parse(h.ts) >= tenMinAgo
156
+ );
157
+ }
158
+
159
+ // Token moving averages
160
+ if (entry.input_tokens != null && entry.output_tokens != null) {
161
+ const key = `${provider}:${tier}`;
162
+ if (!summary.token_averages[key]) {
163
+ summary.token_averages[key] = { count: 0, avg_input: 0, avg_output: 0 };
164
+ }
165
+ const avg = summary.token_averages[key];
166
+ avg.count++;
167
+ avg.avg_input += (entry.input_tokens - avg.avg_input) / avg.count;
168
+ avg.avg_output += (entry.output_tokens - avg.avg_output) / avg.count;
169
+ }
170
+
171
+ // Session handoff: auto-populate from entry metadata
172
+ if (!summary.session_handoff) {
173
+ summary.session_handoff = {
174
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
175
+ friction: [], cross_workstream_patterns: [],
176
+ };
177
+ }
178
+
179
+ // Track completed gates/milestones from quality-gate or review results
180
+ if (entry.type === 'gate_result' && entry.gate === 'pass') {
181
+ summary.session_handoff.gate_passed.push({
182
+ what: entry.reason || 'quality gate passed',
183
+ ts,
184
+ });
185
+ }
186
+
187
+ // Track evidence: file paths from execute-tier entries, commit hashes, PR URLs
188
+ if (tier === 'execute' && entry.files_changed) {
189
+ const files = Array.isArray(entry.files_changed) ? entry.files_changed : [entry.files_changed];
190
+ for (const f of files) {
191
+ if (!summary.session_handoff.evidence.includes(f)) {
192
+ summary.session_handoff.evidence.push(f);
193
+ }
194
+ }
195
+ }
196
+ if (entry.commit_hash) {
197
+ const ref = `commit:${entry.commit_hash}`;
198
+ if (!summary.session_handoff.evidence.includes(ref)) {
199
+ summary.session_handoff.evidence.push(ref);
200
+ }
201
+ }
202
+ if (entry.pr_url) {
203
+ if (!summary.session_handoff.evidence.includes(entry.pr_url)) {
204
+ summary.session_handoff.evidence.push(entry.pr_url);
205
+ }
206
+ }
207
+
208
+ // Track friction: failures, escalations, retries
209
+ if (entry.type === 'failure' || entry.escalated || entry.retry) {
210
+ summary.session_handoff.friction.push({
211
+ what: entry.error || entry.reason || 'unknown failure',
212
+ tier,
213
+ provider,
214
+ ts,
215
+ });
216
+ // Keep friction list bounded
217
+ if (summary.session_handoff.friction.length > 50) {
218
+ summary.session_handoff.friction = summary.session_handoff.friction.slice(-50);
219
+ }
220
+ }
221
+
222
+ // Codex latencies
223
+ if (entry.codex_startup_ms != null) {
224
+ summary.codex_latencies.push({
225
+ startup_ms: entry.codex_startup_ms,
226
+ total_ms: entry.codex_total_ms || null,
227
+ model: model,
228
+ ts,
229
+ });
230
+ // Keep last 50
231
+ if (summary.codex_latencies.length > 50) {
232
+ summary.codex_latencies = summary.codex_latencies.slice(-50);
233
+ }
234
+ }
235
+ }
236
+
237
+ function updateSummary(newEntry, date) {
238
+ const summary = readSummary(date);
239
+ applyEntry(summary, newEntry);
240
+ summary.updated_at = new Date().toISOString();
241
+ atomicWrite(summaryPath(date), summary);
242
+ return summary;
243
+ }
244
+
245
+ function getRecentPromptHashes(date) {
246
+ const summary = readSummary(date);
247
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
248
+ return summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
249
+ }
250
+
251
+ function getPressureBuckets(date) {
252
+ const summary = readSummary(date);
253
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
254
+ const result = {};
255
+
256
+ for (const provider of ['claude', 'openai']) {
257
+ result[provider] = {};
258
+ for (const tier of ['think', 'execute', 'search']) {
259
+ const timestamps = summary.pressure[provider]?.[tier] || [];
260
+ result[provider][tier] = timestamps.filter(t => Date.parse(t) >= cutoff).length;
261
+ }
262
+ }
263
+ return result;
264
+ }
265
+
266
+ function getTokenAverages(date) {
267
+ const summary = readSummary(date);
268
+ return summary.token_averages;
269
+ }
270
+
271
+ function updateSessionInsight(key, value, date) {
272
+ const validKeys = ['gpt_latency_status', 'provider_override_count', 'failure_domains', 'dual_brain_useful', 'balance_posture'];
273
+ if (!validKeys.includes(key)) return;
274
+ const summary = readSummary(date);
275
+ if (!summary.session_insights) summary.session_insights = {};
276
+ summary.session_insights[key] = value;
277
+ summary.updated_at = new Date().toISOString();
278
+ atomicWrite(summaryPath(date), summary);
279
+ }
280
+
281
+ function getAdaptiveCodexThreshold(date) {
282
+ const summary = readSummary(date);
283
+ const latencies = summary.codex_latencies || [];
284
+ if (latencies.length < 5) return { threshold_ms: 180_000, confidence: 'low', samples: latencies.length };
285
+
286
+ const startups = latencies.map(l => l.startup_ms).filter(Boolean).sort((a, b) => a - b);
287
+ if (startups.length < 3) return { threshold_ms: 180_000, confidence: 'low', samples: startups.length };
288
+
289
+ const p75idx = Math.floor(startups.length * 0.75);
290
+ const p75 = startups[p75idx];
291
+ const threshold = Math.max(90_000, p75 * 4);
292
+
293
+ return {
294
+ threshold_ms: Math.round(threshold),
295
+ p75_startup_ms: Math.round(p75),
296
+ confidence: startups.length >= 20 ? 'high' : 'medium',
297
+ samples: startups.length,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Update a specific session handoff field.
303
+ * Valid keys: gate_passed, evidence, pickup_prompt, friction, cross_workstream_patterns
304
+ *
305
+ * For array fields, `value` is appended (string or object).
306
+ * For pickup_prompt, `value` replaces the current string.
307
+ */
308
+ function updateHandoff(key, value, date) {
309
+ const arrayFields = ['gate_passed', 'evidence', 'friction', 'cross_workstream_patterns'];
310
+ const validKeys = [...arrayFields, 'pickup_prompt'];
311
+ if (!validKeys.includes(key)) return;
312
+
313
+ const summary = readSummary(date);
314
+ if (!summary.session_handoff) {
315
+ summary.session_handoff = {
316
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
317
+ friction: [], cross_workstream_patterns: [],
318
+ };
319
+ }
320
+
321
+ if (key === 'pickup_prompt') {
322
+ summary.session_handoff.pickup_prompt = String(value);
323
+ } else if (arrayFields.includes(key)) {
324
+ if (!Array.isArray(summary.session_handoff[key])) {
325
+ summary.session_handoff[key] = [];
326
+ }
327
+ summary.session_handoff[key].push(value);
328
+ }
329
+
330
+ summary.updated_at = new Date().toISOString();
331
+ atomicWrite(summaryPath(date), summary);
332
+ return summary;
333
+ }
334
+
335
+ /**
336
+ * Generate a full session checkpoint for handoff.
337
+ *
338
+ * Auto-enriches evidence from git state (changed files, HEAD commit)
339
+ * and builds a pickup prompt if none was set manually.
340
+ */
341
+ function generateCheckpoint(date) {
342
+ const summary = readSummary(date);
343
+
344
+ if (!summary.session_handoff) {
345
+ summary.session_handoff = {
346
+ gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
347
+ friction: [], cross_workstream_patterns: [],
348
+ };
349
+ }
350
+
351
+ const handoff = summary.session_handoff;
352
+
353
+ // Auto-enrich evidence from git if available
354
+ try {
355
+ // Current HEAD commit
356
+ const head = _execSync('git rev-parse --short HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
357
+ if (head) {
358
+ const ref = `commit:${head}`;
359
+ if (!handoff.evidence.includes(ref)) {
360
+ handoff.evidence.push(ref);
361
+ }
362
+ }
363
+
364
+ // Changed files in working tree
365
+ const diff = _execSync('git diff --name-only HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
366
+ if (diff) {
367
+ for (const f of diff.split('\n').filter(Boolean)) {
368
+ const ref = `changed:${f}`;
369
+ if (!handoff.evidence.includes(ref)) {
370
+ handoff.evidence.push(ref);
371
+ }
372
+ }
373
+ }
374
+
375
+ // Current branch
376
+ const branch = _execSync('git branch --show-current 2>/dev/null', { encoding: 'utf8' }).trim();
377
+ if (branch) {
378
+ handoff.evidence.push(`branch:${branch}`);
379
+ }
380
+ } catch {
381
+ // Git not available — skip enrichment
382
+ }
383
+
384
+ // Auto-generate pickup_prompt if not manually set
385
+ if (handoff.pickup_prompt === 'none recorded' && summary.totals.calls > 0) {
386
+ const topTier = Object.entries(summary.totals.by_tier)
387
+ .sort(([, a], [, b]) => b - a)[0];
388
+ const tierLabel = topTier ? topTier[0] : 'mixed';
389
+ const fileCount = handoff.evidence.filter(e => e.startsWith('changed:')).length;
390
+ const frictionCount = handoff.friction.length;
391
+
392
+ let prompt = `Session had ${summary.totals.calls} calls (mostly ${tierLabel})`;
393
+ if (fileCount > 0) prompt += `, ${fileCount} files modified`;
394
+ if (frictionCount > 0) prompt += `, ${frictionCount} friction points to review`;
395
+ prompt += '.';
396
+ handoff.pickup_prompt = prompt;
397
+ }
398
+
399
+ // Build the checkpoint object
400
+ const checkpoint = {
401
+ version: 1,
402
+ generated_at: new Date().toISOString(),
403
+ date: summary.date,
404
+
405
+ // Existing summary data
406
+ totals: summary.totals,
407
+ session_insights: summary.session_insights,
408
+
409
+ // New handoff fields
410
+ gate_passed: handoff.gate_passed,
411
+ evidence: handoff.evidence,
412
+ pickup_prompt: handoff.pickup_prompt,
413
+ friction: handoff.friction,
414
+ cross_workstream_patterns: handoff.cross_workstream_patterns,
415
+ };
416
+
417
+ return checkpoint;
418
+ }
419
+
420
+ export {
421
+ readSummary,
422
+ updateSummary,
423
+ rebuildSummary,
424
+ getRecentPromptHashes,
425
+ getPressureBuckets,
426
+ getTokenAverages,
427
+ getAdaptiveCodexThreshold,
428
+ updateSessionInsight,
429
+ updateHandoff,
430
+ generateCheckpoint,
431
+ atomicWrite,
432
+ };