dual-brain 2.0.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { dirname, resolve, join } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
9
|
+
const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
10
|
+
|
|
11
|
+
function checkPricingDrift(config) {
|
|
12
|
+
const verified = config.pricing_verified;
|
|
13
|
+
if (!verified) return null;
|
|
14
|
+
|
|
15
|
+
const age = Math.floor((Date.now() - Date.parse(verified)) / 86400000);
|
|
16
|
+
if (age < 30) return null;
|
|
17
|
+
|
|
18
|
+
// Rate limit: only warn once per day
|
|
19
|
+
try {
|
|
20
|
+
const lastWarn = readFileSync(DRIFT_STATE, 'utf8').trim();
|
|
21
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
22
|
+
if (lastWarn === today) return null;
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(DRIFT_STATE, new Date().toISOString().slice(0, 10));
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
return `**[Drift Warning]** Pricing was last verified ${age} days ago. Run \`node .claude/hooks/setup-wizard.mjs\` to update.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logRecommendation(event) {
|
|
33
|
+
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
34
|
+
const entry = JSON.stringify({
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
type: 'tier_recommendation',
|
|
37
|
+
detected_tier: event.tier,
|
|
38
|
+
recommended_model: event.recommended,
|
|
39
|
+
actual_model: event.actual,
|
|
40
|
+
prompt_hash: event.promptHash,
|
|
41
|
+
followed: event.followed,
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(logFile, entry + '\n');
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkDuplicate(promptHash) {
|
|
49
|
+
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
50
|
+
try {
|
|
51
|
+
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
52
|
+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
try {
|
|
55
|
+
const entry = JSON.parse(line);
|
|
56
|
+
if (entry.type === 'tier_recommendation' &&
|
|
57
|
+
entry.prompt_hash === promptHash &&
|
|
58
|
+
Date.parse(entry.timestamp) > tenMinAgo) {
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectProvider(model) {
|
|
68
|
+
if (!model || model === 'main-session') return 'claude';
|
|
69
|
+
const m = String(model).toLowerCase();
|
|
70
|
+
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'openai';
|
|
71
|
+
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku') || m.includes('claude')) return 'claude';
|
|
72
|
+
return 'claude';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function quickPressureCheck(tier) {
|
|
76
|
+
try {
|
|
77
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
78
|
+
const logFile = join(__dirname, `usage-${today}.jsonl`);
|
|
79
|
+
const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
|
|
80
|
+
|
|
81
|
+
const fiveHoursAgo = Date.now() - 5 * 60 * 60 * 1000;
|
|
82
|
+
let claudeCalls = 0, openaiCalls = 0;
|
|
83
|
+
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
try {
|
|
86
|
+
const entry = JSON.parse(line);
|
|
87
|
+
if (Date.parse(entry.timestamp) < fiveHoursAgo) continue;
|
|
88
|
+
if (entry.tier !== tier) continue;
|
|
89
|
+
|
|
90
|
+
const provider = entry.provider ||
|
|
91
|
+
(entry.model?.includes('gpt') ? 'openai' : 'claude');
|
|
92
|
+
if (provider === 'claude') claudeCalls++;
|
|
93
|
+
else openaiCalls++;
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { claudeCalls, openaiCalls };
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|where\s+is|list\s+files|read[-\s]?only|lookup|scan)\b/i;
|
|
104
|
+
const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
|
|
105
|
+
|
|
106
|
+
function preferredModel(config, tier) {
|
|
107
|
+
const models = config?.subscriptions?.claude?.models ?? {};
|
|
108
|
+
for (const [name, meta] of Object.entries(models)) {
|
|
109
|
+
if (meta?.tier === tier) return name;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'));
|
|
116
|
+
|
|
117
|
+
if (input.tool_name !== 'Agent') {
|
|
118
|
+
process.stdout.write('{}');
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ti = input.tool_input || {};
|
|
123
|
+
const text = `${ti.description || ''} ${ti.prompt || ''}`.toLowerCase();
|
|
124
|
+
const subType = (ti.subagent_type || '').toLowerCase();
|
|
125
|
+
const currentModel = (ti.model || '').toLowerCase();
|
|
126
|
+
|
|
127
|
+
// Compute prompt hash early for duplicate detection and logging
|
|
128
|
+
const promptHash = createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
129
|
+
|
|
130
|
+
// Check for duplicate agent dispatch before tier classification
|
|
131
|
+
const duplicate = checkDuplicate(promptHash);
|
|
132
|
+
let duplicateWarning = null;
|
|
133
|
+
if (duplicate) {
|
|
134
|
+
const minutesAgo = Math.round((Date.now() - Date.parse(duplicate.timestamp)) / 60000);
|
|
135
|
+
duplicateWarning = `**[Duplicate Warning]** A similar agent task was dispatched ${minutesAgo} minute${minutesAgo !== 1 ? 's' : ''} ago. Reuse the prior result unless the scope changed.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let config;
|
|
139
|
+
try {
|
|
140
|
+
config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
141
|
+
} catch {
|
|
142
|
+
process.stdout.write('{}');
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const driftWarning = checkPricingDrift(config);
|
|
147
|
+
|
|
148
|
+
const intelligence = config.model_intelligence || {};
|
|
149
|
+
const defaults = config.routing_rules?.subagent_defaults || {};
|
|
150
|
+
let tier = null;
|
|
151
|
+
|
|
152
|
+
for (const [key, val] of Object.entries(defaults)) {
|
|
153
|
+
if (subType === key.toLowerCase()) { tier = val; break; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Balance hint — populated after tier is fully resolved
|
|
157
|
+
let balanceHint = null;
|
|
158
|
+
|
|
159
|
+
// Helper to prepend optional warnings (duplicate + drift + balance) before a message
|
|
160
|
+
const prependWarnings = (msg) => {
|
|
161
|
+
const parts = [duplicateWarning, driftWarning, msg, balanceHint].filter(Boolean);
|
|
162
|
+
return parts.join('\n\n');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Multi-tier detection — only when tier is not already resolved from subagent_defaults
|
|
166
|
+
if (!tier) {
|
|
167
|
+
const hasThink = THINK_WORDS.test(text);
|
|
168
|
+
const hasExecute = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i.test(text);
|
|
169
|
+
const hasSearch = SEARCH_WORDS.test(text);
|
|
170
|
+
|
|
171
|
+
const detectedTiers = [
|
|
172
|
+
hasSearch && 'search',
|
|
173
|
+
hasExecute && 'execute',
|
|
174
|
+
hasThink && 'think',
|
|
175
|
+
].filter(Boolean);
|
|
176
|
+
|
|
177
|
+
if (detectedTiers.length > 1) {
|
|
178
|
+
const splitMsg = `**[Tier Enforcer]** This spans **${detectedTiers.join(' + ')}** work. Consider splitting: ` +
|
|
179
|
+
(hasSearch ? 'search first (haiku), ' : '') +
|
|
180
|
+
(hasExecute ? 'then execute edits (sonnet), ' : '') +
|
|
181
|
+
(hasThink ? 'keep planning/review on think tier (opus).' : '');
|
|
182
|
+
const fullMsg = prependWarnings(splitMsg.replace(/, $/, '.'));
|
|
183
|
+
logRecommendation({
|
|
184
|
+
tier: detectedTiers.join('+'),
|
|
185
|
+
recommended: null,
|
|
186
|
+
actual: currentModel,
|
|
187
|
+
promptHash,
|
|
188
|
+
followed: false,
|
|
189
|
+
});
|
|
190
|
+
process.stdout.write(JSON.stringify({ systemMessage: fullMsg }));
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (THINK_WORDS.test(text)) tier = 'think';
|
|
195
|
+
else if (/\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i.test(text)) tier = 'execute';
|
|
196
|
+
else if (SEARCH_WORDS.test(text)) tier = 'search';
|
|
197
|
+
else tier = 'execute';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Compute balance hint now that tier is resolved
|
|
201
|
+
{
|
|
202
|
+
const currentProvider = detectProvider(currentModel);
|
|
203
|
+
if (currentProvider === 'claude') {
|
|
204
|
+
const balance = quickPressureCheck(tier);
|
|
205
|
+
if (balance && balance.claudeCalls > balance.openaiCalls * 2 && balance.claudeCalls > 10) {
|
|
206
|
+
const dispatchModel = tier === 'think' ? 'gpt-5.5' : tier === 'execute' ? 'gpt-5.4' : 'gpt-4.1-mini';
|
|
207
|
+
balanceHint = `\n\n💡 **Balance tip:** Claude has ${balance.claudeCalls} ${tier} calls vs OpenAI's ${balance.openaiCalls} in the last 5hrs. Consider dispatching isolated work to GPT: \`node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model ${dispatchModel}\``;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const expected = preferredModel(config, tier);
|
|
213
|
+
|
|
214
|
+
if (tier === 'think') {
|
|
215
|
+
const thinkModels = ['opus', 'gpt-5.5', 'o1', 'o3'];
|
|
216
|
+
const isThink = !currentModel || thinkModels.some(m => currentModel.includes(m));
|
|
217
|
+
if (isThink) {
|
|
218
|
+
logRecommendation({
|
|
219
|
+
tier,
|
|
220
|
+
recommended: expected,
|
|
221
|
+
actual: currentModel,
|
|
222
|
+
promptHash,
|
|
223
|
+
followed: true,
|
|
224
|
+
});
|
|
225
|
+
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
226
|
+
if (onlyWarnings) {
|
|
227
|
+
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
228
|
+
} else {
|
|
229
|
+
process.stdout.write('{}');
|
|
230
|
+
}
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
// If we get here, a non-think model is being used for think work
|
|
234
|
+
const thinkBestFor = intelligence[expected || 'opus']?.best_for;
|
|
235
|
+
const thinkBestForSuffix = thinkBestFor ? ` (best for: ${thinkBestFor})` : '';
|
|
236
|
+
const msg = `**[Tier Enforcer]** This looks like **think** work (architecture/review/planning). ` +
|
|
237
|
+
`Don't send it to "${currentModel}" — keep it on the main session (${expected || 'opus'}${thinkBestForSuffix}) for best results.`;
|
|
238
|
+
logRecommendation({
|
|
239
|
+
tier,
|
|
240
|
+
recommended: expected,
|
|
241
|
+
actual: currentModel,
|
|
242
|
+
promptHash,
|
|
243
|
+
followed: false,
|
|
244
|
+
});
|
|
245
|
+
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
246
|
+
} else {
|
|
247
|
+
if (!expected || currentModel.includes(expected)) {
|
|
248
|
+
logRecommendation({
|
|
249
|
+
tier,
|
|
250
|
+
recommended: expected,
|
|
251
|
+
actual: currentModel,
|
|
252
|
+
promptHash,
|
|
253
|
+
followed: true,
|
|
254
|
+
});
|
|
255
|
+
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
256
|
+
if (onlyWarnings) {
|
|
257
|
+
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
258
|
+
} else {
|
|
259
|
+
process.stdout.write('{}');
|
|
260
|
+
}
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
const savings = tier === 'search' ? 'Haiku is 19x cheaper than Opus for read-only lookups.' : 'Sonnet is 5x cheaper than Opus for implementation work.';
|
|
264
|
+
const bestFor = intelligence[expected]?.best_for;
|
|
265
|
+
const bestForSuffix = bestFor ? ` (best for: ${bestFor})` : '';
|
|
266
|
+
const msg = `**[Tier Enforcer]** This looks like **${tier}** work. ` +
|
|
267
|
+
`Use \`model: "${expected}"\`${bestForSuffix} instead of "${currentModel || 'opus (inherited)'}". ${savings}`;
|
|
268
|
+
logRecommendation({
|
|
269
|
+
tier,
|
|
270
|
+
recommended: expected,
|
|
271
|
+
actual: currentModel,
|
|
272
|
+
promptHash,
|
|
273
|
+
followed: false,
|
|
274
|
+
});
|
|
275
|
+
process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
process.stdout.write(JSON.stringify({
|
|
279
|
+
systemMessage: `[Tier Enforcer] Config error: ${err?.message?.slice(0, 100) || 'unknown'}. Falling back to main-session judgment.`
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
process.exit(0);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gpt-work-dispatcher.mjs
|
|
4
|
+
*
|
|
5
|
+
* Dispatches execution tasks to GPT via the Codex CLI.
|
|
6
|
+
* Packages a work order, runs `codex exec`, captures the results,
|
|
7
|
+
* and returns structured output.
|
|
8
|
+
*
|
|
9
|
+
* Usage as CLI:
|
|
10
|
+
* node .claude/hooks/gpt-work-dispatcher.mjs \
|
|
11
|
+
* --task "Add tests for budget-balancer.mjs" \
|
|
12
|
+
* --model gpt-5.4 \
|
|
13
|
+
* --files hooks/budget-balancer.mjs
|
|
14
|
+
*
|
|
15
|
+
* Usage as module:
|
|
16
|
+
* import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
|
|
17
|
+
* const result = await dispatchGptTask({ task, model, files, constraints, timeoutMs });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync, spawnSync } from 'child_process';
|
|
21
|
+
import { appendFileSync } from 'fs';
|
|
22
|
+
import { dirname, join } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Codex discovery — mirrors dual-brain-review.mjs
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function findCodex() {
|
|
32
|
+
const candidates = [
|
|
33
|
+
process.env.CODEX_BIN,
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
for (const c of candidates) {
|
|
36
|
+
try { spawnSync(c, ['--version'], { stdio: 'pipe', timeout: 3000 }); return c; } catch {}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
|
|
40
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
41
|
+
} catch {}
|
|
42
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
43
|
+
const fallbacks = [
|
|
44
|
+
join(home, '.local', 'bin', 'codex'),
|
|
45
|
+
join(home, 'bin', 'codex'),
|
|
46
|
+
'/usr/local/bin/codex',
|
|
47
|
+
];
|
|
48
|
+
for (const p of fallbacks) {
|
|
49
|
+
try { spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 }); return p; } catch {}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Prompt builder
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function buildPrompt(task) {
|
|
59
|
+
let prompt = `You are a GPT execution agent inside the Dual-Brain Orchestrator.
|
|
60
|
+
|
|
61
|
+
Task: ${task.task}
|
|
62
|
+
|
|
63
|
+
Own this task completely. Edit files directly.
|
|
64
|
+
|
|
65
|
+
`;
|
|
66
|
+
if (task.files?.length) {
|
|
67
|
+
prompt += `Relevant files:\n${task.files.map(f => `- ${f}`).join('\n')}\n\n`;
|
|
68
|
+
}
|
|
69
|
+
if (task.constraints?.length) {
|
|
70
|
+
prompt += `Constraints:\n${task.constraints.map(c => `- ${c}`).join('\n')}\n\n`;
|
|
71
|
+
}
|
|
72
|
+
prompt += `When done, output a summary of:
|
|
73
|
+
1. What you changed (files and behavior)
|
|
74
|
+
2. Tests run and results (if applicable)
|
|
75
|
+
3. Remaining risks or edge cases
|
|
76
|
+
4. Any assumptions you made`;
|
|
77
|
+
return prompt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Codex executor
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
|
|
85
|
+
const startTime = Date.now();
|
|
86
|
+
|
|
87
|
+
const proc = spawnSync(codexBin, [
|
|
88
|
+
'exec', '--json', '--ephemeral',
|
|
89
|
+
'-m', model,
|
|
90
|
+
'-s', 'danger-full-access',
|
|
91
|
+
prompt,
|
|
92
|
+
], {
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
95
|
+
timeout: timeoutMs || 120000,
|
|
96
|
+
cwd: cwd || process.cwd(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const durationMs = Date.now() - startTime;
|
|
100
|
+
|
|
101
|
+
// Parse JSONL output
|
|
102
|
+
const messages = (proc.stdout || '')
|
|
103
|
+
.split('\n')
|
|
104
|
+
.filter(l => l.trim())
|
|
105
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
|
|
108
|
+
const agentMessages = messages
|
|
109
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
|
|
110
|
+
.map(m => m.item.text);
|
|
111
|
+
|
|
112
|
+
const usage = messages.find(m => m.type === 'turn.completed')?.usage;
|
|
113
|
+
const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
|
|
114
|
+
|
|
115
|
+
// Detect changed files from command_execution items
|
|
116
|
+
const commands = messages
|
|
117
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
|
|
118
|
+
.map(m => m.item);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
success: proc.status === 0 && errors.length === 0,
|
|
122
|
+
summary: agentMessages.join('\n\n'),
|
|
123
|
+
durationMs,
|
|
124
|
+
model,
|
|
125
|
+
usage: usage || null,
|
|
126
|
+
errors: errors.map(e => e.message || e.error?.message || 'unknown'),
|
|
127
|
+
commands: commands.length,
|
|
128
|
+
exitCode: proc.status,
|
|
129
|
+
signal: proc.signal,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Usage logger
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function logUsageEvent(result, task) {
|
|
138
|
+
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
139
|
+
const entry = JSON.stringify({
|
|
140
|
+
schema_version: 2,
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
provider: 'openai',
|
|
143
|
+
tier: task.tier || 'execute',
|
|
144
|
+
tool: 'codex-exec',
|
|
145
|
+
model: result.model,
|
|
146
|
+
status: result.success ? 'ok' : 'error',
|
|
147
|
+
durationMs: result.durationMs,
|
|
148
|
+
input_tokens: result.usage?.input_tokens ?? null,
|
|
149
|
+
output_tokens: result.usage?.output_tokens ?? null,
|
|
150
|
+
session_id: process.env.CLAUDE_SESSION_ID || null,
|
|
151
|
+
dispatcher: 'gpt-work-dispatcher',
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
appendFileSync(logFile, entry + '\n');
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Main exported function
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export async function dispatchGptTask(task) {
|
|
163
|
+
const codexBin = findCodex();
|
|
164
|
+
if (!codexBin) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
error: 'Codex CLI not found. Install with: npm i -g @openai/codex && codex login',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const model = task.model || 'gpt-5.4';
|
|
172
|
+
const prompt = buildPrompt(task);
|
|
173
|
+
const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs);
|
|
174
|
+
logUsageEvent(result, task);
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// CLI argument parser
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function parseArgs(argv) {
|
|
183
|
+
const args = {};
|
|
184
|
+
let i = 0;
|
|
185
|
+
while (i < argv.length) {
|
|
186
|
+
const arg = argv[i];
|
|
187
|
+
if (arg.startsWith('--')) {
|
|
188
|
+
const eqIdx = arg.indexOf('=');
|
|
189
|
+
if (eqIdx !== -1) {
|
|
190
|
+
// --key=value form
|
|
191
|
+
const key = arg.slice(2, eqIdx);
|
|
192
|
+
const value = arg.slice(eqIdx + 1);
|
|
193
|
+
args[key] = value;
|
|
194
|
+
} else {
|
|
195
|
+
// --key value form
|
|
196
|
+
const key = arg.slice(2);
|
|
197
|
+
const next = argv[i + 1];
|
|
198
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
199
|
+
args[key] = next;
|
|
200
|
+
i++;
|
|
201
|
+
} else {
|
|
202
|
+
args[key] = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Normalize known fields
|
|
210
|
+
if (typeof args.files === 'string') {
|
|
211
|
+
args.files = args.files.split(',').map(f => f.trim()).filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
if (typeof args.constraints === 'string') {
|
|
214
|
+
args.constraints = args.constraints.split(',').map(c => c.trim()).filter(Boolean);
|
|
215
|
+
}
|
|
216
|
+
if (args.timeout !== undefined) {
|
|
217
|
+
args.timeoutMs = Number(args.timeout) * 1000;
|
|
218
|
+
delete args.timeout;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return args;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// CLI entry point
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
229
|
+
const rawArgs = parseArgs(process.argv.slice(2));
|
|
230
|
+
|
|
231
|
+
if (!rawArgs.task) {
|
|
232
|
+
console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--model gpt-5.4] [--files file1,file2] [--timeout 120]');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = await dispatchGptTask(rawArgs);
|
|
237
|
+
|
|
238
|
+
if (result.success) {
|
|
239
|
+
console.log('\n╔══════════════════════════════════════════════════╗');
|
|
240
|
+
console.log('║ GPT Task Completed ║');
|
|
241
|
+
console.log('╠══════════════════════════════════════════════════╣');
|
|
242
|
+
if (result.summary) {
|
|
243
|
+
console.log(result.summary);
|
|
244
|
+
}
|
|
245
|
+
console.log('╠══════════════════════════════════════════════════╣');
|
|
246
|
+
console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
247
|
+
console.log('╚══════════════════════════════════════════════════╝');
|
|
248
|
+
} else {
|
|
249
|
+
console.error('Task failed:', result.errors?.join(', ') || result.error);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Also output JSON for piping
|
|
253
|
+
process.stdout.write('\n' + JSON.stringify(result) + '\n');
|
|
254
|
+
}
|