dual-brain 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,11 @@
23
23
  "./calibration": "./src/calibration.mjs",
24
24
  "./models": "./src/models.mjs",
25
25
  "./prompt-intel": "./src/prompt-intel.mjs",
26
- "./replit": "./src/replit.mjs"
26
+ "./replit": "./src/replit.mjs",
27
+ "./continuity": "./src/continuity.mjs",
28
+ "./checkpoint": "./src/checkpoint.mjs",
29
+ "./pr-agent": "./src/pr-agent.mjs",
30
+ "./ci-triage": "./src/ci-triage.mjs"
27
31
  },
28
32
  "keywords": [
29
33
  "claude-code",
@@ -86,6 +90,10 @@
86
90
  "src/prompt-intel.mjs",
87
91
  "src/replit.mjs",
88
92
  "src/fx.mjs",
93
+ "src/continuity.mjs",
94
+ "src/checkpoint.mjs",
95
+ "src/ci-triage.mjs",
96
+ "src/pr-agent.mjs",
89
97
  "bin/*.mjs",
90
98
  "hooks/enforce-tier.mjs",
91
99
  "hooks/cost-logger.mjs",
package/src/awareness.mjs CHANGED
@@ -358,3 +358,39 @@ export function invalidateCache() {
358
358
  _cache = null;
359
359
  _cacheTime = 0;
360
360
  }
361
+
362
+ // ─── Ambiguity Detection ──────────────────────────────────────────────────────
363
+
364
+ const TECHNICAL_TERMS = /\b(fix|bug|error|test|deploy|refactor|import|export|function|class|module|api|endpoint|auth|token|database|query|schema|migration|build|lint|type|interface|component|route|handler|middleware|config|env|secret|key|file|path|directory|repo|branch|commit|merge|pull|push|install|upgrade|package|dependency|version|release|publish|log|trace|debug|stack|exception|undefined|null|async|await|promise|fetch|request|response|status|server|client|socket|cache|session)\b/i;
365
+
366
+ /**
367
+ * Detect whether a prompt is ambiguous and needs clarification before dispatch.
368
+ *
369
+ * A prompt is considered ambiguous when ALL of the following are true:
370
+ * 1. It is very short (under 4 words)
371
+ * 2. No file context is provided
372
+ * 3. It lacks specific technical terms that narrow the intent
373
+ *
374
+ * @param {string} prompt — the user's raw prompt
375
+ * @param {{ files?: string[] }} [context] — optional context (e.g. file paths)
376
+ * @returns {{ isAmbiguous: boolean, reason: string|null }}
377
+ */
378
+ export function detectAmbiguity(prompt, context = {}) {
379
+ if (!prompt || typeof prompt !== 'string') {
380
+ return { isAmbiguous: true, reason: 'missing context: empty prompt' };
381
+ }
382
+
383
+ const words = prompt.trim().split(/\s+/).filter(Boolean);
384
+ const isTooShort = words.length < 4;
385
+ const hasFileContext = Array.isArray(context?.files) && context.files.length > 0;
386
+ const hasTechnicalTerms = TECHNICAL_TERMS.test(prompt);
387
+
388
+ if (isTooShort && !hasFileContext && !hasTechnicalTerms) {
389
+ return {
390
+ isAmbiguous: true,
391
+ reason: `unclear: prompt is vague ("${prompt.trim()}") — missing context about what to change and where`,
392
+ };
393
+ }
394
+
395
+ return { isAmbiguous: false, reason: null };
396
+ }
@@ -0,0 +1,109 @@
1
+ // checkpoint.mjs — Checkpoint wrapper for dual-brain execution safety.
2
+ // Wraps Replit's native checkpoint system with a git-based fallback.
3
+ // Exports: hasCheckpoints, createCheckpoint, listCheckpoints, getLastCheckpoint
4
+
5
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { execSync } from 'node:child_process';
8
+
9
+ /**
10
+ * Check if checkpoint capability is available.
11
+ * @returns {boolean}
12
+ */
13
+ export function hasCheckpoints() {
14
+ try {
15
+ // Check for Replit checkpoint binary
16
+ if (existsSync('/usr/local/bin/replit-checkpoint')) return true;
17
+ execSync('which replit-checkpoint', { stdio: 'pipe', timeout: 2000 });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Create a checkpoint before a risky operation.
26
+ * @param {string} label — human-readable label like "before auth refactor"
27
+ * @param {object} [opts]
28
+ * @param {string} [opts.cwd]
29
+ * @returns {{ success: boolean, id: string|null, label: string, timestamp: string }}
30
+ */
31
+ export function createCheckpoint(label, opts = {}) {
32
+ const cwd = opts.cwd || process.cwd();
33
+ const timestamp = new Date().toISOString();
34
+ const id = `cp-${Date.now()}`;
35
+
36
+ // Try Replit checkpoint first
37
+ if (hasCheckpoints()) {
38
+ try {
39
+ execSync('replit-checkpoint create', { cwd, stdio: 'pipe', timeout: 10000 });
40
+ _logCheckpoint({ id, label, timestamp, type: 'replit', status: 'created' }, cwd);
41
+ return { success: true, id, label, timestamp };
42
+ } catch {
43
+ // Fall through to git-based checkpoint
44
+ }
45
+ }
46
+
47
+ // Fallback: git stash + tag
48
+ try {
49
+ // Stash any uncommitted changes
50
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
51
+ if (status) {
52
+ execSync(`git stash push -m "dual-brain-checkpoint: ${label}"`, { cwd, stdio: 'pipe', timeout: 10000 });
53
+ execSync('git stash pop', { cwd, stdio: 'pipe', timeout: 10000 });
54
+ }
55
+ // Create a lightweight tag
56
+ const safeLabel = label.replace(/[^a-zA-Z0-9-_]/g, '-').slice(0, 50);
57
+ const tagName = `db-checkpoint/${safeLabel}-${Date.now()}`;
58
+ execSync(`git tag "${tagName}"`, { cwd, stdio: 'pipe', timeout: 5000 });
59
+ _logCheckpoint({ id, label, timestamp, type: 'git-tag', tag: tagName, status: 'created' }, cwd);
60
+ return { success: true, id, label, timestamp };
61
+ } catch {
62
+ _logCheckpoint({ id, label, timestamp, type: 'failed', status: 'failed' }, cwd);
63
+ return { success: false, id: null, label, timestamp };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * List recent checkpoints (most recent first, up to 20).
69
+ * @param {string} [cwd]
70
+ * @returns {object[]}
71
+ */
72
+ export function listCheckpoints(cwd) {
73
+ const logPath = join(cwd || process.cwd(), '.dual-brain', 'checkpoints.jsonl');
74
+ if (!existsSync(logPath)) return [];
75
+ try {
76
+ return readFileSync(logPath, 'utf8')
77
+ .trim()
78
+ .split('\n')
79
+ .filter(Boolean)
80
+ .map(line => JSON.parse(line))
81
+ .reverse()
82
+ .slice(0, 20);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the most recent checkpoint.
90
+ * @param {string} [cwd]
91
+ * @returns {object|null}
92
+ */
93
+ export function getLastCheckpoint(cwd) {
94
+ const checkpoints = listCheckpoints(cwd);
95
+ return checkpoints[0] || null;
96
+ }
97
+
98
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
99
+
100
+ function _logCheckpoint(entry, cwd) {
101
+ const dir = join(cwd || process.cwd(), '.dual-brain');
102
+ mkdirSync(dir, { recursive: true });
103
+ const logPath = join(dir, 'checkpoints.jsonl');
104
+ const line = JSON.stringify(entry) + '\n';
105
+ try {
106
+ const existing = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
107
+ writeFileSync(logPath, existing + line);
108
+ } catch {}
109
+ }
@@ -0,0 +1,191 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Detect CI system in use.
7
+ * @param {string} [cwd]
8
+ * @returns {{ systems: string[], primary: string|null }}
9
+ */
10
+ export function detectCI(cwd) {
11
+ const root = cwd || process.cwd();
12
+ const systems = [];
13
+
14
+ if (existsSync(join(root, '.github/workflows'))) systems.push('github-actions');
15
+ if (existsSync(join(root, '.circleci'))) systems.push('circleci');
16
+ if (existsSync(join(root, '.gitlab-ci.yml'))) systems.push('gitlab-ci');
17
+ if (existsSync(join(root, 'Jenkinsfile'))) systems.push('jenkins');
18
+ if (existsSync(join(root, '.travis.yml'))) systems.push('travis');
19
+ if (existsSync(join(root, 'vercel.json')) || existsSync(join(root, '.vercel'))) systems.push('vercel');
20
+ if (existsSync(join(root, 'netlify.toml'))) systems.push('netlify');
21
+
22
+ return { systems, primary: systems[0] || null };
23
+ }
24
+
25
+ /**
26
+ * Get recent CI run status using gh CLI.
27
+ * @param {string} [cwd]
28
+ * @returns {{ available: boolean, runs: object[], hasFailures: boolean, lastRun: object|null }}
29
+ */
30
+ export function getCIStatus(cwd) {
31
+ try {
32
+ const json = execSync(
33
+ 'gh run list --limit 5 --json databaseId,name,status,conclusion,headBranch,createdAt',
34
+ { cwd, encoding: 'utf8', timeout: 10000 }
35
+ );
36
+ const runs = JSON.parse(json);
37
+ return {
38
+ available: true,
39
+ runs: runs.map(r => ({
40
+ id: r.databaseId,
41
+ name: r.name,
42
+ status: r.status,
43
+ conclusion: r.conclusion,
44
+ branch: r.headBranch,
45
+ createdAt: r.createdAt,
46
+ })),
47
+ hasFailures: runs.some(r => r.conclusion === 'failure'),
48
+ lastRun: runs[0] || null,
49
+ };
50
+ } catch {
51
+ return { available: false, runs: [], hasFailures: false, lastRun: null };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get failed CI run logs and classify the failure.
57
+ * @param {string|number} runId
58
+ * @param {string} [cwd]
59
+ * @returns {object}
60
+ */
61
+ export function triageFailure(runId, cwd) {
62
+ try {
63
+ const logs = execSync(`gh run view ${runId} --log-failed 2>/dev/null | tail -100`, {
64
+ cwd, encoding: 'utf8', timeout: 15000,
65
+ });
66
+
67
+ const classification = classifyFailure(logs);
68
+ const fileHints = extractFileHints(logs, cwd);
69
+
70
+ return {
71
+ success: true,
72
+ runId,
73
+ logs: logs.slice(-3000), // last 3000 chars
74
+ classification,
75
+ fileHints,
76
+ suggestedAction: getSuggestedAction(classification),
77
+ };
78
+ } catch (err) {
79
+ return { success: false, runId, error: err.message };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Classify a CI failure from log output.
85
+ * @param {string} logs
86
+ * @returns {{ type: string, confidence: string }}
87
+ */
88
+ function classifyFailure(logs) {
89
+ const lower = logs.toLowerCase();
90
+
91
+ if (lower.includes('syntaxerror') || lower.includes('parse error')) return { type: 'syntax', confidence: 'high' };
92
+ if (lower.includes('typeerror') || lower.includes('type error')) return { type: 'type-error', confidence: 'high' };
93
+ if (lower.includes('referenceerror')) return { type: 'reference-error', confidence: 'high' };
94
+ if (lower.includes('test fail') || lower.includes('tests failed') || lower.includes('assertion')) return { type: 'test-failure', confidence: 'high' };
95
+ if (lower.includes('enoent') || lower.includes('no such file')) return { type: 'missing-file', confidence: 'high' };
96
+ if (lower.includes('permission denied') || lower.includes('eacces')) return { type: 'permissions', confidence: 'high' };
97
+ if (lower.includes('timeout') || lower.includes('timed out')) return { type: 'timeout', confidence: 'medium' };
98
+ if (lower.includes('out of memory') || lower.includes('heap')) return { type: 'oom', confidence: 'medium' };
99
+ if (lower.includes('npm err') || lower.includes('yarn error') || lower.includes('dependency')) return { type: 'dependency', confidence: 'medium' };
100
+ if (lower.includes('lint') || lower.includes('eslint')) return { type: 'lint', confidence: 'high' };
101
+ if (lower.includes('build fail')) return { type: 'build', confidence: 'medium' };
102
+ if (lower.includes('docker') || lower.includes('container')) return { type: 'container', confidence: 'medium' };
103
+
104
+ return { type: 'unknown', confidence: 'low' };
105
+ }
106
+
107
+ /**
108
+ * Extract local file paths referenced in CI logs.
109
+ * @param {string} logs
110
+ * @param {string} [cwd]
111
+ * @returns {string[]}
112
+ */
113
+ function extractFileHints(logs, cwd) {
114
+ const files = new Set();
115
+ const root = cwd || process.cwd();
116
+
117
+ const patterns = [
118
+ /(?:at\s+)?([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+):(\d+)/g,
119
+ /(?:in\s+)?([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+)\((\d+)\)/g,
120
+ /Error in ([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+)/g,
121
+ ];
122
+
123
+ for (const pattern of patterns) {
124
+ for (const match of logs.matchAll(pattern)) {
125
+ const file = match[1];
126
+ if (file && !file.includes('node_modules') && existsSync(join(root, file))) {
127
+ files.add(file);
128
+ }
129
+ }
130
+ }
131
+
132
+ return [...files];
133
+ }
134
+
135
+ /**
136
+ * Get a human-readable suggested action for a failure classification.
137
+ * @param {{ type: string }} classification
138
+ * @returns {string}
139
+ */
140
+ function getSuggestedAction(classification) {
141
+ const actions = {
142
+ 'syntax': 'Fix syntax error in the identified file',
143
+ 'type-error': 'Check type annotations and function signatures',
144
+ 'reference-error': 'Check for undefined variables or missing imports',
145
+ 'test-failure': 'Run tests locally and fix failing assertions',
146
+ 'missing-file': 'Check if a required file was deleted or not committed',
147
+ 'permissions': 'Check file permissions and access rights',
148
+ 'timeout': 'Investigate slow operations or increase timeout',
149
+ 'oom': 'Check for memory leaks or reduce batch size',
150
+ 'dependency': 'Run npm install and check for version conflicts',
151
+ 'lint': 'Run linter locally and fix violations',
152
+ 'build': 'Check build configuration and dependencies',
153
+ 'container': 'Check Dockerfile and container configuration',
154
+ 'unknown': 'Review full CI logs for error details',
155
+ };
156
+ return actions[classification.type] || actions.unknown;
157
+ }
158
+
159
+ /**
160
+ * Full CI triage: detect CI, fetch status, classify failures, map to files.
161
+ * @param {string} [cwd]
162
+ * @returns {object}
163
+ */
164
+ export function fullTriage(cwd) {
165
+ const ci = detectCI(cwd);
166
+ if (!ci.primary) return { available: false, reason: 'no-ci-detected' };
167
+
168
+ const status = getCIStatus(cwd);
169
+ if (!status.available) return { available: false, reason: 'gh-cli-unavailable' };
170
+ if (!status.hasFailures) return { available: true, healthy: true, message: 'All CI runs passing' };
171
+
172
+ const failedRuns = status.runs.filter(r => r.conclusion === 'failure');
173
+ const triages = failedRuns.slice(0, 3).map(r => triageFailure(r.id, cwd));
174
+
175
+ return {
176
+ available: true,
177
+ healthy: false,
178
+ failedRuns: failedRuns.length,
179
+ triages,
180
+ topIssue: triages[0]?.classification || null,
181
+ };
182
+ }
183
+
184
+ // ─── CLI (direct invocation) ──────────────────────────────────────────────────
185
+
186
+ const isMain = process.argv[1]?.endsWith('ci-triage.mjs');
187
+ if (isMain) {
188
+ const cwd = process.argv[2] || process.cwd();
189
+ const result = fullTriage(cwd);
190
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
191
+ }
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ // continuity.mjs — Session continuity for dual-brain.
3
+ // Generates handoff receipts so the next session can pick up seamlessly
4
+ // when a session hits context limits, crashes, or is manually ended.
5
+ //
6
+ // Exports: generateHandoff, saveHandoff, getLatestHandoff, getHandoffAge,
7
+ // buildCompactionSurvivalKit, buildResumeBrief, pruneHandoffs,
8
+ // extractRoutingPatterns
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ // ─── Session chaining ─────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Generate a compact handoff object from current session state.
17
+ * Designed to fit in ~500 tokens when serialized.
18
+ *
19
+ * @param {object} sessionState
20
+ * @param {string} [sessionState.taskDescription]
21
+ * @param {string[]} [sessionState.filesChanged]
22
+ * @param {string[]} [sessionState.testsRun]
23
+ * @param {object[]} [sessionState.decisions] Most recent routing decisions
24
+ * @param {string[]} [sessionState.unresolved] Open questions / blockers
25
+ * @param {object} [sessionState.routingHistory]
26
+ * @param {string} [sessionState.routingHistory.lastProvider]
27
+ * @param {string} [sessionState.routingHistory.lastModel]
28
+ * @param {string[]} [sessionState.routingHistory.failedProviders]
29
+ * @param {string[]} [sessionState.activePreferences]
30
+ * @param {string} [sessionState.resumeHint] e.g. "continue implementing auth refactor"
31
+ * @returns {object}
32
+ */
33
+ export function generateHandoff(sessionState) {
34
+ return {
35
+ version: 1,
36
+ timestamp: new Date().toISOString(),
37
+ task: sessionState.taskDescription || null,
38
+ progress: {
39
+ filesChanged: (sessionState.filesChanged || []).slice(0, 20),
40
+ testsRun: sessionState.testsRun || [],
41
+ decisions: (sessionState.decisions || []).slice(0, 5), // most recent routing decisions
42
+ },
43
+ unresolved: (sessionState.unresolved || []).slice(0, 5),
44
+ routing: {
45
+ lastProvider: sessionState.routingHistory?.lastProvider || null,
46
+ lastModel: sessionState.routingHistory?.lastModel || null,
47
+ failedProviders: sessionState.routingHistory?.failedProviders || [],
48
+ },
49
+ preferences: sessionState.activePreferences || [],
50
+ resumeHint: sessionState.resumeHint || null,
51
+ };
52
+ }
53
+
54
+ // ─── Handoff persistence ──────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Persist a handoff object to .dual-brain/handoffs/.
58
+ * @param {object} handoff Result of generateHandoff()
59
+ * @param {string} [cwd] Project root (defaults to process.cwd())
60
+ * @returns {string} Absolute path of the written file
61
+ */
62
+ export function saveHandoff(handoff, cwd) {
63
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
64
+ mkdirSync(dir, { recursive: true });
65
+ const filename = `handoff-${Date.now()}.json`;
66
+ writeFileSync(join(dir, filename), JSON.stringify(handoff, null, 2));
67
+ return join(dir, filename);
68
+ }
69
+
70
+ /**
71
+ * Load the most recent handoff from .dual-brain/handoffs/.
72
+ * Returns null when no handoffs exist or all are unreadable.
73
+ * @param {string} [cwd]
74
+ * @returns {object|null}
75
+ */
76
+ export function getLatestHandoff(cwd) {
77
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
78
+ if (!existsSync(dir)) return null;
79
+ const files = readdirSync(dir)
80
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
81
+ .sort()
82
+ .reverse();
83
+ if (files.length === 0) return null;
84
+ try {
85
+ return JSON.parse(readFileSync(join(dir, files[0]), 'utf8'));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Return the age of a handoff in hours.
93
+ * Returns Infinity when the handoff has no timestamp.
94
+ * @param {object|null} handoff
95
+ * @returns {number} Hours since handoff was generated
96
+ */
97
+ export function getHandoffAge(handoff) {
98
+ if (!handoff?.timestamp) return Infinity;
99
+ return (Date.now() - Date.parse(handoff.timestamp)) / 3600000;
100
+ }
101
+
102
+ // ─── Smart compaction ─────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Build a compaction-safe summary string to inject before context compression.
106
+ * The content must survive being summarised by a compression pass, so keep it
107
+ * terse, high-signal, and easy to re-state.
108
+ *
109
+ * @param {object} state
110
+ * @param {string} [state.activeTask]
111
+ * @param {string[]} [state.routingRules]
112
+ * @param {string[]} [state.criticalDecisions]
113
+ * @param {string[]} [state.filesInProgress]
114
+ * @param {string[]} [state.preferences]
115
+ * @param {string[]} [state.warnings]
116
+ * @returns {string}
117
+ */
118
+ export function buildCompactionSurvivalKit(state) {
119
+ const lines = [];
120
+ lines.push('[DUAL-BRAIN CONTINUITY]');
121
+
122
+ if (state.activeTask) {
123
+ lines.push(`TASK: ${state.activeTask}`);
124
+ }
125
+ if (state.routingRules?.length) {
126
+ lines.push(`ROUTING: ${state.routingRules.join('; ')}`);
127
+ }
128
+ if (state.criticalDecisions?.length) {
129
+ lines.push(`DECISIONS: ${state.criticalDecisions.join('; ')}`);
130
+ }
131
+ if (state.filesInProgress?.length) {
132
+ lines.push(`FILES: ${state.filesInProgress.join(', ')}`);
133
+ }
134
+ if (state.preferences?.length) {
135
+ lines.push(`PREFS: ${state.preferences.join('; ')}`);
136
+ }
137
+ if (state.warnings?.length) {
138
+ lines.push(`WARNINGS: ${state.warnings.join('; ')}`);
139
+ }
140
+
141
+ lines.push('[/DUAL-BRAIN CONTINUITY]');
142
+ return lines.join('\n');
143
+ }
144
+
145
+ // ─── Resume brief builder ─────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Check for a recent handoff and build a resume context string for a new session.
149
+ * Returns null when no usable handoff exists (missing, too stale, or unreadable).
150
+ *
151
+ * @param {string} [cwd]
152
+ * @returns {string|null}
153
+ */
154
+ export function buildResumeBrief(cwd) {
155
+ const handoff = getLatestHandoff(cwd);
156
+ if (!handoff) return null;
157
+
158
+ const ageHours = getHandoffAge(handoff);
159
+ if (ageHours > 48) return null; // too stale to be useful
160
+
161
+ const lines = [];
162
+ const ageLabel =
163
+ ageHours < 1
164
+ ? 'just now'
165
+ : ageHours < 24
166
+ ? `${Math.round(ageHours)}h ago`
167
+ : `${Math.round(ageHours / 24)}d ago`;
168
+
169
+ lines.push(`Resuming from previous session (${ageLabel}):`);
170
+
171
+ if (handoff.task) lines.push(` Task: ${handoff.task}`);
172
+ if (handoff.resumeHint) lines.push(` Next: ${handoff.resumeHint}`);
173
+ if (handoff.progress?.filesChanged?.length) {
174
+ const shown = handoff.progress.filesChanged.slice(0, 5);
175
+ const extra = handoff.progress.filesChanged.length > 5
176
+ ? ` (+${handoff.progress.filesChanged.length - 5} more)`
177
+ : '';
178
+ lines.push(` Changed: ${shown.join(', ')}${extra}`);
179
+ }
180
+ if (handoff.unresolved?.length) {
181
+ lines.push(` Unresolved: ${handoff.unresolved.join('; ')}`);
182
+ }
183
+ if (handoff.routing?.failedProviders?.length) {
184
+ lines.push(` Note: ${handoff.routing.failedProviders.join(', ')} failed last session`);
185
+ }
186
+
187
+ return lines.join('\n');
188
+ }
189
+
190
+ // ─── Handoff cleanup ──────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Remove old handoff files, keeping only the most recent `keep` entries.
194
+ * @param {string} [cwd]
195
+ * @param {number} [keep=10]
196
+ * @returns {number} Count of files pruned
197
+ */
198
+ export function pruneHandoffs(cwd, keep = 10) {
199
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
200
+ if (!existsSync(dir)) return 0;
201
+ const files = readdirSync(dir)
202
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
203
+ .sort()
204
+ .reverse();
205
+ let pruned = 0;
206
+ for (const f of files.slice(keep)) {
207
+ try {
208
+ unlinkSync(join(dir, f));
209
+ pruned++;
210
+ } catch {
211
+ // Skip files that can't be removed — best-effort
212
+ }
213
+ }
214
+ return pruned;
215
+ }
216
+
217
+ // ─── Cross-session learning ───────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Extract routing patterns from handoff history to inform provider/model selection.
221
+ *
222
+ * @param {string} [cwd]
223
+ * @returns {{
224
+ * patterns: Array<{ type: string, value: string, count: number }>,
225
+ * confidence: number,
226
+ * sampleSize: number
227
+ * }}
228
+ */
229
+ export function extractRoutingPatterns(cwd) {
230
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
231
+ if (!existsSync(dir)) return { patterns: [], confidence: 0, sampleSize: 0 };
232
+
233
+ const files = readdirSync(dir)
234
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
235
+ .sort()
236
+ .reverse()
237
+ .slice(0, 20);
238
+
239
+ const handoffs = files
240
+ .map(f => {
241
+ try {
242
+ return JSON.parse(readFileSync(join(dir, f), 'utf8'));
243
+ } catch {
244
+ return null;
245
+ }
246
+ })
247
+ .filter(Boolean);
248
+
249
+ // Count provider/model usage patterns
250
+ const providerCounts = {};
251
+ const modelCounts = {};
252
+ const failureCounts = {};
253
+
254
+ for (const h of handoffs) {
255
+ if (h.routing?.lastProvider) {
256
+ providerCounts[h.routing.lastProvider] = (providerCounts[h.routing.lastProvider] || 0) + 1;
257
+ }
258
+ if (h.routing?.lastModel) {
259
+ modelCounts[h.routing.lastModel] = (modelCounts[h.routing.lastModel] || 0) + 1;
260
+ }
261
+ for (const fp of (h.routing?.failedProviders || [])) {
262
+ failureCounts[fp] = (failureCounts[fp] || 0) + 1;
263
+ }
264
+ }
265
+
266
+ const patterns = [];
267
+
268
+ // Most used provider
269
+ const topProvider = Object.entries(providerCounts).sort((a, b) => b[1] - a[1])[0];
270
+ if (topProvider) {
271
+ patterns.push({ type: 'preferred_provider', value: topProvider[0], count: topProvider[1] });
272
+ }
273
+
274
+ // Most used model
275
+ const topModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0];
276
+ if (topModel) {
277
+ patterns.push({ type: 'preferred_model', value: topModel[0], count: topModel[1] });
278
+ }
279
+
280
+ // Frequently failing provider (threshold: 3+ failures)
281
+ const topFailure = Object.entries(failureCounts).sort((a, b) => b[1] - a[1])[0];
282
+ if (topFailure && topFailure[1] >= 3) {
283
+ patterns.push({ type: 'unreliable_provider', value: topFailure[0], count: topFailure[1] });
284
+ }
285
+
286
+ return {
287
+ patterns,
288
+ confidence: Math.min(1, handoffs.length / 10),
289
+ sampleSize: handoffs.length,
290
+ };
291
+ }