dual-brain 0.2.22 → 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.
@@ -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
- // Header panel project, branch, providers, version
2897
- const headerLeft = `${DIM}${projectName}${RST} ${BOLD}${branchStr}${RST} ${DIM}Claude${RST} ${claudeDot} ${DIM}GPT${RST} ${openaiDot}`;
2898
- const headerRight = `${DIM}v${version}${RST}`;
2899
- const headerContent = [headerBar(headerLeft, headerRight, panelW - 4)];
2900
- process.stdout.write('\n' + panel('dual-brain', headerContent, { width: panelW, titleColor: CYAN }) + '\n\n');
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
- resumeContent.push(`${BOLD}Connect a provider to start working${RST}`);
2907
- } else {
2908
- const labelTrunc = (resumeState.label || 'last session').slice(0, 45);
2909
- const agePart = resumeState.ageLabel ? ` · ${resumeState.ageLabel}` : '';
2910
- const nextPart = resumeState.nextAction ? ` · next: ${resumeState.nextAction}` : '';
2911
- resumeContent.push(`${DIM}Last task${RST} ${BOLD}${labelTrunc}${RST}${DIM}${agePart}${RST}`);
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
- // Signals panel recent work items (only when there are items)
2924
- if (recentLines.length > 0) {
2925
- process.stdout.write(panel('Signals', recentLines, { width: panelW }) + '\n\n');
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
- // Shortcut bar always visible so the user never has to guess
3019
+ // ── Shortcuts (vertical layout, one per line) ────────────────────────────
2929
3020
  const shortcuts = [
2930
- [`Enter`, isReturning ? 'resume last session' : 'start working'],
2931
- [`n`, 'new session'],
2932
- [`/`, 'search sessions'],
2933
- [`s`, 'settings & profiles'],
2934
- [`d`, 'doctor (diagnose issues)'],
2935
- [`a`, profile.automode ? 'auto modeon' : 'auto mode'],
2936
- [`q`, 'quit'],
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' ? `${CYAN}Enter${RST}` : ` ${CYAN}${key}${RST} `;
2941
- const padded = key === 'Enter' ? ' ' : ' ';
2942
- process.stdout.write(` ${keyStr}${padded}${DIM}${label}${RST}\n`);
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 shortcut bar
3038
+ // Input bar — rendered below shortcuts
2947
3039
  const inputLeft = tuiPrompt('task or command...');
2948
3040
  process.stdout.write(` ${inputLeft}\n`);
2949
3041
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.22",
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": {
@@ -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,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.
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,