dual-brain 0.2.4 → 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/bin/dual-brain.mjs +349 -2
- package/package.json +10 -2
- package/src/awareness.mjs +36 -0
- package/src/checkpoint.mjs +109 -0
- package/src/ci-triage.mjs +191 -0
- package/src/continuity.mjs +291 -0
- package/src/detect.mjs +38 -0
- package/src/dispatch.mjs +73 -6
- package/src/health.mjs +35 -0
- package/src/pipeline.mjs +58 -1
- package/src/pr-agent.mjs +214 -0
- package/src/repo.mjs +153 -0
|
@@ -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
|
+
}
|
package/src/detect.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
6
|
import { resolve, dirname } from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
|
|
@@ -365,6 +366,39 @@ function detectSuggestedPlugins(prompt) {
|
|
|
365
366
|
return [...matched];
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
// ─── CI risk check ────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Lightweight CI risk check: returns true if the current branch has a recent
|
|
373
|
+
* CI failure, indicating the task may touch already-broken code.
|
|
374
|
+
* Intentionally best-effort — any error returns false (never blocks detection).
|
|
375
|
+
* @param {string} [cwd]
|
|
376
|
+
* @returns {{ hasCIFailure: boolean, failedBranch: string|null }}
|
|
377
|
+
*/
|
|
378
|
+
function checkCIRisk(cwd) {
|
|
379
|
+
try {
|
|
380
|
+
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
381
|
+
cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
382
|
+
}).trim();
|
|
383
|
+
|
|
384
|
+
const json = execSync(
|
|
385
|
+
'gh run list --limit 5 --json conclusion,headBranch 2>/dev/null',
|
|
386
|
+
{ cwd, encoding: 'utf8', timeout: 8000 }
|
|
387
|
+
);
|
|
388
|
+
const runs = JSON.parse(json);
|
|
389
|
+
const branchFailure = runs.find(
|
|
390
|
+
r => r.conclusion === 'failure' && r.headBranch === currentBranch
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
hasCIFailure: Boolean(branchFailure),
|
|
395
|
+
failedBranch: branchFailure ? currentBranch : null,
|
|
396
|
+
};
|
|
397
|
+
} catch {
|
|
398
|
+
return { hasCIFailure: false, failedBranch: null };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
368
402
|
/** Main detection function. Input: { prompt, files?, priorFailures?, sessionContext? } */
|
|
369
403
|
function detectTask(input) {
|
|
370
404
|
const { prompt = '', files = [], sessionContext = null } = input;
|
|
@@ -455,6 +489,9 @@ function detectTask(input) {
|
|
|
455
489
|
// 10. Suggested Codex plugins (keyword-based, static map — no I/O)
|
|
456
490
|
const suggestedPlugins = detectSuggestedPlugins(prompt);
|
|
457
491
|
|
|
492
|
+
// 11. CI risk — check if current branch has failing CI runs (best-effort, never throws)
|
|
493
|
+
const ciRiskResult = checkCIRisk(input.cwd || process.cwd());
|
|
494
|
+
|
|
458
495
|
return {
|
|
459
496
|
intent,
|
|
460
497
|
risk,
|
|
@@ -470,6 +507,7 @@ function detectTask(input) {
|
|
|
470
507
|
reasoningDepth,
|
|
471
508
|
reasoningSignals,
|
|
472
509
|
suggestedPlugins,
|
|
510
|
+
ciRisk: ciRiskResult,
|
|
473
511
|
...(repeatedFailure && { repeatedFailure: true }),
|
|
474
512
|
};
|
|
475
513
|
}
|
package/src/dispatch.mjs
CHANGED
|
@@ -675,6 +675,7 @@ async function dispatch(input = {}) {
|
|
|
675
675
|
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
676
676
|
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
677
677
|
// This closes the receipt → brief → next session loop automatically.
|
|
678
|
+
// Falls back to continuity.mjs handoffs when receipt.mjs returns nothing.
|
|
678
679
|
if (!input.situationBrief) {
|
|
679
680
|
try {
|
|
680
681
|
const { buildResumeBrief } = await import('./receipt.mjs');
|
|
@@ -683,6 +684,17 @@ async function dispatch(input = {}) {
|
|
|
683
684
|
input = { ...input, situationBrief: brief };
|
|
684
685
|
}
|
|
685
686
|
} catch { /* non-blocking */ }
|
|
687
|
+
|
|
688
|
+
// Continuity fallback: check handoff from continuity.mjs if still no brief
|
|
689
|
+
if (!input.situationBrief) {
|
|
690
|
+
try {
|
|
691
|
+
const { buildResumeBrief: buildHandoffBrief } = await import('./continuity.mjs');
|
|
692
|
+
const handoffBrief = buildHandoffBrief(cwd);
|
|
693
|
+
if (handoffBrief) {
|
|
694
|
+
input = { ...input, situationBrief: handoffBrief };
|
|
695
|
+
}
|
|
696
|
+
} catch { /* non-blocking */ }
|
|
697
|
+
}
|
|
686
698
|
}
|
|
687
699
|
// ── End resume brief injection ───────────────────────────────────────────────
|
|
688
700
|
|
|
@@ -848,6 +860,23 @@ async function dispatch(input = {}) {
|
|
|
848
860
|
}
|
|
849
861
|
}
|
|
850
862
|
|
|
863
|
+
// ── Worktree isolation decision ──────────────────────────────────────────────
|
|
864
|
+
// Compute whether this dispatch should run in an isolated worktree based on
|
|
865
|
+
// risk level, file-edit volume, and security/auth signals in the prompt.
|
|
866
|
+
const SECURITY_PATTERN = /\b(auth|secret|token|credential|password|key|oauth|jwt|session|permission|role|acl)\b/i;
|
|
867
|
+
const decisionRisk = (decision.risk ?? 'low').toLowerCase();
|
|
868
|
+
const decisionFilesEst = decision.filesEstimate ?? 0;
|
|
869
|
+
const riskIsElevated = decisionRisk === 'medium' || decisionRisk === 'high' || decisionRisk === 'critical';
|
|
870
|
+
const manyFiles = decisionFilesEst >= 3;
|
|
871
|
+
const hasSecurity = SECURITY_PATTERN.test(prompt);
|
|
872
|
+
const useWorktree = input.useWorktree ?? (riskIsElevated || manyFiles || hasSecurity);
|
|
873
|
+
|
|
874
|
+
// Propagate useWorktree onto effectiveDecision so callers can inspect it
|
|
875
|
+
if (useWorktree) {
|
|
876
|
+
effectiveDecision = { ...effectiveDecision, useWorktree: true };
|
|
877
|
+
}
|
|
878
|
+
// ── End worktree isolation decision ─────────────────────────────────────────
|
|
879
|
+
|
|
851
880
|
// ── Native Claude Code dispatch ──────────────────────────────────────────────
|
|
852
881
|
// When running inside Claude Code AND the provider is claude, execute via the
|
|
853
882
|
// claude CLI directly (foreground subprocess) so results are captured and returned.
|
|
@@ -856,7 +885,7 @@ async function dispatch(input = {}) {
|
|
|
856
885
|
const nativeDescriptor = buildNativeDispatch(
|
|
857
886
|
effectiveDecision,
|
|
858
887
|
prompt,
|
|
859
|
-
{ worktree:
|
|
888
|
+
{ worktree: useWorktree, maxTurns: input.maxTurns },
|
|
860
889
|
);
|
|
861
890
|
|
|
862
891
|
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
@@ -955,6 +984,23 @@ async function dispatch(input = {}) {
|
|
|
955
984
|
success,
|
|
956
985
|
});
|
|
957
986
|
|
|
987
|
+
// ── Auto-review annotation ────────────────────────────────────────────────
|
|
988
|
+
// When execution changed files at medium+ risk, stamp result with a pending
|
|
989
|
+
// review note. The opposite provider from the one that did the work reviews
|
|
990
|
+
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
991
|
+
let autoReview;
|
|
992
|
+
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
993
|
+
try {
|
|
994
|
+
const reviewProvider = currentProvider === 'claude' ? 'openai' : 'claude';
|
|
995
|
+
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
996
|
+
} catch {
|
|
997
|
+
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1001
|
+
}
|
|
1002
|
+
// ── End auto-review annotation ────────────────────────────────────────────
|
|
1003
|
+
|
|
958
1004
|
return {
|
|
959
1005
|
status: success ? 'completed' : 'failed',
|
|
960
1006
|
type: 'native-agent',
|
|
@@ -967,6 +1013,8 @@ async function dispatch(input = {}) {
|
|
|
967
1013
|
summary,
|
|
968
1014
|
durationMs,
|
|
969
1015
|
usage,
|
|
1016
|
+
worktreeUsed: useWorktree,
|
|
1017
|
+
autoReview,
|
|
970
1018
|
error: success ? null : errorText.slice(0, 200),
|
|
971
1019
|
};
|
|
972
1020
|
}
|
|
@@ -1054,16 +1102,35 @@ async function dispatch(input = {}) {
|
|
|
1054
1102
|
success,
|
|
1055
1103
|
});
|
|
1056
1104
|
|
|
1105
|
+
// ── Auto-review annotation ──────────────────────────────────────────────────
|
|
1106
|
+
// When execution changed files at medium+ risk, stamp result with a pending
|
|
1107
|
+
// review note. The opposite provider from the one that did the work reviews
|
|
1108
|
+
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
1109
|
+
let autoReview;
|
|
1110
|
+
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
1111
|
+
try {
|
|
1112
|
+
const reviewProvider = subProvider === 'claude' ? 'openai' : 'claude';
|
|
1113
|
+
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
1114
|
+
} catch {
|
|
1115
|
+
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
1116
|
+
}
|
|
1117
|
+
} else {
|
|
1118
|
+
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1119
|
+
}
|
|
1120
|
+
// ── End auto-review annotation ──────────────────────────────────────────────
|
|
1121
|
+
|
|
1057
1122
|
return {
|
|
1058
|
-
status:
|
|
1059
|
-
provider:
|
|
1060
|
-
model:
|
|
1061
|
-
specialist:
|
|
1062
|
-
command:
|
|
1123
|
+
status: success ? 'completed' : 'failed',
|
|
1124
|
+
provider: subProvider,
|
|
1125
|
+
model: subModel,
|
|
1126
|
+
specialist: specialist ?? 'generic',
|
|
1127
|
+
command: subCommand,
|
|
1063
1128
|
exitCode,
|
|
1064
1129
|
summary,
|
|
1065
1130
|
durationMs,
|
|
1066
1131
|
usage,
|
|
1132
|
+
worktreeUsed: useWorktree,
|
|
1133
|
+
autoReview,
|
|
1067
1134
|
error: success ? null : errorText.slice(0, 200),
|
|
1068
1135
|
};
|
|
1069
1136
|
}
|