dual-brain 0.1.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/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
|
@@ -0,0 +1,512 @@
|
|
|
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
|
+
* --tier execute \
|
|
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, tier, forceModel, files, constraints, timeoutMs });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'child_process';
|
|
21
|
+
import { appendFileSync, readFileSync } from 'fs';
|
|
22
|
+
import { dirname, join } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
27
|
+
const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
|
|
28
|
+
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
|
|
29
|
+
const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
|
|
30
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
31
|
+
const GPT_TIER_SANDBOX = IS_REPLIT
|
|
32
|
+
? { search: 'danger-full-access', execute: 'danger-full-access', think: 'danger-full-access' }
|
|
33
|
+
: { search: 'read-only', execute: 'danger-full-access', think: 'read-only' };
|
|
34
|
+
const GPT_TIER_PROMPTS = {
|
|
35
|
+
search: 'You are a READ-ONLY search agent. Do NOT edit files.',
|
|
36
|
+
execute: 'You are an execution agent. Edit files directly.',
|
|
37
|
+
think: 'You are an architecture/review agent. Analyze and recommend, do not edit unless explicitly asked.',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Codex discovery — mirrors dual-brain-review.mjs
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function findCodex() {
|
|
45
|
+
const candidates = [
|
|
46
|
+
process.env.CODEX_BIN,
|
|
47
|
+
].filter(Boolean);
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
try { spawnSync(c, ['--version'], { stdio: 'pipe', timeout: 3000 }); return c; } catch {}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const which = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
|
|
53
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
54
|
+
} catch {}
|
|
55
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
56
|
+
const fallbacks = [
|
|
57
|
+
join(home, '.local', 'bin', 'codex'),
|
|
58
|
+
join(home, 'bin', 'codex'),
|
|
59
|
+
'/usr/local/bin/codex',
|
|
60
|
+
];
|
|
61
|
+
for (const p of fallbacks) {
|
|
62
|
+
try { spawnSync(p, ['--version'], { stdio: 'pipe', timeout: 3000 }); return p; } catch {}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isCodexAuthenticated(result) {
|
|
68
|
+
const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
|
|
69
|
+
if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
|
|
70
|
+
return result?.status === 0 ||
|
|
71
|
+
/\b(logged\s+in|authenticated|signed\s+in)\b/.test(out);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Prompt builder
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function normalizeTier(tier) {
|
|
79
|
+
return ['search', 'execute', 'think'].includes(tier) ? tier : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadOrchestratorConfig() {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function classifyGptTier(task) {
|
|
91
|
+
const text = [
|
|
92
|
+
task?.task,
|
|
93
|
+
...(Array.isArray(task?.constraints) ? task.constraints : []),
|
|
94
|
+
]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ');
|
|
97
|
+
|
|
98
|
+
if (THINK_WORDS.test(text)) return 'think';
|
|
99
|
+
if (EXECUTE_WORDS.test(text)) return 'execute';
|
|
100
|
+
if (SEARCH_WORDS.test(text)) return 'search';
|
|
101
|
+
return 'execute';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveGptModel(tier, config = loadOrchestratorConfig()) {
|
|
105
|
+
const normalizedTier = normalizeTier(tier);
|
|
106
|
+
if (!normalizedTier) return null;
|
|
107
|
+
|
|
108
|
+
const models = config?.subscriptions?.openai?.models ?? {};
|
|
109
|
+
for (const [model, meta] of Object.entries(models)) {
|
|
110
|
+
if (meta?.tier === normalizedTier) return model;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (normalizedTier === 'think') return 'gpt-5.5';
|
|
114
|
+
if (normalizedTier === 'search') return 'gpt-4.1-mini';
|
|
115
|
+
return 'gpt-5.4';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildPrompt(task) {
|
|
119
|
+
const tierInstruction = GPT_TIER_PROMPTS[task.tier] || GPT_TIER_PROMPTS.execute;
|
|
120
|
+
let prompt = `You are a GPT execution agent inside the Dual-Brain Orchestrator.
|
|
121
|
+
|
|
122
|
+
Task: ${task.task}
|
|
123
|
+
|
|
124
|
+
${tierInstruction}
|
|
125
|
+
|
|
126
|
+
Own this task completely.
|
|
127
|
+
|
|
128
|
+
`;
|
|
129
|
+
if (task.files?.length) {
|
|
130
|
+
prompt += `Relevant files:\n${task.files.map(f => `- ${f}`).join('\n')}\n\n`;
|
|
131
|
+
}
|
|
132
|
+
if (task.constraints?.length) {
|
|
133
|
+
prompt += `Constraints:\n${task.constraints.map(c => `- ${c}`).join('\n')}\n\n`;
|
|
134
|
+
}
|
|
135
|
+
prompt += `When done, output a summary of:
|
|
136
|
+
1. What you changed (files and behavior)
|
|
137
|
+
2. Tests run and results (if applicable)
|
|
138
|
+
3. Remaining risks or edge cases
|
|
139
|
+
4. Any assumptions you made`;
|
|
140
|
+
return prompt;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Codex executor
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function classifyCodexFailure(proc) {
|
|
148
|
+
let failureType = null;
|
|
149
|
+
|
|
150
|
+
if (proc.error?.code === 'ETIMEDOUT') {
|
|
151
|
+
failureType = 'timeout';
|
|
152
|
+
} else if (proc.error?.code === 'ENOENT') {
|
|
153
|
+
failureType = 'not_found';
|
|
154
|
+
} else if (proc.error) {
|
|
155
|
+
failureType = 'spawn_error';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const stderr = (proc.stderr || '').toLowerCase();
|
|
159
|
+
if (stderr.includes('unauthorized') || stderr.includes('401') || stderr.includes('not logged in')) {
|
|
160
|
+
failureType = 'auth';
|
|
161
|
+
} else if (stderr.includes('rate limit') || stderr.includes('429') || stderr.includes('too many')) {
|
|
162
|
+
failureType = 'rate_limit';
|
|
163
|
+
} else if (stderr.includes('timeout') || stderr.includes('timed out')) {
|
|
164
|
+
failureType = 'timeout';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return failureType;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort) {
|
|
171
|
+
const args = [
|
|
172
|
+
'exec', '--json', '--ephemeral',
|
|
173
|
+
'-m', model,
|
|
174
|
+
'-s', sandbox,
|
|
175
|
+
];
|
|
176
|
+
if (effort && ['low', 'medium', 'high', 'xhigh'].includes(effort)) {
|
|
177
|
+
args.push('-c', `reasoning.effort="${effort}"`);
|
|
178
|
+
}
|
|
179
|
+
args.push(prompt);
|
|
180
|
+
return spawnSync(codexBin, args, {
|
|
181
|
+
encoding: 'utf8',
|
|
182
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
183
|
+
timeout: timeoutMs || 120000,
|
|
184
|
+
cwd: cwd || process.cwd(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access', effort = null) {
|
|
189
|
+
const startTime = Date.now();
|
|
190
|
+
|
|
191
|
+
function finalizeAttempt(proc, attemptStartTime, attemptCount) {
|
|
192
|
+
const durationMs = Date.now() - attemptStartTime;
|
|
193
|
+
const failureType = classifyCodexFailure(proc);
|
|
194
|
+
|
|
195
|
+
// Parse JSONL output
|
|
196
|
+
const messages = (proc.stdout || '')
|
|
197
|
+
.split('\n')
|
|
198
|
+
.filter(l => l.trim())
|
|
199
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
200
|
+
.filter(Boolean);
|
|
201
|
+
|
|
202
|
+
const agentMessages = messages
|
|
203
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
|
|
204
|
+
.map(m => m.item.text);
|
|
205
|
+
|
|
206
|
+
const usage = messages.find(m => m.type === 'turn.completed')?.usage;
|
|
207
|
+
const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
|
|
208
|
+
const errorMessages = errors.map(e => e.message || e.error?.message || 'unknown');
|
|
209
|
+
|
|
210
|
+
if (proc.error?.message) {
|
|
211
|
+
errorMessages.unshift(proc.error.message);
|
|
212
|
+
}
|
|
213
|
+
if (proc.stderr?.trim() && errorMessages.length === 0 && proc.status !== 0) {
|
|
214
|
+
errorMessages.push(proc.stderr.trim().slice(0, 200));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Detect changed files from command_execution items
|
|
218
|
+
const commands = messages
|
|
219
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
|
|
220
|
+
.map(m => m.item);
|
|
221
|
+
|
|
222
|
+
// Estimate startup time: time to first agent message or completed item
|
|
223
|
+
const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
|
|
224
|
+
let startupMs = null;
|
|
225
|
+
if (firstItemTs) {
|
|
226
|
+
startupMs = Date.parse(firstItemTs) - attemptStartTime;
|
|
227
|
+
if (startupMs < 0 || startupMs > durationMs) startupMs = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
success: proc.status === 0 && errors.length === 0 && !failureType,
|
|
232
|
+
summary: agentMessages.join('\n\n'),
|
|
233
|
+
durationMs,
|
|
234
|
+
startupMs,
|
|
235
|
+
model,
|
|
236
|
+
usage: usage || null,
|
|
237
|
+
errors: errorMessages,
|
|
238
|
+
commands: commands.length,
|
|
239
|
+
exitCode: proc.status,
|
|
240
|
+
signal: proc.signal,
|
|
241
|
+
failureType: failureType || null,
|
|
242
|
+
stderrSummary: proc.stderr?.trim().slice(0, 200) || null,
|
|
243
|
+
spawnErrorMessage: proc.error?.message || null,
|
|
244
|
+
retryCount: attemptCount - 1,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let attemptCount = 1;
|
|
249
|
+
let attemptStartTime = startTime;
|
|
250
|
+
let proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort);
|
|
251
|
+
let result = finalizeAttempt(proc, attemptStartTime, attemptCount);
|
|
252
|
+
|
|
253
|
+
if (!result.success && (result.failureType === 'rate_limit' || result.failureType === 'timeout')) {
|
|
254
|
+
spawnSync('sleep', ['3'], { stdio: 'ignore' });
|
|
255
|
+
attemptCount += 1;
|
|
256
|
+
attemptStartTime = Date.now();
|
|
257
|
+
proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox, effort);
|
|
258
|
+
result = finalizeAttempt(proc, attemptStartTime, attemptCount);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Usage logger
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
function loadActiveProfile() {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(readFileSync(join(__dirname, '..', 'dual-brain.profile.json'), 'utf8')).active || 'balanced';
|
|
271
|
+
} catch { return 'balanced'; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
|
|
275
|
+
|
|
276
|
+
function logUsageEvent(result, task) {
|
|
277
|
+
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
278
|
+
const entryObj = {
|
|
279
|
+
schema_version: 4,
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
provider: 'openai',
|
|
282
|
+
tier: task.tier || 'execute',
|
|
283
|
+
classified_tier: task.classifiedTier || task.tier || 'execute',
|
|
284
|
+
tool: 'codex-exec',
|
|
285
|
+
model: result.model,
|
|
286
|
+
model_override: task.modelOverride || null,
|
|
287
|
+
status: result.success ? 'ok' : 'error',
|
|
288
|
+
durationMs: result.durationMs,
|
|
289
|
+
codex_startup_ms: result.startupMs || null,
|
|
290
|
+
codex_total_ms: result.durationMs,
|
|
291
|
+
input_tokens: result.usage?.input_tokens ?? null,
|
|
292
|
+
output_tokens: result.usage?.output_tokens ?? null,
|
|
293
|
+
session_id: SESSION_ID,
|
|
294
|
+
profile: result.profile || 'balanced',
|
|
295
|
+
dispatcher: 'gpt-work-dispatcher',
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
appendFileSync(logFile, JSON.stringify(entryObj) + '\n');
|
|
299
|
+
} catch {}
|
|
300
|
+
|
|
301
|
+
// Update summary checkpoint with codex latency
|
|
302
|
+
import('./summary-checkpoint.mjs').then(({ updateSummary }) => {
|
|
303
|
+
updateSummary(entryObj);
|
|
304
|
+
}).catch(() => {});
|
|
305
|
+
|
|
306
|
+
// Record to decision ledger
|
|
307
|
+
import('./decision-ledger.mjs').then(({ recordDecision, recordOutcome }) => {
|
|
308
|
+
const id = recordDecision({
|
|
309
|
+
session_id: SESSION_ID,
|
|
310
|
+
profile: entryObj.profile,
|
|
311
|
+
tier: task.tier || 'execute',
|
|
312
|
+
provider: 'openai',
|
|
313
|
+
model: result.model,
|
|
314
|
+
});
|
|
315
|
+
recordOutcome(id, {
|
|
316
|
+
actual_duration_ms: result.durationMs,
|
|
317
|
+
codex_startup_ms: result.startupMs || null,
|
|
318
|
+
success: result.success,
|
|
319
|
+
actual_input_tokens: result.usage?.input_tokens || null,
|
|
320
|
+
actual_output_tokens: result.usage?.output_tokens || null,
|
|
321
|
+
});
|
|
322
|
+
}).catch(() => {});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Main exported function
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
function tryHealCodexAuth(codexBin) {
|
|
330
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
331
|
+
if (!apiKey) return false;
|
|
332
|
+
const pipe = spawnSync(codexBin, ['login', '--with-api-key'], {
|
|
333
|
+
input: apiKey,
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
336
|
+
timeout: 10000,
|
|
337
|
+
});
|
|
338
|
+
return pipe.status === 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function dispatchGptTask(task) {
|
|
342
|
+
const codexBin = findCodex();
|
|
343
|
+
if (!codexBin) {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
error: 'Codex CLI not found. Install with: npm i -g @openai/codex && codex login',
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Pre-flight: check auth and heal if possible
|
|
351
|
+
const loginCheck = spawnSync(codexBin, ['login', 'status'], {
|
|
352
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
|
|
353
|
+
});
|
|
354
|
+
const isAuthed = isCodexAuthenticated(loginCheck);
|
|
355
|
+
if (!isAuthed) {
|
|
356
|
+
const healed = tryHealCodexAuth(codexBin);
|
|
357
|
+
if (!healed) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: 'Codex not authenticated. Run: npx dual-brain (sign in with your ChatGPT subscription) or codex login --device-auth',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const config = loadOrchestratorConfig();
|
|
366
|
+
const classifiedTier = classifyGptTier(task);
|
|
367
|
+
const explicitTier = normalizeTier(task.tier);
|
|
368
|
+
const tier = explicitTier || classifiedTier;
|
|
369
|
+
const expectedModel = resolveGptModel(tier, config) || 'gpt-5.4';
|
|
370
|
+
|
|
371
|
+
let model = task.model || expectedModel;
|
|
372
|
+
let modelOverride = null;
|
|
373
|
+
|
|
374
|
+
if (task.model && !task.forceModel && task.model !== expectedModel) {
|
|
375
|
+
console.warn(`[gpt-work-dispatcher] Warning: task classified as "${tier}", overriding requested model "${task.model}" with "${expectedModel}". Use --force-model to bypass.`);
|
|
376
|
+
model = expectedModel;
|
|
377
|
+
modelOverride = {
|
|
378
|
+
requested: task.model,
|
|
379
|
+
effective: expectedModel,
|
|
380
|
+
forced: false,
|
|
381
|
+
reason: `tier:${tier}`,
|
|
382
|
+
};
|
|
383
|
+
} else if (!task.model) {
|
|
384
|
+
modelOverride = {
|
|
385
|
+
requested: null,
|
|
386
|
+
effective: expectedModel,
|
|
387
|
+
forced: false,
|
|
388
|
+
reason: `auto-select:${tier}`,
|
|
389
|
+
};
|
|
390
|
+
} else if (task.forceModel) {
|
|
391
|
+
modelOverride = {
|
|
392
|
+
requested: task.model,
|
|
393
|
+
effective: task.model,
|
|
394
|
+
forced: true,
|
|
395
|
+
reason: `force-model:${tier}`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const preparedTask = {
|
|
400
|
+
...task,
|
|
401
|
+
tier,
|
|
402
|
+
classifiedTier,
|
|
403
|
+
modelOverride,
|
|
404
|
+
};
|
|
405
|
+
const prompt = buildPrompt(preparedTask);
|
|
406
|
+
const sandbox = GPT_TIER_SANDBOX[tier] || GPT_TIER_SANDBOX.execute;
|
|
407
|
+
const effort = task.effort || null;
|
|
408
|
+
const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs, sandbox, effort);
|
|
409
|
+
result.tier = tier;
|
|
410
|
+
result.classifiedTier = classifiedTier;
|
|
411
|
+
result.modelOverride = modelOverride;
|
|
412
|
+
result.effort = effort;
|
|
413
|
+
result.sandbox = sandbox;
|
|
414
|
+
result.profile = loadActiveProfile();
|
|
415
|
+
logUsageEvent(result, preparedTask);
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// CLI argument parser
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
function parseArgs(argv) {
|
|
424
|
+
const args = {};
|
|
425
|
+
let i = 0;
|
|
426
|
+
while (i < argv.length) {
|
|
427
|
+
const arg = argv[i];
|
|
428
|
+
if (arg.startsWith('--')) {
|
|
429
|
+
const eqIdx = arg.indexOf('=');
|
|
430
|
+
if (eqIdx !== -1) {
|
|
431
|
+
// --key=value form
|
|
432
|
+
const key = arg.slice(2, eqIdx);
|
|
433
|
+
const value = arg.slice(eqIdx + 1);
|
|
434
|
+
args[key] = value;
|
|
435
|
+
} else {
|
|
436
|
+
// --key value form
|
|
437
|
+
const key = arg.slice(2);
|
|
438
|
+
const next = argv[i + 1];
|
|
439
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
440
|
+
args[key] = next;
|
|
441
|
+
i++;
|
|
442
|
+
} else {
|
|
443
|
+
args[key] = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
i++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Normalize known fields
|
|
451
|
+
if (typeof args.files === 'string') {
|
|
452
|
+
args.files = args.files.split(',').map(f => f.trim()).filter(Boolean);
|
|
453
|
+
}
|
|
454
|
+
if (typeof args.constraints === 'string') {
|
|
455
|
+
args.constraints = args.constraints.split(',').map(c => c.trim()).filter(Boolean);
|
|
456
|
+
}
|
|
457
|
+
if (args.timeout !== undefined) {
|
|
458
|
+
args.timeoutMs = Number(args.timeout) * 1000;
|
|
459
|
+
delete args.timeout;
|
|
460
|
+
}
|
|
461
|
+
if (typeof args['force-model'] === 'boolean') {
|
|
462
|
+
args.forceModel = args['force-model'];
|
|
463
|
+
delete args['force-model'];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return args;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// CLI entry point
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
474
|
+
const rawArgs = parseArgs(process.argv.slice(2));
|
|
475
|
+
|
|
476
|
+
if (!rawArgs.task) {
|
|
477
|
+
console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--tier think|execute|search] [--model MODEL] [--force-model] [--files file1,file2] [--timeout 120] [--effort low|medium|high|xhigh]');
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const result = await dispatchGptTask(rawArgs);
|
|
482
|
+
|
|
483
|
+
if (result.success) {
|
|
484
|
+
console.log('\n╔══════════════════════════════════════════════════╗');
|
|
485
|
+
console.log('║ GPT Task Completed ║');
|
|
486
|
+
console.log('╠══════════════════════════════════════════════════╣');
|
|
487
|
+
if (result.summary) {
|
|
488
|
+
console.log(result.summary);
|
|
489
|
+
}
|
|
490
|
+
console.log('╠══════════════════════════════════════════════════╣');
|
|
491
|
+
console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
492
|
+
console.log('╚══════════════════════════════════════════════════╝');
|
|
493
|
+
} else {
|
|
494
|
+
if (result.failureType) {
|
|
495
|
+
const friendlyMessage = {
|
|
496
|
+
auth: 'Codex not authenticated. Run: codex login --device-auth',
|
|
497
|
+
rate_limit: 'Rate limited by OpenAI. Try again in a few minutes.',
|
|
498
|
+
timeout: 'Codex timed out. Try a simpler task or increase timeout.',
|
|
499
|
+
not_found: 'Codex CLI not found. Run: npm i -g @openai/codex',
|
|
500
|
+
spawn_error: `Failed to start Codex: ${result.spawnErrorMessage || 'unknown spawn error'}`,
|
|
501
|
+
}[result.failureType];
|
|
502
|
+
|
|
503
|
+
if (friendlyMessage) {
|
|
504
|
+
console.error(friendlyMessage);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
console.error('Task failed:', result.errors?.join(', ') || result.error);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Also output JSON for piping
|
|
511
|
+
process.stdout.write('\n' + JSON.stringify(result) + '\n');
|
|
512
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// head-guard.mjs — Blocks HEAD from using mutation tools.
|
|
3
|
+
// Reads Claude Code hook stdin JSON protocol (PreToolUse event).
|
|
4
|
+
//
|
|
5
|
+
// Protocol (Claude Code sends this on stdin):
|
|
6
|
+
// { session_id, hook_event_name, tool_name, tool_input,
|
|
7
|
+
// tool_use_id, agent_id?, agent_type? }
|
|
8
|
+
//
|
|
9
|
+
// Exit behaviour:
|
|
10
|
+
// exit 0 → allow
|
|
11
|
+
// exit 2 + stdout JSON → block (permissionDecision: "deny")
|
|
12
|
+
//
|
|
13
|
+
// Key insight: `agent_id` is present when the hook fires inside a spawned
|
|
14
|
+
// subagent (work agent). If absent we are in the HEAD session.
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'fs';
|
|
17
|
+
|
|
18
|
+
const BLOCKED_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
19
|
+
|
|
20
|
+
// Patterns that indicate a Bash command is writing/mutating the filesystem.
|
|
21
|
+
// Anchored to avoid false positives on grep/find output containing these words.
|
|
22
|
+
const WRITE_BASH_RE = /\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\btouch\b|\bchmod\b|\bchown\b|\bdd\b|\binstall\b|\btruncate\b|\btee\b|\bsed\s+-i\b|\bawk\s+-i\b|>>|(?<![><])>(?![>=])/;
|
|
23
|
+
|
|
24
|
+
function isBashWriteIntent(command) {
|
|
25
|
+
return WRITE_BASH_RE.test(command);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read stdin JSON payload
|
|
29
|
+
let input;
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync('/dev/stdin', 'utf8');
|
|
32
|
+
input = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
// Can't parse input — fail closed to avoid guard bypass.
|
|
35
|
+
const output = {
|
|
36
|
+
hookSpecificOutput: {
|
|
37
|
+
hookEventName: 'PreToolUse',
|
|
38
|
+
permissionDecision: 'deny',
|
|
39
|
+
permissionDecisionReason: '[dual-brain] head-guard could not parse hook input — blocking as a safety measure.',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
process.stdout.write(JSON.stringify(output));
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toolName = input.tool_name || '';
|
|
47
|
+
|
|
48
|
+
// If this hook is firing inside a subagent, ALLOW — subagents are work agents
|
|
49
|
+
// and are permitted to edit/write/bash.
|
|
50
|
+
if (input.agent_id) {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// HEAD session: block direct mutation tools
|
|
55
|
+
if (BLOCKED_TOOLS.has(toolName)) {
|
|
56
|
+
const output = {
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName: 'PreToolUse',
|
|
59
|
+
permissionDecision: 'deny',
|
|
60
|
+
permissionDecisionReason:
|
|
61
|
+
`[dual-brain] HEAD cannot use ${toolName} directly. Dispatch via: dual-brain go "task description"`,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
process.stdout.write(JSON.stringify(output));
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Bash: allow read-only commands; block write-intent ones.
|
|
69
|
+
// Always allow node .claude/hooks/ and node hooks/ — CLAUDE.md instructs HEAD to run these.
|
|
70
|
+
if (toolName === 'Bash') {
|
|
71
|
+
const command = (input.tool_input && input.tool_input.command) || '';
|
|
72
|
+
if (/^node\s+\.?(?:\.claude\/)?hooks\//.test(command.trimStart())) {
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
if (isBashWriteIntent(command)) {
|
|
76
|
+
const output = {
|
|
77
|
+
hookSpecificOutput: {
|
|
78
|
+
hookEventName: 'PreToolUse',
|
|
79
|
+
permissionDecision: 'deny',
|
|
80
|
+
permissionDecisionReason:
|
|
81
|
+
'[dual-brain] HEAD cannot run write-intent Bash commands. Dispatch via: dual-brain go "task description"',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
process.stdout.write(JSON.stringify(output));
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Block MCP filesystem write tools by name.
|
|
91
|
+
if (toolName.startsWith('mcp__') && /write|create|delete|remove|move|rename|append|patch|truncate|copy|commit|push|stage|merge|update|overwrite/i.test(toolName)) {
|
|
92
|
+
const output = {
|
|
93
|
+
hookSpecificOutput: {
|
|
94
|
+
hookEventName: 'PreToolUse',
|
|
95
|
+
permissionDecision: 'deny',
|
|
96
|
+
permissionDecisionReason:
|
|
97
|
+
'[dual-brain] HEAD cannot use MCP write tools. Dispatch via: dual-brain go "task description"',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
process.stdout.write(JSON.stringify(output));
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Allow everything else (Read, Agent handled by enforce-tier, etc.)
|
|
105
|
+
process.exit(0);
|