dual-brain 4.2.0 → 4.5.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/CLAUDE.md +130 -35
- package/README.md +171 -44
- package/hooks/agent-chains.mjs +369 -0
- package/hooks/agent-templates.mjs +441 -0
- package/hooks/atomic-write.mjs +5 -3
- package/hooks/config-validator.mjs +156 -0
- package/hooks/confirmation-policy.mjs +167 -0
- package/hooks/cost-logger.mjs +32 -12
- package/hooks/cost-report.mjs +60 -114
- package/hooks/decision-ledger.mjs +3 -2
- package/hooks/dual-brain-review.mjs +249 -2
- package/hooks/dual-brain-think.mjs +294 -25
- package/hooks/enforce-tier.mjs +246 -87
- package/hooks/error-channel.mjs +68 -0
- package/hooks/failure-detector.mjs +2 -1
- package/hooks/health-check.mjs +16 -17
- package/hooks/risk-classifier.mjs +135 -2
- package/hooks/session-report.mjs +41 -71
- package/hooks/ship-captain.mjs +1176 -0
- package/hooks/ship-gate.mjs +971 -0
- package/hooks/summary-checkpoint.mjs +31 -4
- package/hooks/test-orchestrator.mjs +1975 -11
- package/install.mjs +1064 -31
- package/orchestrator.json +73 -96
- package/package.json +7 -2
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agent-templates.mjs — Pre-built specialist agent templates for dual-brain.
|
|
4
|
+
*
|
|
5
|
+
* Exports:
|
|
6
|
+
* TEMPLATES — all template definitions
|
|
7
|
+
* getTemplate(name) → template object or null
|
|
8
|
+
* listTemplates() → [{name, description, tier, risk}]
|
|
9
|
+
* buildAgentPrompt(templateName, flags) → { prompt, model, description, risk, quality_gate, tier, output_contract }
|
|
10
|
+
* buildPrompt(templateName, args) → prompt string (legacy compat)
|
|
11
|
+
*
|
|
12
|
+
* CLI:
|
|
13
|
+
* node agent-templates.mjs --list
|
|
14
|
+
* node agent-templates.mjs --template explorer --question "where is auth handled?"
|
|
15
|
+
* node agent-templates.mjs --run explorer --question "where is auth handled?"
|
|
16
|
+
*
|
|
17
|
+
* Via npx dual-brain:
|
|
18
|
+
* npx dual-brain agents # list all templates
|
|
19
|
+
* npx dual-brain agent explorer --question "..."
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'child_process';
|
|
23
|
+
import { dirname, resolve } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
// ─── Model Mapping ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Maps tier name → Claude model ID */
|
|
31
|
+
const TIER_MODELS = {
|
|
32
|
+
search: 'claude-haiku-4-5',
|
|
33
|
+
execute: 'claude-sonnet-4-5',
|
|
34
|
+
think: 'claude-opus-4-5',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ─── Template Definitions ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Built-in agent templates.
|
|
41
|
+
*
|
|
42
|
+
* Fields:
|
|
43
|
+
* name - unique slug (also used as CLI argument)
|
|
44
|
+
* tier - search | execute | think
|
|
45
|
+
* model - haiku | sonnet | opus (short form, for display)
|
|
46
|
+
* risk - low | medium | high | critical
|
|
47
|
+
* quality_gate - self_check | review_recommended | dual_brain_review
|
|
48
|
+
* description - one-line shown in --list
|
|
49
|
+
* output_contract - array of items the agent MUST return
|
|
50
|
+
* flags - { '--flag': { required, description } }
|
|
51
|
+
* prompt_template - string with {var} tokens OR function(args) → string
|
|
52
|
+
*/
|
|
53
|
+
export const TEMPLATES = {
|
|
54
|
+
explorer: {
|
|
55
|
+
name: 'explorer',
|
|
56
|
+
tier: 'search',
|
|
57
|
+
model: 'haiku',
|
|
58
|
+
risk: 'low',
|
|
59
|
+
quality_gate: 'self_check',
|
|
60
|
+
description: 'Read-only codebase exploration — find files, symbols, and patterns',
|
|
61
|
+
output_contract: [
|
|
62
|
+
'files found (with line numbers)',
|
|
63
|
+
'key symbols/functions discovered',
|
|
64
|
+
'confidence level (high/medium/low)',
|
|
65
|
+
'areas you did NOT check',
|
|
66
|
+
],
|
|
67
|
+
flags: {
|
|
68
|
+
'--question': { required: true, description: 'What to explore or find' },
|
|
69
|
+
'--scope': { required: false, description: 'Limit search to a path (e.g. src/auth)' },
|
|
70
|
+
},
|
|
71
|
+
prompt_template:
|
|
72
|
+
'You are a codebase exploration agent. Your task: {question}. ' +
|
|
73
|
+
'{scope ? \'Focus on: \' + scope : \'Search the entire codebase.\'}. ' +
|
|
74
|
+
'Return a structured report with: ' +
|
|
75
|
+
'1) Files found (with line numbers), ' +
|
|
76
|
+
'2) Key symbols/functions discovered, ' +
|
|
77
|
+
'3) Confidence level (high/medium/low), ' +
|
|
78
|
+
'4) Areas you did NOT check. ' +
|
|
79
|
+
'Do not modify any files.',
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
'security-review': {
|
|
83
|
+
name: 'security-review',
|
|
84
|
+
tier: 'think',
|
|
85
|
+
model: 'opus',
|
|
86
|
+
risk: 'high',
|
|
87
|
+
quality_gate: 'dual_brain_review',
|
|
88
|
+
description: 'Security audit agent — OWASP Top 10 scan with severity-filtered findings',
|
|
89
|
+
output_contract: [
|
|
90
|
+
'vulnerabilities found (severity)',
|
|
91
|
+
'recommendations',
|
|
92
|
+
'files reviewed',
|
|
93
|
+
'OWASP categories checked',
|
|
94
|
+
],
|
|
95
|
+
flags: {
|
|
96
|
+
'--scope': { required: false, description: 'Path to audit (e.g. src/auth); defaults to entire codebase' },
|
|
97
|
+
'--severity': { required: false, description: 'Minimum severity to report: critical|high|medium|low' },
|
|
98
|
+
},
|
|
99
|
+
prompt_template:
|
|
100
|
+
'You are a security review agent. ' +
|
|
101
|
+
'Audit {scope || \'the current codebase\'} for vulnerabilities. ' +
|
|
102
|
+
'Check OWASP Top 10 categories. ' +
|
|
103
|
+
'For each finding report: severity (critical/high/medium/low), file and line, description, remediation. ' +
|
|
104
|
+
'{severity ? \'Only report \' + severity + \' severity and above.\' : \'\'} ' +
|
|
105
|
+
'Do not modify any files.',
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
'test-writer': {
|
|
109
|
+
name: 'test-writer',
|
|
110
|
+
tier: 'execute',
|
|
111
|
+
model: 'sonnet',
|
|
112
|
+
risk: 'medium',
|
|
113
|
+
quality_gate: 'review_recommended',
|
|
114
|
+
description: 'Test generation agent — comprehensive tests with edge cases',
|
|
115
|
+
output_contract: [
|
|
116
|
+
'test files created',
|
|
117
|
+
'coverage areas',
|
|
118
|
+
'edge cases covered',
|
|
119
|
+
'assumptions made',
|
|
120
|
+
],
|
|
121
|
+
flags: {
|
|
122
|
+
'--file': { required: true, description: 'Source file to generate tests for (e.g. src/api.ts)' },
|
|
123
|
+
'--framework': { required: false, description: 'Test framework to use (e.g. jest); auto-detect if omitted' },
|
|
124
|
+
},
|
|
125
|
+
prompt_template:
|
|
126
|
+
'You are a test writing agent. Generate comprehensive tests for {file}. ' +
|
|
127
|
+
'{framework ? \'Use \' + framework + \' framework.\' : \'Auto-detect the test framework from the project.\'} ' +
|
|
128
|
+
'Cover: happy path, edge cases, error handling, boundary conditions. ' +
|
|
129
|
+
'Create the test file adjacent to the source. ' +
|
|
130
|
+
'Return: files created, test count, coverage areas, assumptions made.',
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
'bug-hunter': {
|
|
134
|
+
name: 'bug-hunter',
|
|
135
|
+
tier: 'execute',
|
|
136
|
+
model: 'sonnet',
|
|
137
|
+
risk: 'medium',
|
|
138
|
+
quality_gate: 'review_recommended',
|
|
139
|
+
description: 'Bug finding agent — logic errors, race conditions, edge cases',
|
|
140
|
+
output_contract: [
|
|
141
|
+
'bugs found (severity)',
|
|
142
|
+
'reproduction steps',
|
|
143
|
+
'suggested fixes',
|
|
144
|
+
'files examined',
|
|
145
|
+
],
|
|
146
|
+
flags: {
|
|
147
|
+
'--area': { required: true, description: 'Code area to hunt bugs in (e.g. payments)' },
|
|
148
|
+
'--depth': { required: false, description: 'Analysis depth: normal (default) or deep' },
|
|
149
|
+
},
|
|
150
|
+
prompt_template:
|
|
151
|
+
'You are a bug hunting agent. ' +
|
|
152
|
+
'Search {area} for bugs, logic errors, race conditions, and edge cases. ' +
|
|
153
|
+
'{depth === \'deep\' ? \'Do an exhaustive analysis including data flow tracing.\' : \'Focus on the most likely problem areas.\'} ' +
|
|
154
|
+
'For each bug found report: severity, file and line, description, reproduction scenario, suggested fix. ' +
|
|
155
|
+
'Do not modify any files.',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return the template object for the given name, or null if not found.
|
|
163
|
+
* @param {string} name
|
|
164
|
+
* @returns {object|null}
|
|
165
|
+
*/
|
|
166
|
+
export function getTemplate(name) {
|
|
167
|
+
return TEMPLATES[name] ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Return a summary list of all templates.
|
|
172
|
+
* @returns {{name: string, description: string, tier: string, risk: string, quality_gate: string, model: string}[]}
|
|
173
|
+
*/
|
|
174
|
+
export function listTemplates() {
|
|
175
|
+
return Object.values(TEMPLATES).map(({ name, description, tier, risk, quality_gate, model }) => ({
|
|
176
|
+
name,
|
|
177
|
+
description,
|
|
178
|
+
tier,
|
|
179
|
+
model,
|
|
180
|
+
risk,
|
|
181
|
+
quality_gate,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build an agent prompt config by interpolating flags into the prompt template.
|
|
187
|
+
*
|
|
188
|
+
* Supports both string templates (with {var} tokens that may contain JS
|
|
189
|
+
* expressions) and function templates (legacy, for backwards compatibility).
|
|
190
|
+
*
|
|
191
|
+
* @param {string} templateName
|
|
192
|
+
* @param {Record<string, string>} flags e.g. { question: 'where is auth?', scope: 'src/auth' }
|
|
193
|
+
* @returns {{ prompt, model, description, risk, quality_gate, tier, output_contract }|null}
|
|
194
|
+
*/
|
|
195
|
+
export function buildAgentPrompt(templateName, flags = {}) {
|
|
196
|
+
const tmpl = getTemplate(templateName);
|
|
197
|
+
if (!tmpl) return null;
|
|
198
|
+
|
|
199
|
+
const {
|
|
200
|
+
question = '',
|
|
201
|
+
scope = '',
|
|
202
|
+
severity = '',
|
|
203
|
+
framework = '',
|
|
204
|
+
file = '',
|
|
205
|
+
area = '',
|
|
206
|
+
depth = 'normal',
|
|
207
|
+
context = '',
|
|
208
|
+
} = flags;
|
|
209
|
+
|
|
210
|
+
let prompt;
|
|
211
|
+
|
|
212
|
+
if (typeof tmpl.prompt_template === 'function') {
|
|
213
|
+
// Legacy function-style template
|
|
214
|
+
prompt = tmpl.prompt_template({ question, scope, severity, framework, file, area, depth, context });
|
|
215
|
+
} else {
|
|
216
|
+
// String template: replace {expr} tokens with evaluated JS expressions.
|
|
217
|
+
// We convert {expr} → ${expr} and evaluate via template literal inside a
|
|
218
|
+
// Function scope that has all flag vars bound. Fallback to naive {var}
|
|
219
|
+
// replacement if the Function evaluation throws.
|
|
220
|
+
try {
|
|
221
|
+
// eslint-disable-next-line no-new-func
|
|
222
|
+
const interpolate = new Function(
|
|
223
|
+
'question', 'scope', 'severity', 'framework', 'file', 'area', 'depth', 'context',
|
|
224
|
+
// Escape backticks and backslashes in the template before wrapping
|
|
225
|
+
`return \`${tmpl.prompt_template
|
|
226
|
+
.replace(/\\/g, '\\\\')
|
|
227
|
+
.replace(/`/g, '\\`')
|
|
228
|
+
.replace(/\{/g, '${')
|
|
229
|
+
}\`;`
|
|
230
|
+
);
|
|
231
|
+
prompt = interpolate(question, scope, severity, framework, file, area, depth, context);
|
|
232
|
+
} catch {
|
|
233
|
+
// Fallback: naive {var} token replacement
|
|
234
|
+
prompt = tmpl.prompt_template
|
|
235
|
+
.replace(/\{question\}/g, question)
|
|
236
|
+
.replace(/\{scope\}/g, scope)
|
|
237
|
+
.replace(/\{severity\}/g, severity)
|
|
238
|
+
.replace(/\{framework\}/g, framework)
|
|
239
|
+
.replace(/\{file\}/g, file)
|
|
240
|
+
.replace(/\{area\}/g, area)
|
|
241
|
+
.replace(/\{depth\}/g, depth)
|
|
242
|
+
.replace(/\{context\}/g, context);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Normalise whitespace
|
|
247
|
+
prompt = prompt.replace(/\s+/g, ' ').trim();
|
|
248
|
+
|
|
249
|
+
const modelId = TIER_MODELS[tmpl.tier] || TIER_MODELS.execute;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
prompt,
|
|
253
|
+
model: modelId,
|
|
254
|
+
description: tmpl.description,
|
|
255
|
+
risk: tmpl.risk,
|
|
256
|
+
quality_gate: tmpl.quality_gate,
|
|
257
|
+
tier: tmpl.tier,
|
|
258
|
+
output_contract: tmpl.output_contract || [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Legacy compat: build just the prompt string.
|
|
264
|
+
* Use buildAgentPrompt() for new code.
|
|
265
|
+
* @param {string} templateName
|
|
266
|
+
* @param {object} args
|
|
267
|
+
* @returns {string}
|
|
268
|
+
*/
|
|
269
|
+
export function buildPrompt(templateName, args = {}) {
|
|
270
|
+
const template = getTemplate(templateName);
|
|
271
|
+
if (!template) throw new Error(`Unknown template: ${templateName}`);
|
|
272
|
+
if (typeof template.prompt_template === 'function') {
|
|
273
|
+
return template.prompt_template(args);
|
|
274
|
+
}
|
|
275
|
+
const config = buildAgentPrompt(templateName, args);
|
|
276
|
+
return config ? config.prompt : '';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── CLI Helpers ────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function parseCliFlags(argv) {
|
|
282
|
+
const flags = {};
|
|
283
|
+
for (let i = 0; i < argv.length; i++) {
|
|
284
|
+
const a = argv[i];
|
|
285
|
+
if (a === '--list') { flags.list = true; continue; }
|
|
286
|
+
if (a.startsWith('--')) {
|
|
287
|
+
const key = a.slice(2);
|
|
288
|
+
const next = argv[i + 1];
|
|
289
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
290
|
+
flags[key] = next;
|
|
291
|
+
i++;
|
|
292
|
+
} else {
|
|
293
|
+
flags[key] = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return flags;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function printTable(templates) {
|
|
301
|
+
const TIER_ICON = { search: '🔍', execute: '⚙️ ', think: '🧠' };
|
|
302
|
+
const RISK_ICON = { low: '🟢', medium: '🟡', high: '🔴', critical: '🚨' };
|
|
303
|
+
|
|
304
|
+
const maxName = Math.max(...templates.map(t => t.name.length), 14);
|
|
305
|
+
const hr = '─'.repeat(maxName + 8 + 10 + 55);
|
|
306
|
+
|
|
307
|
+
console.log('');
|
|
308
|
+
console.log(' 🧠 dual-brain — Agent Templates');
|
|
309
|
+
console.log(` ${hr}`);
|
|
310
|
+
console.log(` ${'Template'.padEnd(maxName)} ${'Tier'.padEnd(9)} ${'Risk'.padEnd(8)} Description`);
|
|
311
|
+
console.log(` ${hr}`);
|
|
312
|
+
|
|
313
|
+
for (const t of templates) {
|
|
314
|
+
const tierLabel = (TIER_ICON[t.tier] || ' ') + ' ' + t.tier;
|
|
315
|
+
const riskLabel = (RISK_ICON[t.risk] || ' ') + ' ' + t.risk;
|
|
316
|
+
console.log(
|
|
317
|
+
` ${t.name.padEnd(maxName)} ${tierLabel.padEnd(12)} ${riskLabel.padEnd(11)} ${t.description}`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log(` ${hr}`);
|
|
322
|
+
console.log('');
|
|
323
|
+
console.log(' Usage:');
|
|
324
|
+
console.log(' npx dual-brain agent <template> [flags]');
|
|
325
|
+
console.log(' npx dual-brain agent explorer --question "where is auth handled?"');
|
|
326
|
+
console.log(' npx dual-brain agent security-review --scope src/auth --severity high');
|
|
327
|
+
console.log(' npx dual-brain agent test-writer --file src/api.ts --framework jest');
|
|
328
|
+
console.log(' npx dual-brain agent bug-hunter --area payments --depth deep');
|
|
329
|
+
console.log('');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function printAgentConfig(config, templateName) {
|
|
333
|
+
console.log('');
|
|
334
|
+
console.log(` Agent: ${templateName}`);
|
|
335
|
+
console.log(` Tier: ${config.tier} → model: ${config.model}`);
|
|
336
|
+
console.log(` Risk: ${config.risk}`);
|
|
337
|
+
console.log(` Quality gate: ${config.quality_gate}`);
|
|
338
|
+
console.log('');
|
|
339
|
+
console.log(' Prompt:');
|
|
340
|
+
console.log(' ' + '─'.repeat(60));
|
|
341
|
+
const words = config.prompt.split(' ');
|
|
342
|
+
let line = ' ';
|
|
343
|
+
for (const w of words) {
|
|
344
|
+
if ((line + w).length > 78) { console.log(line.trimEnd()); line = ' ' + w + ' '; }
|
|
345
|
+
else line += w + ' ';
|
|
346
|
+
}
|
|
347
|
+
if (line.trim()) console.log(line.trimEnd());
|
|
348
|
+
console.log(' ' + '─'.repeat(60));
|
|
349
|
+
console.log('');
|
|
350
|
+
if (config.output_contract && config.output_contract.length) {
|
|
351
|
+
console.log(' Output contract (agent must return):');
|
|
352
|
+
for (const item of config.output_contract) console.log(` • ${item}`);
|
|
353
|
+
console.log('');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function runAgent(templateName, flags) {
|
|
358
|
+
const tmpl = getTemplate(templateName);
|
|
359
|
+
if (!tmpl) {
|
|
360
|
+
console.error(` Unknown template: ${templateName}`);
|
|
361
|
+
console.error(` Available: ${Object.keys(TEMPLATES).join(', ')}`);
|
|
362
|
+
console.error(` Run: npx dual-brain agents to see all templates`);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Validate required flags
|
|
367
|
+
const missing = Object.entries(tmpl.flags || {})
|
|
368
|
+
.filter(([flagName, meta]) => meta.required && !flags[flagName.slice(2)])
|
|
369
|
+
.map(([flagName, meta]) => `${flagName} — ${meta.description}`);
|
|
370
|
+
|
|
371
|
+
if (missing.length > 0) {
|
|
372
|
+
console.error(` Missing required flags for "${templateName}":`);
|
|
373
|
+
for (const m of missing) console.error(` ${m}`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const config = buildAgentPrompt(templateName, flags);
|
|
378
|
+
|
|
379
|
+
console.log('');
|
|
380
|
+
console.log(` 🧠 Spawning agent: ${templateName}`);
|
|
381
|
+
console.log(` Tier: ${config.tier} | Model: ${config.model} | Risk: ${config.risk}`);
|
|
382
|
+
console.log('');
|
|
383
|
+
|
|
384
|
+
const claudeWhich = spawnSync('which', ['claude'], { encoding: 'utf8' });
|
|
385
|
+
const claudeBin = (claudeWhich.status === 0 && claudeWhich.stdout.trim())
|
|
386
|
+
? claudeWhich.stdout.trim()
|
|
387
|
+
: 'claude';
|
|
388
|
+
|
|
389
|
+
const claudeArgs = ['-p', config.prompt, '--model', config.model];
|
|
390
|
+
|
|
391
|
+
console.log(` Running: claude -p "<prompt>" --model ${config.model}`);
|
|
392
|
+
console.log('');
|
|
393
|
+
|
|
394
|
+
const { status } = spawnSync(claudeBin, claudeArgs, {
|
|
395
|
+
stdio: 'inherit',
|
|
396
|
+
cwd: resolve(process.cwd()),
|
|
397
|
+
env: process.env,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
process.exit(status ?? 0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Main (direct execution only) ──────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
406
|
+
const cliFlags = parseCliFlags(process.argv.slice(2));
|
|
407
|
+
|
|
408
|
+
if (cliFlags.list) {
|
|
409
|
+
printTable(listTemplates());
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --template <name> → build and print the agent prompt config (dry-run)
|
|
414
|
+
if (cliFlags.template) {
|
|
415
|
+
const config = buildAgentPrompt(cliFlags.template, cliFlags);
|
|
416
|
+
if (!config) {
|
|
417
|
+
console.error(` Unknown template: ${cliFlags.template}`);
|
|
418
|
+
console.error(` Available: ${Object.keys(TEMPLATES).join(', ')}`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
printAgentConfig(config, cliFlags.template);
|
|
422
|
+
process.exit(0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --run <name> → actually execute the agent
|
|
426
|
+
if (cliFlags.run) {
|
|
427
|
+
runAgent(cliFlags.run, cliFlags);
|
|
428
|
+
// runAgent calls process.exit internally
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// No recognised command
|
|
432
|
+
console.log(`
|
|
433
|
+
Usage:
|
|
434
|
+
node agent-templates.mjs --list
|
|
435
|
+
node agent-templates.mjs --template <name> [--flag value ...]
|
|
436
|
+
node agent-templates.mjs --run <name> [--flag value ...]
|
|
437
|
+
|
|
438
|
+
Templates: ${Object.keys(TEMPLATES).join(', ')}
|
|
439
|
+
`);
|
|
440
|
+
process.exit(0);
|
|
441
|
+
}
|
package/hooks/atomic-write.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
13
13
|
import { constants } from 'fs';
|
|
14
|
+
import { logHookError } from './error-channel.mjs';
|
|
14
15
|
|
|
15
16
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
16
17
|
const STALE_LOCK_MS = 10_000;
|
|
@@ -86,8 +87,9 @@ export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
|
|
|
86
87
|
const locked = acquireLock(lockPath);
|
|
87
88
|
|
|
88
89
|
if (!locked) {
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
const err = new Error(`Lock acquisition timed out after ${LOCK_TIMEOUT_MS}ms for ${filePath}`);
|
|
91
|
+
logHookError('atomic-write', 'lockedReadModifyWrite', err, { filePath });
|
|
92
|
+
throw err;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
try {
|
|
@@ -102,6 +104,6 @@ export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
|
|
|
102
104
|
atomicWriteJSON(filePath, updated);
|
|
103
105
|
return updated;
|
|
104
106
|
} finally {
|
|
105
|
-
|
|
107
|
+
releaseLock(lockPath);
|
|
106
108
|
}
|
|
107
109
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-validator.mjs — Validates orchestrator.json on load.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* validateConfig(config) → { valid: boolean, errors: string[], warnings: string[] }
|
|
6
|
+
* loadAndValidateConfig(configPath) → { config, validation }
|
|
7
|
+
*
|
|
8
|
+
* On invalid config: logs errors via error-channel.mjs, returns sensible defaults.
|
|
9
|
+
* On unknown top-level keys: warns (potential typos).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'fs';
|
|
13
|
+
import { logHookError } from './error-channel.mjs';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Known top-level keys (anything else triggers a typo warning)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
19
|
+
'subscriptions',
|
|
20
|
+
'tiers',
|
|
21
|
+
'routing',
|
|
22
|
+
'routing_rules',
|
|
23
|
+
'quality_gate',
|
|
24
|
+
'pricing_verified',
|
|
25
|
+
'budgets',
|
|
26
|
+
'providers',
|
|
27
|
+
'dual_thinking',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Sensible defaults for graceful degradation
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const DEFAULT_CONFIG = {
|
|
34
|
+
subscriptions: {
|
|
35
|
+
claude: {
|
|
36
|
+
plan: '$100',
|
|
37
|
+
models: {
|
|
38
|
+
opus: { tier: 'think', name: 'opus', provider: 'claude' },
|
|
39
|
+
sonnet: { tier: 'execute', name: 'sonnet', provider: 'claude' },
|
|
40
|
+
haiku: { tier: 'search', name: 'haiku', provider: 'claude' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
tiers: {
|
|
45
|
+
search: { description: 'Read-only lookups' },
|
|
46
|
+
execute: { description: 'Implementation and edits' },
|
|
47
|
+
think: { description: 'Architecture and review' },
|
|
48
|
+
},
|
|
49
|
+
routing: {
|
|
50
|
+
strategy: 'hybrid-specialized-balanced',
|
|
51
|
+
},
|
|
52
|
+
quality_gate: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Validation
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate an orchestrator config object.
|
|
63
|
+
* Returns { valid, errors, warnings }.
|
|
64
|
+
*/
|
|
65
|
+
export function validateConfig(config) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const warnings = [];
|
|
68
|
+
|
|
69
|
+
if (!config || typeof config !== 'object') {
|
|
70
|
+
errors.push('Config is not a valid object');
|
|
71
|
+
return { valid: false, errors, warnings };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Required top-level keys
|
|
75
|
+
const requiredKeys = ['subscriptions', 'tiers', 'routing', 'quality_gate'];
|
|
76
|
+
for (const key of requiredKeys) {
|
|
77
|
+
if (!(key in config)) {
|
|
78
|
+
errors.push(`Missing required top-level key: "${key}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Unknown top-level keys (typo detection)
|
|
83
|
+
for (const key of Object.keys(config)) {
|
|
84
|
+
if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
|
|
85
|
+
warnings.push(`Unknown top-level key: "${key}" — possible typo?`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validate subscriptions structure
|
|
90
|
+
if (config.subscriptions && typeof config.subscriptions === 'object') {
|
|
91
|
+
for (const [providerName, provider] of Object.entries(config.subscriptions)) {
|
|
92
|
+
if (!provider.models || typeof provider.models !== 'object') {
|
|
93
|
+
errors.push(`subscriptions.${providerName} missing "models" object`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const [modelName, meta] of Object.entries(provider.models)) {
|
|
97
|
+
if (!meta.tier) {
|
|
98
|
+
errors.push(`subscriptions.${providerName}.models.${modelName} missing "tier"`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate tiers has search, execute, think
|
|
105
|
+
if (config.tiers && typeof config.tiers === 'object') {
|
|
106
|
+
for (const required of ['search', 'execute', 'think']) {
|
|
107
|
+
if (!(required in config.tiers)) {
|
|
108
|
+
errors.push(`tiers missing required tier: "${required}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
valid: errors.length === 0,
|
|
115
|
+
errors,
|
|
116
|
+
warnings,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load orchestrator.json from disk, validate it, log issues, and return
|
|
122
|
+
* either the parsed config or sensible defaults on failure.
|
|
123
|
+
*/
|
|
124
|
+
export function loadAndValidateConfig(configPath) {
|
|
125
|
+
let config;
|
|
126
|
+
try {
|
|
127
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logHookError('config-validator', 'load', err, { configPath });
|
|
130
|
+
return { config: DEFAULT_CONFIG, validation: { valid: false, errors: [`Failed to parse config: ${err.message}`], warnings: [] } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const validation = validateConfig(config);
|
|
134
|
+
|
|
135
|
+
// Log errors
|
|
136
|
+
for (const err of validation.errors) {
|
|
137
|
+
logHookError('config-validator', 'validate', new Error(err), { configPath });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Log warnings to stderr (non-fatal)
|
|
141
|
+
for (const warn of validation.warnings) {
|
|
142
|
+
process.stderr.write(`[config-validator] WARNING: ${warn}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If invalid, merge defaults for missing keys
|
|
146
|
+
if (!validation.valid) {
|
|
147
|
+
const merged = { ...DEFAULT_CONFIG, ...config };
|
|
148
|
+
if (!config.subscriptions) merged.subscriptions = DEFAULT_CONFIG.subscriptions;
|
|
149
|
+
if (!config.tiers) merged.tiers = DEFAULT_CONFIG.tiers;
|
|
150
|
+
if (!config.routing) merged.routing = DEFAULT_CONFIG.routing;
|
|
151
|
+
if (!config.quality_gate) merged.quality_gate = DEFAULT_CONFIG.quality_gate;
|
|
152
|
+
return { config: merged, validation };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { config, validation };
|
|
156
|
+
}
|