dual-brain 0.2.7 → 0.2.9
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/CLAUDE.md +29 -143
- package/bin/dual-brain.mjs +80 -44
- package/package.json +11 -2
- package/src/dispatch.mjs +87 -2
- package/src/head.mjs +353 -0
- package/src/health.mjs +156 -0
- package/src/integrity.mjs +245 -0
- package/src/profile.mjs +82 -1
- package/src/prompt-audit.mjs +231 -0
- package/src/templates.mjs +223 -0
- package/src/tui.mjs +79 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Task contract — every dispatch must have one.
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* id: string,
|
|
8
|
+
* objective: string,
|
|
9
|
+
* scope: string[],
|
|
10
|
+
* nonGoals?: string[],
|
|
11
|
+
* risk: 'low'|'medium'|'high'|'critical',
|
|
12
|
+
* acceptanceCriteria: string[],
|
|
13
|
+
* allowedOperations?: string[],
|
|
14
|
+
* context?: string,
|
|
15
|
+
* files?: string[],
|
|
16
|
+
* timeoutMs?: number,
|
|
17
|
+
* }} TaskContract
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a task contract has all required fields.
|
|
22
|
+
* Returns { valid, missing }
|
|
23
|
+
*/
|
|
24
|
+
export function validateContract(contract) {
|
|
25
|
+
const required = ['objective', 'scope', 'risk', 'acceptanceCriteria'];
|
|
26
|
+
const missing = required.filter(f => !contract?.[f] || (Array.isArray(contract[f]) && contract[f].length === 0));
|
|
27
|
+
return {
|
|
28
|
+
valid: missing.length === 0,
|
|
29
|
+
missing,
|
|
30
|
+
contract: missing.length === 0 ? { ...contract, id: contract.id || Date.now().toString(36) } : null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Template definitions ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const TEMPLATES = {
|
|
37
|
+
search: {
|
|
38
|
+
id: 'search',
|
|
39
|
+
version: '1.0',
|
|
40
|
+
tier: 'search',
|
|
41
|
+
description: 'Read-only lookups, grep, explore. Returns files found, line refs, confidence.',
|
|
42
|
+
requiredFields: ['objective', 'scope'],
|
|
43
|
+
render(contract, context = {}) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`Find: ${contract.objective}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
if (contract.scope.length) lines.push(`Scope: ${contract.scope.join(', ')}`);
|
|
48
|
+
if (contract.files?.length) lines.push(`Start with: ${contract.files.join(', ')}`);
|
|
49
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('Return: file paths, line numbers, relevant code snippets, and confidence level.');
|
|
52
|
+
if (contract.nonGoals?.length) lines.push(`Do NOT: ${contract.nonGoals.join('; ')}`);
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
execute: {
|
|
58
|
+
id: 'execute',
|
|
59
|
+
version: '1.0',
|
|
60
|
+
tier: 'execute',
|
|
61
|
+
description: 'Edits, tests, git ops. Returns files changed, tests run, edge cases.',
|
|
62
|
+
requiredFields: ['objective', 'scope', 'acceptanceCriteria'],
|
|
63
|
+
render(contract, context = {}) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
lines.push(contract.objective);
|
|
66
|
+
lines.push('');
|
|
67
|
+
if (contract.scope.length) lines.push(`Files in scope: ${contract.scope.join(', ')}`);
|
|
68
|
+
if (contract.files?.length) lines.push(`Read first: ${contract.files.join(', ')}`);
|
|
69
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
70
|
+
lines.push('');
|
|
71
|
+
lines.push('Acceptance criteria:');
|
|
72
|
+
for (const c of contract.acceptanceCriteria) {
|
|
73
|
+
lines.push(`- ${c}`);
|
|
74
|
+
}
|
|
75
|
+
if (contract.nonGoals?.length) {
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push('Non-goals (do NOT do these):');
|
|
78
|
+
for (const ng of contract.nonGoals) lines.push(`- ${ng}`);
|
|
79
|
+
}
|
|
80
|
+
if (contract.allowedOperations?.length) {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(`Allowed operations: ${contract.allowedOperations.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push('Return: files changed, tests run, edge cases found.');
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
think: {
|
|
91
|
+
id: 'think',
|
|
92
|
+
version: '1.0',
|
|
93
|
+
tier: 'think',
|
|
94
|
+
description: 'Architecture decisions, design review, planning.',
|
|
95
|
+
requiredFields: ['objective'],
|
|
96
|
+
render(contract, context = {}) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(contract.objective);
|
|
99
|
+
lines.push('');
|
|
100
|
+
if (contract.scope?.length) lines.push(`Relevant modules: ${contract.scope.join(', ')}`);
|
|
101
|
+
if (contract.context) lines.push(`Background: ${contract.context}`);
|
|
102
|
+
if (contract.files?.length) lines.push(`Key files: ${contract.files.join(', ')}`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push('Provide: recommendation, rationale, alternatives considered, risks, and confidence level.');
|
|
105
|
+
if (contract.acceptanceCriteria?.length) {
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push('Decision criteria:');
|
|
108
|
+
for (const c of contract.acceptanceCriteria) lines.push(`- ${c}`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
review: {
|
|
115
|
+
id: 'review',
|
|
116
|
+
version: '1.0',
|
|
117
|
+
tier: 'review',
|
|
118
|
+
description: 'Code review with severity, line refs, test gaps, security concerns.',
|
|
119
|
+
requiredFields: ['objective', 'scope'],
|
|
120
|
+
render(contract, context = {}) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`Review: ${contract.objective}`);
|
|
123
|
+
lines.push('');
|
|
124
|
+
if (contract.scope.length) lines.push(`Files to review: ${contract.scope.join(', ')}`);
|
|
125
|
+
if (contract.context) lines.push(`Context: ${contract.context}`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('Check for:');
|
|
128
|
+
lines.push('- Correctness and edge cases');
|
|
129
|
+
lines.push('- Security vulnerabilities (OWASP top 10)');
|
|
130
|
+
lines.push('- Test coverage gaps');
|
|
131
|
+
lines.push('- Architectural drift');
|
|
132
|
+
lines.push('- Performance concerns');
|
|
133
|
+
if (contract.acceptanceCriteria?.length) {
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push('Specific concerns:');
|
|
136
|
+
for (const c of contract.acceptanceCriteria) lines.push(`- ${c}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push('Return: findings with severity (critical/high/medium/low), file:line refs, and suggested fixes.');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── Template API ─────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a template by tier name.
|
|
149
|
+
*/
|
|
150
|
+
export function getTemplate(tier) {
|
|
151
|
+
return TEMPLATES[tier] || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* List all available templates.
|
|
156
|
+
*/
|
|
157
|
+
export function listTemplates() {
|
|
158
|
+
return Object.values(TEMPLATES).map(t => ({
|
|
159
|
+
id: t.id,
|
|
160
|
+
version: t.version,
|
|
161
|
+
tier: t.tier,
|
|
162
|
+
description: t.description,
|
|
163
|
+
requiredFields: t.requiredFields,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render a prompt from a template and task contract.
|
|
169
|
+
* Validates contract first. Returns { prompt, template, contract, valid, errors }
|
|
170
|
+
*/
|
|
171
|
+
export function renderPrompt(tier, contract, context = {}) {
|
|
172
|
+
const template = TEMPLATES[tier];
|
|
173
|
+
if (!template) {
|
|
174
|
+
return { prompt: null, valid: false, errors: [`Unknown template tier: ${tier}`] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate required fields
|
|
178
|
+
const missing = template.requiredFields.filter(f => !contract?.[f] || (Array.isArray(contract[f]) && contract[f].length === 0));
|
|
179
|
+
if (missing.length > 0) {
|
|
180
|
+
return {
|
|
181
|
+
prompt: null,
|
|
182
|
+
valid: false,
|
|
183
|
+
errors: missing.map(f => `Missing required field: ${f}`),
|
|
184
|
+
template: { id: template.id, version: template.version },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prompt = template.render(contract, context);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
prompt,
|
|
192
|
+
valid: true,
|
|
193
|
+
errors: [],
|
|
194
|
+
template: { id: template.id, version: template.version },
|
|
195
|
+
contract: { ...contract, id: contract.id || Date.now().toString(36) },
|
|
196
|
+
stats: {
|
|
197
|
+
words: prompt.split(/\s+/).length,
|
|
198
|
+
chars: prompt.length,
|
|
199
|
+
estimatedTokens: Math.ceil(prompt.length / 4),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Quick render: build a contract from minimal inputs and render.
|
|
206
|
+
* For when HEAD knows the tier and objective but hasn't built a full contract.
|
|
207
|
+
*/
|
|
208
|
+
export function quickRender(tier, objective, opts = {}) {
|
|
209
|
+
const { scope = [], files = [], risk = 'medium', criteria = [], nonGoals = [], context = '' } = opts;
|
|
210
|
+
|
|
211
|
+
const contract = {
|
|
212
|
+
objective,
|
|
213
|
+
scope,
|
|
214
|
+
files,
|
|
215
|
+
risk,
|
|
216
|
+
acceptanceCriteria: criteria.length ? criteria : [`${objective} is complete and working`],
|
|
217
|
+
nonGoals,
|
|
218
|
+
context,
|
|
219
|
+
allowedOperations: tier === 'search' ? ['read'] : tier === 'execute' ? ['read', 'write', 'test'] : ['read', 'analyze'],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return renderPrompt(tier, contract);
|
|
223
|
+
}
|
package/src/tui.mjs
CHANGED
|
@@ -171,6 +171,85 @@ export function menu(options, opts = {}) {
|
|
|
171
171
|
return rows.join('\n');
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// ── Modern box rendering with rounded corners ────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const ROUNDED = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', ml: '├', mr: '┤' };
|
|
177
|
+
|
|
178
|
+
export function panel(title, content, opts = {}) {
|
|
179
|
+
const { width = 70, titleColor = '\x1b[36m', borderColor = '\x1b[2m', reset = '\x1b[0m' } = opts;
|
|
180
|
+
const lines = [];
|
|
181
|
+
const innerW = width - 2;
|
|
182
|
+
|
|
183
|
+
// Top border with title
|
|
184
|
+
if (title) {
|
|
185
|
+
const titleStr = ` ${title} `;
|
|
186
|
+
const remaining = innerW - titleStr.length - 1;
|
|
187
|
+
lines.push(`${borderColor}${ROUNDED.tl}${ROUNDED.h} ${titleColor}${title}${borderColor} ${ROUNDED.h.repeat(Math.max(0, remaining))}${ROUNDED.tr}${reset}`);
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(`${borderColor}${ROUNDED.tl}${ROUNDED.h.repeat(innerW)}${ROUNDED.tr}${reset}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Content lines
|
|
193
|
+
const contentLines = (typeof content === 'string' ? content.split('\n') : content);
|
|
194
|
+
for (const line of contentLines) {
|
|
195
|
+
const stripped = line.replace(/\x1b\[[0-9;]*m/g, '');
|
|
196
|
+
const pad = Math.max(0, innerW - stripped.length);
|
|
197
|
+
lines.push(`${borderColor}${ROUNDED.v}${reset} ${line}${' '.repeat(pad)}${borderColor}${ROUNDED.v}${reset}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Bottom border
|
|
201
|
+
lines.push(`${borderColor}${ROUNDED.bl}${ROUNDED.h.repeat(innerW)}${ROUNDED.br}${reset}`);
|
|
202
|
+
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function divider(width = 70) {
|
|
207
|
+
const borderColor = '\x1b[2m';
|
|
208
|
+
const reset = '\x1b[0m';
|
|
209
|
+
return `${borderColor}${ROUNDED.ml}${ROUNDED.h.repeat(width - 2)}${ROUNDED.mr}${reset}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function statusChip(label, healthy, opts = {}) {
|
|
213
|
+
const green = '\x1b[32m';
|
|
214
|
+
const red = '\x1b[31m';
|
|
215
|
+
const dim = '\x1b[2m';
|
|
216
|
+
const reset = '\x1b[0m';
|
|
217
|
+
const icon = healthy ? `${green}●${reset}` : `${red}●${reset}`;
|
|
218
|
+
return `${icon} ${dim}${label}${reset}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function headerBar(left, right, width = 70) {
|
|
222
|
+
const leftStripped = left.replace(/\x1b\[[0-9;]*m/g, '');
|
|
223
|
+
const rightStripped = right.replace(/\x1b\[[0-9;]*m/g, '');
|
|
224
|
+
const gap = Math.max(1, width - leftStripped.length - rightStripped.length);
|
|
225
|
+
return `${left}${' '.repeat(gap)}${right}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function prompt(text = '> task or /help') {
|
|
229
|
+
const cyan = '\x1b[36m';
|
|
230
|
+
const dim = '\x1b[2m';
|
|
231
|
+
const reset = '\x1b[0m';
|
|
232
|
+
return `${cyan}>${reset} ${dim}${text.replace(/^>\s*/, '')}${reset}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function signalLine(type, text, meta = '') {
|
|
236
|
+
const green = '\x1b[32m';
|
|
237
|
+
const yellow = '\x1b[33m';
|
|
238
|
+
const dim = '\x1b[2m';
|
|
239
|
+
const reset = '\x1b[0m';
|
|
240
|
+
|
|
241
|
+
let icon;
|
|
242
|
+
switch (type) {
|
|
243
|
+
case 'success': icon = `${green}✓${reset}`; break;
|
|
244
|
+
case 'warning': icon = `${yellow}!${reset}`; break;
|
|
245
|
+
case 'info': icon = `${dim}·${reset}`; break;
|
|
246
|
+
default: icon = `${dim}·${reset}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const metaStr = meta ? `${dim}${meta}${reset}` : '';
|
|
250
|
+
return `${icon} ${text}${metaStr ? ' ' + metaStr : ''}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
174
253
|
// ─── Self-test ────────────────────────────────────────────────────────────────
|
|
175
254
|
|
|
176
255
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|