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.
@@ -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
+ }