dual-brain 0.2.23 → 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.
@@ -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:\s*[a-z0-9]+\s*-->/i;
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.23",
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": {
package/src/dispatch.mjs CHANGED
@@ -18,6 +18,7 @@ import { getFailoverOrder } from './decide.mjs';
18
18
  import { getTemplate, renderPrompt, quickRender } from './templates.mjs';
19
19
  import { compilePacket, shapeForRole } from './context-intel.mjs';
20
20
  import { buildContextPack } from './context.mjs';
21
+ import { scoreTask, computeRequiredTier } from './governance.mjs';
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
  const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
@@ -706,8 +707,8 @@ function _renderTemplatedPrompt(prompt, decision, context = {}) {
706
707
  // Prepend a marker to every prompt that goes through the official dispatch pipeline.
707
708
  // The enforce-tier hook checks for this marker to distinguish legitimate dispatches
708
709
  // from raw Agent calls made by the HEAD that bypass the dual-brain pipeline.
709
- // Format: <!-- dual-brain-dispatch: <runId> -->
710
- // runId is a short timestamp-based ID that ties back to this dispatch session.
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.
711
712
 
712
713
  let _dispatchRunId = null;
713
714
 
@@ -719,9 +720,14 @@ function _getDispatchRunId() {
719
720
  return _dispatchRunId;
720
721
  }
721
722
 
722
- function _prependDispatchMarker(prompt) {
723
+ function _prependDispatchMarker(prompt, decision = {}) {
723
724
  const runId = _getDispatchRunId();
724
- return `<!-- dual-brain-dispatch: ${runId} -->\n${prompt}`;
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}`;
725
731
  }
726
732
 
727
733
  // ─── Related session age label ────────────────────────────────────────────────
@@ -845,7 +851,12 @@ async function dispatch(input = {}) {
845
851
 
846
852
  // Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
847
853
  // that this agent call came through the official pipeline.
848
- prompt = _prependDispatchMarker(prompt);
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);
849
860
 
850
861
  // ── Situation brief injection ────────────────────────────────────────────────
851
862
  // Prepend a compact project-state summary when provided by the pipeline.
@@ -1149,7 +1160,7 @@ async function dispatch(input = {}) {
1149
1160
  }
1150
1161
  // ── End auto-review annotation ────────────────────────────────────────────
1151
1162
 
1152
- return {
1163
+ const nativeResult = {
1153
1164
  status: success ? 'completed' : 'failed',
1154
1165
  type: 'native-agent',
1155
1166
  provider: currentProvider,
@@ -1166,6 +1177,11 @@ async function dispatch(input = {}) {
1166
1177
  authVerified: true,
1167
1178
  error: success ? null : errorText.slice(0, 200),
1168
1179
  };
1180
+ try {
1181
+ const { recordDispatchOutcome } = await import('./outcome.mjs');
1182
+ recordDispatchOutcome(input, nativeResult);
1183
+ } catch { /* never block */ }
1184
+ return nativeResult;
1169
1185
  }
1170
1186
 
1171
1187
  const command = buildCommand(effectiveDecision, prompt, files, cwd);
@@ -1268,7 +1284,7 @@ async function dispatch(input = {}) {
1268
1284
  }
1269
1285
  // ── End auto-review annotation ──────────────────────────────────────────────
1270
1286
 
1271
- return {
1287
+ const subResult = {
1272
1288
  status: success ? 'completed' : 'failed',
1273
1289
  provider: subProvider,
1274
1290
  model: subModel,
@@ -1283,6 +1299,11 @@ async function dispatch(input = {}) {
1283
1299
  authVerified: true,
1284
1300
  error: success ? null : errorText.slice(0, 200),
1285
1301
  };
1302
+ try {
1303
+ const { recordDispatchOutcome } = await import('./outcome.mjs');
1304
+ recordDispatchOutcome(input, subResult);
1305
+ } catch { /* never block */ }
1306
+ return subResult;
1286
1307
  }
1287
1308
 
1288
1309
  // ─── Dual-brain dispatch (parallel) ───────────────────────────────────────────
@@ -1295,7 +1316,12 @@ async function dispatchDualBrain(input = {}) {
1295
1316
  prompt = redact(prompt);
1296
1317
 
1297
1318
  // Stamp with dispatch marker so enforce-tier.mjs allows this Agent call
1298
- prompt = _prependDispatchMarker(prompt);
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);
1299
1325
 
1300
1326
  // ── Situation brief injection ────────────────────────────────────────────────
1301
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
- const decision = { ...run.plan._decision };
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, effectivePrompt].filter(Boolean);
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${effectivePrompt}`
1196
- : effectivePrompt;
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,