dual-brain 0.2.4 → 0.2.6
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 +366 -43
- package/package.json +10 -2
- package/src/awareness.mjs +71 -6
- 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 -7
- package/src/doctor.mjs +6 -6
- package/src/health.mjs +37 -0
- package/src/pipeline.mjs +60 -3
- package/src/pr-agent.mjs +214 -0
- package/src/profile.mjs +39 -124
- package/src/replit.mjs +1 -1
- package/src/repo.mjs +153 -0
|
@@ -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
|
+
}
|
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
|
}
|