dual-brain 0.2.30 → 0.3.1
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/.dual-brain/docs/claude-code-extension-points.md +32 -0
- package/.dual-brain/docs/data-tools-capabilities.md +181 -0
- package/.dual-brain/docs/ecosystem-tools.md +91 -0
- package/.dual-brain/docs/panel-handoff.md +124 -0
- package/.dual-brain/docs/ruflo-analysis.md +48 -0
- package/bin/dual-brain.mjs +56 -56
- package/dist/mcp-server/index.d.ts +27 -0
- package/dist/mcp-server/index.js +359 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/agent-protocol.d.ts +163 -0
- package/dist/src/agent-protocol.js +368 -0
- package/dist/src/agent-protocol.js.map +1 -0
- package/dist/src/agents/registry.d.ts +52 -0
- package/dist/src/agents/registry.js +393 -0
- package/dist/src/agents/registry.js.map +1 -0
- package/dist/src/awareness.d.ts +93 -0
- package/dist/src/awareness.js +406 -0
- package/dist/src/awareness.js.map +1 -0
- package/dist/src/brief.d.ts +48 -0
- package/dist/src/brief.js +179 -0
- package/dist/src/brief.js.map +1 -0
- package/dist/src/calibration.d.ts +32 -0
- package/dist/src/calibration.js +133 -0
- package/dist/src/calibration.js.map +1 -0
- package/dist/src/checkpoint.d.ts +33 -0
- package/dist/src/checkpoint.js +99 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/ci-triage.d.ts +33 -0
- package/dist/src/ci-triage.js +193 -0
- package/dist/src/ci-triage.js.map +1 -0
- package/dist/src/cognitive-loop.d.ts +56 -0
- package/dist/src/cognitive-loop.js +495 -0
- package/dist/src/cognitive-loop.js.map +1 -0
- package/dist/src/collaboration.d.ts +147 -0
- package/dist/src/collaboration.js +438 -0
- package/dist/src/collaboration.js.map +1 -0
- package/dist/src/context-intel.d.ts +47 -0
- package/dist/src/context-intel.js +156 -0
- package/dist/src/context-intel.js.map +1 -0
- package/dist/src/context.d.ts +53 -0
- package/dist/src/context.js +332 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/continuity.d.ts +89 -0
- package/dist/src/continuity.js +230 -0
- package/dist/src/continuity.js.map +1 -0
- package/dist/src/cost-tracker.d.ts +47 -0
- package/dist/src/cost-tracker.js +170 -0
- package/dist/src/cost-tracker.js.map +1 -0
- package/dist/src/debrief.d.ts +53 -0
- package/dist/src/debrief.js +222 -0
- package/dist/src/debrief.js.map +1 -0
- package/dist/src/decide.d.ts +96 -0
- package/dist/src/decide.js +744 -0
- package/dist/src/decide.js.map +1 -0
- package/dist/src/decompose.d.ts +39 -0
- package/dist/src/decompose.js +218 -0
- package/dist/src/decompose.js.map +1 -0
- package/dist/src/detect.d.ts +91 -0
- package/dist/src/detect.js +544 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/dispatch.d.ts +154 -0
- package/dist/src/dispatch.js +1306 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/doctor.d.ts +421 -0
- package/dist/src/doctor.js +1689 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/engine.d.ts +70 -0
- package/dist/src/engine.js +155 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/envelope.d.ts +36 -0
- package/dist/src/envelope.js +80 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/failure-memory.d.ts +55 -0
- package/dist/src/failure-memory.js +175 -0
- package/dist/src/failure-memory.js.map +1 -0
- package/dist/src/fx.d.ts +87 -0
- package/dist/src/fx.js +272 -0
- package/dist/src/fx.js.map +1 -0
- package/dist/src/governance.d.ts +93 -0
- package/dist/src/governance.js +261 -0
- package/dist/src/governance.js.map +1 -0
- package/dist/src/handoff.d.ts +11 -0
- package/dist/src/handoff.js +90 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/head-protocol.d.ts +76 -0
- package/dist/src/head-protocol.js +109 -0
- package/dist/src/head-protocol.js.map +1 -0
- package/dist/src/head.d.ts +222 -0
- package/dist/src/head.js +765 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/health.d.ts +132 -0
- package/dist/src/health.js +435 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/inbox.d.ts +70 -0
- package/dist/src/inbox.js +218 -0
- package/dist/src/inbox.js.map +1 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +38 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install-hooks.d.ts +13 -0
- package/dist/src/install-hooks.js +88 -0
- package/dist/src/install-hooks.js.map +1 -0
- package/dist/src/integrity.d.ts +59 -0
- package/dist/src/integrity.js +206 -0
- package/dist/src/integrity.js.map +1 -0
- package/dist/src/intelligence.d.ts +104 -0
- package/dist/src/intelligence.js +391 -0
- package/dist/src/intelligence.js.map +1 -0
- package/dist/src/ledger.d.ts +54 -0
- package/dist/src/ledger.js +179 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/living-docs.d.ts +14 -0
- package/dist/src/living-docs.js +197 -0
- package/dist/src/living-docs.js.map +1 -0
- package/dist/src/memory-tiers.d.ts +37 -0
- package/dist/src/memory-tiers.js +160 -0
- package/dist/src/memory-tiers.js.map +1 -0
- package/dist/src/model-profiles.d.ts +65 -0
- package/dist/src/model-profiles.js +568 -0
- package/dist/src/model-profiles.js.map +1 -0
- package/dist/src/models.d.ts +58 -0
- package/dist/src/models.js +327 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/narrative.d.ts +54 -0
- package/dist/src/narrative.js +163 -0
- package/dist/src/narrative.js.map +1 -0
- package/dist/src/nextstep.d.ts +16 -0
- package/dist/src/nextstep.js +103 -0
- package/dist/src/nextstep.js.map +1 -0
- package/dist/src/observer.d.ts +18 -0
- package/dist/src/observer.js +251 -0
- package/dist/src/observer.js.map +1 -0
- package/dist/src/outcome.d.ts +110 -0
- package/dist/src/outcome.js +377 -0
- package/dist/src/outcome.js.map +1 -0
- package/dist/src/pipeline.d.ts +167 -0
- package/dist/src/pipeline.js +1503 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/playbook.d.ts +59 -0
- package/dist/src/playbook.js +238 -0
- package/dist/src/playbook.js.map +1 -0
- package/dist/src/pr-agent.d.ts +97 -0
- package/dist/src/pr-agent.js +195 -0
- package/dist/src/pr-agent.js.map +1 -0
- package/dist/src/predictive.d.ts +57 -0
- package/dist/src/predictive.js +230 -0
- package/dist/src/predictive.js.map +1 -0
- package/dist/src/profile.d.ts +294 -0
- package/dist/src/profile.js +1347 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/prompt-audit.d.ts +22 -0
- package/dist/src/prompt-audit.js +194 -0
- package/dist/src/prompt-audit.js.map +1 -0
- package/dist/src/prompt-intel.d.ts +12 -0
- package/dist/src/prompt-intel.js +321 -0
- package/dist/src/prompt-intel.js.map +1 -0
- package/dist/src/provider-context.d.ts +121 -0
- package/dist/src/provider-context.js +222 -0
- package/dist/src/provider-context.js.map +1 -0
- package/dist/src/provider-manager.d.ts +92 -0
- package/dist/src/provider-manager.js +428 -0
- package/dist/src/provider-manager.js.map +1 -0
- package/dist/src/receipt.d.ts +87 -0
- package/dist/src/receipt.js +326 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/recommendations.d.ts +13 -0
- package/dist/src/recommendations.js +291 -0
- package/dist/src/recommendations.js.map +1 -0
- package/dist/src/redact.d.ts +15 -0
- package/dist/src/redact.js +129 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/replit.d.ts +397 -0
- package/dist/src/replit.js +1160 -0
- package/dist/src/replit.js.map +1 -0
- package/dist/src/repo.d.ts +149 -0
- package/dist/src/repo.js +416 -0
- package/dist/src/repo.js.map +1 -0
- package/dist/src/revert.d.ts +30 -0
- package/dist/src/revert.js +166 -0
- package/dist/src/revert.js.map +1 -0
- package/dist/src/room.d.ts +102 -0
- package/dist/src/room.js +212 -0
- package/dist/src/room.js.map +1 -0
- package/dist/src/routing-advisor.d.ts +57 -0
- package/dist/src/routing-advisor.js +221 -0
- package/dist/src/routing-advisor.js.map +1 -0
- package/dist/src/self-correct.d.ts +40 -0
- package/dist/src/self-correct.js +137 -0
- package/dist/src/self-correct.js.map +1 -0
- package/dist/src/session-lock.d.ts +35 -0
- package/dist/src/session-lock.js +134 -0
- package/dist/src/session-lock.js.map +1 -0
- package/dist/src/session.d.ts +267 -0
- package/dist/src/session.js +1660 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/settings-tui.d.ts +5 -0
- package/dist/src/settings-tui.js +422 -0
- package/dist/src/settings-tui.js.map +1 -0
- package/dist/src/setup-flow.d.ts +63 -0
- package/dist/src/setup-flow.js +233 -0
- package/dist/src/setup-flow.js.map +1 -0
- package/dist/src/signal.d.ts +19 -0
- package/dist/src/signal.js +122 -0
- package/dist/src/signal.js.map +1 -0
- package/dist/src/simmer.d.ts +85 -0
- package/dist/src/simmer.js +224 -0
- package/dist/src/simmer.js.map +1 -0
- package/dist/src/state-export.d.ts +129 -0
- package/dist/src/state-export.js +233 -0
- package/dist/src/state-export.js.map +1 -0
- package/dist/src/strategy.d.ts +54 -0
- package/dist/src/strategy.js +95 -0
- package/dist/src/strategy.js.map +1 -0
- package/dist/src/subscription.d.ts +40 -0
- package/dist/src/subscription.js +189 -0
- package/dist/src/subscription.js.map +1 -0
- package/dist/src/templates.d.ts +208 -0
- package/dist/src/templates.js +238 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/test.d.ts +9 -0
- package/dist/src/test.js +1173 -0
- package/dist/src/test.js.map +1 -0
- package/dist/src/think-engine.d.ts +67 -0
- package/dist/src/think-engine.js +412 -0
- package/dist/src/think-engine.js.map +1 -0
- package/dist/src/tui.d.ts +71 -0
- package/dist/src/tui.js +242 -0
- package/dist/src/tui.js.map +1 -0
- package/dist/src/types.d.ts +177 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update-check.d.ts +7 -0
- package/dist/src/update-check.js +36 -0
- package/dist/src/update-check.js.map +1 -0
- package/dist/src/wave-planner.d.ts +30 -0
- package/dist/src/wave-planner.js +281 -0
- package/dist/src/wave-planner.js.map +1 -0
- package/hooks/head-guard.sh +41 -0
- package/hooks/precompact.mjs +3 -3
- package/hooks/session-end.mjs +3 -3
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/vibe-router.mjs +387 -0
- package/install.mjs +2 -2
- package/package.json +29 -153
- package/src/agents/registry.mjs +0 -405
- package/src/awareness.mjs +0 -425
- package/src/brief.mjs +0 -266
- package/src/calibration.mjs +0 -148
- package/src/checkpoint.mjs +0 -109
- package/src/ci-triage.mjs +0 -191
- package/src/cognitive-loop.mjs +0 -562
- package/src/collaboration.mjs +0 -545
- package/src/context-intel.mjs +0 -158
- package/src/context.mjs +0 -389
- package/src/continuity.mjs +0 -298
- package/src/cost-tracker.mjs +0 -184
- package/src/debrief.mjs +0 -228
- package/src/decide.mjs +0 -1099
- package/src/decompose.mjs +0 -331
- package/src/detect.mjs +0 -702
- package/src/dispatch.mjs +0 -1447
- package/src/doctor.mjs +0 -1607
- package/src/envelope.mjs +0 -139
- package/src/failure-memory.mjs +0 -178
- package/src/fx.mjs +0 -276
- package/src/governance.mjs +0 -279
- package/src/handoff.mjs +0 -87
- package/src/head-protocol.mjs +0 -128
- package/src/head.mjs +0 -952
- package/src/health.mjs +0 -528
- package/src/inbox.mjs +0 -195
- package/src/index.mjs +0 -44
- package/src/install-hooks.mjs +0 -100
- package/src/integrity.mjs +0 -245
- package/src/intelligence.mjs +0 -447
- package/src/ledger.mjs +0 -196
- package/src/living-docs.mjs +0 -210
- package/src/memory-tiers.mjs +0 -193
- package/src/models.mjs +0 -363
- package/src/narrative.mjs +0 -169
- package/src/nextstep.mjs +0 -100
- package/src/observer.mjs +0 -241
- package/src/outcome.mjs +0 -400
- package/src/pipeline.mjs +0 -1711
- package/src/playbook.mjs +0 -257
- package/src/pr-agent.mjs +0 -214
- package/src/predictive.mjs +0 -250
- package/src/profile.mjs +0 -1411
- package/src/prompt-audit.mjs +0 -231
- package/src/prompt-intel.mjs +0 -325
- package/src/provider-context.mjs +0 -257
- package/src/receipt.mjs +0 -344
- package/src/recommendations.mjs +0 -296
- package/src/redact.mjs +0 -192
- package/src/replit.mjs +0 -1210
- package/src/repo.mjs +0 -445
- package/src/revert.mjs +0 -149
- package/src/routing-advisor.mjs +0 -204
- package/src/self-correct.mjs +0 -147
- package/src/session-lock.mjs +0 -160
- package/src/session.mjs +0 -1655
- package/src/settings-tui.mjs +0 -373
- package/src/setup-flow.mjs +0 -223
- package/src/signal.mjs +0 -115
- package/src/simmer.mjs +0 -241
- package/src/strategy.mjs +0 -235
- package/src/subscription.mjs +0 -212
- package/src/templates.mjs +0 -260
- package/src/think-engine.mjs +0 -428
- package/src/tui.mjs +0 -276
- package/src/update-check.mjs +0 -35
- package/src/wave-planner.mjs +0 -294
package/src/dispatch.mjs
DELETED
|
@@ -1,1447 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// dispatch.mjs — Dispatch/execution module for dual-brain.
|
|
3
|
-
// Takes a routing decision and launches the agent via Claude CLI or Codex CLI.
|
|
4
|
-
// CLI: node src/dispatch.mjs --dry-run --provider claude --model sonnet --prompt "fix the bug"
|
|
5
|
-
// node src/dispatch.mjs --detect-runtime
|
|
6
|
-
// Exports: dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain,
|
|
7
|
-
// validateDispatch, checkWorktreeClean, getRetryBudget,
|
|
8
|
-
// isInsideClaude, buildNativeDispatch, normalizeResult
|
|
9
|
-
|
|
10
|
-
import { spawn } from 'node:child_process';
|
|
11
|
-
import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
12
|
-
import { join, dirname } from 'node:path';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { createHash } from 'node:crypto';
|
|
15
|
-
import { markHot, markDegraded, markHealthy, recordDispatch } from './health.mjs';
|
|
16
|
-
import { redact } from './redact.mjs';
|
|
17
|
-
import { getFailoverOrder } from './decide.mjs';
|
|
18
|
-
import { getTemplate, renderPrompt, quickRender } from './templates.mjs';
|
|
19
|
-
import { compilePacket, shapeForRole } from './context-intel.mjs';
|
|
20
|
-
import { buildContextPack } from './context.mjs';
|
|
21
|
-
import { scoreTask, computeRequiredTier } from './governance.mjs';
|
|
22
|
-
|
|
23
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
|
|
25
|
-
const TIER_TIMEOUT_MS = { search: 60_000, execute: 120_000, think: 180_000 };
|
|
26
|
-
const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
|
27
|
-
|
|
28
|
-
// ─── Specialist prompt loader ─────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
const SPECIALISTS_DIR = join(__dirname, '..', 'agents', 'specialists');
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Load specialist registry from agents/specialists/registry.json.
|
|
34
|
-
* Returns null if registry is missing or malformed.
|
|
35
|
-
* @returns {object|null}
|
|
36
|
-
*/
|
|
37
|
-
function _loadSpecialistRegistry() {
|
|
38
|
-
try {
|
|
39
|
-
const raw = readFileSync(join(SPECIALISTS_DIR, 'registry.json'), 'utf8');
|
|
40
|
-
return JSON.parse(raw);
|
|
41
|
-
} catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Read agents/specialists/_base.md and agents/specialists/{specialist}.md,
|
|
48
|
-
* concatenate them (base first, specialist second). Falls back gracefully:
|
|
49
|
-
* - If base is missing, only specialist content is returned.
|
|
50
|
-
* - If specialist file is missing, only base content is returned.
|
|
51
|
-
* - If both are missing, returns an empty string.
|
|
52
|
-
*
|
|
53
|
-
* @param {string} specialist Specialist key (e.g. 'python', 'security')
|
|
54
|
-
* @returns {string}
|
|
55
|
-
*/
|
|
56
|
-
function loadSpecialistPrompt(specialist) {
|
|
57
|
-
if (!specialist || specialist === 'generic') return '';
|
|
58
|
-
|
|
59
|
-
const tryRead = (filePath) => {
|
|
60
|
-
try { return readFileSync(filePath, 'utf8').trim(); } catch { return ''; }
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const registry = _loadSpecialistRegistry();
|
|
64
|
-
const entry = registry?.specialists?.[specialist];
|
|
65
|
-
const promptFile = entry?.prompt_file ?? `${specialist}.md`;
|
|
66
|
-
|
|
67
|
-
const base = tryRead(join(SPECIALISTS_DIR, '_base.md'));
|
|
68
|
-
const specific = tryRead(join(SPECIALISTS_DIR, promptFile));
|
|
69
|
-
|
|
70
|
-
const parts = [base, specific].filter(Boolean);
|
|
71
|
-
return parts.join('\n\n');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ─── Median dispatch time tracker (in-process, for slow-response detection) ──
|
|
75
|
-
// Rolling window of recent dispatch durations keyed by "provider:modelClass"
|
|
76
|
-
const _durationHistory = new Map();
|
|
77
|
-
const DURATION_WINDOW = 10; // keep last N durations per model class
|
|
78
|
-
|
|
79
|
-
function recordDuration(provider, model, durationMs) {
|
|
80
|
-
const k = `${provider}:${model}`;
|
|
81
|
-
if (!_durationHistory.has(k)) _durationHistory.set(k, []);
|
|
82
|
-
const arr = _durationHistory.get(k);
|
|
83
|
-
arr.push(durationMs);
|
|
84
|
-
if (arr.length > DURATION_WINDOW) arr.shift();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function medianDuration(provider, model) {
|
|
88
|
-
const k = `${provider}:${model}`;
|
|
89
|
-
const arr = _durationHistory.get(k);
|
|
90
|
-
if (!arr || arr.length < 3) return null; // not enough data
|
|
91
|
-
const sorted = [...arr].sort((a, b) => a - b);
|
|
92
|
-
const mid = Math.floor(sorted.length / 2);
|
|
93
|
-
return sorted.length % 2 === 0
|
|
94
|
-
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
95
|
-
: sorted[mid];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Rate-limit error keywords
|
|
99
|
-
const RATE_LIMIT_PATTERNS = /rate.?limit|quota|capacity|too many requests|overloaded|throttl/i;
|
|
100
|
-
|
|
101
|
-
// ─── Auto-heal failover helpers ───────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
const FAILOVER_LOG_DIR = join(__dirname, '..', '.dualbrain', 'audit');
|
|
104
|
-
|
|
105
|
-
/** Retryable exit-code-1 patterns: rate limits, quota, capacity, timeouts */
|
|
106
|
-
const RETRYABLE_PATTERNS = /rate.?limit|429|quota.?exceeded|capacity|overloaded|timeout/i;
|
|
107
|
-
|
|
108
|
-
/** Non-retryable patterns: auth failures, bad input, user cancellation */
|
|
109
|
-
const NON_RETRYABLE_PATTERNS = /unauthorized|forbidden|invalid.?api.?key|authentication|bad.?request|cancelled|canceled/i;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Decide if a subprocess result is a retryable failure.
|
|
113
|
-
* Must be exit code 1 (or non-zero) AND match retryable keywords AND NOT match
|
|
114
|
-
* non-retryable keywords.
|
|
115
|
-
* @param {{ exitCode: number, stderr: string, stdout: string }} result
|
|
116
|
-
* @returns {boolean}
|
|
117
|
-
*/
|
|
118
|
-
function isRetryableFailure({ exitCode, stderr, stdout }) {
|
|
119
|
-
if (exitCode === 0) return false;
|
|
120
|
-
const errText = `${stderr} ${stdout}`.slice(0, 1000);
|
|
121
|
-
if (NON_RETRYABLE_PATTERNS.test(errText)) return false;
|
|
122
|
-
return RETRYABLE_PATTERNS.test(errText);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Append a failover event to .dualbrain/audit/failover.jsonl.
|
|
127
|
-
* @param {{ from: string, to: string, reason: string, attempt: number }} info
|
|
128
|
-
*/
|
|
129
|
-
function logFailover({ from, to, reason, attempt }) {
|
|
130
|
-
try {
|
|
131
|
-
mkdirSync(FAILOVER_LOG_DIR, { recursive: true });
|
|
132
|
-
appendFileSync(
|
|
133
|
-
join(FAILOVER_LOG_DIR, 'failover.jsonl'),
|
|
134
|
-
JSON.stringify({ ts: new Date().toISOString(), from, to, reason, attempt }) + '\n',
|
|
135
|
-
);
|
|
136
|
-
} catch {}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ─── Native Claude Code detection ────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Detect whether we are running inside Claude Code (as a subagent or tool call).
|
|
143
|
-
* Checks the CLAUDE_CODE env var or the presence of .claude/settings.json in the project root.
|
|
144
|
-
* @returns {boolean}
|
|
145
|
-
*/
|
|
146
|
-
function isInsideClaude() {
|
|
147
|
-
if (process.env.CLAUDE_CODE) return true;
|
|
148
|
-
// Walk up from __dirname (src/) to find .claude/settings.json in project root
|
|
149
|
-
const projectRoot = join(__dirname, '..');
|
|
150
|
-
return existsSync(join(projectRoot, '.claude', 'settings.json'));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ─── Tier defaults for maxTurns ──────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
const TIER_MAX_TURNS = { search: 5, execute: 15, think: 10 };
|
|
156
|
-
|
|
157
|
-
// ─── Agent model mapper ───────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Map a model alias or model ID to the canonical agent model name (haiku|sonnet|opus).
|
|
161
|
-
* Falls back to tier-based defaults when no match is found.
|
|
162
|
-
* @param {string} modelAlias Short alias or full model ID
|
|
163
|
-
* @param {string} [tier] Tier fallback ('search'|'execute'|'think')
|
|
164
|
-
* @returns {'haiku'|'sonnet'|'opus'}
|
|
165
|
-
*/
|
|
166
|
-
function mapToAgentModel(modelAlias, tier) {
|
|
167
|
-
if (!modelAlias) {
|
|
168
|
-
const tierDefaults = { search: 'haiku', execute: 'sonnet', think: 'opus' };
|
|
169
|
-
return tierDefaults[tier] ?? 'sonnet';
|
|
170
|
-
}
|
|
171
|
-
const lower = String(modelAlias).toLowerCase();
|
|
172
|
-
if (lower === 'haiku' || lower.startsWith('claude-3-haiku') || lower.includes('haiku')) return 'haiku';
|
|
173
|
-
if (lower === 'opus' || lower.startsWith('claude-opus') || lower.includes('opus')) return 'opus';
|
|
174
|
-
if (lower === 'sonnet'|| lower.startsWith('claude-sonnet') || lower.includes('sonnet')) return 'sonnet';
|
|
175
|
-
// Tier-based fallback
|
|
176
|
-
const tierDefaults = { search: 'haiku', execute: 'sonnet', think: 'opus' };
|
|
177
|
-
return tierDefaults[tier] ?? 'sonnet';
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ─── Native dispatch builder ──────────────────────────────────────────────────
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Build a structured native Agent tool call descriptor instead of a shell command.
|
|
184
|
-
* The caller (CLI or plugin) is responsible for invoking the Agent tool with this object.
|
|
185
|
-
*
|
|
186
|
-
* @param {object} decision Routing decision from decide.mjs
|
|
187
|
-
* @param {string} prompt Task prompt (already redacted)
|
|
188
|
-
* @param {object} [options] Optional extras: worktree, maxTurns
|
|
189
|
-
* @returns {{
|
|
190
|
-
* type: 'native-agent',
|
|
191
|
-
* description: string,
|
|
192
|
-
* model: 'haiku'|'sonnet'|'opus',
|
|
193
|
-
* prompt: string,
|
|
194
|
-
* isolation: string|undefined,
|
|
195
|
-
* maxTurns: number,
|
|
196
|
-
* disallowedTools: string[],
|
|
197
|
-
* background: boolean
|
|
198
|
-
* }}
|
|
199
|
-
*/
|
|
200
|
-
function buildNativeDispatch(decision, prompt, options = {}) {
|
|
201
|
-
const tier = decision.tier ?? 'execute';
|
|
202
|
-
const model = mapToAgentModel(decision.model, tier);
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
type: 'native-agent',
|
|
206
|
-
description: `dual-brain ${tier}: ${String(prompt).slice(0, 50)}`,
|
|
207
|
-
model,
|
|
208
|
-
prompt,
|
|
209
|
-
isolation: options.worktree ? 'worktree' : undefined,
|
|
210
|
-
maxTurns: options.maxTurns ?? TIER_MAX_TURNS[tier] ?? 15,
|
|
211
|
-
disallowedTools: tier === 'search' ? ['Edit', 'Write', 'NotebookEdit'] : [],
|
|
212
|
-
background: false,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ─── Result normalizer ────────────────────────────────────────────────────────
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Normalize a raw result from either a native Agent call or subprocess stdout
|
|
220
|
-
* into a common result shape.
|
|
221
|
-
*
|
|
222
|
-
* @param {object|string} rawResult Raw result from agent or subprocess
|
|
223
|
-
* @param {'native-agent'|'subprocess'} dispatchType
|
|
224
|
-
* @returns {{
|
|
225
|
-
* status: 'success'|'failure'|'partial',
|
|
226
|
-
* provider: string,
|
|
227
|
-
* model: string,
|
|
228
|
-
* tier: string,
|
|
229
|
-
* filesChanged: string[],
|
|
230
|
-
* filesFound: string[],
|
|
231
|
-
* testsRun: number,
|
|
232
|
-
* edgeCases: string[],
|
|
233
|
-
* tokensUsed: { input: number, output: number },
|
|
234
|
-
* errors: string[],
|
|
235
|
-
* rawOutput: string
|
|
236
|
-
* }}
|
|
237
|
-
*/
|
|
238
|
-
function normalizeResult(rawResult, dispatchType) {
|
|
239
|
-
const raw = rawResult ?? {};
|
|
240
|
-
|
|
241
|
-
// Determine raw output string regardless of dispatch type
|
|
242
|
-
let rawOutput = '';
|
|
243
|
-
if (typeof raw === 'string') {
|
|
244
|
-
rawOutput = raw;
|
|
245
|
-
} else if (typeof raw.stdout === 'string') {
|
|
246
|
-
rawOutput = raw.stdout;
|
|
247
|
-
} else if (typeof raw.output === 'string') {
|
|
248
|
-
rawOutput = raw.output;
|
|
249
|
-
} else if (typeof raw.result === 'string') {
|
|
250
|
-
rawOutput = raw.result;
|
|
251
|
-
} else {
|
|
252
|
-
try { rawOutput = JSON.stringify(raw); } catch { rawOutput = String(raw); }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Determine status
|
|
256
|
-
let status = 'success';
|
|
257
|
-
if (dispatchType === 'subprocess') {
|
|
258
|
-
const exitCode = typeof raw.exitCode === 'number' ? raw.exitCode : (raw.code ?? null);
|
|
259
|
-
if (exitCode !== null) {
|
|
260
|
-
status = exitCode === 0 ? 'success' : 'failure';
|
|
261
|
-
} else if (raw.status === 'failed' || raw.status === 'error') {
|
|
262
|
-
status = 'failure';
|
|
263
|
-
} else if (raw.status === 'partial') {
|
|
264
|
-
status = 'partial';
|
|
265
|
-
}
|
|
266
|
-
} else {
|
|
267
|
-
// native-agent
|
|
268
|
-
if (raw.status === 'failed' || raw.status === 'error' || raw.error) {
|
|
269
|
-
status = 'failure';
|
|
270
|
-
} else if (raw.status === 'partial') {
|
|
271
|
-
status = 'partial';
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Extract fields from raw
|
|
276
|
-
const provider = raw.provider ?? (dispatchType === 'native-agent' ? 'claude' : 'unknown');
|
|
277
|
-
const model = raw.model ?? (raw.agentModel ?? 'unknown');
|
|
278
|
-
const tier = raw.tier ?? 'execute';
|
|
279
|
-
|
|
280
|
-
// Files changed / found — best-effort extraction from raw output
|
|
281
|
-
const filesChangedSet = new Set();
|
|
282
|
-
const filesFoundSet = new Set();
|
|
283
|
-
if (Array.isArray(raw.filesChanged)) raw.filesChanged.forEach(f => filesChangedSet.add(f));
|
|
284
|
-
if (Array.isArray(raw.filesFound)) raw.filesFound.forEach(f => filesFoundSet.add(f));
|
|
285
|
-
|
|
286
|
-
// Scan rawOutput for file hints
|
|
287
|
-
const changeMatches = rawOutput.matchAll(/(?:changed|edited|wrote|created|modified)\s+([^\s,]+\.[a-z]{1,6})/gi);
|
|
288
|
-
for (const m of changeMatches) filesChangedSet.add(m[1]);
|
|
289
|
-
const foundMatches = rawOutput.matchAll(/(?:found|located|in)\s+([^\s,]+\.[a-z]{1,6})/gi);
|
|
290
|
-
for (const m of foundMatches) filesFoundSet.add(m[1]);
|
|
291
|
-
|
|
292
|
-
// Tests run
|
|
293
|
-
let testsRun = raw.testsRun ?? 0;
|
|
294
|
-
if (testsRun === 0) {
|
|
295
|
-
const testMatch = rawOutput.match(/(\d+)\s+(?:tests?|specs?)\s+(?:passed|run|ran)/i);
|
|
296
|
-
if (testMatch) testsRun = parseInt(testMatch[1], 10);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Edge cases
|
|
300
|
-
const edgeCases = Array.isArray(raw.edgeCases) ? raw.edgeCases : [];
|
|
301
|
-
|
|
302
|
-
// Token usage
|
|
303
|
-
const tokensUsed = {
|
|
304
|
-
input: raw.tokensUsed?.input ?? raw.usage?.inputTokens ?? raw.usage?.input_tokens ?? 0,
|
|
305
|
-
output: raw.tokensUsed?.output ?? raw.usage?.outputTokens ?? raw.usage?.output_tokens ?? 0,
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
// Errors
|
|
309
|
-
const errors = [];
|
|
310
|
-
if (Array.isArray(raw.errors)) errors.push(...raw.errors);
|
|
311
|
-
if (typeof raw.error === 'string' && raw.error) errors.push(raw.error);
|
|
312
|
-
if (typeof raw.stderr === 'string' && raw.stderr) errors.push(raw.stderr.slice(0, 200));
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
status,
|
|
316
|
-
provider,
|
|
317
|
-
model,
|
|
318
|
-
tier,
|
|
319
|
-
filesChanged: [...filesChangedSet],
|
|
320
|
-
filesFound: [...filesFoundSet],
|
|
321
|
-
testsRun,
|
|
322
|
-
edgeCases,
|
|
323
|
-
tokensUsed,
|
|
324
|
-
errors,
|
|
325
|
-
rawOutput: rawOutput.slice(0, 2000),
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ─── Runtime detection (cached) ───────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
let _runtimeCache = null;
|
|
332
|
-
|
|
333
|
-
async function detectRuntime() {
|
|
334
|
-
if (_runtimeCache) return _runtimeCache;
|
|
335
|
-
|
|
336
|
-
const check = (cmd) => new Promise((resolve) => {
|
|
337
|
-
const p = spawn(cmd, ['--version'], { stdio: 'pipe' });
|
|
338
|
-
p.on('error', () => resolve(false));
|
|
339
|
-
p.on('close', (code) => resolve(code === 0));
|
|
340
|
-
setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
const [claudeAvailable, codexAvailable] = await Promise.all([
|
|
344
|
-
check('claude'),
|
|
345
|
-
check('codex'),
|
|
346
|
-
]);
|
|
347
|
-
|
|
348
|
-
const runtime =
|
|
349
|
-
claudeAvailable && codexAvailable ? 'claude-code'
|
|
350
|
-
: claudeAvailable ? 'claude-code'
|
|
351
|
-
: codexAvailable ? 'codex-cli'
|
|
352
|
-
: 'none';
|
|
353
|
-
|
|
354
|
-
_runtimeCache = { claudeAvailable, codexAvailable, runtime };
|
|
355
|
-
return _runtimeCache;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// ─── Feature 1: Model validation + graceful fallback ─────────────────────────
|
|
359
|
-
|
|
360
|
-
/** Valid CLI model flags per provider */
|
|
361
|
-
const VALID_MODELS = {
|
|
362
|
-
claude: ['opus', 'sonnet', 'haiku'],
|
|
363
|
-
openai: ['o4-mini', 'o3', 'o1', 'o1-mini', 'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4-turbo'],
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
/** Safest default model for a given provider + tier */
|
|
367
|
-
function _safeModel(provider, tier) {
|
|
368
|
-
if (provider === 'claude') {
|
|
369
|
-
return tier === 'search' ? 'haiku' : 'sonnet';
|
|
370
|
-
}
|
|
371
|
-
return 'o4-mini';
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Validate a routing decision against CLI availability and valid model lists.
|
|
376
|
-
* Returns either a (possibly corrected) decision object, or an error sentinel
|
|
377
|
-
* `{ _error: string }` when no CLI is available at all.
|
|
378
|
-
*
|
|
379
|
-
* @param {object} decision
|
|
380
|
-
* @param {{ claudeAvailable: boolean, codexAvailable: boolean }} rt Runtime info
|
|
381
|
-
* @returns {object} Corrected decision or `{ _error: string }`
|
|
382
|
-
*/
|
|
383
|
-
function validateDispatch(decision, rt) {
|
|
384
|
-
let { provider = 'claude', model, tier = 'execute' } = decision;
|
|
385
|
-
|
|
386
|
-
// ── CLI availability ──────────────────────────────────────────────────────
|
|
387
|
-
const claudeOk = rt.claudeAvailable;
|
|
388
|
-
const codexOk = rt.codexAvailable;
|
|
389
|
-
|
|
390
|
-
if (!claudeOk && !codexOk) {
|
|
391
|
-
return { _error: 'No AI CLI available. Install claude or codex CLI.' };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (provider === 'claude' && !claudeOk && codexOk) {
|
|
395
|
-
process.stderr.write('[dual-brain] Claude unavailable, falling back to OpenAI (codex)\n');
|
|
396
|
-
provider = 'openai';
|
|
397
|
-
} else if (provider === 'openai' && !codexOk && claudeOk) {
|
|
398
|
-
process.stderr.write('[dual-brain] OpenAI unavailable, falling back to Claude (claude)\n');
|
|
399
|
-
provider = 'claude';
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ── Model validation ──────────────────────────────────────────────────────
|
|
403
|
-
const validList = VALID_MODELS[provider] ?? [];
|
|
404
|
-
if (model && !validList.includes(model)) {
|
|
405
|
-
const safe = _safeModel(provider, tier);
|
|
406
|
-
process.stderr.write(`[dual-brain] Model "${model}" not valid for ${provider} CLI; defaulting to "${safe}"\n`);
|
|
407
|
-
model = safe;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return { ...decision, provider, model };
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// ─── Feature 2: Dirty-worktree guard ─────────────────────────────────────────
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Simple glob match:
|
|
417
|
-
* - `dir/*` → prefix match on `dir/`
|
|
418
|
-
* - `*.ext` → suffix match on `.ext`
|
|
419
|
-
* - otherwise → exact match
|
|
420
|
-
*/
|
|
421
|
-
function _globMatch(pattern, filePath) {
|
|
422
|
-
if (pattern.endsWith('/*')) {
|
|
423
|
-
const prefix = pattern.slice(0, -1); // 'src/auth/'
|
|
424
|
-
return filePath.startsWith(prefix);
|
|
425
|
-
}
|
|
426
|
-
if (pattern.startsWith('*.')) {
|
|
427
|
-
const suffix = pattern.slice(1); // '.mjs'
|
|
428
|
-
return filePath.endsWith(suffix);
|
|
429
|
-
}
|
|
430
|
-
return filePath === pattern;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Check whether dirty worktree files overlap with the agent's ownership globs.
|
|
435
|
-
*
|
|
436
|
-
* @param {string[]} owns Glob patterns for files the agent will touch
|
|
437
|
-
* @param {string} cwd Working directory for git
|
|
438
|
-
* @returns {Promise<{ safe: boolean, conflicts?: string[] }>}
|
|
439
|
-
*/
|
|
440
|
-
async function checkWorktreeClean(owns, cwd) {
|
|
441
|
-
if (!owns || owns.length === 0) return { safe: true };
|
|
442
|
-
|
|
443
|
-
const dirty = await new Promise((resolve) => {
|
|
444
|
-
const proc = spawn('git', ['status', '--porcelain', '-u'], {
|
|
445
|
-
cwd: cwd || process.cwd(),
|
|
446
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
447
|
-
});
|
|
448
|
-
let out = '';
|
|
449
|
-
proc.stdout.on('data', (d) => { out += d; });
|
|
450
|
-
proc.on('close', () => {
|
|
451
|
-
// Each line: "XY path" — grab the path part (columns 4+, after "XY ")
|
|
452
|
-
const files = out.split('\n')
|
|
453
|
-
.map(l => l.slice(3).trim())
|
|
454
|
-
.filter(Boolean);
|
|
455
|
-
resolve(files);
|
|
456
|
-
});
|
|
457
|
-
proc.on('error', () => resolve([])); // git not available → skip guard
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
if (dirty.length === 0) return { safe: true };
|
|
461
|
-
|
|
462
|
-
const conflicts = dirty.filter(f =>
|
|
463
|
-
owns.some(pattern => _globMatch(pattern, f))
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
if (conflicts.length > 0) return { safe: false, conflicts };
|
|
467
|
-
return { safe: true };
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ─── Feature 3: Retry budget ──────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
/** Per-prompt retry count (keyed by first 16 hex chars of SHA-256 of prompt) */
|
|
473
|
-
const _retryCount = new Map();
|
|
474
|
-
|
|
475
|
-
/** Recent dispatch timestamps for the 5-minute window rate-limit */
|
|
476
|
-
const _recentDispatches = [];
|
|
477
|
-
|
|
478
|
-
const MAX_RETRIES_PER_TASK = 2;
|
|
479
|
-
const MAX_DISPATCHES_PER_5MIN = 5;
|
|
480
|
-
const WINDOW_MS = 5 * 60 * 1000;
|
|
481
|
-
|
|
482
|
-
function _promptKey(prompt) {
|
|
483
|
-
return createHash('sha256').update(String(prompt)).digest('hex').slice(0, 16);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Check whether this dispatch is within budget.
|
|
488
|
-
* @param {string} prompt
|
|
489
|
-
* @returns {{ allowed: boolean, reason?: string }}
|
|
490
|
-
*/
|
|
491
|
-
function _checkRetryBudget(prompt) {
|
|
492
|
-
const now = Date.now();
|
|
493
|
-
|
|
494
|
-
// Evict dispatch timestamps older than 5 minutes
|
|
495
|
-
while (_recentDispatches.length > 0 && now - _recentDispatches[0] > WINDOW_MS) {
|
|
496
|
-
_recentDispatches.shift();
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
if (_recentDispatches.length >= MAX_DISPATCHES_PER_5MIN) {
|
|
500
|
-
return { allowed: false, reason: 'Retry budget exhausted. Wait or adjust task.' };
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const key = _promptKey(prompt);
|
|
504
|
-
const count = _retryCount.get(key) ?? 0;
|
|
505
|
-
if (count > MAX_RETRIES_PER_TASK) {
|
|
506
|
-
return { allowed: false, reason: 'Retry budget exhausted. Wait or adjust task.' };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return { allowed: true };
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function _recordDispatchBudget(prompt) {
|
|
513
|
-
_recentDispatches.push(Date.now());
|
|
514
|
-
const key = _promptKey(prompt);
|
|
515
|
-
_retryCount.set(key, (_retryCount.get(key) ?? 0) + 1);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Return current retry budget state for status display.
|
|
520
|
-
* @returns {object}
|
|
521
|
-
*/
|
|
522
|
-
function getRetryBudget() {
|
|
523
|
-
const now = Date.now();
|
|
524
|
-
const active = _recentDispatches.filter(t => now - t <= WINDOW_MS).length;
|
|
525
|
-
return {
|
|
526
|
-
perTaskRetries: Object.fromEntries(_retryCount),
|
|
527
|
-
recentDispatches: active,
|
|
528
|
-
windowMs: WINDOW_MS,
|
|
529
|
-
maxPerTask: MAX_RETRIES_PER_TASK,
|
|
530
|
-
maxPerWindow: MAX_DISPATCHES_PER_5MIN,
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// ─── Preflight auth check ─────────────────────────────────────────────────────
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Verify a provider CLI is present and (optionally) responds to --version.
|
|
538
|
-
* Uses `which` for the fast path and a 3s-capped --version call to confirm.
|
|
539
|
-
*
|
|
540
|
-
* @param {'claude'|'openai'} provider
|
|
541
|
-
* @param {string} [cwd] Working directory (unused, kept for signature parity)
|
|
542
|
-
* @returns {Promise<{ ready: boolean, provider: string, error?: string, suggestion?: string }>}
|
|
543
|
-
*/
|
|
544
|
-
async function preflightAuth(provider, _cwd) {
|
|
545
|
-
const bin = provider === 'openai' ? 'codex' : 'claude';
|
|
546
|
-
|
|
547
|
-
// Fast path: check binary existence with `which`
|
|
548
|
-
const whichResult = await new Promise((resolve) => {
|
|
549
|
-
const p = spawn('which', [bin], { stdio: 'pipe' });
|
|
550
|
-
p.on('error', () => resolve(false));
|
|
551
|
-
p.on('close', (code) => resolve(code === 0));
|
|
552
|
-
setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 2000);
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
if (!whichResult) {
|
|
556
|
-
const installHint = provider === 'openai'
|
|
557
|
-
? 'Install: npm install -g @openai/codex'
|
|
558
|
-
: 'Install: npm install -g @anthropic-ai/claude-code';
|
|
559
|
-
return {
|
|
560
|
-
ready: false,
|
|
561
|
-
provider,
|
|
562
|
-
error: `${bin} CLI not found in PATH`,
|
|
563
|
-
suggestion: installHint,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Version check: confirms the binary actually runs (catches broken installs)
|
|
568
|
-
const versionOk = await new Promise((resolve) => {
|
|
569
|
-
const p = spawn(bin, ['--version'], { stdio: 'pipe' });
|
|
570
|
-
p.on('error', () => resolve(false));
|
|
571
|
-
p.on('close', (code) => resolve(code === 0));
|
|
572
|
-
setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
if (!versionOk) {
|
|
576
|
-
const loginHint = provider === 'openai' ? 'Run: codex login' : 'Run: claude login';
|
|
577
|
-
return {
|
|
578
|
-
ready: false,
|
|
579
|
-
provider,
|
|
580
|
-
error: `${bin} --version failed (auth may have expired)`,
|
|
581
|
-
suggestion: loginHint,
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
return { ready: true, provider };
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// ─── Command builder ──────────────────────────────────────────────────────────
|
|
589
|
-
|
|
590
|
-
function buildCommand(decision, prompt, files = [], _cwd) {
|
|
591
|
-
const provider = decision?.provider ?? 'claude';
|
|
592
|
-
const modelAlias = decision?.model ?? 'sonnet';
|
|
593
|
-
const effort = decision?.effort ?? null;
|
|
594
|
-
const sandbox = decision?.sandbox ?? 'danger-full-access';
|
|
595
|
-
|
|
596
|
-
if (provider === 'claude') {
|
|
597
|
-
const modelId = CLAUDE_MODEL_IDS[modelAlias] ?? modelAlias;
|
|
598
|
-
const cmd = ['claude', '--model', modelId, '--print', '--output-format', 'json', '-p', prompt];
|
|
599
|
-
if (effort) cmd.push('--effort', effort);
|
|
600
|
-
return cmd;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// openai / codex
|
|
604
|
-
const cmd = ['codex', 'exec', '-m', modelAlias, '-s', sandbox, prompt];
|
|
605
|
-
if (effort) cmd.push('-c', `reasoning.effort="${effort}"`);
|
|
606
|
-
return cmd;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// ─── Usage recorder ───────────────────────────────────────────────────────────
|
|
610
|
-
function recordUsage(entry) {
|
|
611
|
-
try {
|
|
612
|
-
mkdirSync(USAGE_DIR, { recursive: true });
|
|
613
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
614
|
-
appendFileSync(
|
|
615
|
-
join(USAGE_DIR, `${date}.jsonl`),
|
|
616
|
-
JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n',
|
|
617
|
-
);
|
|
618
|
-
} catch {}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// ─── Result compressor ────────────────────────────────────────────────────────
|
|
622
|
-
function compressResult(rawOutput = '', maxLength = 300) {
|
|
623
|
-
if (!rawOutput) return '(no output)';
|
|
624
|
-
|
|
625
|
-
// Try JSON parse first (claude --output-format json)
|
|
626
|
-
try {
|
|
627
|
-
const parsed = JSON.parse(rawOutput);
|
|
628
|
-
const text = parsed?.result ?? parsed?.content ?? parsed?.message ?? JSON.stringify(parsed);
|
|
629
|
-
return String(text).slice(0, maxLength);
|
|
630
|
-
} catch {}
|
|
631
|
-
|
|
632
|
-
// Strip code blocks
|
|
633
|
-
let cleaned = rawOutput
|
|
634
|
-
.replace(/```[\s\S]*?```/g, '[code block]')
|
|
635
|
-
.replace(/^\s+at\s+.+$/gm, '') // stack trace lines
|
|
636
|
-
.replace(/\n{3,}/g, '\n\n') // collapse blank lines
|
|
637
|
-
.trim();
|
|
638
|
-
|
|
639
|
-
// Extract first 2 meaningful sentences
|
|
640
|
-
const sentences = cleaned.match(/[^.!?\n]+[.!?\n]+/g) ?? [cleaned];
|
|
641
|
-
const meaningful = sentences.filter(s => s.trim().length > 15).slice(0, 2);
|
|
642
|
-
const head = meaningful.join(' ').trim() || cleaned.slice(0, maxLength);
|
|
643
|
-
|
|
644
|
-
// Append file-change hints if present
|
|
645
|
-
const fileHints = rawOutput.match(/(?:changed|edited|wrote|created|modified)\s+([^\s,]+\.[a-z]+)/gi) ?? [];
|
|
646
|
-
const suffix = fileHints.length ? ` | files: ${[...new Set(fileHints)].slice(0, 3).join(', ')}` : '';
|
|
647
|
-
|
|
648
|
-
return (head + suffix).slice(0, maxLength);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// ─── Core runner ──────────────────────────────────────────────────────────────
|
|
652
|
-
function runProcess(cmd, cwd, timeoutMs, env) {
|
|
653
|
-
return new Promise((resolve) => {
|
|
654
|
-
const [bin, ...args] = cmd;
|
|
655
|
-
const start = Date.now();
|
|
656
|
-
let stdout = '';
|
|
657
|
-
let stderr = '';
|
|
658
|
-
|
|
659
|
-
const spawnEnv = env ? { ...process.env, ...env } : undefined;
|
|
660
|
-
const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}) });
|
|
661
|
-
|
|
662
|
-
proc.stdout.on('data', (d) => { stdout += d; });
|
|
663
|
-
proc.stderr.on('data', (d) => { stderr += d; });
|
|
664
|
-
|
|
665
|
-
const killer = setTimeout(() => {
|
|
666
|
-
try { proc.kill('SIGTERM'); } catch {}
|
|
667
|
-
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 5000);
|
|
668
|
-
}, timeoutMs);
|
|
669
|
-
|
|
670
|
-
proc.on('close', (code) => {
|
|
671
|
-
clearTimeout(killer);
|
|
672
|
-
resolve({ exitCode: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), durationMs: Date.now() - start });
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
proc.on('error', (err) => {
|
|
676
|
-
clearTimeout(killer);
|
|
677
|
-
resolve({ exitCode: 1, stdout: '', stderr: err.message, durationMs: Date.now() - start });
|
|
678
|
-
});
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ─── Template-based prompt rendering ─────────────────────────────────────────
|
|
683
|
-
|
|
684
|
-
function _renderTemplatedPrompt(prompt, decision, context = {}) {
|
|
685
|
-
const tier = decision.tier ?? 'execute';
|
|
686
|
-
const template = getTemplate(tier);
|
|
687
|
-
if (!template) return prompt;
|
|
688
|
-
|
|
689
|
-
if (decision.contract) {
|
|
690
|
-
const rendered = renderPrompt(tier, decision.contract, context);
|
|
691
|
-
if (rendered.valid) return rendered.prompt;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const rendered = quickRender(tier, prompt, {
|
|
695
|
-
scope: decision.owns || decision.scope || [],
|
|
696
|
-
files: decision.files || [],
|
|
697
|
-
risk: decision.risk || 'medium',
|
|
698
|
-
criteria: decision.acceptanceCriteria || [],
|
|
699
|
-
nonGoals: decision.nonGoals || [],
|
|
700
|
-
context: decision.taskContext || '',
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
return rendered.valid ? rendered.prompt : prompt;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// ─── Dispatch marker ─────────────────────────────────────────────────────────
|
|
707
|
-
// Prepend a marker to every prompt that goes through the official dispatch pipeline.
|
|
708
|
-
// The enforce-tier hook checks for this marker to distinguish legitimate dispatches
|
|
709
|
-
// from raw Agent calls made by the HEAD that bypass the dual-brain pipeline.
|
|
710
|
-
// Format: <!-- dual-brain-dispatch:<runId>|tier:<tier>|model:<model>|risk:<risk>|req:<requiredTier> -->
|
|
711
|
-
// runId is a short timestamp-based ID; governance fields enable over-provisioning validation.
|
|
712
|
-
|
|
713
|
-
let _dispatchRunId = null;
|
|
714
|
-
|
|
715
|
-
function _getDispatchRunId() {
|
|
716
|
-
if (!_dispatchRunId) {
|
|
717
|
-
// Generate once per process: timestamp + random suffix
|
|
718
|
-
_dispatchRunId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
719
|
-
}
|
|
720
|
-
return _dispatchRunId;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
function _prependDispatchMarker(prompt, decision = {}) {
|
|
724
|
-
const runId = _getDispatchRunId();
|
|
725
|
-
const tier = decision.tier || 'execute';
|
|
726
|
-
const model = decision.model || 'sonnet';
|
|
727
|
-
const risk = decision.risk || 'medium';
|
|
728
|
-
const requiredTier = decision._requiredTier || '';
|
|
729
|
-
const marker = `<!-- dual-brain-dispatch:${runId}|tier:${tier}|model:${model}|risk:${risk}|req:${requiredTier} -->`;
|
|
730
|
-
return `${marker}\n${prompt}`;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// ─── Related session age label ────────────────────────────────────────────────
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Human-readable age label for a related session date string.
|
|
737
|
-
* @param {string} isoDate
|
|
738
|
-
* @returns {string}
|
|
739
|
-
*/
|
|
740
|
-
function _relatedSessionAge(isoDate) {
|
|
741
|
-
const diff = Date.now() - Date.parse(isoDate);
|
|
742
|
-
const mins = Math.floor(diff / 60000);
|
|
743
|
-
if (mins < 60) return `${mins}m ago`;
|
|
744
|
-
const hours = Math.floor(mins / 60);
|
|
745
|
-
if (hours < 24) return `${hours}h ago`;
|
|
746
|
-
const days = Math.floor(hours / 24);
|
|
747
|
-
return `${days}d ago`;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// ─── Main dispatch ────────────────────────────────────────────────────────────
|
|
751
|
-
async function dispatch(input = {}) {
|
|
752
|
-
const { files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
|
|
753
|
-
let decision = input.decision ?? {};
|
|
754
|
-
let { prompt } = input;
|
|
755
|
-
|
|
756
|
-
if (!prompt) throw new Error('prompt is required');
|
|
757
|
-
|
|
758
|
-
// Safety gate: redact secrets before anything reaches a subprocess or log
|
|
759
|
-
prompt = redact(prompt);
|
|
760
|
-
|
|
761
|
-
// ── Template-based prompt rendering ─────────────────────────────────────────
|
|
762
|
-
// When a tier and/or contract are present, render through templates.mjs for
|
|
763
|
-
// structured, typed prompts. Falls back to raw prompt when no template matches.
|
|
764
|
-
prompt = _renderTemplatedPrompt(prompt, decision);
|
|
765
|
-
|
|
766
|
-
// ── Context intelligence: model-specific prompt shaping ─────────────────────
|
|
767
|
-
// When we have files and a target model, shape the prompt context for optimal
|
|
768
|
-
// model consumption. This adds structured context without replacing the template output.
|
|
769
|
-
if (files.length > 0 || decision.tier) {
|
|
770
|
-
try {
|
|
771
|
-
const pack = await buildContextPack(prompt, files, cwd);
|
|
772
|
-
const role = decision.tier === 'think' ? 'thinker'
|
|
773
|
-
: decision.tier === 'review' ? 'reviewer'
|
|
774
|
-
: 'worker';
|
|
775
|
-
const targetModel = decision.model || 'sonnet';
|
|
776
|
-
const tokenBudget = role === 'thinker' ? 3000
|
|
777
|
-
: role === 'reviewer' ? 4000
|
|
778
|
-
: 8000;
|
|
779
|
-
const { shaped, tokenEstimate } = shapeForRole(pack, role, targetModel, tokenBudget);
|
|
780
|
-
if (shaped && tokenEstimate > 0) {
|
|
781
|
-
prompt = `${shaped}\n\n---\n\n${prompt}`;
|
|
782
|
-
if (verbose) process.stderr.write(`[dual-brain] context-intel: ${role} packet shaped for ${targetModel} (~${tokenEstimate} tokens)\n`);
|
|
783
|
-
}
|
|
784
|
-
} catch { /* non-blocking — context shaping failure never prevents dispatch */ }
|
|
785
|
-
}
|
|
786
|
-
// ── End context intelligence ─────────────────────────────────────────────────
|
|
787
|
-
|
|
788
|
-
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
789
|
-
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
790
|
-
// This closes the receipt → brief → next session loop automatically.
|
|
791
|
-
// Falls back to continuity.mjs handoffs when receipt.mjs returns nothing.
|
|
792
|
-
if (!input.situationBrief) {
|
|
793
|
-
try {
|
|
794
|
-
const { buildResumeBrief } = await import('./receipt.mjs');
|
|
795
|
-
const brief = buildResumeBrief(cwd);
|
|
796
|
-
if (brief) {
|
|
797
|
-
input = { ...input, situationBrief: brief };
|
|
798
|
-
}
|
|
799
|
-
} catch { /* non-blocking */ }
|
|
800
|
-
|
|
801
|
-
// Provider-aware continuity fallback: adapts resume format for target provider
|
|
802
|
-
if (!input.situationBrief) {
|
|
803
|
-
try {
|
|
804
|
-
const { buildProviderResumeBrief } = await import('./provider-context.mjs');
|
|
805
|
-
const targetProvider = decision.provider || 'claude';
|
|
806
|
-
const providerBrief = buildProviderResumeBrief(cwd, targetProvider);
|
|
807
|
-
if (providerBrief) {
|
|
808
|
-
input = { ...input, situationBrief: providerBrief };
|
|
809
|
-
}
|
|
810
|
-
} catch { /* non-blocking */ }
|
|
811
|
-
|
|
812
|
-
// Legacy fallback: continuity.mjs handoff (provider-unaware)
|
|
813
|
-
if (!input.situationBrief) {
|
|
814
|
-
try {
|
|
815
|
-
const { buildResumeBrief: buildHandoffBrief } = await import('./continuity.mjs');
|
|
816
|
-
const handoffBrief = buildHandoffBrief(cwd);
|
|
817
|
-
if (handoffBrief) {
|
|
818
|
-
input = { ...input, situationBrief: handoffBrief };
|
|
819
|
-
}
|
|
820
|
-
} catch { /* non-blocking */ }
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// ── End resume brief injection ───────────────────────────────────────────────
|
|
825
|
-
|
|
826
|
-
// ── Related session context injection ────────────────────────────────────────
|
|
827
|
-
// Find past sessions related to this task and prepend a context block.
|
|
828
|
-
// Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
|
|
829
|
-
if (!input._skipRelatedContext) {
|
|
830
|
-
try {
|
|
831
|
-
const { findRelatedSessions } = await import('./session.mjs');
|
|
832
|
-
const related = findRelatedSessions(prompt, files, cwd);
|
|
833
|
-
const highConfidence = related.filter(r => r.score > 5);
|
|
834
|
-
if (highConfidence.length > 0) {
|
|
835
|
-
const lines = highConfidence.map(r => {
|
|
836
|
-
const dateLabel = r.date ? _relatedSessionAge(r.date) : null;
|
|
837
|
-
const datePart = dateLabel ? `, ${dateLabel}` : '';
|
|
838
|
-
const msgPart = r.messageCount > 0 ? `, ${r.messageCount} messages` : '';
|
|
839
|
-
const fileList = r.matchedFiles.length > 0
|
|
840
|
-
? `: touched ${r.matchedFiles.map(f => f.split('/').pop()).join(', ')}`
|
|
841
|
-
: '';
|
|
842
|
-
return `- "${r.smartName}"${datePart}${msgPart}${fileList}`;
|
|
843
|
-
});
|
|
844
|
-
const contextBlock = `[Prior context from related sessions:]\n${lines.join('\n')}\n[End prior context]\n\n`;
|
|
845
|
-
prompt = contextBlock + prompt;
|
|
846
|
-
if (verbose) process.stderr.write(`[dual-brain] injected related session context (${highConfidence.length} sessions)\n`);
|
|
847
|
-
}
|
|
848
|
-
} catch { /* non-fatal — never block dispatch */ }
|
|
849
|
-
}
|
|
850
|
-
// ── End related session context ──────────────────────────────────────────────
|
|
851
|
-
|
|
852
|
-
// Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
|
|
853
|
-
// that this agent call came through the official pipeline.
|
|
854
|
-
// Compute required tier for governance validation
|
|
855
|
-
try {
|
|
856
|
-
const scores = scoreTask({ intent: decision.tier, risk: decision.risk, files, objective: prompt.slice(0, 200) });
|
|
857
|
-
decision = { ...decision, _requiredTier: computeRequiredTier(scores) };
|
|
858
|
-
} catch { /* non-blocking */ }
|
|
859
|
-
prompt = _prependDispatchMarker(prompt, decision);
|
|
860
|
-
|
|
861
|
-
// ── Situation brief injection ────────────────────────────────────────────────
|
|
862
|
-
// Prepend a compact project-state summary when provided by the pipeline.
|
|
863
|
-
// This gives every dispatched agent immediate context about the project reality.
|
|
864
|
-
const situationBrief = typeof input.situationBrief === 'string' && input.situationBrief.trim()
|
|
865
|
-
? input.situationBrief.trim()
|
|
866
|
-
: null;
|
|
867
|
-
if (situationBrief) {
|
|
868
|
-
prompt = `[SITUATION BRIEF]\n${situationBrief}\n[END BRIEF]\n\n${prompt}`;
|
|
869
|
-
}
|
|
870
|
-
// ── End situation brief ──────────────────────────────────────────────────────
|
|
871
|
-
|
|
872
|
-
// ── Specialist prompt injection ──────────────────────────────────────────────
|
|
873
|
-
const specialist = decision.specialist && decision.specialist !== 'generic'
|
|
874
|
-
? decision.specialist
|
|
875
|
-
: null;
|
|
876
|
-
|
|
877
|
-
if (specialist) {
|
|
878
|
-
const specialistPrompt = loadSpecialistPrompt(specialist);
|
|
879
|
-
if (specialistPrompt) {
|
|
880
|
-
prompt = `${specialistPrompt}\n\n---\n\n${prompt}`;
|
|
881
|
-
if (verbose) process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// Apply tier_bias from registry if decision didn't already pin a tier
|
|
885
|
-
if (!decision.tier) {
|
|
886
|
-
const registry = _loadSpecialistRegistry();
|
|
887
|
-
const tierBias = registry?.specialists?.[specialist]?.tier_bias;
|
|
888
|
-
if (tierBias) {
|
|
889
|
-
decision = { ...decision, tier: tierBias };
|
|
890
|
-
if (verbose) process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
// ── End specialist injection ─────────────────────────────────────────────────
|
|
895
|
-
|
|
896
|
-
// ── Plugin hint injection (Codex path) ──────────────────────────────────────
|
|
897
|
-
// When dispatching to OpenAI/Codex, check if any Codex plugins match the task
|
|
898
|
-
// and append an advisory hint so the agent can choose to use them.
|
|
899
|
-
// Uses dynamic import so failure is always non-fatal.
|
|
900
|
-
const targetProvider = decision.provider ?? 'claude';
|
|
901
|
-
if (targetProvider === 'openai') {
|
|
902
|
-
try {
|
|
903
|
-
const { matchPluginsForTask } = await import('./replit.mjs');
|
|
904
|
-
const matched = matchPluginsForTask(prompt, undefined, cwd);
|
|
905
|
-
if (matched.length > 0) {
|
|
906
|
-
const pluginNames = matched.slice(0, 3).map(m => m.plugin.id).join(', ');
|
|
907
|
-
const hint = `\n\n[Available Codex plugins for this task: ${pluginNames}. Consider using the matching plugin for direct API access.]`;
|
|
908
|
-
prompt = prompt + hint;
|
|
909
|
-
if (verbose) process.stderr.write(`[dual-brain] plugin hint injected: ${pluginNames}\n`);
|
|
910
|
-
}
|
|
911
|
-
} catch { /* non-fatal — never block dispatch */ }
|
|
912
|
-
}
|
|
913
|
-
// ── End plugin hint injection ────────────────────────────────────────────────
|
|
914
|
-
|
|
915
|
-
const tier = decision.tier ?? 'execute';
|
|
916
|
-
const timeoutMs = TIER_TIMEOUT_MS[tier] ?? 120_000;
|
|
917
|
-
|
|
918
|
-
// ── Feature 3: Retry budget check ────────────────────────────────────────
|
|
919
|
-
const budget = _checkRetryBudget(prompt);
|
|
920
|
-
if (!budget.allowed) {
|
|
921
|
-
return {
|
|
922
|
-
status: 'error',
|
|
923
|
-
provider: decision.provider ?? 'claude',
|
|
924
|
-
model: decision.model ?? 'sonnet',
|
|
925
|
-
command: null,
|
|
926
|
-
exitCode: null,
|
|
927
|
-
summary: budget.reason,
|
|
928
|
-
durationMs: 0,
|
|
929
|
-
usage: null,
|
|
930
|
-
error: budget.reason,
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ── Feature 1: Validate dispatch (CLI availability + model) ──────────────
|
|
935
|
-
const rt = await detectRuntime();
|
|
936
|
-
const validated = validateDispatch({ ...decision, tier }, rt);
|
|
937
|
-
|
|
938
|
-
if (validated._error) {
|
|
939
|
-
return {
|
|
940
|
-
status: 'error',
|
|
941
|
-
provider: decision.provider ?? 'claude',
|
|
942
|
-
model: decision.model ?? 'sonnet',
|
|
943
|
-
command: null,
|
|
944
|
-
exitCode: null,
|
|
945
|
-
summary: validated._error,
|
|
946
|
-
durationMs: 0,
|
|
947
|
-
usage: null,
|
|
948
|
-
error: validated._error,
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
const effectiveProvider = validated.provider;
|
|
953
|
-
let effectiveModel = validated.model ?? decision.model ?? 'sonnet';
|
|
954
|
-
let effectiveDecision = { ...validated };
|
|
955
|
-
|
|
956
|
-
// modelSuggestion influence: if the pipeline provided a model suggestion from models.mjs,
|
|
957
|
-
// apply it when the current model is a tier default/fallback (not an explicit override).
|
|
958
|
-
// The suggestion is advisory — it only applies when the decision didn't pin a specific model.
|
|
959
|
-
if (input.modelSuggestion?.model && effectiveProvider === 'claude') {
|
|
960
|
-
const TIER_DEFAULTS = new Set(['haiku', 'sonnet', 'opus']);
|
|
961
|
-
const decisionModelExplicit = decision._explicit?.model ?? false;
|
|
962
|
-
const isDefault = !decisionModelExplicit && TIER_DEFAULTS.has(effectiveModel);
|
|
963
|
-
if (isDefault) {
|
|
964
|
-
const suggestedAlias = mapToAgentModel(input.modelSuggestion.model, effectiveDecision.tier ?? 'execute');
|
|
965
|
-
const validList = VALID_MODELS[effectiveProvider] ?? [];
|
|
966
|
-
if (validList.includes(suggestedAlias)) {
|
|
967
|
-
effectiveModel = suggestedAlias;
|
|
968
|
-
effectiveDecision = { ...effectiveDecision, model: suggestedAlias };
|
|
969
|
-
if (verbose) process.stderr.write(`\x1b[2m[dual-brain] modelSuggestion applied: ${suggestedAlias} (${input.modelSuggestion.reason})\x1b[0m\n`);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// ── Preflight auth check ─────────────────────────────────────────────────
|
|
975
|
-
// Verify the target provider CLI is present and responsive before dispatching.
|
|
976
|
-
// Runs after model/provider resolution so we check the effective provider.
|
|
977
|
-
const preflight = await preflightAuth(effectiveProvider, cwd);
|
|
978
|
-
if (!preflight.ready) {
|
|
979
|
-
// Check if the other provider is available as a fallback
|
|
980
|
-
const otherProvider = effectiveProvider === 'claude' ? 'openai' : 'claude';
|
|
981
|
-
const otherPreflight = await preflightAuth(otherProvider, cwd);
|
|
982
|
-
const fallbackNote = otherPreflight.ready
|
|
983
|
-
? ` Fallback available: ${otherProvider}.`
|
|
984
|
-
: '';
|
|
985
|
-
const errMsg = `${preflight.error}. ${preflight.suggestion}${fallbackNote}`;
|
|
986
|
-
return {
|
|
987
|
-
status: 'error',
|
|
988
|
-
provider: effectiveProvider,
|
|
989
|
-
model: effectiveModel,
|
|
990
|
-
command: null,
|
|
991
|
-
exitCode: null,
|
|
992
|
-
summary: errMsg,
|
|
993
|
-
durationMs: 0,
|
|
994
|
-
usage: null,
|
|
995
|
-
error: errMsg,
|
|
996
|
-
authVerified: false,
|
|
997
|
-
suggestion: preflight.suggestion,
|
|
998
|
-
};
|
|
999
|
-
}
|
|
1000
|
-
// ── End preflight auth check ─────────────────────────────────────────────
|
|
1001
|
-
|
|
1002
|
-
// ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
|
|
1003
|
-
if (tier === 'execute' && decision.owns && !decision._force) {
|
|
1004
|
-
const wtCheck = await checkWorktreeClean(decision.owns, cwd);
|
|
1005
|
-
if (!wtCheck.safe) {
|
|
1006
|
-
const msg = `Uncommitted changes conflict with agent scope: ${wtCheck.conflicts.join(', ')}. Commit or stash before dispatching.`;
|
|
1007
|
-
return {
|
|
1008
|
-
status: 'error',
|
|
1009
|
-
provider: effectiveProvider,
|
|
1010
|
-
model: effectiveModel,
|
|
1011
|
-
command: null,
|
|
1012
|
-
exitCode: null,
|
|
1013
|
-
summary: msg,
|
|
1014
|
-
durationMs: 0,
|
|
1015
|
-
usage: null,
|
|
1016
|
-
error: msg,
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// ── Worktree isolation decision ──────────────────────────────────────────────
|
|
1022
|
-
// Compute whether this dispatch should run in an isolated worktree based on
|
|
1023
|
-
// risk level, file-edit volume, and security/auth signals in the prompt.
|
|
1024
|
-
const SECURITY_PATTERN = /\b(auth|secret|token|credential|password|key|oauth|jwt|session|permission|role|acl)\b/i;
|
|
1025
|
-
const decisionRisk = (decision.risk ?? 'low').toLowerCase();
|
|
1026
|
-
const decisionFilesEst = decision.filesEstimate ?? 0;
|
|
1027
|
-
const riskIsElevated = decisionRisk === 'medium' || decisionRisk === 'high' || decisionRisk === 'critical';
|
|
1028
|
-
const manyFiles = decisionFilesEst >= 3;
|
|
1029
|
-
const hasSecurity = SECURITY_PATTERN.test(prompt);
|
|
1030
|
-
const useWorktree = input.useWorktree ?? (riskIsElevated || manyFiles || hasSecurity);
|
|
1031
|
-
|
|
1032
|
-
// Propagate useWorktree onto effectiveDecision so callers can inspect it
|
|
1033
|
-
if (useWorktree) {
|
|
1034
|
-
effectiveDecision = { ...effectiveDecision, useWorktree: true };
|
|
1035
|
-
}
|
|
1036
|
-
// ── End worktree isolation decision ─────────────────────────────────────────
|
|
1037
|
-
|
|
1038
|
-
// ── Native Claude Code dispatch ──────────────────────────────────────────────
|
|
1039
|
-
// When running inside Claude Code AND the provider is claude, execute via the
|
|
1040
|
-
// claude CLI directly (foreground subprocess) so results are captured and returned.
|
|
1041
|
-
// DUAL_BRAIN_DISPATCH=1 is set so the enforce-tier hook allows this agent call.
|
|
1042
|
-
if (isInsideClaude() && effectiveProvider === 'claude') {
|
|
1043
|
-
const nativeDescriptor = buildNativeDispatch(
|
|
1044
|
-
effectiveDecision,
|
|
1045
|
-
prompt,
|
|
1046
|
-
{ worktree: useWorktree, maxTurns: input.maxTurns },
|
|
1047
|
-
);
|
|
1048
|
-
|
|
1049
|
-
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
1050
|
-
|
|
1051
|
-
if (dryRun) {
|
|
1052
|
-
return {
|
|
1053
|
-
status: 'dry-run',
|
|
1054
|
-
provider: effectiveProvider,
|
|
1055
|
-
model: effectiveModel,
|
|
1056
|
-
specialist: specialist ?? 'generic',
|
|
1057
|
-
command,
|
|
1058
|
-
nativeDispatch: nativeDescriptor,
|
|
1059
|
-
exitCode: null,
|
|
1060
|
-
summary: null,
|
|
1061
|
-
durationMs: 0,
|
|
1062
|
-
usage: null,
|
|
1063
|
-
error: null,
|
|
1064
|
-
authVerified: true,
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
_recordDispatchBudget(prompt);
|
|
1069
|
-
|
|
1070
|
-
const dispatchEnv = { DUAL_BRAIN_DISPATCH: '1' };
|
|
1071
|
-
|
|
1072
|
-
// ── Auto-heal failover retry loop (native Claude path) ────────────────
|
|
1073
|
-
const MAX_FAILOVER_ATTEMPTS = 2;
|
|
1074
|
-
let currentProvider = effectiveProvider;
|
|
1075
|
-
let currentModel = effectiveModel;
|
|
1076
|
-
let currentDecision = effectiveDecision;
|
|
1077
|
-
let currentCommand = command;
|
|
1078
|
-
let lastRaw;
|
|
1079
|
-
|
|
1080
|
-
for (let attempt = 0; attempt <= MAX_FAILOVER_ATTEMPTS; attempt++) {
|
|
1081
|
-
lastRaw = await runProcess(currentCommand, cwd, timeoutMs, dispatchEnv);
|
|
1082
|
-
if (lastRaw.exitCode === 0 || !isRetryableFailure(lastRaw) || attempt === MAX_FAILOVER_ATTEMPTS) break;
|
|
1083
|
-
|
|
1084
|
-
const failoverList = getFailoverOrder(
|
|
1085
|
-
{ provider: currentProvider, model: currentModel, tier },
|
|
1086
|
-
input.profile ?? {},
|
|
1087
|
-
);
|
|
1088
|
-
if (failoverList.length === 0) break;
|
|
1089
|
-
|
|
1090
|
-
const next = failoverList[0];
|
|
1091
|
-
const reason = `${lastRaw.stderr || lastRaw.stdout}`.slice(0, 120);
|
|
1092
|
-
logFailover({ from: `${currentProvider}/${currentModel}`, to: `${next.provider}/${next.model}`, reason, attempt: attempt + 1 });
|
|
1093
|
-
process.stderr.write(`\x1b[2m[dual-brain] Provider busy, failing over to ${next.label}...\x1b[0m\n`);
|
|
1094
|
-
|
|
1095
|
-
markHot(currentProvider, currentModel, cwd);
|
|
1096
|
-
currentProvider = next.provider;
|
|
1097
|
-
currentModel = next.model;
|
|
1098
|
-
currentDecision = { ...currentDecision, provider: currentProvider, model: currentModel };
|
|
1099
|
-
currentCommand = buildCommand(currentDecision, prompt, files, cwd);
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
const { exitCode, stdout, stderr, durationMs } = lastRaw;
|
|
1103
|
-
// ── End failover loop ────────────────────────────────────────────────
|
|
1104
|
-
|
|
1105
|
-
// Extract token usage from JSON output if available
|
|
1106
|
-
let usage = null;
|
|
1107
|
-
try {
|
|
1108
|
-
const parsed = JSON.parse(stdout);
|
|
1109
|
-
if (parsed?.usage) {
|
|
1110
|
-
usage = { inputTokens: parsed.usage.input_tokens ?? 0, outputTokens: parsed.usage.output_tokens ?? 0 };
|
|
1111
|
-
}
|
|
1112
|
-
} catch {}
|
|
1113
|
-
|
|
1114
|
-
const success = exitCode === 0;
|
|
1115
|
-
const errorText = (stderr || stdout).slice(0, 500);
|
|
1116
|
-
const summary = success ? compressResult(stdout) : compressResult(stderr || stdout);
|
|
1117
|
-
|
|
1118
|
-
// ── Health tracking ────────────────────────────────────────────────────
|
|
1119
|
-
if (success) {
|
|
1120
|
-
recordDuration(currentProvider, currentModel, durationMs);
|
|
1121
|
-
const median = medianDuration(currentProvider, currentModel);
|
|
1122
|
-
if (median !== null && durationMs > median * 3) {
|
|
1123
|
-
markDegraded(currentProvider, currentModel, cwd);
|
|
1124
|
-
} else {
|
|
1125
|
-
markHealthy(currentProvider, currentModel, cwd);
|
|
1126
|
-
}
|
|
1127
|
-
const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
1128
|
-
recordDispatch(currentProvider, currentModel, totalTokens, cwd);
|
|
1129
|
-
} else {
|
|
1130
|
-
if (RATE_LIMIT_PATTERNS.test(errorText)) {
|
|
1131
|
-
markHot(currentProvider, currentModel, cwd);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
// ── End health tracking ────────────────────────────────────────────────
|
|
1135
|
-
|
|
1136
|
-
recordUsage({
|
|
1137
|
-
provider: currentProvider,
|
|
1138
|
-
model: currentModel,
|
|
1139
|
-
tier,
|
|
1140
|
-
durationMs,
|
|
1141
|
-
inputTokens: usage?.inputTokens ?? null,
|
|
1142
|
-
outputTokens: usage?.outputTokens ?? null,
|
|
1143
|
-
success,
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
// ── Auto-review annotation ────────────────────────────────────────────────
|
|
1147
|
-
// When execution changed files at medium+ risk, stamp result with a pending
|
|
1148
|
-
// review note. The opposite provider from the one that did the work reviews
|
|
1149
|
-
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
1150
|
-
let autoReview;
|
|
1151
|
-
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
1152
|
-
try {
|
|
1153
|
-
const reviewProvider = currentProvider === 'claude' ? 'openai' : 'claude';
|
|
1154
|
-
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
1155
|
-
} catch {
|
|
1156
|
-
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
1157
|
-
}
|
|
1158
|
-
} else {
|
|
1159
|
-
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1160
|
-
}
|
|
1161
|
-
// ── End auto-review annotation ────────────────────────────────────────────
|
|
1162
|
-
|
|
1163
|
-
const nativeResult = {
|
|
1164
|
-
status: success ? 'completed' : 'failed',
|
|
1165
|
-
type: 'native-agent',
|
|
1166
|
-
provider: currentProvider,
|
|
1167
|
-
model: currentModel,
|
|
1168
|
-
specialist: specialist ?? 'generic',
|
|
1169
|
-
command: currentCommand,
|
|
1170
|
-
nativeDispatch: nativeDescriptor,
|
|
1171
|
-
exitCode,
|
|
1172
|
-
summary,
|
|
1173
|
-
durationMs,
|
|
1174
|
-
usage,
|
|
1175
|
-
worktreeUsed: useWorktree,
|
|
1176
|
-
autoReview,
|
|
1177
|
-
authVerified: true,
|
|
1178
|
-
error: success ? null : errorText.slice(0, 200),
|
|
1179
|
-
};
|
|
1180
|
-
try {
|
|
1181
|
-
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1182
|
-
recordDispatchOutcome(input, nativeResult);
|
|
1183
|
-
} catch { /* never block */ }
|
|
1184
|
-
|
|
1185
|
-
// ── Self-correction: intelligent retry after failover exhaustion ──────────
|
|
1186
|
-
if (!success) {
|
|
1187
|
-
const attemptNumber = input._retryAttempt || 1;
|
|
1188
|
-
try {
|
|
1189
|
-
const { shouldRetry } = await import('./self-correct.mjs');
|
|
1190
|
-
const retry = shouldRetry(nativeResult, decision, attemptNumber);
|
|
1191
|
-
if (retry.retry && retry.decision) {
|
|
1192
|
-
if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
|
|
1193
|
-
return dispatch({
|
|
1194
|
-
...input,
|
|
1195
|
-
decision: retry.decision,
|
|
1196
|
-
_retryAttempt: attemptNumber + 1,
|
|
1197
|
-
_skipPreDispatchThink: retry.strategy !== 'rethink',
|
|
1198
|
-
_skipRelatedContext: true,
|
|
1199
|
-
});
|
|
1200
|
-
} else if (verbose) {
|
|
1201
|
-
process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
|
|
1202
|
-
}
|
|
1203
|
-
} catch { /* non-blocking — if self-correct fails, return original failure */ }
|
|
1204
|
-
}
|
|
1205
|
-
// ── End self-correction ───────────────────────────────────────────────────
|
|
1206
|
-
|
|
1207
|
-
return nativeResult;
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
1211
|
-
|
|
1212
|
-
if (dryRun) {
|
|
1213
|
-
return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null, authVerified: true };
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
// Record this dispatch against the budget
|
|
1217
|
-
_recordDispatchBudget(prompt);
|
|
1218
|
-
|
|
1219
|
-
// ── Auto-heal failover retry loop (subprocess path) ──────────────────────
|
|
1220
|
-
const MAX_FAILOVER_ATTEMPTS_SUB = 2;
|
|
1221
|
-
let subProvider = effectiveProvider;
|
|
1222
|
-
let subModel = effectiveModel;
|
|
1223
|
-
let subDecision = effectiveDecision;
|
|
1224
|
-
let subCommand = command;
|
|
1225
|
-
let subRaw;
|
|
1226
|
-
|
|
1227
|
-
for (let attempt = 0; attempt <= MAX_FAILOVER_ATTEMPTS_SUB; attempt++) {
|
|
1228
|
-
subRaw = await runProcess(subCommand, cwd, timeoutMs);
|
|
1229
|
-
if (subRaw.exitCode === 0 || !isRetryableFailure(subRaw) || attempt === MAX_FAILOVER_ATTEMPTS_SUB) break;
|
|
1230
|
-
|
|
1231
|
-
const failoverList = getFailoverOrder(
|
|
1232
|
-
{ provider: subProvider, model: subModel, tier },
|
|
1233
|
-
input.profile ?? {},
|
|
1234
|
-
);
|
|
1235
|
-
if (failoverList.length === 0) break;
|
|
1236
|
-
|
|
1237
|
-
const next = failoverList[0];
|
|
1238
|
-
const reason = `${subRaw.stderr || subRaw.stdout}`.slice(0, 120);
|
|
1239
|
-
logFailover({ from: `${subProvider}/${subModel}`, to: `${next.provider}/${next.model}`, reason, attempt: attempt + 1 });
|
|
1240
|
-
process.stderr.write(`\x1b[2m[dual-brain] Provider busy, failing over to ${next.label}...\x1b[0m\n`);
|
|
1241
|
-
|
|
1242
|
-
markHot(subProvider, subModel, cwd);
|
|
1243
|
-
subProvider = next.provider;
|
|
1244
|
-
subModel = next.model;
|
|
1245
|
-
subDecision = { ...subDecision, provider: subProvider, model: subModel };
|
|
1246
|
-
subCommand = buildCommand(subDecision, prompt, files, cwd);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const { exitCode, stdout, stderr, durationMs } = subRaw;
|
|
1250
|
-
// ── End failover loop ──────────────────────────────────────────────────────
|
|
1251
|
-
|
|
1252
|
-
// Extract token usage from JSON output if available
|
|
1253
|
-
let usage = null;
|
|
1254
|
-
try {
|
|
1255
|
-
const parsed = JSON.parse(stdout);
|
|
1256
|
-
if (parsed?.usage) {
|
|
1257
|
-
usage = { inputTokens: parsed.usage.input_tokens ?? 0, outputTokens: parsed.usage.output_tokens ?? 0 };
|
|
1258
|
-
}
|
|
1259
|
-
} catch {}
|
|
1260
|
-
|
|
1261
|
-
const success = exitCode === 0;
|
|
1262
|
-
const errorText = (stderr || stdout).slice(0, 500);
|
|
1263
|
-
const summary = success ? compressResult(stdout) : compressResult(stderr || stdout);
|
|
1264
|
-
|
|
1265
|
-
// ── Health tracking ──────────────────────────────────────────────────────
|
|
1266
|
-
if (success) {
|
|
1267
|
-
recordDuration(subProvider, subModel, durationMs);
|
|
1268
|
-
const median = medianDuration(subProvider, subModel);
|
|
1269
|
-
if (median !== null && durationMs > median * 3) {
|
|
1270
|
-
markDegraded(subProvider, subModel, cwd);
|
|
1271
|
-
} else {
|
|
1272
|
-
markHealthy(subProvider, subModel, cwd);
|
|
1273
|
-
}
|
|
1274
|
-
const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
1275
|
-
recordDispatch(subProvider, subModel, totalTokens, cwd);
|
|
1276
|
-
} else {
|
|
1277
|
-
if (RATE_LIMIT_PATTERNS.test(errorText)) {
|
|
1278
|
-
markHot(subProvider, subModel, cwd);
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
// ── End health tracking ──────────────────────────────────────────────────
|
|
1282
|
-
|
|
1283
|
-
recordUsage({
|
|
1284
|
-
provider: subProvider,
|
|
1285
|
-
model: subModel,
|
|
1286
|
-
tier,
|
|
1287
|
-
durationMs,
|
|
1288
|
-
inputTokens: usage?.inputTokens ?? null,
|
|
1289
|
-
outputTokens: usage?.outputTokens ?? null,
|
|
1290
|
-
success,
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
// ── Auto-review annotation ──────────────────────────────────────────────────
|
|
1294
|
-
// When execution changed files at medium+ risk, stamp result with a pending
|
|
1295
|
-
// review note. The opposite provider from the one that did the work reviews
|
|
1296
|
-
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
1297
|
-
let autoReview;
|
|
1298
|
-
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
1299
|
-
try {
|
|
1300
|
-
const reviewProvider = subProvider === 'claude' ? 'openai' : 'claude';
|
|
1301
|
-
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
1302
|
-
} catch {
|
|
1303
|
-
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
1304
|
-
}
|
|
1305
|
-
} else {
|
|
1306
|
-
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1307
|
-
}
|
|
1308
|
-
// ── End auto-review annotation ──────────────────────────────────────────────
|
|
1309
|
-
|
|
1310
|
-
const subResult = {
|
|
1311
|
-
status: success ? 'completed' : 'failed',
|
|
1312
|
-
provider: subProvider,
|
|
1313
|
-
model: subModel,
|
|
1314
|
-
specialist: specialist ?? 'generic',
|
|
1315
|
-
command: subCommand,
|
|
1316
|
-
exitCode,
|
|
1317
|
-
summary,
|
|
1318
|
-
durationMs,
|
|
1319
|
-
usage,
|
|
1320
|
-
worktreeUsed: useWorktree,
|
|
1321
|
-
autoReview,
|
|
1322
|
-
authVerified: true,
|
|
1323
|
-
error: success ? null : errorText.slice(0, 200),
|
|
1324
|
-
};
|
|
1325
|
-
try {
|
|
1326
|
-
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1327
|
-
recordDispatchOutcome(input, subResult);
|
|
1328
|
-
} catch { /* never block */ }
|
|
1329
|
-
|
|
1330
|
-
// ── Self-correction: intelligent retry after failover exhaustion ──────────
|
|
1331
|
-
if (!success) {
|
|
1332
|
-
const attemptNumber = input._retryAttempt || 1;
|
|
1333
|
-
try {
|
|
1334
|
-
const { shouldRetry } = await import('./self-correct.mjs');
|
|
1335
|
-
const retry = shouldRetry(subResult, decision, attemptNumber);
|
|
1336
|
-
if (retry.retry && retry.decision) {
|
|
1337
|
-
if (verbose) process.stderr.write(`[dual-brain] self-correct: ${retry.strategy} (attempt ${attemptNumber + 1}, reason: ${retry.reason})\n`);
|
|
1338
|
-
return dispatch({
|
|
1339
|
-
...input,
|
|
1340
|
-
decision: retry.decision,
|
|
1341
|
-
_retryAttempt: attemptNumber + 1,
|
|
1342
|
-
_skipPreDispatchThink: retry.strategy !== 'rethink',
|
|
1343
|
-
_skipRelatedContext: true,
|
|
1344
|
-
});
|
|
1345
|
-
} else if (verbose) {
|
|
1346
|
-
process.stderr.write(`[dual-brain] self-correct: giving up (${retry.reason})\n`);
|
|
1347
|
-
}
|
|
1348
|
-
} catch { /* non-blocking — if self-correct fails, return original failure */ }
|
|
1349
|
-
}
|
|
1350
|
-
// ── End self-correction ───────────────────────────────────────────────────
|
|
1351
|
-
|
|
1352
|
-
return subResult;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// ─── Dual-brain dispatch (parallel) ───────────────────────────────────────────
|
|
1356
|
-
async function dispatchDualBrain(input = {}) {
|
|
1357
|
-
const { decision = {}, files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
|
|
1358
|
-
let { prompt } = input;
|
|
1359
|
-
if (!prompt) throw new Error('prompt is required');
|
|
1360
|
-
|
|
1361
|
-
// Safety gate: redact secrets before sending to either provider
|
|
1362
|
-
prompt = redact(prompt);
|
|
1363
|
-
|
|
1364
|
-
// Stamp with dispatch marker so enforce-tier.mjs allows this Agent call
|
|
1365
|
-
// Compute required tier for governance validation
|
|
1366
|
-
try {
|
|
1367
|
-
const scores = scoreTask({ intent: decision.tier, risk: decision.risk, files, objective: prompt.slice(0, 200) });
|
|
1368
|
-
decision = { ...decision, _requiredTier: computeRequiredTier(scores) };
|
|
1369
|
-
} catch { /* non-blocking */ }
|
|
1370
|
-
prompt = _prependDispatchMarker(prompt, decision);
|
|
1371
|
-
|
|
1372
|
-
// ── Situation brief injection ────────────────────────────────────────────────
|
|
1373
|
-
const _dualBrainBrief = typeof input.situationBrief === 'string' && input.situationBrief.trim()
|
|
1374
|
-
? input.situationBrief.trim()
|
|
1375
|
-
: null;
|
|
1376
|
-
if (_dualBrainBrief) {
|
|
1377
|
-
prompt = `[SITUATION BRIEF]\n${_dualBrainBrief}\n[END BRIEF]\n\n${prompt}`;
|
|
1378
|
-
}
|
|
1379
|
-
// ── End situation brief ──────────────────────────────────────────────────────
|
|
1380
|
-
|
|
1381
|
-
// Feature 1: Validate both sub-decisions before spawning anything
|
|
1382
|
-
const rt = await detectRuntime();
|
|
1383
|
-
const tier = decision.tier ?? 'execute';
|
|
1384
|
-
|
|
1385
|
-
const claudeDecision = { ...decision, provider: 'claude', model: decision.model ?? 'sonnet', tier };
|
|
1386
|
-
const _oaiDefault = tier === 'think' ? 'o3' : tier === 'search' ? 'gpt-4o-mini' : 'gpt-4o';
|
|
1387
|
-
const openaiDecision = { ...decision, provider: 'openai', model: decision.openaiModel ?? _oaiDefault, tier };
|
|
1388
|
-
|
|
1389
|
-
const validatedClaude = validateDispatch(claudeDecision, rt);
|
|
1390
|
-
const validatedOpenai = validateDispatch(openaiDecision, rt);
|
|
1391
|
-
|
|
1392
|
-
const [claudeResult, openaiResult] = await Promise.all([
|
|
1393
|
-
validatedClaude._error
|
|
1394
|
-
? Promise.resolve({ status: 'error', provider: 'claude', model: claudeDecision.model, command: null, exitCode: null, summary: validatedClaude._error, durationMs: 0, usage: null, error: validatedClaude._error })
|
|
1395
|
-
: dispatch({ decision: validatedClaude, prompt, files, cwd, dryRun, verbose }),
|
|
1396
|
-
validatedOpenai._error
|
|
1397
|
-
? Promise.resolve({ status: 'error', provider: 'openai', model: openaiDecision.model, command: null, exitCode: null, summary: validatedOpenai._error, durationMs: 0, usage: null, error: validatedOpenai._error })
|
|
1398
|
-
: dispatch({ decision: validatedOpenai, prompt, files, cwd, dryRun, verbose }),
|
|
1399
|
-
]);
|
|
1400
|
-
|
|
1401
|
-
return {
|
|
1402
|
-
tier,
|
|
1403
|
-
claude: claudeResult,
|
|
1404
|
-
openai: openaiResult,
|
|
1405
|
-
consensus: claudeResult.status === 'completed' && openaiResult.status === 'completed'
|
|
1406
|
-
? 'both-passed'
|
|
1407
|
-
: claudeResult.status === 'failed' && openaiResult.status === 'failed'
|
|
1408
|
-
? 'both-failed'
|
|
1409
|
-
: 'split',
|
|
1410
|
-
};
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
1414
|
-
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
1415
|
-
const args = process.argv.slice(2);
|
|
1416
|
-
const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; };
|
|
1417
|
-
|
|
1418
|
-
if (args.includes('--detect-runtime')) {
|
|
1419
|
-
const rt = await detectRuntime();
|
|
1420
|
-
console.log(JSON.stringify(rt, null, 2));
|
|
1421
|
-
process.exit(0);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
const prompt = flag('--prompt') || args.find(a => !a.startsWith('--'));
|
|
1425
|
-
if (!prompt) {
|
|
1426
|
-
console.error('Usage: node src/dispatch.mjs --prompt "..." [--provider claude|openai] [--model sonnet] [--tier execute] [--dry-run]');
|
|
1427
|
-
console.error(' node src/dispatch.mjs --detect-runtime');
|
|
1428
|
-
process.exit(1);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
const decision = {
|
|
1432
|
-
provider: flag('--provider') || 'claude',
|
|
1433
|
-
model: flag('--model') || 'sonnet',
|
|
1434
|
-
tier: flag('--tier') || 'execute',
|
|
1435
|
-
effort: flag('--effort') || null,
|
|
1436
|
-
};
|
|
1437
|
-
|
|
1438
|
-
try {
|
|
1439
|
-
const result = await dispatch({ decision, prompt, dryRun: args.includes('--dry-run') });
|
|
1440
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1441
|
-
} catch (err) {
|
|
1442
|
-
console.error('dispatch error:', err.message);
|
|
1443
|
-
process.exit(1);
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt, preflightAuth };
|