dual-brain 7.1.21 → 7.1.23
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 +2580 -717
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +14 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +195 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/intelligence.mjs +423 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +808 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.23",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"test": "node .claude/hooks/test-orchestrator.mjs",
|
|
42
42
|
"test:core": "node --test src/test.mjs",
|
|
43
|
-
"postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'"
|
|
43
|
+
"postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'",
|
|
44
|
+
"postpublish": "node scripts/verify-publish.mjs"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|
|
46
47
|
"node": ">=20.0.0"
|
|
@@ -57,7 +58,16 @@
|
|
|
57
58
|
"src/decompose.mjs",
|
|
58
59
|
"src/brief.mjs",
|
|
59
60
|
"src/redact.mjs",
|
|
61
|
+
"src/pipeline.mjs",
|
|
62
|
+
"src/context.mjs",
|
|
63
|
+
"src/outcome.mjs",
|
|
64
|
+
"src/observer.mjs",
|
|
65
|
+
"src/nextstep.mjs",
|
|
66
|
+
"src/doctor.mjs",
|
|
67
|
+
"src/receipt.mjs",
|
|
68
|
+
"src/failure-memory.mjs",
|
|
60
69
|
"src/index.mjs",
|
|
70
|
+
"src/intelligence.mjs",
|
|
61
71
|
"src/tui.mjs",
|
|
62
72
|
"src/install-hooks.mjs",
|
|
63
73
|
"src/update-check.mjs",
|
|
@@ -103,6 +113,7 @@
|
|
|
103
113
|
"plugin.json",
|
|
104
114
|
"skills/*.md",
|
|
105
115
|
"agents/*.md",
|
|
106
|
-
"shell-hook.sh"
|
|
116
|
+
"shell-hook.sh",
|
|
117
|
+
"scripts/verify-publish.mjs"
|
|
107
118
|
]
|
|
108
119
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
|
+
const url = `https://registry.npmjs.org/dual-brain/${version}`;
|
|
5
|
+
const maxWait = 30000;
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
|
|
8
|
+
async function check() {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (res.ok) {
|
|
12
|
+
console.log(`✓ dual-brain@${version} verified on registry`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
|
|
17
|
+
if (Date.now() - start > maxWait) {
|
|
18
|
+
console.log(`⚠ dual-brain@${version} published but CDN propagation may take a moment`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
23
|
+
return check();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
check();
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join, resolve, dirname, extname, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { detectTask } from './detect.mjs';
|
|
6
|
+
|
|
7
|
+
// ─── Language detection ───────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const EXT_LANG = {
|
|
10
|
+
'.mjs': 'javascript', '.js': 'javascript', '.cjs': 'javascript',
|
|
11
|
+
'.ts': 'typescript', '.tsx': 'typescript', '.mts': 'typescript',
|
|
12
|
+
'.py': 'python', '.pyx': 'python', '.pyi': 'python',
|
|
13
|
+
'.rs': 'rust',
|
|
14
|
+
'.go': 'go',
|
|
15
|
+
'.rb': 'ruby',
|
|
16
|
+
'.java': 'java',
|
|
17
|
+
'.kt': 'kotlin', '.kts': 'kotlin',
|
|
18
|
+
'.swift': 'swift',
|
|
19
|
+
'.c': 'c', '.h': 'c',
|
|
20
|
+
'.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp',
|
|
21
|
+
'.cs': 'csharp',
|
|
22
|
+
'.php': 'php',
|
|
23
|
+
'.sh': 'shell', '.bash': 'shell', '.zsh': 'shell',
|
|
24
|
+
'.html': 'html', '.htm': 'html',
|
|
25
|
+
'.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less',
|
|
26
|
+
'.json': 'json', '.jsonl': 'json',
|
|
27
|
+
'.yaml': 'yaml', '.yml': 'yaml',
|
|
28
|
+
'.toml': 'toml',
|
|
29
|
+
'.md': 'markdown', '.mdx': 'markdown',
|
|
30
|
+
'.sql': 'sql',
|
|
31
|
+
'.graphql': 'graphql', '.gql': 'graphql',
|
|
32
|
+
'.dockerfile': 'dockerfile',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function detectLanguage(filePath) {
|
|
36
|
+
const ext = extname(filePath).toLowerCase();
|
|
37
|
+
if (!ext && filePath.toLowerCase().endsWith('dockerfile')) return 'dockerfile';
|
|
38
|
+
return EXT_LANG[ext] || 'unknown';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function git(cmd, cwd) {
|
|
44
|
+
return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeGit(cmd, cwd, fallback = '') {
|
|
48
|
+
try { return git(cmd, cwd); } catch { return fallback; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getGitChangedFiles(cwd) {
|
|
52
|
+
const raw = safeGit('git status --porcelain', cwd, '');
|
|
53
|
+
if (!raw) return { files: [], statusMap: {} };
|
|
54
|
+
|
|
55
|
+
const statusMap = {};
|
|
56
|
+
const files = [];
|
|
57
|
+
|
|
58
|
+
for (const line of raw.split('\n')) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
const code = line.slice(0, 2).trim() || '?';
|
|
61
|
+
const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
|
|
62
|
+
if (filePath) {
|
|
63
|
+
statusMap[filePath] = code;
|
|
64
|
+
files.push(filePath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { files, statusMap };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getRepoState(cwd) {
|
|
72
|
+
const branch = safeGit('git branch --show-current', cwd, 'unknown');
|
|
73
|
+
|
|
74
|
+
const statusRaw = safeGit('git status --porcelain', cwd, '');
|
|
75
|
+
const uncommittedCount = statusRaw
|
|
76
|
+
? statusRaw.split('\n').filter(l => l.trim()).length
|
|
77
|
+
: 0;
|
|
78
|
+
|
|
79
|
+
const lastCommitMessage = safeGit('git log -1 --pretty=format:%s', cwd, '');
|
|
80
|
+
|
|
81
|
+
let lastCommitAge = 'unknown';
|
|
82
|
+
try {
|
|
83
|
+
const epochStr = git('git log -1 --pretty=format:%ct', cwd);
|
|
84
|
+
const epoch = parseInt(epochStr, 10);
|
|
85
|
+
if (!isNaN(epoch)) {
|
|
86
|
+
lastCommitAge = formatTimeAgo(epoch * 1000);
|
|
87
|
+
}
|
|
88
|
+
} catch { /* non-fatal */ }
|
|
89
|
+
|
|
90
|
+
return { branch, uncommittedCount, lastCommitMessage, lastCommitAge };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatTimeAgo(timestampMs) {
|
|
94
|
+
const diff = Date.now() - timestampMs;
|
|
95
|
+
const mins = Math.floor(diff / 60_000);
|
|
96
|
+
if (mins < 1) return 'just now';
|
|
97
|
+
if (mins < 60) return `${mins}m ago`;
|
|
98
|
+
const hours = Math.floor(mins / 60);
|
|
99
|
+
if (hours < 24) return `${hours}h ago`;
|
|
100
|
+
const days = Math.floor(hours / 24);
|
|
101
|
+
return `${days}d ago`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Related files (import graph, one hop) ───────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const IMPORT_RE = /(?:import\s+.*?from\s+|require\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
107
|
+
|
|
108
|
+
function findRelatedFiles(explicitFiles, cwd) {
|
|
109
|
+
const related = new Set();
|
|
110
|
+
|
|
111
|
+
for (const filePath of explicitFiles) {
|
|
112
|
+
const absPath = resolve(cwd, filePath);
|
|
113
|
+
if (!existsSync(absPath)) continue;
|
|
114
|
+
|
|
115
|
+
let content;
|
|
116
|
+
try { content = readFileSync(absPath, 'utf8'); } catch { continue; }
|
|
117
|
+
|
|
118
|
+
const fileDir = dirname(absPath);
|
|
119
|
+
let match;
|
|
120
|
+
IMPORT_RE.lastIndex = 0;
|
|
121
|
+
|
|
122
|
+
while ((match = IMPORT_RE.exec(content)) !== null) {
|
|
123
|
+
const specifier = match[1];
|
|
124
|
+
if (!specifier.startsWith('.')) continue; // skip node_modules / bare specifiers
|
|
125
|
+
|
|
126
|
+
// Try common extensions in order
|
|
127
|
+
const candidates = [
|
|
128
|
+
specifier,
|
|
129
|
+
specifier + '.mjs', specifier + '.js', specifier + '.ts',
|
|
130
|
+
specifier + '/index.mjs', specifier + '/index.js', specifier + '/index.ts',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
const abs = resolve(fileDir, candidate);
|
|
135
|
+
if (existsSync(abs)) {
|
|
136
|
+
const rel = relative(cwd, abs);
|
|
137
|
+
if (!explicitFiles.includes(rel)) related.add(rel);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [...related];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── File summaries ───────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function buildFileSummary(filePath, cwd, statusMap = {}) {
|
|
150
|
+
const absPath = resolve(cwd, filePath);
|
|
151
|
+
const language = detectLanguage(filePath);
|
|
152
|
+
|
|
153
|
+
let lines = 0;
|
|
154
|
+
try {
|
|
155
|
+
const content = readFileSync(absPath, 'utf8');
|
|
156
|
+
lines = content.split('\n').length;
|
|
157
|
+
} catch { /* file missing or unreadable */ }
|
|
158
|
+
|
|
159
|
+
const rawStatus = statusMap[filePath] || statusMap[filePath.replace(/\\/g, '/')];
|
|
160
|
+
const gitStatus = rawStatus || 'clean';
|
|
161
|
+
|
|
162
|
+
return { path: filePath, language, lines, gitStatus };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Constraints from CLAUDE.md ───────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const CONSTRAINT_RE = /\b(must|never|always|require[sd]?|do not|don't)\b/i;
|
|
168
|
+
|
|
169
|
+
function extractConstraints(cwd) {
|
|
170
|
+
const candidates = [
|
|
171
|
+
join(cwd, 'CLAUDE.md'),
|
|
172
|
+
join(cwd, '.claude', 'CLAUDE.md'),
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const constraints = [];
|
|
176
|
+
|
|
177
|
+
for (const p of candidates) {
|
|
178
|
+
if (!existsSync(p)) continue;
|
|
179
|
+
try {
|
|
180
|
+
const lines = readFileSync(p, 'utf8').split('\n');
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (trimmed && CONSTRAINT_RE.test(trimmed) && trimmed.length < 200) {
|
|
184
|
+
constraints.push(trimmed.replace(/^[-*#\s]+/, '').trim());
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch { /* non-fatal */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return constraints;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Prior attempts from .dualbrain/outcomes/ ────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function loadPriorAttempts(prompt, cwd) {
|
|
196
|
+
const outcomesDir = join(cwd, '.dualbrain', 'outcomes');
|
|
197
|
+
if (!existsSync(outcomesDir)) return [];
|
|
198
|
+
|
|
199
|
+
const promptWords = new Set(
|
|
200
|
+
prompt.toLowerCase().split(/\W+/).filter(w => w.length > 3),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const attempts = [];
|
|
204
|
+
|
|
205
|
+
let entries;
|
|
206
|
+
try { entries = readdirSync(outcomesDir); } catch { return []; }
|
|
207
|
+
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (!entry.endsWith('.json')) continue;
|
|
210
|
+
try {
|
|
211
|
+
const raw = JSON.parse(readFileSync(join(outcomesDir, entry), 'utf8'));
|
|
212
|
+
if (!raw.prompt) continue;
|
|
213
|
+
|
|
214
|
+
// Simple word-overlap similarity
|
|
215
|
+
const entryWords = raw.prompt.toLowerCase().split(/\W+/).filter(w => w.length > 3);
|
|
216
|
+
const overlap = entryWords.filter(w => promptWords.has(w)).length;
|
|
217
|
+
const similarity = overlap / Math.max(promptWords.size, entryWords.length, 1);
|
|
218
|
+
|
|
219
|
+
if (similarity >= 0.3) {
|
|
220
|
+
attempts.push({
|
|
221
|
+
timestamp: raw.timestamp || 0,
|
|
222
|
+
prompt: raw.prompt,
|
|
223
|
+
success: raw.success ?? false,
|
|
224
|
+
lesson: raw.lesson || raw.summary || '',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
} catch { /* non-fatal */ }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return attempts.sort((a, b) => b.timestamp - a.timestamp).slice(0, 5);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Related sessions ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
async function loadRelatedSessions(prompt, files, cwd) {
|
|
236
|
+
try {
|
|
237
|
+
// Dynamic import so missing module doesn't break the whole pack
|
|
238
|
+
const { findRelatedSessions } = await import('./session.mjs');
|
|
239
|
+
const raw = findRelatedSessions(prompt, files, cwd);
|
|
240
|
+
return raw.map(s => ({
|
|
241
|
+
id: s.sessionId,
|
|
242
|
+
name: s.smartName || s.sessionId.slice(0, 8),
|
|
243
|
+
score: s.score,
|
|
244
|
+
}));
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Acceptance criteria ──────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const CRITERIA_PATTERNS = [
|
|
253
|
+
{ re: /\btests?\s+pass\b/i, label: 'tests pass' },
|
|
254
|
+
{ re: /\bno\s+regression[s]?\b/i, label: 'no regressions' },
|
|
255
|
+
{ re: /\bbuilds?\s+clean\b/i, label: 'builds clean' },
|
|
256
|
+
{ re: /\bbuild[s]?\b/i, label: 'builds clean' },
|
|
257
|
+
{ re: /\blint\s+clean\b/i, label: 'lint clean' },
|
|
258
|
+
{ re: /\bno\s+error[s]?\b/i, label: 'no errors' },
|
|
259
|
+
{ re: /\btype.?check\b/i, label: 'type-check passes' },
|
|
260
|
+
{ re: /\bworks?\s+on\s+\w+/i, label: (m) => `works on ${m[0].match(/works?\s+on\s+(\w+)/i)?.[1]}` },
|
|
261
|
+
{ re: /\bcompatible\s+with\s+\w+/i, label: (m) => `compatible with ${m[0].match(/compatible\s+with\s+(\w+)/i)?.[1]}` },
|
|
262
|
+
{ re: /\bno\s+breaking\s+change[s]?\b/i, label: 'no breaking changes' },
|
|
263
|
+
{ re: /\bbackward[s]?\s+compat/i, label: 'backward compatible' },
|
|
264
|
+
{ re: /\ball\s+tests?\s+pass/i, label: 'all tests pass' },
|
|
265
|
+
{ re: /\bci\s+pass(?:es)?\b/i, label: 'CI passes' },
|
|
266
|
+
{ re: /\bcoverage\b/i, label: 'coverage maintained' },
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
function inferAcceptanceCriteria(prompt) {
|
|
270
|
+
const found = new Set();
|
|
271
|
+
for (const { re, label } of CRITERIA_PATTERNS) {
|
|
272
|
+
const m = prompt.match(re);
|
|
273
|
+
if (m) {
|
|
274
|
+
const criterion = typeof label === 'function' ? label([m[0]]) : label;
|
|
275
|
+
if (criterion) found.add(criterion);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return [...found];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Build a structured context pack for a task. All fields are best-effort —
|
|
285
|
+
* missing git, missing files, and missing optional modules all degrade gracefully.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} prompt
|
|
288
|
+
* @param {string[]} files - Explicitly mentioned file paths (may be relative)
|
|
289
|
+
* @param {string} cwd - Working directory (absolute)
|
|
290
|
+
* @param {object} options
|
|
291
|
+
* @param {number} [options.priorFailures=0]
|
|
292
|
+
* @returns {Promise<object>}
|
|
293
|
+
*/
|
|
294
|
+
export async function buildContextPack(prompt = '', files = [], cwd = process.cwd(), options = {}) {
|
|
295
|
+
const { priorFailures = 0 } = options;
|
|
296
|
+
|
|
297
|
+
// 1. Detection (intent / tier / risk)
|
|
298
|
+
const detection = detectTask({ prompt, files, priorFailures });
|
|
299
|
+
|
|
300
|
+
// 2. Git changed files + status map
|
|
301
|
+
const { files: gitChanged, statusMap } = getGitChangedFiles(cwd);
|
|
302
|
+
|
|
303
|
+
// 3. Related files (import graph, one hop from explicit files)
|
|
304
|
+
const relatedFiles = findRelatedFiles(files, cwd);
|
|
305
|
+
|
|
306
|
+
const filesPack = {
|
|
307
|
+
explicit: files,
|
|
308
|
+
gitChanged,
|
|
309
|
+
related: relatedFiles,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// 4. File summaries — explicit + gitChanged, deduped
|
|
313
|
+
const summaryTargets = [...new Set([...files, ...gitChanged])];
|
|
314
|
+
const fileSummaries = summaryTargets.map(f => buildFileSummary(f, cwd, statusMap));
|
|
315
|
+
|
|
316
|
+
// 5. Repo state
|
|
317
|
+
const repoState = getRepoState(cwd);
|
|
318
|
+
|
|
319
|
+
// 6. Constraints from CLAUDE.md
|
|
320
|
+
const constraints = extractConstraints(cwd);
|
|
321
|
+
|
|
322
|
+
// 7. Prior attempts
|
|
323
|
+
const priorAttempts = loadPriorAttempts(prompt, cwd);
|
|
324
|
+
|
|
325
|
+
// 8. Related sessions (async, may fail silently)
|
|
326
|
+
const allFiles = [...new Set([...files, ...gitChanged])];
|
|
327
|
+
const relatedSessions = await loadRelatedSessions(prompt, allFiles, cwd);
|
|
328
|
+
|
|
329
|
+
// 9. Acceptance criteria
|
|
330
|
+
const acceptanceCriteria = inferAcceptanceCriteria(prompt);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
intent: detection.intent,
|
|
334
|
+
prompt,
|
|
335
|
+
tier: detection.tier,
|
|
336
|
+
risk: detection.risk,
|
|
337
|
+
files: filesPack,
|
|
338
|
+
fileSummaries,
|
|
339
|
+
repoState,
|
|
340
|
+
constraints,
|
|
341
|
+
priorAttempts,
|
|
342
|
+
relatedSessions,
|
|
343
|
+
acceptanceCriteria,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── Summarizer ───────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Return a human-readable 3-5 line summary of a context pack for logging/display.
|
|
351
|
+
*
|
|
352
|
+
* @param {object} pack - Result of buildContextPack()
|
|
353
|
+
* @returns {string}
|
|
354
|
+
*/
|
|
355
|
+
export function summarizeContextPack(pack) {
|
|
356
|
+
const lines = [];
|
|
357
|
+
|
|
358
|
+
lines.push(
|
|
359
|
+
`Task: ${pack.intent} (${pack.tier} tier, ${pack.risk} risk)`,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const explicit = pack.files?.explicit?.length ?? 0;
|
|
363
|
+
const changed = pack.files?.gitChanged?.length ?? 0;
|
|
364
|
+
const related = pack.files?.related?.length ?? 0;
|
|
365
|
+
lines.push(`Files: ${explicit} explicit, ${changed} changed, ${related} related`);
|
|
366
|
+
|
|
367
|
+
const { branch, uncommittedCount, lastCommitAge } = pack.repoState ?? {};
|
|
368
|
+
const branchStr = branch ? `${branch} branch` : 'unknown branch';
|
|
369
|
+
const uncommittedStr = uncommittedCount != null
|
|
370
|
+
? `${uncommittedCount} uncommitted file${uncommittedCount === 1 ? '' : 's'}`
|
|
371
|
+
: 'commit count unknown';
|
|
372
|
+
const ageStr = lastCommitAge && lastCommitAge !== 'unknown' ? `, last commit ${lastCommitAge}` : '';
|
|
373
|
+
lines.push(`Repo: ${branchStr}, ${uncommittedStr}${ageStr}`);
|
|
374
|
+
|
|
375
|
+
if (pack.priorAttempts?.length > 0) {
|
|
376
|
+
const failed = pack.priorAttempts.filter(a => !a.success).length;
|
|
377
|
+
const total = pack.priorAttempts.length;
|
|
378
|
+
const label = failed > 0
|
|
379
|
+
? `${failed} failed attempt${failed === 1 ? '' : 's'} on similar task`
|
|
380
|
+
: `${total} prior attempt${total === 1 ? '' : 's'} on similar task`;
|
|
381
|
+
lines.push(`Prior: ${label}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (pack.acceptanceCriteria?.length > 0) {
|
|
385
|
+
lines.push(`Criteria: ${pack.acceptanceCriteria.join(', ')}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return lines.join('\n');
|
|
389
|
+
}
|