dual-brain 0.2.21 → 0.2.23
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/bin/dual-brain.mjs +136 -44
- package/hooks/enforce-tier.mjs +104 -3
- package/package.json +6 -2
- package/src/context-intel.mjs +156 -0
- package/src/dispatch.mjs +24 -0
- package/src/governance.mjs +279 -0
- package/src/templates.mjs +37 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import { loadRepoCache } from '../src/repo.mjs';
|
|
|
68
68
|
import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
|
|
69
69
|
|
|
70
70
|
import { box, bar, badge, menu, separator, panel, divider, statusChip, headerBar, prompt as tuiPrompt, signalLine } from '../src/tui.mjs';
|
|
71
|
+
import { checkBudget } from '../src/governance.mjs';
|
|
71
72
|
|
|
72
73
|
// ─── Dynamic imports for receipts + failure memory ───────────────────────────
|
|
73
74
|
|
|
@@ -2884,66 +2885,157 @@ async function mainScreen(rl, ask) {
|
|
|
2884
2885
|
if (_spinnerTimeout) clearTimeout(_spinnerTimeout);
|
|
2885
2886
|
if (dashSpinner) dashSpinner.succeed('Dashboard ready');
|
|
2886
2887
|
|
|
2887
|
-
// ── Stale hint ────────────────────────────────────────────────────────────
|
|
2888
|
-
if (staleCount >= 3) {
|
|
2889
|
-
process.stdout.write(`${DIM}${staleCount} stale sessions (>7d) — type "sessions" to manage${RST}\n`);
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
2888
|
// ── Render Studio Console (paneled layout) ────────────────────────────────
|
|
2893
2889
|
const CYAN = '\x1b[36m';
|
|
2894
2890
|
const panelW = Math.min(sepW + 2, 72);
|
|
2895
2891
|
|
|
2896
|
-
//
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2892
|
+
// ── Budget / governance data ──────────────────────────────────────────────
|
|
2893
|
+
let budgetInfo = null;
|
|
2894
|
+
try {
|
|
2895
|
+
const orchestratorCfg = JSON.parse(readFileSync(join(cwd, '.claude', 'orchestrator.json'), 'utf8'));
|
|
2896
|
+
budgetInfo = checkBudget(cwd, orchestratorCfg);
|
|
2897
|
+
} catch {
|
|
2898
|
+
// No orchestrator config or no state — use a fresh read with defaults
|
|
2899
|
+
try { budgetInfo = checkBudget(cwd, {}); } catch {}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// ── Panel 1: Providers + Budget (top priority — always render) ───────────
|
|
2903
|
+
{
|
|
2904
|
+
const providerLines = [];
|
|
2905
|
+
|
|
2906
|
+
// Provider health rows
|
|
2907
|
+
const claudeLabel = claudeAvail ? `${GRN}✓${RST} Claude` : `${RED}✗${RST} ${DIM}Claude${RST}`;
|
|
2908
|
+
const openaiLabel = openaiAvail ? `${GRN}✓${RST} GPT-4` : `${RED}✗${RST} ${DIM}GPT-4${RST}`;
|
|
2909
|
+
|
|
2910
|
+
// Detect subscription expiry warnings
|
|
2911
|
+
const claudeWarnStr = (claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < Date.now())
|
|
2912
|
+
? ` ${YLW}⚠ expired${RST}` : '';
|
|
2913
|
+
const openaiWarnStr = (openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < Date.now())
|
|
2914
|
+
? ` ${YLW}⚠ expired${RST}` : '';
|
|
2915
|
+
|
|
2916
|
+
const providerCols = `${claudeLabel}${claudeWarnStr} ${openaiLabel}${openaiWarnStr}`;
|
|
2917
|
+
|
|
2918
|
+
// Budget row
|
|
2919
|
+
let budgetRow = null;
|
|
2920
|
+
if (budgetInfo) {
|
|
2921
|
+
const spent = budgetInfo.spent.toFixed(2);
|
|
2922
|
+
const remaining = budgetInfo.remaining.toFixed(2);
|
|
2923
|
+
const limit = budgetInfo.limit.toFixed(2);
|
|
2924
|
+
const tc = budgetInfo.tierCounts || {};
|
|
2925
|
+
const tierStr = `${DIM}t1:${tc[1] || 0} t2:${tc[2] || 0} t3:${tc[3] || 0}${RST}`;
|
|
2926
|
+
|
|
2927
|
+
if (budgetInfo.blocked) {
|
|
2928
|
+
budgetRow = `${RED}✗${RST} Budget exhausted $${spent}/$${limit} ${tierStr}`;
|
|
2929
|
+
} else if (budgetInfo.warning) {
|
|
2930
|
+
budgetRow = `${YLW}⚠${RST} Budget low ${YLW}$${remaining} remaining${RST} of $${limit} ${tierStr}`;
|
|
2931
|
+
} else if (budgetInfo.spent > 0) {
|
|
2932
|
+
budgetRow = `${GRN}✓${RST} Budget ${DIM}$${spent} spent · $${remaining} remaining${RST} ${tierStr}`;
|
|
2933
|
+
} else {
|
|
2934
|
+
budgetRow = `${DIM}· Budget $0 spent · $${remaining} remaining t1:0 t2:0 t3:0${RST}`;
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
providerLines.push(providerCols);
|
|
2939
|
+
if (budgetRow) providerLines.push(budgetRow);
|
|
2940
|
+
|
|
2941
|
+
process.stdout.write('\n' + panel('dual-brain', providerLines, { width: panelW, titleColor: CYAN }) + '\n\n');
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// ── Panel 2: Workspace signals (contextual, semantic icons) ──────────────
|
|
2945
|
+
{
|
|
2946
|
+
const signalLines = [];
|
|
2947
|
+
|
|
2948
|
+
// Git workspace status
|
|
2949
|
+
if (gitBranch !== 'unknown') {
|
|
2950
|
+
const dirtyStr = gitUncommitted > 0
|
|
2951
|
+
? `${YLW}⚠${RST} ${gitUncommitted} uncommitted file${gitUncommitted !== 1 ? 's' : ''}`
|
|
2952
|
+
: `${GRN}✓${RST} ${DIM}clean${RST}`;
|
|
2953
|
+
const aheadStr = gitAheadCount > 0 ? ` ${YLW}⚠${RST} ${gitAheadCount} ahead of remote` : '';
|
|
2954
|
+
signalLines.push(`${DIM}${gitBranch}${RST} ${dirtyStr}${aheadStr}`);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// Last commit
|
|
2958
|
+
if (gitLastMsg) {
|
|
2959
|
+
const isStale = /\d{2,}d ago/.test(gitLastAgo);
|
|
2960
|
+
const icon = isStale ? `${YLW}⚠${RST}` : `${DIM}·${RST}`;
|
|
2961
|
+
signalLines.push(`${icon} ${DIM}${gitLastMsg} ${gitLastAgo}${RST}`);
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// Open PRs
|
|
2965
|
+
if (openPRs.length > 0) {
|
|
2966
|
+
const prSummary = openPRs.slice(0, 2).map(pr => `#${pr.number}`).join(', ');
|
|
2967
|
+
const trunc = openPRs.length > 2 ? ` +${openPRs.length - 2}` : '';
|
|
2968
|
+
signalLines.push(`${DIM}·${RST} ${openPRs.length} open PR${openPRs.length !== 1 ? 's' : ''}${DIM}: ${prSummary}${trunc}${RST}`);
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// Observer / awareness signals (high-priority only)
|
|
2972
|
+
for (const obs of quickObservations) {
|
|
2973
|
+
if (obs.priority === 'high') {
|
|
2974
|
+
signalLines.push(`${RED}✗${RST} ${obs.message}`);
|
|
2975
|
+
} else if (obs.priority === 'medium') {
|
|
2976
|
+
signalLines.push(`${YLW}⚠${RST} ${obs.message}`);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
// Risk / model registry
|
|
2981
|
+
if (awarenessLine3 && !/No risk flags/.test(awarenessLine3)) {
|
|
2982
|
+
const clean3 = awarenessLine3.replace(/\x1b\[[0-9;]*m/g, '').replace(/^[⚠✓]\s*/, '').trim();
|
|
2983
|
+
if (clean3) signalLines.push(`${YLW}⚠${RST} ${clean3}`);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// Stale sessions hint
|
|
2987
|
+
if (staleCount >= 3) {
|
|
2988
|
+
signalLines.push(`${DIM}· ${staleCount} stale sessions (>7d) — type "sessions" to manage${RST}`);
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
// Resume / continuation hint
|
|
2992
|
+
if (isReturning) {
|
|
2993
|
+
const labelTrunc = (resumeState.label || 'last session').slice(0, 40);
|
|
2994
|
+
const agePart = resumeState.ageLabel ? ` ${DIM}${resumeState.ageLabel}${RST}` : '';
|
|
2995
|
+
const nextPart = resumeState.nextAction ? ` ${DIM}→ ${resumeState.nextAction}${RST}` : '';
|
|
2996
|
+
signalLines.push(`${CYAN}↩${RST} Resume: ${BOLD}${labelTrunc}${RST}${agePart}${nextPart}`);
|
|
2997
|
+
}
|
|
2901
2998
|
|
|
2902
|
-
// Resume / prompt panel (only when there is something to show)
|
|
2903
|
-
if (isReturning || !anyProviderAvail) {
|
|
2904
|
-
const resumeContent = [];
|
|
2905
2999
|
if (!anyProviderAvail) {
|
|
2906
|
-
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
if (nextPart) resumeContent.push(`${DIM}Next step${RST} ${nextPart.replace(/^ · /, '')}`);
|
|
2913
|
-
}
|
|
2914
|
-
resumeContent.push('');
|
|
2915
|
-
resumeContent.push(` ${CYAN}›${RST} ${BOLD}${suggestions[0]}${RST} ${DIM}${suggestions[1] || ''}${RST} ${DIM}${suggestions[2] || ''}${RST}`);
|
|
2916
|
-
process.stdout.write(panel(isReturning ? 'Resume work' : 'Get started', resumeContent, { width: panelW }) + '\n\n');
|
|
2917
|
-
} else {
|
|
2918
|
-
// Fresh / no-resume state — just show suggestions inline
|
|
2919
|
-
const suggestContent = [` ${CYAN}›${RST} ${BOLD}${suggestions[0]}${RST} ${DIM}${suggestions[1] || ''}${RST} ${DIM}${suggestions[2] || ''}${RST}`];
|
|
2920
|
-
process.stdout.write(panel('Get started', suggestContent, { width: panelW }) + '\n\n');
|
|
3000
|
+
signalLines.push(`${RED}✗${RST} ${BOLD}No provider connected${RST} — run: dual-brain login`);
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
if (signalLines.length > 0) {
|
|
3004
|
+
process.stdout.write(panel('Workspace', signalLines, { width: panelW }) + '\n\n');
|
|
3005
|
+
}
|
|
2921
3006
|
}
|
|
2922
3007
|
|
|
2923
|
-
//
|
|
2924
|
-
|
|
2925
|
-
|
|
3008
|
+
// ── Panel 3: What do you want to do? (suggestions) ───────────────────────
|
|
3009
|
+
{
|
|
3010
|
+
const promptTitle = !anyProviderAvail ? 'Get started' : isReturning ? 'Continue' : 'Start';
|
|
3011
|
+
const suggestContent = suggestions.map((s, i) => {
|
|
3012
|
+
return i === 0
|
|
3013
|
+
? ` ${CYAN}›${RST} ${BOLD}${s}${RST}`
|
|
3014
|
+
: ` ${DIM}${s}${RST}`;
|
|
3015
|
+
});
|
|
3016
|
+
process.stdout.write(panel(promptTitle, suggestContent, { width: panelW }) + '\n\n');
|
|
2926
3017
|
}
|
|
2927
3018
|
|
|
2928
|
-
//
|
|
3019
|
+
// ── Shortcuts (vertical layout, one per line) ────────────────────────────
|
|
2929
3020
|
const shortcuts = [
|
|
2930
|
-
[`Enter`, isReturning ? 'resume last session' : 'start working'],
|
|
2931
|
-
[`n`,
|
|
2932
|
-
[`/`,
|
|
2933
|
-
[`s`,
|
|
2934
|
-
[`d`,
|
|
2935
|
-
[`a`,
|
|
2936
|
-
[`q`,
|
|
3021
|
+
[`Enter`, isReturning ? 'resume last session' : 'start working (or type a task)'],
|
|
3022
|
+
[`n`, 'new session'],
|
|
3023
|
+
[`/`, 'search sessions'],
|
|
3024
|
+
[`s`, 'settings & profiles'],
|
|
3025
|
+
[`d`, 'doctor — diagnose issues'],
|
|
3026
|
+
[`a`, profile.automode ? 'auto mode (on)' : 'auto mode (off)'],
|
|
3027
|
+
[`q`, 'quit'],
|
|
2937
3028
|
];
|
|
2938
|
-
process.stdout.write('\n');
|
|
2939
3029
|
for (const [key, label] of shortcuts) {
|
|
2940
|
-
const keyStr = key === 'Enter'
|
|
2941
|
-
|
|
2942
|
-
|
|
3030
|
+
const keyStr = key === 'Enter'
|
|
3031
|
+
? `${CYAN}Enter${RST}`
|
|
3032
|
+
: `${CYAN}${key}${RST} `;
|
|
3033
|
+
const padder = key === 'Enter' ? ' ' : '';
|
|
3034
|
+
process.stdout.write(` ${keyStr}${padder}${DIM}${label}${RST}\n`);
|
|
2943
3035
|
}
|
|
2944
3036
|
process.stdout.write('\n');
|
|
2945
3037
|
|
|
2946
|
-
// Input bar — rendered below
|
|
3038
|
+
// Input bar — rendered below shortcuts
|
|
2947
3039
|
const inputLeft = tuiPrompt('task or command...');
|
|
2948
3040
|
process.stdout.write(` ${inputLeft}\n`);
|
|
2949
3041
|
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -192,6 +192,96 @@ function quickPressureCheck(tier) {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// ─── Governance Check (inlined for standalone hook execution) ─────────────────
|
|
196
|
+
|
|
197
|
+
const GOVERNANCE_MODEL_TIERS = {
|
|
198
|
+
1: ['claude-haiku-4-5-20251001', 'haiku', 'gpt-4o-mini', 'o4-mini'],
|
|
199
|
+
2: ['claude-sonnet-4-6', 'sonnet', 'gpt-4o', 'gpt-4.1'],
|
|
200
|
+
3: ['claude-opus-4-6', 'claude-opus-4-7', 'opus', 'o3'],
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function getGovernanceTier(modelId) {
|
|
204
|
+
if (!modelId) return 2;
|
|
205
|
+
const normalized = String(modelId).toLowerCase();
|
|
206
|
+
for (const [tier, models] of Object.entries(GOVERNANCE_MODEL_TIERS)) {
|
|
207
|
+
if (models.some(m => normalized.includes(m))) return Number(tier);
|
|
208
|
+
}
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function loadWorkStyle() {
|
|
213
|
+
try {
|
|
214
|
+
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
215
|
+
return data.workStyle || data.active || 'auto';
|
|
216
|
+
} catch { return 'auto'; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function loadGovernanceBudget() {
|
|
220
|
+
const statePath = resolve(__dirname, '..', '..', '.dualbrain', 'governance-state.json');
|
|
221
|
+
try {
|
|
222
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
223
|
+
// Check staleness (30 min gap = new session)
|
|
224
|
+
const lastDispatch = raw.dispatches?.[raw.dispatches.length - 1];
|
|
225
|
+
if (lastDispatch && (Date.now() - Date.parse(lastDispatch.ts)) > 30 * 60 * 1000) {
|
|
226
|
+
return { totalEstimatedCost: 0 };
|
|
227
|
+
}
|
|
228
|
+
return raw;
|
|
229
|
+
} catch {
|
|
230
|
+
return { totalEstimatedCost: 0 };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function governanceCheck(input) {
|
|
235
|
+
const ti = input.tool_input || {};
|
|
236
|
+
const model = ti.model || '';
|
|
237
|
+
const tier = getGovernanceTier(model);
|
|
238
|
+
|
|
239
|
+
// Only apply governance enforcement to tier 3 models
|
|
240
|
+
if (tier < 3) return null;
|
|
241
|
+
|
|
242
|
+
const workStyle = loadWorkStyle();
|
|
243
|
+
|
|
244
|
+
// cost-saver profile: DENY tier 3
|
|
245
|
+
if (workStyle === 'cost-saver') {
|
|
246
|
+
return {
|
|
247
|
+
hookSpecificOutput: {
|
|
248
|
+
hookEventName: 'PreToolUse',
|
|
249
|
+
permissionDecision: 'deny',
|
|
250
|
+
permissionDecisionReason:
|
|
251
|
+
'[governance] Tier 3 (heavy) model denied — profile is cost-saver. Use tier 1-2 models or switch profile.',
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Budget check
|
|
257
|
+
try {
|
|
258
|
+
const configPath = resolve(__dirname, '..', 'orchestrator.json');
|
|
259
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
260
|
+
const sessionLimit = config?.budgets?.session_limit_usd || 10;
|
|
261
|
+
const state = loadGovernanceBudget();
|
|
262
|
+
const remaining = sessionLimit - (state.totalEstimatedCost || 0);
|
|
263
|
+
if (remaining <= 0) {
|
|
264
|
+
return {
|
|
265
|
+
hookSpecificOutput: {
|
|
266
|
+
hookEventName: 'PreToolUse',
|
|
267
|
+
permissionDecision: 'deny',
|
|
268
|
+
permissionDecisionReason:
|
|
269
|
+
`[governance] Session budget exhausted ($${state.totalEstimatedCost.toFixed(2)} / $${sessionLimit}). Wait for session reset or increase budget.`,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
} catch {}
|
|
274
|
+
|
|
275
|
+
// auto/balanced profile: emit warning for tier 3 (pipeline handles consent)
|
|
276
|
+
if (workStyle === 'auto' || workStyle === 'balanced') {
|
|
277
|
+
return {
|
|
278
|
+
systemMessage: `[governance] Tier 3 (heavy) model requested: ${model || 'opus'}. Profile "${workStyle}" requires consent for heavy models. Proceeding — pipeline will handle approval.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
195
285
|
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|where\s+is|list\s+files|read[-\s]?only|lookup|scan)\b/i;
|
|
196
286
|
const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
|
|
197
287
|
|
|
@@ -257,6 +347,16 @@ try {
|
|
|
257
347
|
// (If hasMarker is true OR the prompt is read-only we fall through to normal
|
|
258
348
|
// tier-routing logic below.)
|
|
259
349
|
|
|
350
|
+
// ── Governance enforcement (tier 3 gating + budget) ──────────────────────────
|
|
351
|
+
const govResult = governanceCheck(input);
|
|
352
|
+
if (govResult) {
|
|
353
|
+
if (govResult.hookSpecificOutput?.permissionDecision === 'deny') {
|
|
354
|
+
process.stdout.write(JSON.stringify(govResult));
|
|
355
|
+
process.exit(2);
|
|
356
|
+
}
|
|
357
|
+
// Non-blocking governance warning — will be included in final output
|
|
358
|
+
}
|
|
359
|
+
|
|
260
360
|
// Compute prompt hash early for duplicate detection and logging
|
|
261
361
|
const promptHash = computePromptHash(ti);
|
|
262
362
|
|
|
@@ -303,8 +403,9 @@ try {
|
|
|
303
403
|
let autoStatus = null;
|
|
304
404
|
|
|
305
405
|
// Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
|
|
406
|
+
const govWarning = govResult?.systemMessage || null;
|
|
306
407
|
const prependWarnings = (msg) => {
|
|
307
|
-
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
408
|
+
const parts = [govWarning, duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
308
409
|
return parts.join('\n\n');
|
|
309
410
|
};
|
|
310
411
|
|
|
@@ -408,7 +509,7 @@ try {
|
|
|
408
509
|
followed: true,
|
|
409
510
|
profile: profileName,
|
|
410
511
|
});
|
|
411
|
-
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
512
|
+
const onlyWarnings = [govWarning, duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
412
513
|
if (onlyWarnings) {
|
|
413
514
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
414
515
|
} else {
|
|
@@ -439,7 +540,7 @@ try {
|
|
|
439
540
|
followed: true,
|
|
440
541
|
profile: profileName,
|
|
441
542
|
});
|
|
442
|
-
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
543
|
+
const onlyWarnings = [govWarning, duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
443
544
|
if (onlyWarnings) {
|
|
444
545
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
445
546
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
"./simmer": "./src/simmer.mjs",
|
|
46
46
|
"./memory-tiers": "./src/memory-tiers.mjs",
|
|
47
47
|
"./envelope": "./src/envelope.mjs",
|
|
48
|
-
"./session-lock": "./src/session-lock.mjs"
|
|
48
|
+
"./session-lock": "./src/session-lock.mjs",
|
|
49
|
+
"./governance": "./src/governance.mjs",
|
|
50
|
+
"./context-intel": "./src/context-intel.mjs"
|
|
49
51
|
},
|
|
50
52
|
"keywords": [
|
|
51
53
|
"claude-code",
|
|
@@ -130,6 +132,8 @@
|
|
|
130
132
|
"src/memory-tiers.mjs",
|
|
131
133
|
"src/envelope.mjs",
|
|
132
134
|
"src/session-lock.mjs",
|
|
135
|
+
"src/governance.mjs",
|
|
136
|
+
"src/context-intel.mjs",
|
|
133
137
|
"bin/*.mjs",
|
|
134
138
|
"hooks/enforce-tier.mjs",
|
|
135
139
|
"hooks/cost-logger.mjs",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const MODEL_FORMAT = {
|
|
5
|
+
claude: 'xml', sonnet: 'xml', haiku: 'xml', opus: 'xml',
|
|
6
|
+
gpt: 'markdown', o3: 'markdown', 'o4-mini': 'markdown',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function detectFormat(targetModel, role) {
|
|
10
|
+
const m = targetModel.toLowerCase();
|
|
11
|
+
if (m.includes('o3') || (m.includes('opus') && role === 'thinker')) return 'prose';
|
|
12
|
+
if (m.includes('gpt') || m.includes('o3') || m.includes('o4')) return 'markdown';
|
|
13
|
+
return 'xml';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function selectRelevant(pack, role) {
|
|
17
|
+
const { intent, constraints, priorAttempts, repoState, fileSummaries,
|
|
18
|
+
acceptanceCriteria, files } = pack;
|
|
19
|
+
if (role === 'thinker') {
|
|
20
|
+
return { intent, constraints, priorAttempts, repoState,
|
|
21
|
+
fileSummaries, acceptanceCriteria };
|
|
22
|
+
}
|
|
23
|
+
if (role === 'worker') {
|
|
24
|
+
const inScope = [...(files?.explicit || []), ...(files?.gitChanged || [])];
|
|
25
|
+
return { intent, acceptanceCriteria, constraints, inScope };
|
|
26
|
+
}
|
|
27
|
+
// reviewer
|
|
28
|
+
return { intent, acceptanceCriteria, constraints, fileSummaries, repoState };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readFiles(paths, cwd) {
|
|
32
|
+
const base = cwd || process.cwd();
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const p of paths) {
|
|
35
|
+
const abs = resolve(base, p);
|
|
36
|
+
if (existsSync(abs)) {
|
|
37
|
+
try { out[p] = readFileSync(abs, 'utf8'); } catch { out[p] = '(unreadable)'; }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderForModel(sections, targetModel, role) {
|
|
44
|
+
const fmt = detectFormat(targetModel, role);
|
|
45
|
+
|
|
46
|
+
if (fmt === 'prose') {
|
|
47
|
+
const parts = [];
|
|
48
|
+
if (sections.intent) parts.push(`Task: ${sections.intent}`);
|
|
49
|
+
if (sections.acceptanceCriteria?.length)
|
|
50
|
+
parts.push(`Success looks like: ${sections.acceptanceCriteria.join('; ')}`);
|
|
51
|
+
if (sections.constraints?.length)
|
|
52
|
+
parts.push(`Constraints: ${sections.constraints.join('; ')}`);
|
|
53
|
+
if (sections.repoState) parts.push(`Repo: ${JSON.stringify(sections.repoState)}`);
|
|
54
|
+
if (sections.fileSummaries) parts.push(`Files: ${JSON.stringify(sections.fileSummaries)}`);
|
|
55
|
+
if (sections.priorAttempts?.length)
|
|
56
|
+
parts.push(`Prior attempts: ${JSON.stringify(sections.priorAttempts)}`);
|
|
57
|
+
return parts.join('\n\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fmt === 'markdown') {
|
|
61
|
+
const lines = [];
|
|
62
|
+
if (sections.intent) lines.push(`## Objective\n${sections.intent}`);
|
|
63
|
+
if (sections.constraints?.length)
|
|
64
|
+
lines.push(`## Constraints\n${sections.constraints.map(c => `- ${c}`).join('\n')}`);
|
|
65
|
+
if (sections.acceptanceCriteria?.length)
|
|
66
|
+
lines.push(`## Acceptance Criteria\n${sections.acceptanceCriteria.map(c => `- ${c}`).join('\n')}`);
|
|
67
|
+
if (sections.repoState) lines.push(`## Repo State\n\`\`\`json\n${JSON.stringify(sections.repoState, null, 2)}\n\`\`\``);
|
|
68
|
+
if (sections.fileSummaries) lines.push(`## Files\n\`\`\`json\n${JSON.stringify(sections.fileSummaries, null, 2)}\n\`\`\``);
|
|
69
|
+
if (sections.fileContents) {
|
|
70
|
+
lines.push('## File Contents');
|
|
71
|
+
for (const [p, content] of Object.entries(sections.fileContents))
|
|
72
|
+
lines.push(`### ${p}\n\`\`\`\n${content}\n\`\`\``);
|
|
73
|
+
}
|
|
74
|
+
if (sections.inScope?.length)
|
|
75
|
+
lines.push(`## In-Scope Files\n${sections.inScope.map(f => `- ${f}`).join('\n')}`);
|
|
76
|
+
if (sections.priorAttempts?.length)
|
|
77
|
+
lines.push(`## Prior Attempts\n${JSON.stringify(sections.priorAttempts, null, 2)}`);
|
|
78
|
+
return lines.join('\n\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// xml (Claude models)
|
|
82
|
+
const tags = [];
|
|
83
|
+
if (sections.intent) tags.push(`<objective>${sections.intent}</objective>`);
|
|
84
|
+
if (sections.constraints?.length)
|
|
85
|
+
tags.push(`<constraints>\n${sections.constraints.map(c => ` <constraint>${c}</constraint>`).join('\n')}\n</constraints>`);
|
|
86
|
+
if (sections.acceptanceCriteria?.length)
|
|
87
|
+
tags.push(`<criteria>\n${sections.acceptanceCriteria.map(c => ` <criterion>${c}</criterion>`).join('\n')}\n</criteria>`);
|
|
88
|
+
if (sections.repoState)
|
|
89
|
+
tags.push(`<repo_state>${JSON.stringify(sections.repoState)}</repo_state>`);
|
|
90
|
+
if (sections.fileSummaries)
|
|
91
|
+
tags.push(`<files>${JSON.stringify(sections.fileSummaries)}</files>`);
|
|
92
|
+
if (sections.fileContents) {
|
|
93
|
+
const fc = Object.entries(sections.fileContents)
|
|
94
|
+
.map(([p, c]) => ` <file path="${p}">\n${c}\n </file>`).join('\n');
|
|
95
|
+
tags.push(`<file_contents>\n${fc}\n</file_contents>`);
|
|
96
|
+
}
|
|
97
|
+
if (sections.inScope?.length)
|
|
98
|
+
tags.push(`<in_scope_files>\n${sections.inScope.map(f => ` <file>${f}</file>`).join('\n')}\n</in_scope_files>`);
|
|
99
|
+
if (sections.priorAttempts?.length)
|
|
100
|
+
tags.push(`<prior_attempts>${JSON.stringify(sections.priorAttempts)}</prior_attempts>`);
|
|
101
|
+
return `<context>\n${tags.join('\n')}\n</context>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function enforceTokenBudget(rendered, budget) {
|
|
105
|
+
const chars = budget * 4;
|
|
106
|
+
const originalTokens = Math.ceil(rendered.length / 4);
|
|
107
|
+
if (rendered.length <= chars) return { text: rendered, truncated: false, originalTokens, finalTokens: originalTokens };
|
|
108
|
+
|
|
109
|
+
// Try dropping prior_attempts / prior attempts block
|
|
110
|
+
let text = rendered
|
|
111
|
+
.replace(/<prior_attempts>[\s\S]*?<\/prior_attempts>/g, '')
|
|
112
|
+
.replace(/## Prior Attempts[\s\S]*?(?=\n## |$)/, '')
|
|
113
|
+
.replace(/Prior attempts:.*?(?=\n\n|$)/s, '');
|
|
114
|
+
|
|
115
|
+
if (text.length <= chars) return { text: text.trim(), truncated: true, originalTokens, finalTokens: Math.ceil(text.length / 4) };
|
|
116
|
+
|
|
117
|
+
// Summarize git/repo state
|
|
118
|
+
text = text
|
|
119
|
+
.replace(/<repo_state>[\s\S]*?<\/repo_state>/g, '<repo_state>(truncated)</repo_state>')
|
|
120
|
+
.replace(/## Repo State[\s\S]*?(?=\n## |$)/, '## Repo State\n(truncated)');
|
|
121
|
+
|
|
122
|
+
if (text.length <= chars) return { text: text.trim(), truncated: true, originalTokens, finalTokens: Math.ceil(text.length / 4) };
|
|
123
|
+
|
|
124
|
+
// Hard truncate
|
|
125
|
+
text = text.slice(0, chars) + '\n...(truncated)';
|
|
126
|
+
return { text, truncated: true, originalTokens, finalTokens: Math.ceil(text.length / 4) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function attachOutputSchema(role) {
|
|
130
|
+
if (role === 'thinker')
|
|
131
|
+
return 'Return JSON: { decision: string, confidence: 0-1, reasoning: string, workSpec: { objective, files, criteria } }';
|
|
132
|
+
if (role === 'worker')
|
|
133
|
+
return 'Return JSON: { filesChanged: string[], testsRun: boolean, issues: string[] }';
|
|
134
|
+
return 'Return JSON: { pass: boolean, findings: [{ severity, file, line, issue, fix }] }';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function shapeForRole(pack, role, targetModel, tokenBudget) {
|
|
138
|
+
const sections = selectRelevant(pack, role);
|
|
139
|
+
|
|
140
|
+
if (role === 'worker' && sections.inScope?.length) {
|
|
141
|
+
const cwd = pack.repoState?.cwd;
|
|
142
|
+
sections.fileContents = readFiles(sections.inScope, cwd);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rendered = renderForModel(sections, targetModel, role);
|
|
146
|
+
const { text, truncated, originalTokens, finalTokens } = enforceTokenBudget(rendered, tokenBudget);
|
|
147
|
+
const sectionKeys = Object.keys(sections);
|
|
148
|
+
|
|
149
|
+
return { shaped: text, role, model: targetModel, tokenEstimate: finalTokens, sections: sectionKeys };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function compilePacket(pack, role, targetModel, tokenBudget) {
|
|
153
|
+
const { shaped } = shapeForRole(pack, role, targetModel, tokenBudget);
|
|
154
|
+
const schema = attachOutputSchema(role);
|
|
155
|
+
return `${shaped}\n\n${schema}`;
|
|
156
|
+
}
|
package/src/dispatch.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { markHot, markDegraded, markHealthy, recordDispatch } from './health.mjs
|
|
|
16
16
|
import { redact } from './redact.mjs';
|
|
17
17
|
import { getFailoverOrder } from './decide.mjs';
|
|
18
18
|
import { getTemplate, renderPrompt, quickRender } from './templates.mjs';
|
|
19
|
+
import { compilePacket, shapeForRole } from './context-intel.mjs';
|
|
20
|
+
import { buildContextPack } from './context.mjs';
|
|
19
21
|
|
|
20
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
23
|
const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
|
|
@@ -755,6 +757,28 @@ async function dispatch(input = {}) {
|
|
|
755
757
|
// structured, typed prompts. Falls back to raw prompt when no template matches.
|
|
756
758
|
prompt = _renderTemplatedPrompt(prompt, decision);
|
|
757
759
|
|
|
760
|
+
// ── Context intelligence: model-specific prompt shaping ─────────────────────
|
|
761
|
+
// When we have files and a target model, shape the prompt context for optimal
|
|
762
|
+
// model consumption. This adds structured context without replacing the template output.
|
|
763
|
+
if (files.length > 0 || decision.tier) {
|
|
764
|
+
try {
|
|
765
|
+
const pack = await buildContextPack(prompt, files, cwd);
|
|
766
|
+
const role = decision.tier === 'think' ? 'thinker'
|
|
767
|
+
: decision.tier === 'review' ? 'reviewer'
|
|
768
|
+
: 'worker';
|
|
769
|
+
const targetModel = decision.model || 'sonnet';
|
|
770
|
+
const tokenBudget = role === 'thinker' ? 3000
|
|
771
|
+
: role === 'reviewer' ? 4000
|
|
772
|
+
: 8000;
|
|
773
|
+
const { shaped, tokenEstimate } = shapeForRole(pack, role, targetModel, tokenBudget);
|
|
774
|
+
if (shaped && tokenEstimate > 0) {
|
|
775
|
+
prompt = `${shaped}\n\n---\n\n${prompt}`;
|
|
776
|
+
if (verbose) process.stderr.write(`[dual-brain] context-intel: ${role} packet shaped for ${targetModel} (~${tokenEstimate} tokens)\n`);
|
|
777
|
+
}
|
|
778
|
+
} catch { /* non-blocking — context shaping failure never prevents dispatch */ }
|
|
779
|
+
}
|
|
780
|
+
// ── End context intelligence ─────────────────────────────────────────────────
|
|
781
|
+
|
|
758
782
|
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
759
783
|
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
760
784
|
// This closes the receipt → brief → next session loop automatically.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// governance.mjs — Model tier enforcement + multi-model collaboration
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// ─── Tier Definitions ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export const MODEL_TIERS = Object.freeze({
|
|
9
|
+
1: {
|
|
10
|
+
label: 'lightweight',
|
|
11
|
+
models: ['claude-haiku-4-5-20251001', 'haiku', 'gpt-4o-mini', 'o4-mini'],
|
|
12
|
+
autoApprove: true,
|
|
13
|
+
},
|
|
14
|
+
2: {
|
|
15
|
+
label: 'standard',
|
|
16
|
+
models: ['claude-sonnet-4-6', 'sonnet', 'gpt-4o', 'gpt-4.1'],
|
|
17
|
+
autoApprove: true,
|
|
18
|
+
},
|
|
19
|
+
3: {
|
|
20
|
+
label: 'heavy',
|
|
21
|
+
models: ['claude-opus-4-6', 'claude-opus-4-7', 'opus', 'o3'],
|
|
22
|
+
autoApprove: false, // requires consent check per profile
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Reverse lookup: model ID → tier number
|
|
27
|
+
export function getModelTier(modelId) {
|
|
28
|
+
if (!modelId) return 2; // default to standard
|
|
29
|
+
const normalized = String(modelId).toLowerCase();
|
|
30
|
+
for (const [tier, def] of Object.entries(MODEL_TIERS)) {
|
|
31
|
+
if (def.models.some(m => normalized.includes(m))) return Number(tier);
|
|
32
|
+
}
|
|
33
|
+
return 2; // unknown models default to standard
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Task Scoring ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function scoreTask(detection) {
|
|
39
|
+
// detection comes from detect.mjs — has intent, risk, scope, files, etc.
|
|
40
|
+
const scores = {
|
|
41
|
+
complexity: 0, // 0-3
|
|
42
|
+
risk: 0, // 0-3
|
|
43
|
+
creativity: 0, // 0-2
|
|
44
|
+
precision: 0, // 0-2
|
|
45
|
+
contextVolume: 0, // 0-3
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Complexity from file count / scope
|
|
49
|
+
const fileCount = detection?.files?.length || detection?.scope?.fileCount || 0;
|
|
50
|
+
if (fileCount >= 6) scores.complexity = 3;
|
|
51
|
+
else if (fileCount >= 3) scores.complexity = 2;
|
|
52
|
+
else if (fileCount >= 1) scores.complexity = 1;
|
|
53
|
+
|
|
54
|
+
// Risk from explicit risk field or keywords
|
|
55
|
+
const risk = detection?.risk || detection?.riskLevel || 'low';
|
|
56
|
+
const riskMap = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
57
|
+
scores.risk = riskMap[risk] ?? 0;
|
|
58
|
+
|
|
59
|
+
// Boost risk for security/auth/billing keywords
|
|
60
|
+
const text = (detection?.objective || detection?.intent || '').toLowerCase();
|
|
61
|
+
if (/\b(auth|security|credential|secret|billing|payment|migration|delete|drop)\b/.test(text)) {
|
|
62
|
+
scores.risk = Math.max(scores.risk, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Creativity from intent type
|
|
66
|
+
const intent = (detection?.intent || detection?.type || '').toLowerCase();
|
|
67
|
+
if (/\b(architect|design|brainstorm|explore|research)\b/.test(intent)) scores.creativity = 2;
|
|
68
|
+
else if (/\b(refactor|plan|decide)\b/.test(intent)) scores.creativity = 1;
|
|
69
|
+
|
|
70
|
+
// Precision — one-shot tasks need higher precision
|
|
71
|
+
if (/\b(security|deploy|publish|migration)\b/.test(text)) scores.precision = 2;
|
|
72
|
+
else if (/\b(implement|build|create)\b/.test(text)) scores.precision = 1;
|
|
73
|
+
|
|
74
|
+
// Context volume
|
|
75
|
+
const contextSize = detection?.contextTokens || detection?.estimatedContext || 0;
|
|
76
|
+
if (contextSize > 200000) scores.contextVolume = 3;
|
|
77
|
+
else if (contextSize > 50000) scores.contextVolume = 2;
|
|
78
|
+
else if (contextSize > 10000) scores.contextVolume = 1;
|
|
79
|
+
|
|
80
|
+
return scores;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function computeRequiredTier(scores) {
|
|
84
|
+
const total = Object.values(scores).reduce((a, b) => a + b, 0);
|
|
85
|
+
if (total <= 2) return 1;
|
|
86
|
+
if (total <= 6) return 2;
|
|
87
|
+
return 3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Governance Assessment ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// Profile governance defaults
|
|
93
|
+
const GOVERNANCE_PERMISSIONS = {
|
|
94
|
+
'auto': { 1: 'auto', 2: 'auto', 3: 'ask' },
|
|
95
|
+
'balanced': { 1: 'auto', 2: 'auto', 3: 'ask' },
|
|
96
|
+
'cost-saver': { 1: 'auto', 2: 'auto', 3: 'deny' },
|
|
97
|
+
'quality-first': { 1: 'auto', 2: 'auto', 3: 'auto' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Pricing per million tokens (input/output) for cost estimation
|
|
101
|
+
const MODEL_PRICING = {
|
|
102
|
+
'haiku': { input: 1.00, output: 5.00 },
|
|
103
|
+
'sonnet': { input: 3.00, output: 15.00 },
|
|
104
|
+
'opus': { input: 5.00, output: 25.00 },
|
|
105
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
106
|
+
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
107
|
+
'gpt-4.1': { input: 2.00, output: 8.00 },
|
|
108
|
+
'o3': { input: 2.00, output: 8.00 },
|
|
109
|
+
'o4-mini': { input: 1.10, output: 4.40 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function estimateCost(modelId, estimatedTokens = 8000) {
|
|
113
|
+
const normalized = String(modelId).toLowerCase();
|
|
114
|
+
let pricing = MODEL_PRICING['sonnet']; // default
|
|
115
|
+
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
|
116
|
+
if (normalized.includes(key)) { pricing = p; break; }
|
|
117
|
+
}
|
|
118
|
+
// Assume 20% input, 80% output for agent tasks
|
|
119
|
+
const inputTokens = estimatedTokens * 0.2;
|
|
120
|
+
const outputTokens = estimatedTokens * 0.8;
|
|
121
|
+
return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function assessGovernance(model, detection, profile) {
|
|
125
|
+
const tier = getModelTier(model);
|
|
126
|
+
const scores = scoreTask(detection);
|
|
127
|
+
const requiredTier = computeRequiredTier(scores);
|
|
128
|
+
const workStyle = profile?.workStyle || profile?.name || 'auto';
|
|
129
|
+
const permissions = GOVERNANCE_PERMISSIONS[workStyle] || GOVERNANCE_PERMISSIONS['auto'];
|
|
130
|
+
const permission = permissions[tier] || 'ask';
|
|
131
|
+
const estimatedCost = estimateCost(model);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
requestedTier: tier,
|
|
135
|
+
requiredTier,
|
|
136
|
+
overProvisioned: tier > requiredTier,
|
|
137
|
+
underProvisioned: tier < requiredTier,
|
|
138
|
+
permission, // 'auto' | 'ask' | 'deny'
|
|
139
|
+
estimatedCost,
|
|
140
|
+
scores,
|
|
141
|
+
justification: buildJustification(scores, tier, requiredTier),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildJustification(scores, requestedTier, requiredTier) {
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (scores.risk >= 2) parts.push('high-risk');
|
|
148
|
+
if (scores.complexity >= 2) parts.push('complex');
|
|
149
|
+
if (scores.creativity >= 2) parts.push('creative/architectural');
|
|
150
|
+
if (scores.contextVolume >= 2) parts.push('large-context');
|
|
151
|
+
if (requestedTier > requiredTier) parts.push('over-provisioned');
|
|
152
|
+
if (requestedTier < requiredTier) parts.push('under-provisioned');
|
|
153
|
+
return parts.join(', ') || 'standard task';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Collaboration Assessment ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export function shouldCollaborate(detection, governance, profile) {
|
|
159
|
+
// Never collaborate on tier-1 tasks
|
|
160
|
+
if (governance.requiredTier <= 1) return { collaborate: false };
|
|
161
|
+
|
|
162
|
+
// Never collaborate in cost-saver mode unless explicitly requested
|
|
163
|
+
const workStyle = profile?.workStyle || profile?.name || 'auto';
|
|
164
|
+
if (workStyle === 'cost-saver') return { collaborate: false };
|
|
165
|
+
|
|
166
|
+
// Check collaboration triggers (need ANY two)
|
|
167
|
+
const triggers = [];
|
|
168
|
+
const text = (detection?.objective || detection?.intent || '').toLowerCase();
|
|
169
|
+
|
|
170
|
+
if (/\b(auth|security|credential|billing|migration)\b/.test(text)) triggers.push('irreversibility');
|
|
171
|
+
if (detection?.ambiguity === 'high' || /\b(should we|how to|best approach|tradeoff)\b/.test(text)) triggers.push('ambiguity');
|
|
172
|
+
if (detection?.novelty === 'high' || /\b(new|first time|never done|greenfield)\b/.test(text)) triggers.push('novelty');
|
|
173
|
+
if ((detection?.files?.length || 0) >= 4 && /\b(security|performance|ux)\b/.test(text)) triggers.push('cross-domain');
|
|
174
|
+
if (governance.requestedTier >= 3 && detection?.confidence && detection.confidence < 0.8) triggers.push('low-confidence');
|
|
175
|
+
|
|
176
|
+
const shouldDo = triggers.length >= 2;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
collaborate: shouldDo,
|
|
180
|
+
triggers,
|
|
181
|
+
pattern: shouldDo ? selectPattern(triggers, detection) : null,
|
|
182
|
+
estimatedOverhead: shouldDo ? estimateCost('gpt-4.1') : 0, // secondary model cost
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function selectPattern(triggers, detection) {
|
|
187
|
+
const text = (detection?.objective || detection?.intent || '').toLowerCase();
|
|
188
|
+
|
|
189
|
+
// Security → adversarial review
|
|
190
|
+
if (triggers.includes('irreversibility') && /\b(auth|security|credential)\b/.test(text)) {
|
|
191
|
+
return 'adversarial-review';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Architecture/greenfield → second opinion (perspective rotation reserved for Phase 4)
|
|
195
|
+
if (triggers.includes('novelty') || triggers.includes('ambiguity')) {
|
|
196
|
+
return 'second-opinion';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Default
|
|
200
|
+
return 'second-opinion';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Governance State (Session Budget Tracking) ──────────────────────────────
|
|
204
|
+
|
|
205
|
+
const STATE_FILE = '.dualbrain/governance-state.json';
|
|
206
|
+
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min gap = new session
|
|
207
|
+
|
|
208
|
+
export function loadGovernanceState(cwd) {
|
|
209
|
+
const statePath = join(cwd, STATE_FILE);
|
|
210
|
+
try {
|
|
211
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
212
|
+
// Check if session is stale
|
|
213
|
+
const lastDispatch = raw.dispatches?.[raw.dispatches.length - 1];
|
|
214
|
+
if (lastDispatch && (Date.now() - Date.parse(lastDispatch.ts)) > SESSION_GAP_MS) {
|
|
215
|
+
// Stale session — reset
|
|
216
|
+
return freshState();
|
|
217
|
+
}
|
|
218
|
+
return raw;
|
|
219
|
+
} catch {
|
|
220
|
+
return freshState();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function freshState() {
|
|
225
|
+
return {
|
|
226
|
+
sessionStartedAt: new Date().toISOString(),
|
|
227
|
+
dispatches: [],
|
|
228
|
+
totalEstimatedCost: 0,
|
|
229
|
+
tierCounts: { 1: 0, 2: 0, 3: 0 },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function recordDispatch(cwd, tier, model, estimatedCost, approved = true) {
|
|
234
|
+
const state = loadGovernanceState(cwd);
|
|
235
|
+
state.dispatches.push({
|
|
236
|
+
tier,
|
|
237
|
+
model: String(model),
|
|
238
|
+
estimatedCost,
|
|
239
|
+
approved,
|
|
240
|
+
ts: new Date().toISOString(),
|
|
241
|
+
});
|
|
242
|
+
state.totalEstimatedCost += estimatedCost;
|
|
243
|
+
state.tierCounts[tier] = (state.tierCounts[tier] || 0) + 1;
|
|
244
|
+
|
|
245
|
+
const dir = join(cwd, '.dualbrain');
|
|
246
|
+
mkdirSync(dir, { recursive: true });
|
|
247
|
+
writeFileSync(join(cwd, STATE_FILE), JSON.stringify(state, null, 2) + '\n');
|
|
248
|
+
return state;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function checkBudget(cwd, orchestratorConfig) {
|
|
252
|
+
const state = loadGovernanceState(cwd);
|
|
253
|
+
const sessionLimit = orchestratorConfig?.budgets?.session_limit_usd || 10;
|
|
254
|
+
const remaining = sessionLimit - state.totalEstimatedCost;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
spent: state.totalEstimatedCost,
|
|
258
|
+
remaining,
|
|
259
|
+
limit: sessionLimit,
|
|
260
|
+
warning: remaining < sessionLimit * 0.2, // <20% remaining
|
|
261
|
+
blocked: remaining <= 0,
|
|
262
|
+
tierCounts: state.tierCounts,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Format for User Display ─────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export function formatGovernancePrompt(governance, collaboration) {
|
|
269
|
+
const tierLabel = MODEL_TIERS[governance.requestedTier]?.label || 'unknown';
|
|
270
|
+
const lines = [];
|
|
271
|
+
|
|
272
|
+
lines.push(`[governance] Task requires ${tierLabel} model (tier ${governance.requestedTier}, ~$${governance.estimatedCost.toFixed(2)})`);
|
|
273
|
+
if (governance.justification) lines.push(` Reason: ${governance.justification}`);
|
|
274
|
+
if (collaboration?.collaborate) {
|
|
275
|
+
lines.push(` + ${collaboration.pattern} with secondary model (+$${collaboration.estimatedOverhead.toFixed(2)})`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return lines.join('\n');
|
|
279
|
+
}
|
package/src/templates.mjs
CHANGED
|
@@ -142,8 +142,44 @@ const TEMPLATES = {
|
|
|
142
142
|
},
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
// ── Output schemas ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const OUTPUT_SCHEMAS = {
|
|
148
|
+
think: '{ "decision": "string", "confidence": 0.0-1.0, "reasoning": "string", "workSpec": { "objective": "string", "files": ["path"], "criteria": ["string"] } }',
|
|
149
|
+
execute: '{ "filesChanged": ["path"], "testsRun": boolean, "issues": ["string"] }',
|
|
150
|
+
review: '{ "pass": boolean, "findings": [{ "severity": "critical|high|medium|low", "file": "path", "line": number, "issue": "string", "fix": "string" }] }',
|
|
151
|
+
search: '{ "found": [{ "file": "path", "line": number, "snippet": "string" }], "confidence": 0.0-1.0 }',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// ── Model render hints ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const MODEL_RENDER_HINTS = {
|
|
157
|
+
xml: ['claude', 'sonnet', 'haiku', 'opus'],
|
|
158
|
+
markdown: ['gpt', 'gpt-4', 'gpt-4.1', 'gpt-4o'],
|
|
159
|
+
prose: ['o3', 'o4-mini'],
|
|
160
|
+
};
|
|
161
|
+
|
|
145
162
|
// ── Template API ─────────────────────────────────────────────────────────────
|
|
146
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Get the structured output schema for a tier.
|
|
166
|
+
*/
|
|
167
|
+
export function getOutputSchema(tier) {
|
|
168
|
+
return OUTPUT_SCHEMAS[tier] || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the preferred prompt rendering format for a given model ID.
|
|
173
|
+
*/
|
|
174
|
+
export function getRenderHint(modelId) {
|
|
175
|
+
if (!modelId) return 'markdown';
|
|
176
|
+
const normalized = String(modelId).toLowerCase();
|
|
177
|
+
for (const [format, patterns] of Object.entries(MODEL_RENDER_HINTS)) {
|
|
178
|
+
if (patterns.some(p => normalized.includes(p))) return format;
|
|
179
|
+
}
|
|
180
|
+
return 'markdown';
|
|
181
|
+
}
|
|
182
|
+
|
|
147
183
|
/**
|
|
148
184
|
* Get a template by tier name.
|
|
149
185
|
*/
|
|
@@ -193,6 +229,7 @@ export function renderPrompt(tier, contract, context = {}) {
|
|
|
193
229
|
errors: [],
|
|
194
230
|
template: { id: template.id, version: template.version },
|
|
195
231
|
contract: { ...contract, id: contract.id || Date.now().toString(36) },
|
|
232
|
+
outputSchema: OUTPUT_SCHEMAS[tier] || null,
|
|
196
233
|
stats: {
|
|
197
234
|
words: prompt.split(/\s+/).length,
|
|
198
235
|
chars: prompt.length,
|