dual-brain 0.2.22 → 0.2.24
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 +29 -1
- package/package.json +4 -2
- package/src/context-intel.mjs +156 -0
- package/src/dispatch.mjs +58 -8
- package/src/outcome.mjs +31 -1
- package/src/pipeline.mjs +171 -6
- 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
|
@@ -290,7 +290,19 @@ const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?re
|
|
|
290
290
|
const WRITE_INTENT_WORDS = /\b(edit|fix|change|update|create|write|modify|implement|refactor|add|remove|delete|build|install|configure|patch|apply|move|rename|migrate|replace|rewrite|generate|scaffold|init(?:ialize)?|setup|deploy|run\s+tests?|commit|push|install|uninstall)\b/i;
|
|
291
291
|
|
|
292
292
|
// Dispatch marker prefix stamped by src/dispatch.mjs for all legitimate dispatches.
|
|
293
|
-
const DISPATCH_MARKER_RE = /<!--\s*dual-brain-dispatch
|
|
293
|
+
const DISPATCH_MARKER_RE = /<!--\s*dual-brain-dispatch:[a-z0-9|:.\-]+\s*-->/i;
|
|
294
|
+
|
|
295
|
+
function parseDispatchMarker(prompt) {
|
|
296
|
+
const match = prompt?.match(/<!-- dual-brain-dispatch:([^>]+) -->/);
|
|
297
|
+
if (!match) return null;
|
|
298
|
+
const parts = match[1].split('|');
|
|
299
|
+
const fields = { runId: parts[0] };
|
|
300
|
+
for (const part of parts.slice(1)) {
|
|
301
|
+
const [key, val] = part.split(':');
|
|
302
|
+
if (key && val) fields[key] = val;
|
|
303
|
+
}
|
|
304
|
+
return fields;
|
|
305
|
+
}
|
|
294
306
|
|
|
295
307
|
/**
|
|
296
308
|
* Determine whether a prompt is purely read-only (no write keywords at all).
|
|
@@ -357,6 +369,22 @@ try {
|
|
|
357
369
|
// Non-blocking governance warning — will be included in final output
|
|
358
370
|
}
|
|
359
371
|
|
|
372
|
+
// ── Over-provisioning check via enriched dispatch marker ───────────────────
|
|
373
|
+
// If the marker carries governance scores, validate that the model tier isn't
|
|
374
|
+
// higher than the task actually requires (closes the brainstorm-opus loophole).
|
|
375
|
+
const markerFields = parseDispatchMarker(rawPrompt);
|
|
376
|
+
if (markerFields?.req && markerFields?.model) {
|
|
377
|
+
const reqTier = parseInt(markerFields.req, 10);
|
|
378
|
+
const modelTier = getGovernanceTier(markerFields.model);
|
|
379
|
+
if (!isNaN(reqTier) && modelTier > reqTier && reqTier <= 2) {
|
|
380
|
+
process.stdout.write(JSON.stringify({
|
|
381
|
+
systemMessage: `[governance] Over-provisioned: task requires tier ${reqTier} but using tier ${modelTier} model (${markerFields.model}). Consider downgrading.`,
|
|
382
|
+
}));
|
|
383
|
+
process.exit(0);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// ── End over-provisioning check ────────────────────────────────────────────
|
|
387
|
+
|
|
360
388
|
// Compute prompt hash early for duplicate detection and logging
|
|
361
389
|
const promptHash = computePromptHash(ti);
|
|
362
390
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"./memory-tiers": "./src/memory-tiers.mjs",
|
|
47
47
|
"./envelope": "./src/envelope.mjs",
|
|
48
48
|
"./session-lock": "./src/session-lock.mjs",
|
|
49
|
-
"./governance": "./src/governance.mjs"
|
|
49
|
+
"./governance": "./src/governance.mjs",
|
|
50
|
+
"./context-intel": "./src/context-intel.mjs"
|
|
50
51
|
},
|
|
51
52
|
"keywords": [
|
|
52
53
|
"claude-code",
|
|
@@ -132,6 +133,7 @@
|
|
|
132
133
|
"src/envelope.mjs",
|
|
133
134
|
"src/session-lock.mjs",
|
|
134
135
|
"src/governance.mjs",
|
|
136
|
+
"src/context-intel.mjs",
|
|
135
137
|
"bin/*.mjs",
|
|
136
138
|
"hooks/enforce-tier.mjs",
|
|
137
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,9 @@ 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';
|
|
21
|
+
import { scoreTask, computeRequiredTier } from './governance.mjs';
|
|
19
22
|
|
|
20
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
|
|
@@ -704,8 +707,8 @@ function _renderTemplatedPrompt(prompt, decision, context = {}) {
|
|
|
704
707
|
// Prepend a marker to every prompt that goes through the official dispatch pipeline.
|
|
705
708
|
// The enforce-tier hook checks for this marker to distinguish legitimate dispatches
|
|
706
709
|
// from raw Agent calls made by the HEAD that bypass the dual-brain pipeline.
|
|
707
|
-
// Format: <!-- dual-brain-dispatch
|
|
708
|
-
// runId is a short timestamp-based ID
|
|
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.
|
|
709
712
|
|
|
710
713
|
let _dispatchRunId = null;
|
|
711
714
|
|
|
@@ -717,9 +720,14 @@ function _getDispatchRunId() {
|
|
|
717
720
|
return _dispatchRunId;
|
|
718
721
|
}
|
|
719
722
|
|
|
720
|
-
function _prependDispatchMarker(prompt) {
|
|
723
|
+
function _prependDispatchMarker(prompt, decision = {}) {
|
|
721
724
|
const runId = _getDispatchRunId();
|
|
722
|
-
|
|
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}`;
|
|
723
731
|
}
|
|
724
732
|
|
|
725
733
|
// ─── Related session age label ────────────────────────────────────────────────
|
|
@@ -755,6 +763,28 @@ async function dispatch(input = {}) {
|
|
|
755
763
|
// structured, typed prompts. Falls back to raw prompt when no template matches.
|
|
756
764
|
prompt = _renderTemplatedPrompt(prompt, decision);
|
|
757
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
|
+
|
|
758
788
|
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
759
789
|
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
760
790
|
// This closes the receipt → brief → next session loop automatically.
|
|
@@ -821,7 +851,12 @@ async function dispatch(input = {}) {
|
|
|
821
851
|
|
|
822
852
|
// Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
|
|
823
853
|
// that this agent call came through the official pipeline.
|
|
824
|
-
|
|
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);
|
|
825
860
|
|
|
826
861
|
// ── Situation brief injection ────────────────────────────────────────────────
|
|
827
862
|
// Prepend a compact project-state summary when provided by the pipeline.
|
|
@@ -1125,7 +1160,7 @@ async function dispatch(input = {}) {
|
|
|
1125
1160
|
}
|
|
1126
1161
|
// ── End auto-review annotation ────────────────────────────────────────────
|
|
1127
1162
|
|
|
1128
|
-
|
|
1163
|
+
const nativeResult = {
|
|
1129
1164
|
status: success ? 'completed' : 'failed',
|
|
1130
1165
|
type: 'native-agent',
|
|
1131
1166
|
provider: currentProvider,
|
|
@@ -1142,6 +1177,11 @@ async function dispatch(input = {}) {
|
|
|
1142
1177
|
authVerified: true,
|
|
1143
1178
|
error: success ? null : errorText.slice(0, 200),
|
|
1144
1179
|
};
|
|
1180
|
+
try {
|
|
1181
|
+
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1182
|
+
recordDispatchOutcome(input, nativeResult);
|
|
1183
|
+
} catch { /* never block */ }
|
|
1184
|
+
return nativeResult;
|
|
1145
1185
|
}
|
|
1146
1186
|
|
|
1147
1187
|
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
@@ -1244,7 +1284,7 @@ async function dispatch(input = {}) {
|
|
|
1244
1284
|
}
|
|
1245
1285
|
// ── End auto-review annotation ──────────────────────────────────────────────
|
|
1246
1286
|
|
|
1247
|
-
|
|
1287
|
+
const subResult = {
|
|
1248
1288
|
status: success ? 'completed' : 'failed',
|
|
1249
1289
|
provider: subProvider,
|
|
1250
1290
|
model: subModel,
|
|
@@ -1259,6 +1299,11 @@ async function dispatch(input = {}) {
|
|
|
1259
1299
|
authVerified: true,
|
|
1260
1300
|
error: success ? null : errorText.slice(0, 200),
|
|
1261
1301
|
};
|
|
1302
|
+
try {
|
|
1303
|
+
const { recordDispatchOutcome } = await import('./outcome.mjs');
|
|
1304
|
+
recordDispatchOutcome(input, subResult);
|
|
1305
|
+
} catch { /* never block */ }
|
|
1306
|
+
return subResult;
|
|
1262
1307
|
}
|
|
1263
1308
|
|
|
1264
1309
|
// ─── Dual-brain dispatch (parallel) ───────────────────────────────────────────
|
|
@@ -1271,7 +1316,12 @@ async function dispatchDualBrain(input = {}) {
|
|
|
1271
1316
|
prompt = redact(prompt);
|
|
1272
1317
|
|
|
1273
1318
|
// Stamp with dispatch marker so enforce-tier.mjs allows this Agent call
|
|
1274
|
-
|
|
1319
|
+
// Compute required tier for governance validation
|
|
1320
|
+
try {
|
|
1321
|
+
const scores = scoreTask({ intent: decision.tier, risk: decision.risk, files, objective: prompt.slice(0, 200) });
|
|
1322
|
+
decision = { ...decision, _requiredTier: computeRequiredTier(scores) };
|
|
1323
|
+
} catch { /* non-blocking */ }
|
|
1324
|
+
prompt = _prependDispatchMarker(prompt, decision);
|
|
1275
1325
|
|
|
1276
1326
|
// ── Situation brief injection ────────────────────────────────────────────────
|
|
1277
1327
|
const _dualBrainBrief = typeof input.situationBrief === 'string' && input.situationBrief.trim()
|
package/src/outcome.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
|
|
1
|
+
import { mkdirSync, appendFileSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
|
|
@@ -44,6 +44,36 @@ function last7DaysFiles(cwd) {
|
|
|
44
44
|
return files;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function recordDispatchOutcome(dispatchInput, result) {
|
|
48
|
+
try {
|
|
49
|
+
const cwd = dispatchInput.cwd ?? process.cwd();
|
|
50
|
+
const decision = dispatchInput.decision ?? {};
|
|
51
|
+
ensureDir(cwd);
|
|
52
|
+
|
|
53
|
+
const id = `out_${Date.now().toString(36)}`;
|
|
54
|
+
const record = {
|
|
55
|
+
id,
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
prompt: (dispatchInput.prompt ?? '').slice(0, 200),
|
|
58
|
+
tier: decision.tier ?? result.tier ?? 'execute',
|
|
59
|
+
model: decision.model ?? result.model ?? 'unknown',
|
|
60
|
+
provider: decision.provider ?? result.provider ?? 'unknown',
|
|
61
|
+
success: result.status === 'success' || result.status === 'completed',
|
|
62
|
+
status: result.status ?? 'unknown',
|
|
63
|
+
durationMs: result.durationMs ?? 0,
|
|
64
|
+
filesChanged: result.filesChanged?.length ?? 0,
|
|
65
|
+
errors: (result.errors ?? (result.error ? [result.error] : [])).slice(0, 3),
|
|
66
|
+
lesson: '',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const filePath = join(outcomesDir(cwd), `outcome_${id}.json`);
|
|
70
|
+
writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
|
|
71
|
+
return record;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
export function computeRoutingScore(plan, result, verification) {
|
|
48
78
|
let score = 3;
|
|
49
79
|
if (result.success && result.duration < 60_000) score += 1;
|
package/src/pipeline.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { dispatch } from './dispatch.mjs';
|
|
|
12
12
|
import { loadProfile } from './profile.mjs';
|
|
13
13
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { buildContextPack as buildContextPackIntel } from './context.mjs';
|
|
16
|
+
import { compilePacket } from './context-intel.mjs';
|
|
15
17
|
|
|
16
18
|
// Lazy-load collaboration module
|
|
17
19
|
let _collab = null;
|
|
@@ -648,6 +650,143 @@ function runGate(run, gateName, gateFn) {
|
|
|
648
650
|
return result.passed;
|
|
649
651
|
}
|
|
650
652
|
|
|
653
|
+
// ─── Pre-dispatch think (Position 1: context intelligence) ───────────────────
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Optionally spawn a cheap think agent to produce a refined work spec before
|
|
657
|
+
* the real dispatch. Non-blocking on any failure.
|
|
658
|
+
*
|
|
659
|
+
* @param {string} prompt
|
|
660
|
+
* @param {string[]} files
|
|
661
|
+
* @param {object} decision — from plan._decision
|
|
662
|
+
* @param {string} cwd
|
|
663
|
+
* @param {object} profile
|
|
664
|
+
* @param {object} [opts]
|
|
665
|
+
* @param {boolean} [opts._skipPreDispatchThink] — set true on recursive calls
|
|
666
|
+
* @param {object} [opts.log] — logging function
|
|
667
|
+
* @returns {Promise<{ refined: boolean, prompt?, files?, decision? }>}
|
|
668
|
+
*/
|
|
669
|
+
async function preDispatchThink(prompt, files, decision, cwd, profile, opts = {}) {
|
|
670
|
+
const log = opts.log ?? (() => {});
|
|
671
|
+
|
|
672
|
+
// Guard: never recurse
|
|
673
|
+
if (opts._skipPreDispatchThink) {
|
|
674
|
+
log('[dual-brain] pre-dispatch think: skipped (recursive call)');
|
|
675
|
+
return { refined: false };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Guard: only execute/think tiers
|
|
679
|
+
const tier = decision?.tier ?? 'execute';
|
|
680
|
+
if (tier === 'search') {
|
|
681
|
+
log('[dual-brain] pre-dispatch think: skipped (search tier)');
|
|
682
|
+
return { refined: false };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Guard: governance tier >= 2 (map tier names to numeric levels)
|
|
686
|
+
const TIER_LEVEL = { search: 1, execute: 2, think: 3 };
|
|
687
|
+
const tierLevel = TIER_LEVEL[tier] ?? 2;
|
|
688
|
+
if (tierLevel < 2) {
|
|
689
|
+
log('[dual-brain] pre-dispatch think: skipped (tier < 2)');
|
|
690
|
+
return { refined: false };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Guard: decision confidence must be < 0.9
|
|
694
|
+
const confidence = decision?.confidence ?? 0.5;
|
|
695
|
+
if (confidence >= 0.9) {
|
|
696
|
+
log('[dual-brain] pre-dispatch think: skipped (confidence >= 0.9)');
|
|
697
|
+
return { refined: false };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Guard: not cost-saver work style
|
|
701
|
+
try {
|
|
702
|
+
const style = getWorkStyle(profile);
|
|
703
|
+
if (style.key === 'cost-saver') {
|
|
704
|
+
log('[dual-brain] pre-dispatch think: skipped (cost-saver profile)');
|
|
705
|
+
return { refined: false };
|
|
706
|
+
}
|
|
707
|
+
} catch {
|
|
708
|
+
// profile unavailable — proceed
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
log('[dual-brain] pre-dispatch think: refining work spec...');
|
|
713
|
+
|
|
714
|
+
// Build the thinker context pack
|
|
715
|
+
const pack = await buildContextPackIntel(prompt, files, cwd);
|
|
716
|
+
|
|
717
|
+
// Compile to a thinker-shaped prompt (sonnet, 3000 token budget)
|
|
718
|
+
const thinkerPrompt = compilePacket(pack, 'thinker', 'sonnet', 3000);
|
|
719
|
+
|
|
720
|
+
// Dispatch to a think agent — use sonnet, tier=think, skip all extras
|
|
721
|
+
const thinkDecision = {
|
|
722
|
+
provider: 'claude',
|
|
723
|
+
model: 'sonnet',
|
|
724
|
+
tier: 'think',
|
|
725
|
+
confidence: 1, // internal call — fully confident
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const thinkResult = await dispatch({
|
|
729
|
+
decision: thinkDecision,
|
|
730
|
+
prompt: thinkerPrompt,
|
|
731
|
+
files: [],
|
|
732
|
+
cwd,
|
|
733
|
+
dryRun: false,
|
|
734
|
+
verbose: false,
|
|
735
|
+
profile,
|
|
736
|
+
_skipPreDispatchThink: true,
|
|
737
|
+
_skipRelatedContext: true,
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Parse the think result — expect JSON with { decision, confidence, workSpec }
|
|
741
|
+
let parsed = null;
|
|
742
|
+
try {
|
|
743
|
+
const raw = typeof thinkResult === 'string'
|
|
744
|
+
? thinkResult
|
|
745
|
+
: (thinkResult?.output ?? thinkResult?.result ?? thinkResult?.text ?? JSON.stringify(thinkResult));
|
|
746
|
+
|
|
747
|
+
// Extract JSON from possible prose wrapping
|
|
748
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
749
|
+
if (jsonMatch) {
|
|
750
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
751
|
+
}
|
|
752
|
+
} catch {
|
|
753
|
+
// JSON parse failed — proceed unchanged
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (!parsed || typeof parsed.confidence !== 'number' || parsed.confidence <= 0.7) {
|
|
757
|
+
const reason = !parsed ? 'unparseable response' : `confidence ${parsed.confidence} <= 0.7`;
|
|
758
|
+
log(`[dual-brain] pre-dispatch think: skipped (${reason})`);
|
|
759
|
+
return { refined: false };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const ws = parsed.workSpec;
|
|
763
|
+
if (!ws || !ws.objective) {
|
|
764
|
+
log('[dual-brain] pre-dispatch think: skipped (no workSpec.objective)');
|
|
765
|
+
return { refined: false };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Apply refinements
|
|
769
|
+
const newObjective = ws.objective;
|
|
770
|
+
const newFiles = [...new Set([...files, ...(ws.files ?? [])])];
|
|
771
|
+
const newDecision = ws.criteria?.length
|
|
772
|
+
? { ...decision, acceptanceCriteria: [...(decision.acceptanceCriteria ?? []), ...ws.criteria] }
|
|
773
|
+
: decision;
|
|
774
|
+
|
|
775
|
+
log(`[dual-brain] think refined: "${newObjective.slice(0, 60)}..." (confidence: ${parsed.confidence})`);
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
refined: true,
|
|
779
|
+
prompt: newObjective,
|
|
780
|
+
files: newFiles,
|
|
781
|
+
decision: newDecision,
|
|
782
|
+
};
|
|
783
|
+
} catch (err) {
|
|
784
|
+
// Non-blocking on any failure
|
|
785
|
+
log(`[dual-brain] pre-dispatch think: skipped (error: ${err.message})`);
|
|
786
|
+
return { refined: false };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
651
790
|
// ─── Main entry point ─────────────────────────────────────────────────────────
|
|
652
791
|
|
|
653
792
|
/**
|
|
@@ -1070,7 +1209,33 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1070
1209
|
}
|
|
1071
1210
|
}
|
|
1072
1211
|
|
|
1073
|
-
|
|
1212
|
+
let decision = { ...run.plan._decision };
|
|
1213
|
+
|
|
1214
|
+
// ── Pre-dispatch think (Position 1: context intelligence) ────────────────
|
|
1215
|
+
// For tier-2+ non-trivial tasks with decision confidence < 0.9, spawn a
|
|
1216
|
+
// cheap sonnet think agent to produce a refined work spec before the real
|
|
1217
|
+
// dispatch. Non-blocking — if it fails or confidence is low, proceed as-is.
|
|
1218
|
+
{
|
|
1219
|
+
const thinkRefinement = await preDispatchThink(
|
|
1220
|
+
effectivePrompt,
|
|
1221
|
+
files,
|
|
1222
|
+
decision,
|
|
1223
|
+
cwd,
|
|
1224
|
+
run.context?.profile ?? {},
|
|
1225
|
+
{ log, _skipPreDispatchThink: options._skipPreDispatchThink }
|
|
1226
|
+
);
|
|
1227
|
+
if (thinkRefinement.refined) {
|
|
1228
|
+
// Mutate locals so both collab and direct paths use the refined inputs
|
|
1229
|
+
// (effectivePrompt is const — store refinement in a mutable local)
|
|
1230
|
+
run._thinkRefinedPrompt = thinkRefinement.prompt;
|
|
1231
|
+
run._thinkRefinedFiles = thinkRefinement.files;
|
|
1232
|
+
decision = thinkRefinement.decision;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Resolve the (possibly refined) prompt and file list for dispatch
|
|
1237
|
+
const dispatchPrompt = run._thinkRefinedPrompt ?? effectivePrompt;
|
|
1238
|
+
const dispatchFiles = run._thinkRefinedFiles ?? files;
|
|
1074
1239
|
|
|
1075
1240
|
// ── HEAD judgment injection into agent prompts ─────────────────────────────
|
|
1076
1241
|
// HEAD's obligations, noticings, and uncertainties flow to the work agent
|
|
@@ -1130,13 +1295,13 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1130
1295
|
|
|
1131
1296
|
// Inject collaboration context + HEAD judgment into prompt
|
|
1132
1297
|
const collabContext = collab.buildAgentContext(session, primaryId);
|
|
1133
|
-
const promptParts = [collabContext, headJudgmentBlock,
|
|
1298
|
+
const promptParts = [collabContext, headJudgmentBlock, dispatchPrompt].filter(Boolean);
|
|
1134
1299
|
const collabPrompt = promptParts.join('\n\n');
|
|
1135
1300
|
|
|
1136
1301
|
run.result = await dispatch({
|
|
1137
1302
|
decision,
|
|
1138
1303
|
prompt: collabPrompt,
|
|
1139
|
-
files,
|
|
1304
|
+
files: dispatchFiles,
|
|
1140
1305
|
cwd,
|
|
1141
1306
|
dryRun: false,
|
|
1142
1307
|
verbose,
|
|
@@ -1192,13 +1357,13 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1192
1357
|
try { collab.persistEvents(session, cwd); } catch {}
|
|
1193
1358
|
} else {
|
|
1194
1359
|
const directPrompt = headJudgmentBlock
|
|
1195
|
-
? `${headJudgmentBlock}\n\n${
|
|
1196
|
-
:
|
|
1360
|
+
? `${headJudgmentBlock}\n\n${dispatchPrompt}`
|
|
1361
|
+
: dispatchPrompt;
|
|
1197
1362
|
|
|
1198
1363
|
run.result = await dispatch({
|
|
1199
1364
|
decision,
|
|
1200
1365
|
prompt: directPrompt,
|
|
1201
|
-
files,
|
|
1366
|
+
files: dispatchFiles,
|
|
1202
1367
|
cwd,
|
|
1203
1368
|
dryRun: false,
|
|
1204
1369
|
verbose,
|
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,
|