dual-brain 0.1.22 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dual-brain.mjs +676 -265
- package/package.json +16 -2
- package/src/awareness.mjs +343 -0
- package/src/calibration.mjs +148 -0
- package/src/cost-tracker.mjs +184 -0
- package/src/decide.mjs +162 -10
- package/src/dispatch.mjs +40 -2
- package/src/doctor.mjs +716 -1
- package/src/fx.mjs +276 -0
- package/src/intelligence.mjs +423 -0
- package/src/ledger.mjs +196 -0
- package/src/living-docs.mjs +210 -0
- package/src/models.mjs +363 -0
- package/src/pipeline.mjs +367 -8
- package/src/prompt-intel.mjs +325 -0
- package/src/think-engine.mjs +428 -0
package/src/fx.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// fx.mjs — zero-dependency animated shell effects for dual-brain CLI
|
|
2
|
+
|
|
3
|
+
const isTTY = process.stdout.isTTY && !process.env.CI;
|
|
4
|
+
const hasColor = isTTY && !process.env.NO_COLOR;
|
|
5
|
+
const isUnicode = process.platform !== 'win32' || process.env.WT_SESSION;
|
|
6
|
+
|
|
7
|
+
const c = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
green: '\x1b[32m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
blue: '\x1b[34m',
|
|
15
|
+
magenta: '\x1b[35m',
|
|
16
|
+
cyan: '\x1b[36m',
|
|
17
|
+
white: '\x1b[37m',
|
|
18
|
+
gray: '\x1b[90m',
|
|
19
|
+
bgGreen: '\x1b[42m',
|
|
20
|
+
bgRed: '\x1b[41m',
|
|
21
|
+
bgYellow: '\x1b[43m',
|
|
22
|
+
bgBlue: '\x1b[44m',
|
|
23
|
+
bgMagenta: '\x1b[45m',
|
|
24
|
+
clearLine: '\x1b[2K',
|
|
25
|
+
cursorUp: '\x1b[1A',
|
|
26
|
+
cursorHide: '\x1b[?25l',
|
|
27
|
+
cursorShow: '\x1b[?25h',
|
|
28
|
+
saveCursor: '\x1b[s',
|
|
29
|
+
restoreCursor: '\x1b[u'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function color(text, ...styles) {
|
|
33
|
+
if (!hasColor) return text;
|
|
34
|
+
return styles.join('') + text + c.reset;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const colors = c;
|
|
38
|
+
|
|
39
|
+
export function sleep(ms) {
|
|
40
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function clearScreen() {
|
|
44
|
+
if (isTTY) process.stdout.write('\x1b[2J\x1b[H');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function nl(n = 1) {
|
|
48
|
+
process.stdout.write('\n'.repeat(n));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getMode() {
|
|
52
|
+
if (process.env.CI) return 'ci';
|
|
53
|
+
if (!process.stdout.isTTY) return 'plain';
|
|
54
|
+
if (process.env.DUAL_BRAIN_FX === 'subtle') return 'subtle';
|
|
55
|
+
return 'full';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function spinner(text) {
|
|
59
|
+
const frames = isUnicode ? ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] : ['|','/','-','\\'];
|
|
60
|
+
let i = 0, interval = null, currentText = text;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
start() {
|
|
64
|
+
if (!isTTY) { process.stdout.write(currentText + '\n'); return this; }
|
|
65
|
+
process.stdout.write(c.cursorHide);
|
|
66
|
+
interval = setInterval(() => {
|
|
67
|
+
process.stdout.write(`\r${c.clearLine} ${color(frames[i % frames.length], c.cyan)} ${currentText}`);
|
|
68
|
+
i++;
|
|
69
|
+
}, 80);
|
|
70
|
+
return this;
|
|
71
|
+
},
|
|
72
|
+
update(newText) { currentText = newText; return this; },
|
|
73
|
+
succeed(msg) {
|
|
74
|
+
if (interval) clearInterval(interval);
|
|
75
|
+
const sym = isUnicode ? '✓' : '+';
|
|
76
|
+
process.stdout.write(`\r${c.clearLine} ${color(sym, c.green)} ${msg || currentText}\n`);
|
|
77
|
+
if (isTTY) process.stdout.write(c.cursorShow);
|
|
78
|
+
return this;
|
|
79
|
+
},
|
|
80
|
+
fail(msg) {
|
|
81
|
+
if (interval) clearInterval(interval);
|
|
82
|
+
const sym = isUnicode ? '✗' : 'x';
|
|
83
|
+
process.stdout.write(`\r${c.clearLine} ${color(sym, c.red)} ${msg || currentText}\n`);
|
|
84
|
+
if (isTTY) process.stdout.write(c.cursorShow);
|
|
85
|
+
return this;
|
|
86
|
+
},
|
|
87
|
+
warn(msg) {
|
|
88
|
+
if (interval) clearInterval(interval);
|
|
89
|
+
const sym = isUnicode ? '⚠' : '!';
|
|
90
|
+
process.stdout.write(`\r${c.clearLine} ${color(sym, c.yellow)} ${msg || currentText}\n`);
|
|
91
|
+
if (isTTY) process.stdout.write(c.cursorShow);
|
|
92
|
+
return this;
|
|
93
|
+
},
|
|
94
|
+
stop() {
|
|
95
|
+
if (interval) clearInterval(interval);
|
|
96
|
+
process.stdout.write(`\r${c.clearLine}`);
|
|
97
|
+
if (isTTY) process.stdout.write(c.cursorShow);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function progress(current, total, label = '', width = 30) {
|
|
104
|
+
const pct = Math.min(1, current / total);
|
|
105
|
+
if (!isTTY) {
|
|
106
|
+
process.stdout.write(`${Math.round(pct * 100)}% ${label}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const filled = Math.round(pct * width);
|
|
110
|
+
const empty = width - filled;
|
|
111
|
+
const bar = isUnicode
|
|
112
|
+
? color('█'.repeat(filled) + '░'.repeat(empty), c.cyan)
|
|
113
|
+
: color('#'.repeat(filled) + '-'.repeat(empty), c.cyan);
|
|
114
|
+
const pctStr = String(Math.round(pct * 100)).padStart(3) + '%';
|
|
115
|
+
process.stdout.write(`\r${c.clearLine} ${bar} ${color(pctStr, c.bold)} ${label}`);
|
|
116
|
+
if (current >= total) process.stdout.write('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function success(text) {
|
|
120
|
+
const sym = isUnicode ? '✓' : '+';
|
|
121
|
+
process.stdout.write(` ${color(sym, c.green)} ${text}\n`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function error(text) {
|
|
125
|
+
const sym = isUnicode ? '✗' : 'x';
|
|
126
|
+
process.stdout.write(` ${color(sym, c.red)} ${text}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function warn(text) {
|
|
130
|
+
const sym = isUnicode ? '⚠' : '!';
|
|
131
|
+
process.stdout.write(` ${color(sym, c.yellow)} ${text}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function info(text) {
|
|
135
|
+
const sym = isUnicode ? 'ℹ' : 'i';
|
|
136
|
+
process.stdout.write(` ${color(sym, c.blue)} ${text}\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function dim(text) {
|
|
140
|
+
process.stdout.write(`${color(text, c.gray)}\n`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function step(current, total, text) {
|
|
144
|
+
if (!isUnicode) {
|
|
145
|
+
process.stdout.write(` [${current}/${total}] ${text}\n`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const dots = [];
|
|
149
|
+
for (let i = 1; i <= total; i++) {
|
|
150
|
+
if (i < current) dots.push(color('●', c.green));
|
|
151
|
+
else if (i === current) dots.push(color('●', c.cyan));
|
|
152
|
+
else dots.push(color('○', c.gray));
|
|
153
|
+
}
|
|
154
|
+
process.stdout.write(` ${dots.join(' ')} ${color(`Step ${current} of ${total}`, c.bold)} · ${text}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function banner(text) {
|
|
158
|
+
const pkg = 'DUAL-BRAIN';
|
|
159
|
+
const inner = ` ${isUnicode ? '🧠' : '**'} ${pkg} ${text} `;
|
|
160
|
+
const width = inner.length + 2;
|
|
161
|
+
if (!isUnicode || !hasColor) {
|
|
162
|
+
process.stdout.write(`\n +${'='.repeat(width - 2)}+\n | ${inner} |\n +${'='.repeat(width - 2)}+\n\n`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const top = ` ╔${'═'.repeat(width)}╗`;
|
|
166
|
+
const mid = ` ║${inner}║`;
|
|
167
|
+
const bot = ` ╚${'═'.repeat(width)}╝`;
|
|
168
|
+
process.stdout.write(`\n${color(top, c.cyan, c.bold)}\n${color(mid, c.cyan, c.bold)}\n${color(bot, c.cyan, c.bold)}\n\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function box(content, options = {}) {
|
|
172
|
+
const { color: colorName = 'cyan', padding = 1, title = '' } = options;
|
|
173
|
+
const ansiColor = c[colorName] || c.cyan;
|
|
174
|
+
const lines = Array.isArray(content) ? content : content.split('\n');
|
|
175
|
+
const innerWidth = Math.max(...lines.map(l => stripAnsi(l).length), title ? stripAnsi(title).length : 0) + padding * 2;
|
|
176
|
+
|
|
177
|
+
function draw(text, ansi) {
|
|
178
|
+
if (!hasColor) return text;
|
|
179
|
+
return ansi + text + c.reset;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const titleStr = title ? ` ${title} ` : '';
|
|
183
|
+
const topFill = '─'.repeat(Math.max(0, innerWidth - stripAnsi(titleStr).length));
|
|
184
|
+
const top = isUnicode
|
|
185
|
+
? draw(`┌${titleStr}${'─'.repeat(Math.floor(topFill.length / 2))}${'─'.repeat(Math.ceil(topFill.length / 2))}┐`, ansiColor)
|
|
186
|
+
: draw(`+${titleStr}${'-'.repeat(topFill.length)}+`, ansiColor);
|
|
187
|
+
const bot = isUnicode
|
|
188
|
+
? draw(`└${'─'.repeat(innerWidth)}┘`, ansiColor)
|
|
189
|
+
: draw(`+${'-'.repeat(innerWidth)}+`, ansiColor);
|
|
190
|
+
|
|
191
|
+
process.stdout.write(` ${top}\n`);
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
const pad = ' '.repeat(padding);
|
|
194
|
+
const visible = stripAnsi(line).length;
|
|
195
|
+
const right = ' '.repeat(Math.max(0, innerWidth - padding - visible));
|
|
196
|
+
const border = isUnicode ? draw('│', ansiColor) : draw('|', ansiColor);
|
|
197
|
+
process.stdout.write(` ${border}${pad}${line}${right}${border}\n`);
|
|
198
|
+
}
|
|
199
|
+
process.stdout.write(` ${bot}\n`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripAnsi(str) {
|
|
203
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function gradient(text, fromColor = 196, toColor = 226) {
|
|
207
|
+
if (!hasColor) return process.stdout.write(text + '\n');
|
|
208
|
+
const chars = [...text];
|
|
209
|
+
const result = chars.map((ch, i) => {
|
|
210
|
+
const t = chars.length <= 1 ? 0 : i / (chars.length - 1);
|
|
211
|
+
const colorIdx = Math.round(fromColor + t * (toColor - fromColor));
|
|
212
|
+
return `\x1b[38;5;${colorIdx}m${ch}`;
|
|
213
|
+
}).join('') + c.reset;
|
|
214
|
+
process.stdout.write(result + '\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function celebrate(text) {
|
|
218
|
+
const sym = isUnicode ? '✨' : '*';
|
|
219
|
+
if (!isTTY || getMode() === 'ci' || getMode() === 'plain') {
|
|
220
|
+
process.stdout.write(` ${sym} ${text} ${sym}\n`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(`\r${c.clearLine} ${color(`${sym} ${text} ${sym}`, c.bgGreen, c.bold)}`);
|
|
224
|
+
await sleep(100);
|
|
225
|
+
process.stdout.write(`\r${c.clearLine} ${color(`${sym} ${text} ${sym}`, c.green, c.bold)}\n`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function loadingSequence(steps) {
|
|
229
|
+
for (const s of steps) {
|
|
230
|
+
const sp = spinner(s.text).start();
|
|
231
|
+
await sleep(s.duration || 800);
|
|
232
|
+
sp.succeed(s.successText || s.text);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function agentDispatch(model, task) {
|
|
237
|
+
const mode = getMode();
|
|
238
|
+
if (mode === 'ci' || mode === 'plain') {
|
|
239
|
+
process.stdout.write(`Dispatching ${model}...\n`);
|
|
240
|
+
process.stdout.write(`Agent dispatched: ${task}\n`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const sp = spinner(`Dispatching ${color(model, c.cyan)}...`).start();
|
|
244
|
+
await sleep(mode === 'subtle' ? 0 : 600);
|
|
245
|
+
sp.succeed(`Agent dispatched: ${task}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function thinkRound(round, provider, question) {
|
|
249
|
+
const mode = getMode();
|
|
250
|
+
const providerLabel = color(provider, c.magenta);
|
|
251
|
+
const roundLabel = color(`Round ${round}`, c.bold);
|
|
252
|
+
|
|
253
|
+
if (mode === 'ci' || mode === 'plain') {
|
|
254
|
+
process.stdout.write(`Dual-Brain Think · ${roundLabel} · ${provider} analyzing: ${question}\n`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const title = `Dual-Brain Think · ${roundLabel}`;
|
|
259
|
+
const titleVisible = stripAnsi(title);
|
|
260
|
+
const width = Math.max(titleVisible.length + 4, question.length + 4, 36);
|
|
261
|
+
const topFill = '─'.repeat(Math.max(0, width - titleVisible.length - 2));
|
|
262
|
+
|
|
263
|
+
if (isUnicode && hasColor) {
|
|
264
|
+
process.stdout.write(` ${color(`╭─ ${title} ${'─'.repeat(topFill.length)}╮`, c.cyan)}\n`);
|
|
265
|
+
process.stdout.write(` ${color('│', c.cyan)} ${isUnicode ? '🤖' : '>>'} ${providerLabel} analyzing...${' '.repeat(Math.max(0, width - 4 - stripAnsi(provider).length - 13))}${color('│', c.cyan)}\n`);
|
|
266
|
+
process.stdout.write(` ${color(`╰${'─'.repeat(width)}╯`, c.cyan)}\n`);
|
|
267
|
+
} else {
|
|
268
|
+
process.stdout.write(` +-- ${title} --+\n`);
|
|
269
|
+
process.stdout.write(` | ${provider} analyzing: ${question}\n`);
|
|
270
|
+
process.stdout.write(` +${'─'.repeat(width + 2)}+\n`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sp = spinner(`${provider} thinking on: ${question}`).start();
|
|
274
|
+
await sleep(mode === 'subtle' ? 0 : 900);
|
|
275
|
+
sp.succeed(`${provider} analysis complete`);
|
|
276
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intelligence.mjs — Situational awareness for every pipeline run.
|
|
3
|
+
* Reads project reality fresh, derives task context, and detects contradictions
|
|
4
|
+
* between what an agent plans to do and what is actually true.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const PROTECTED_PATHS = [
|
|
12
|
+
'src/pipeline.mjs',
|
|
13
|
+
'src/dispatch.mjs',
|
|
14
|
+
'src/decide.mjs',
|
|
15
|
+
'.claude/hooks/head-guard.mjs',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function safeExec(cmd, cwd) {
|
|
21
|
+
try {
|
|
22
|
+
return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getDirtyFiles(cwd) {
|
|
29
|
+
const raw = safeExec('git status --porcelain', cwd);
|
|
30
|
+
if (!raw) return [];
|
|
31
|
+
return raw
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(l => l.trim())
|
|
34
|
+
.map(l => l.slice(3).trim().replace(/^"(.*)"$/, '$1'))
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getRecentCommits(cwd, n = 5) {
|
|
39
|
+
const raw = safeExec(`git log -${n} --pretty=format:%s`, cwd);
|
|
40
|
+
if (!raw) return [];
|
|
41
|
+
return raw.split('\n').filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getAheadCount(cwd) {
|
|
45
|
+
const raw = safeExec('git rev-list --count @{u}..HEAD', cwd);
|
|
46
|
+
const n = parseInt(raw, 10);
|
|
47
|
+
return isNaN(n) ? 0 : n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCurrentBranch(cwd) {
|
|
51
|
+
return safeExec('git branch --show-current', cwd) || 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Failure reader ───────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function readRecentFailures(cwd, limit = 10) {
|
|
57
|
+
const path = join(cwd, '.dualbrain', 'failures.jsonl');
|
|
58
|
+
if (!existsSync(path)) return [];
|
|
59
|
+
try {
|
|
60
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
61
|
+
return lines
|
|
62
|
+
.slice(-limit)
|
|
63
|
+
.reverse()
|
|
64
|
+
.map(line => {
|
|
65
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
66
|
+
})
|
|
67
|
+
.filter(r => r && !r.resolved)
|
|
68
|
+
.map(r => ({
|
|
69
|
+
prompt: r.prompt ?? '',
|
|
70
|
+
error: r.error ?? '',
|
|
71
|
+
approach: r.tier ? `${r.tier}/${r.model ?? 'unknown'}` : (r.model ?? 'unknown'),
|
|
72
|
+
timestamp: r.timestamp ?? 0,
|
|
73
|
+
}));
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Outcome reader ───────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function readRecentOutcomes(cwd, limit = 10) {
|
|
82
|
+
const dir = join(cwd, '.dualbrain', 'outcomes');
|
|
83
|
+
if (!existsSync(dir)) return [];
|
|
84
|
+
try {
|
|
85
|
+
const files = readdirSync(dir)
|
|
86
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
87
|
+
.sort()
|
|
88
|
+
.reverse()
|
|
89
|
+
.slice(0, 3);
|
|
90
|
+
|
|
91
|
+
const records = [];
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
try {
|
|
94
|
+
const lines = readFileSync(join(dir, file), 'utf8').split('\n').filter(Boolean);
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
try {
|
|
97
|
+
const r = JSON.parse(line);
|
|
98
|
+
records.push({
|
|
99
|
+
task: r.prompt ?? '',
|
|
100
|
+
success: r.result?.success ?? false,
|
|
101
|
+
timestamp: r.timestamp ?? 0,
|
|
102
|
+
});
|
|
103
|
+
} catch { /* skip bad line */ }
|
|
104
|
+
}
|
|
105
|
+
} catch { /* skip unreadable file */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return records
|
|
109
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
110
|
+
.slice(0, limit);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Package.json reader ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function readPackageJson(cwd) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
121
|
+
} catch {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read project reality fresh. No cache. Returns a ProjectBrief.
|
|
130
|
+
*/
|
|
131
|
+
export function deriveProjectState(cwd = process.cwd()) {
|
|
132
|
+
const pkg = readPackageJson(cwd);
|
|
133
|
+
|
|
134
|
+
const version = pkg.version ?? '0.0.0';
|
|
135
|
+
const versionMajor = parseInt(version.split('.')[0], 10) || 0;
|
|
136
|
+
|
|
137
|
+
const dirtyFiles = getDirtyFiles(cwd);
|
|
138
|
+
const recentCommits = getRecentCommits(cwd, 5);
|
|
139
|
+
const branch = getCurrentBranch(cwd);
|
|
140
|
+
const aheadOfRemote = getAheadCount(cwd);
|
|
141
|
+
|
|
142
|
+
const binField = pkg.bin ?? {};
|
|
143
|
+
const binValues = Object.values(binField);
|
|
144
|
+
const entryPoint = binValues[0] ?? (pkg.main ?? '');
|
|
145
|
+
|
|
146
|
+
const testCommand = pkg.scripts?.test ?? null;
|
|
147
|
+
|
|
148
|
+
const recentFailures = readRecentFailures(cwd, 10);
|
|
149
|
+
const recentOutcomes = readRecentOutcomes(cwd, 10);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
packageName: pkg.name ?? 'unknown',
|
|
153
|
+
version,
|
|
154
|
+
versionMajor,
|
|
155
|
+
description: pkg.description ?? '',
|
|
156
|
+
|
|
157
|
+
branch,
|
|
158
|
+
dirty: dirtyFiles.length > 0,
|
|
159
|
+
dirtyFiles,
|
|
160
|
+
recentCommits,
|
|
161
|
+
aheadOfRemote,
|
|
162
|
+
|
|
163
|
+
brandName: 'dual-brain',
|
|
164
|
+
cliCommand: 'dual-brain',
|
|
165
|
+
|
|
166
|
+
moduleType: pkg.type === 'module' ? 'esm' : 'cjs',
|
|
167
|
+
entryPoint,
|
|
168
|
+
testCommand,
|
|
169
|
+
|
|
170
|
+
protectedPaths: PROTECTED_PATHS,
|
|
171
|
+
|
|
172
|
+
recentFailures,
|
|
173
|
+
recentOutcomes,
|
|
174
|
+
|
|
175
|
+
derivedAt: Date.now(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Derive task-scoped context from the current prompt and optional session events.
|
|
181
|
+
*/
|
|
182
|
+
export function deriveTaskContext(task = '', recentEvents = []) {
|
|
183
|
+
const priorAttempts = [];
|
|
184
|
+
const filesOutOfScope = [];
|
|
185
|
+
const filesInScopeSet = new Set();
|
|
186
|
+
|
|
187
|
+
const FILE_RE = /(?:^|\s)((?:src|hooks|bin|scripts|\.claude)\/[\w./\-]+\.\w+)/g;
|
|
188
|
+
let m;
|
|
189
|
+
|
|
190
|
+
FILE_RE.lastIndex = 0;
|
|
191
|
+
while ((m = FILE_RE.exec(task)) !== null) filesInScopeSet.add(m[1]);
|
|
192
|
+
|
|
193
|
+
for (const ev of (recentEvents ?? [])) {
|
|
194
|
+
if (!ev) continue;
|
|
195
|
+
|
|
196
|
+
if (ev.type === 'failure' || ev.failed) {
|
|
197
|
+
priorAttempts.push({
|
|
198
|
+
approach: ev.approach ?? ev.tier ?? 'unknown',
|
|
199
|
+
failed: true,
|
|
200
|
+
reason: ev.error ?? ev.reason ?? '',
|
|
201
|
+
});
|
|
202
|
+
for (const f of (ev.files ?? ev.filesChanged ?? [])) {
|
|
203
|
+
filesOutOfScope.push(f);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
FILE_RE.lastIndex = 0;
|
|
208
|
+
const evText = JSON.stringify(ev);
|
|
209
|
+
while ((m = FILE_RE.exec(evText)) !== null) filesInScopeSet.add(m[1]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const failureCount = priorAttempts.filter(a => a.failed).length;
|
|
213
|
+
const escalationLevel =
|
|
214
|
+
failureCount >= 3 ? 'critical' :
|
|
215
|
+
failureCount >= 1 ? 'elevated' :
|
|
216
|
+
'normal';
|
|
217
|
+
|
|
218
|
+
const constraintKeywords = [];
|
|
219
|
+
const CONSTRAINT_RE = /\b(must|never|always|do not|don't|only|no\s+\w+)\b[^.!?]{0,80}/gi;
|
|
220
|
+
let cm;
|
|
221
|
+
CONSTRAINT_RE.lastIndex = 0;
|
|
222
|
+
while ((cm = CONSTRAINT_RE.exec(task)) !== null) {
|
|
223
|
+
constraintKeywords.push(cm[0].trim());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
task,
|
|
228
|
+
priorAttempts,
|
|
229
|
+
activeConstraints: constraintKeywords,
|
|
230
|
+
filesInScope: [...filesInScopeSet],
|
|
231
|
+
filesOutOfScope: [...new Set(filesOutOfScope)],
|
|
232
|
+
escalationLevel,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Detect contradictions between project reality, task context, and a proposed plan.
|
|
238
|
+
* Returns an array of contradiction objects.
|
|
239
|
+
*/
|
|
240
|
+
export function detectContradictions(projectBrief, taskBrief, plan = {}) {
|
|
241
|
+
const contradictions = [];
|
|
242
|
+
|
|
243
|
+
const planDesc = plan.description ?? '';
|
|
244
|
+
const planAssumptions = plan.assumptions ?? {};
|
|
245
|
+
const targetFiles = Array.isArray(plan.targetFiles) ? plan.targetFiles : [];
|
|
246
|
+
|
|
247
|
+
// 1. version_mismatch
|
|
248
|
+
const assumedVersion = typeof planAssumptions === 'string'
|
|
249
|
+
? planAssumptions
|
|
250
|
+
: (planAssumptions.version ?? planAssumptions.packageVersion ?? '');
|
|
251
|
+
|
|
252
|
+
if (assumedVersion) {
|
|
253
|
+
const assumedMajor = parseInt(String(assumedVersion).split('.')[0], 10);
|
|
254
|
+
if (!isNaN(assumedMajor) && assumedMajor !== projectBrief.versionMajor) {
|
|
255
|
+
contradictions.push({
|
|
256
|
+
type: 'version_mismatch',
|
|
257
|
+
severity: 'block',
|
|
258
|
+
message: `Plan assumes major version ${assumedMajor} but package is v${projectBrief.versionMajor} (${projectBrief.version})`,
|
|
259
|
+
evidence: { expected: projectBrief.version, actual: assumedVersion },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// version reference in description
|
|
265
|
+
const versionInDesc = planDesc.match(/\bv?(\d+)\.\d+\.\d+\b/);
|
|
266
|
+
if (versionInDesc) {
|
|
267
|
+
const descMajor = parseInt(versionInDesc[1], 10);
|
|
268
|
+
if (!isNaN(descMajor) && descMajor !== projectBrief.versionMajor) {
|
|
269
|
+
contradictions.push({
|
|
270
|
+
type: 'version_mismatch',
|
|
271
|
+
severity: 'warn',
|
|
272
|
+
message: `Plan description references v${versionInDesc[0]} but package is v${projectBrief.version}`,
|
|
273
|
+
evidence: { expected: projectBrief.version, actual: versionInDesc[0] },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 2. branding_error
|
|
279
|
+
const WRONG_NAMES = ['data-tools', 'orchestrator', 'dual_brain', 'dualbrain', 'brain-dual'];
|
|
280
|
+
const searchText = [planDesc, JSON.stringify(planAssumptions)].join(' ').toLowerCase();
|
|
281
|
+
for (const wrongName of WRONG_NAMES) {
|
|
282
|
+
if (searchText.includes(wrongName) && !searchText.includes('dual-brain')) {
|
|
283
|
+
contradictions.push({
|
|
284
|
+
type: 'branding_error',
|
|
285
|
+
severity: 'block',
|
|
286
|
+
message: `Plan references "${wrongName}" but correct package name is "${projectBrief.brandName}"`,
|
|
287
|
+
evidence: { expected: projectBrief.brandName, actual: wrongName },
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// check explicit packageName assumption
|
|
294
|
+
const assumedName = typeof planAssumptions === 'object' ? planAssumptions.packageName : null;
|
|
295
|
+
if (assumedName && assumedName !== projectBrief.packageName) {
|
|
296
|
+
contradictions.push({
|
|
297
|
+
type: 'name_mismatch',
|
|
298
|
+
severity: 'block',
|
|
299
|
+
message: `Plan assumes packageName "${assumedName}" but actual package is "${projectBrief.packageName}"`,
|
|
300
|
+
evidence: { expected: projectBrief.packageName, actual: assumedName },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 3. repeated_failure
|
|
305
|
+
const planWords = new Set(
|
|
306
|
+
planDesc.toLowerCase().split(/\W+/).filter(w => w.length > 3)
|
|
307
|
+
);
|
|
308
|
+
for (const failure of (projectBrief.recentFailures ?? [])) {
|
|
309
|
+
const failureWords = (failure.prompt ?? '').toLowerCase().split(/\W+/).filter(w => w.length > 3);
|
|
310
|
+
const overlap = failureWords.filter(w => planWords.has(w)).length;
|
|
311
|
+
const similarity = overlap / Math.max(planWords.size, failureWords.length, 1);
|
|
312
|
+
if (similarity >= 0.4) {
|
|
313
|
+
contradictions.push({
|
|
314
|
+
type: 'repeated_failure',
|
|
315
|
+
severity: 'warn',
|
|
316
|
+
message: `Plan resembles a recent failed attempt: "${failure.prompt.slice(0, 80)}"`,
|
|
317
|
+
evidence: { expected: 'novel approach', actual: failure.prompt.slice(0, 80) },
|
|
318
|
+
});
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 4. scope_violation + 5. protected_file
|
|
324
|
+
const taskFiles = new Set(taskBrief?.filesInScope ?? []);
|
|
325
|
+
const protectedSet = new Set(projectBrief.protectedPaths ?? []);
|
|
326
|
+
|
|
327
|
+
for (const f of targetFiles) {
|
|
328
|
+
const isProtected = protectedSet.has(f) || [...protectedSet].some(p => f.endsWith(p));
|
|
329
|
+
const inScope = taskFiles.has(f) || taskFiles.size === 0;
|
|
330
|
+
|
|
331
|
+
if (isProtected && !inScope) {
|
|
332
|
+
contradictions.push({
|
|
333
|
+
type: 'protected_file',
|
|
334
|
+
severity: 'block',
|
|
335
|
+
message: `Plan targets protected file "${f}" without explicit scope justification`,
|
|
336
|
+
evidence: { expected: 'file not in plan', actual: f },
|
|
337
|
+
});
|
|
338
|
+
} else if (!inScope && isProtected) {
|
|
339
|
+
contradictions.push({
|
|
340
|
+
type: 'scope_violation',
|
|
341
|
+
severity: 'warn',
|
|
342
|
+
message: `Plan targets "${f}" which is protected and not mentioned in task scope`,
|
|
343
|
+
evidence: { expected: [...taskFiles].join(', ') || 'none', actual: f },
|
|
344
|
+
});
|
|
345
|
+
} else if (!inScope && taskFiles.size > 0) {
|
|
346
|
+
contradictions.push({
|
|
347
|
+
type: 'scope_violation',
|
|
348
|
+
severity: 'warn',
|
|
349
|
+
message: `Plan targets "${f}" which is outside the task's stated file scope`,
|
|
350
|
+
evidence: { expected: [...taskFiles].join(', '), actual: f },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return contradictions;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Format a compact situational awareness summary (max 15 lines) for agent prompts.
|
|
360
|
+
*/
|
|
361
|
+
export function formatBrief(projectBrief, taskBrief) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
|
|
364
|
+
const dirtyLabel = projectBrief.dirty ? 'dirty' : 'clean';
|
|
365
|
+
lines.push(
|
|
366
|
+
`PROJECT: ${projectBrief.packageName} v${projectBrief.version} (${projectBrief.moduleType})`
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
lines.push(
|
|
370
|
+
`BRANCH: ${projectBrief.branch} (${dirtyLabel}) | ${projectBrief.aheadOfRemote} ahead`
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (projectBrief.recentCommits?.length > 0) {
|
|
374
|
+
const preview = projectBrief.recentCommits
|
|
375
|
+
.slice(0, 2)
|
|
376
|
+
.map(c => `"${c.slice(0, 50)}"`)
|
|
377
|
+
.join(' · ');
|
|
378
|
+
lines.push(`RECENT: ${preview}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const failureCount = (projectBrief.recentFailures ?? []).length;
|
|
382
|
+
if (failureCount > 0) {
|
|
383
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
384
|
+
const cutoff = Date.now() - dayMs;
|
|
385
|
+
const recent24 = projectBrief.recentFailures.filter(f => f.timestamp >= cutoff).length;
|
|
386
|
+
const categories = [...new Set(
|
|
387
|
+
projectBrief.recentFailures.slice(0, 5).map(f => f.error?.split(':')[0]?.trim()).filter(Boolean)
|
|
388
|
+
)].slice(0, 2).join(', ');
|
|
389
|
+
lines.push(
|
|
390
|
+
`FAILURES: ${recent24} in last 24h${categories ? ` (${categories})` : ''}`
|
|
391
|
+
);
|
|
392
|
+
} else {
|
|
393
|
+
lines.push('FAILURES: none');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const protectedNames = (projectBrief.protectedPaths ?? [])
|
|
397
|
+
.map(p => p.split('/').pop())
|
|
398
|
+
.join(', ');
|
|
399
|
+
if (protectedNames) lines.push(`PROTECTED: ${protectedNames}`);
|
|
400
|
+
|
|
401
|
+
if (taskBrief) {
|
|
402
|
+
const taskPreview = (taskBrief.task ?? '').slice(0, 80);
|
|
403
|
+
if (taskPreview) lines.push(`TASK: "${taskPreview}"`);
|
|
404
|
+
|
|
405
|
+
const failedAttempts = (taskBrief.priorAttempts ?? []).filter(a => a.failed);
|
|
406
|
+
if (failedAttempts.length > 0) {
|
|
407
|
+
const lastReason = failedAttempts[0].reason
|
|
408
|
+
? ` (${failedAttempts[0].reason.slice(0, 40)})`
|
|
409
|
+
: '';
|
|
410
|
+
lines.push(`PRIOR ATTEMPTS: ${failedAttempts.length} failed${lastReason}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (taskBrief.escalationLevel && taskBrief.escalationLevel !== 'normal') {
|
|
414
|
+
lines.push(`ESCALATION: ${taskBrief.escalationLevel}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (taskBrief.filesInScope?.length > 0) {
|
|
418
|
+
lines.push(`IN SCOPE: ${taskBrief.filesInScope.slice(0, 4).join(', ')}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return lines.slice(0, 15).join('\n');
|
|
423
|
+
}
|