dual-brain 0.2.20 → 0.2.22

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.
@@ -2755,18 +2755,23 @@ async function mainScreen(rl, ask) {
2755
2755
  const recentWorkItems = [];
2756
2756
  // Add awareness observations as recent work if meaningful
2757
2757
  if (awarenessLine1 && !awarenessLine1.includes('Ready to work')) {
2758
- const plainAware1 = awarenessLine1.replace(/\x1b\[[0-9;]*m/g, '').replace(/[︀-️]/g, '').trim();
2759
- if (plainAware1) recentWorkItems.push({ ok: !plainAware1.startsWith('⚠') && !plainAware1.startsWith('🔴'), text: plainAware1.replace(/^[🔴🟡💡]\s*/, '') });
2758
+ const plainAware1 = awarenessLine1.replace(/\x1b\[[0-9;]*m/g, '').replace(/[︀-️‍]/g, '').trim();
2759
+ if (plainAware1) {
2760
+ const isWarning = /uncommitted|stale|failure|expired|old|⚠/.test(plainAware1);
2761
+ recentWorkItems.push({ ok: !isWarning, text: plainAware1.replace(/^[🔴🟡💡⚠]\s*/, '') });
2762
+ }
2760
2763
  }
2761
2764
  // Add last commit as a recent work item
2762
2765
  if (gitLastMsg) {
2763
- recentWorkItems.push({ ok: true, text: `${gitLastMsg} (${gitLastAgo})` });
2766
+ const isStale = /\d+d ago|\d{2,}h ago/.test(gitLastAgo);
2767
+ recentWorkItems.push({ ok: !isStale, text: `${gitLastMsg} (${gitLastAgo})` });
2764
2768
  }
2765
2769
  // Fill from sessions if still room
2766
2770
  if (recentWorkItems.length < 3 && recentSessions.length > 0) {
2767
2771
  const sess = recentSessions[0];
2768
2772
  let rawName = sess.name || '';
2769
- if (/^Session [0-9a-f]{8,}$/i.test(rawName)) rawName = sess.id.slice(0, 8);
2773
+ if (/^Session [0-9a-f]{8,}$/i.test(rawName)) rawName = '';
2774
+ if (/^[0-9a-f]{6,}$/i.test(rawName)) rawName = '';
2770
2775
  if (rawName) recentWorkItems.push({ ok: true, text: rawName.slice(0, 50) });
2771
2776
  }
2772
2777
 
@@ -2921,17 +2926,22 @@ async function mainScreen(rl, ask) {
2921
2926
  }
2922
2927
 
2923
2928
  // Shortcut bar — always visible so the user never has to guess
2924
- const autoLabel = profile.automode ? `\x1b[32m⚡auto\x1b[0m` : `${DIM}auto${RST}`;
2925
- const shortcutItems = [
2926
- `${CYAN}Enter${RST} resume`,
2927
- `${CYAN}n${RST} new`,
2928
- `${CYAN}/${RST} search`,
2929
- `${CYAN}s${RST} settings`,
2930
- `${CYAN}d${RST} doctor`,
2931
- autoLabel,
2932
- `${CYAN}q${RST} quit`,
2929
+ 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 mode ⚡ on' : 'auto mode'],
2936
+ [`q`, 'quit'],
2933
2937
  ];
2934
- process.stdout.write(` ${DIM}${shortcutItems.join(' ')}${RST}\n\n`);
2938
+ process.stdout.write('\n');
2939
+ 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`);
2943
+ }
2944
+ process.stdout.write('\n');
2935
2945
 
2936
2946
  // Input bar — rendered below shortcut bar
2937
2947
  const inputLeft = tuiPrompt('task or command...');
@@ -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.20",
3
+ "version": "0.2.22",
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,8 @@
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"
49
50
  },
50
51
  "keywords": [
51
52
  "claude-code",
@@ -130,6 +131,7 @@
130
131
  "src/memory-tiers.mjs",
131
132
  "src/envelope.mjs",
132
133
  "src/session-lock.mjs",
134
+ "src/governance.mjs",
133
135
  "bin/*.mjs",
134
136
  "hooks/enforce-tier.mjs",
135
137
  "hooks/cost-logger.mjs",
@@ -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
+ }