dual-brain 0.1.22 → 0.2.0

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.
@@ -0,0 +1,184 @@
1
+ // cost-tracker.mjs — Lightweight cost estimation and efficiency tracking for .dual-brain/costs.jsonl.
2
+
3
+ import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const TOKEN_COSTS = {
7
+ 'claude-opus-4-6': 0.03,
8
+ 'claude-sonnet-4-6': 0.006,
9
+ 'claude-haiku-4-5-20251001': 0.001,
10
+ 'gpt-5.5': 0.04,
11
+ 'o3': 0.03,
12
+ 'gpt-4o': 0.005,
13
+ 'gpt-4o-mini': 0.0003,
14
+ 'default': 0.01,
15
+ };
16
+
17
+ export function estimateTokenCost(model, tokens) {
18
+ const rate = TOKEN_COSTS[model] ?? TOKEN_COSTS['default'];
19
+ return (tokens / 1000) * rate;
20
+ }
21
+
22
+ export function trackCost(action, cwd = process.cwd()) {
23
+ try {
24
+ const dir = join(cwd, '.dual-brain');
25
+ mkdirSync(dir, { recursive: true });
26
+ const entry = {
27
+ timestamp: new Date().toISOString(),
28
+ action: action.action ?? 'execute',
29
+ model: action.model ?? 'default',
30
+ tokensEstimated: action.tokensEstimated ?? 0,
31
+ costEstimated: estimateTokenCost(action.model ?? 'default', action.tokensEstimated ?? 0),
32
+ tier: action.tier ?? 'standard',
33
+ wasCacheHit: action.wasCacheHit ?? false,
34
+ tokensSaved: action.tokensSaved ?? 0,
35
+ };
36
+ appendFileSync(join(dir, 'costs.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
37
+ return entry;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function readCostLines(cwd) {
44
+ const p = join(cwd, '.dual-brain', 'costs.jsonl');
45
+ if (!existsSync(p)) return [];
46
+ try {
47
+ return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).flatMap(line => {
48
+ try { return [JSON.parse(line)]; } catch { return []; }
49
+ });
50
+ } catch { return []; }
51
+ }
52
+
53
+ export function getCostSummary(cwd = process.cwd(), days = 7) {
54
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
55
+ const all = readCostLines(cwd).filter(e => e.timestamp >= cutoff);
56
+
57
+ if (all.length === 0) {
58
+ return {
59
+ period: `${days} days`,
60
+ totalCost: 0, totalTokens: 0, totalActions: 0,
61
+ cacheHits: 0, tokensSaved: 0, costSaved: 0, savingsRate: 0,
62
+ byTier: {}, byModel: {}, trend: 'stable',
63
+ };
64
+ }
65
+
66
+ let totalCost = 0, totalTokens = 0, cacheHits = 0, tokensSaved = 0;
67
+ const byTier = {};
68
+ const byModel = {};
69
+
70
+ for (const e of all) {
71
+ totalCost += e.costEstimated ?? 0;
72
+ totalTokens += e.tokensEstimated ?? 0;
73
+ if (e.wasCacheHit) { cacheHits++; tokensSaved += e.tokensSaved ?? 0; }
74
+
75
+ const tier = e.tier ?? 'standard';
76
+ if (!byTier[tier]) byTier[tier] = { count: 0, tokens: 0, cost: 0 };
77
+ byTier[tier].count += 1;
78
+ byTier[tier].tokens += e.tokensEstimated ?? 0;
79
+ byTier[tier].cost += e.costEstimated ?? 0;
80
+
81
+ const model = e.model ?? 'default';
82
+ if (!byModel[model]) byModel[model] = { count: 0, tokens: 0, cost: 0 };
83
+ byModel[model].count += 1;
84
+ byModel[model].tokens += e.tokensEstimated ?? 0;
85
+ byModel[model].cost += e.costEstimated ?? 0;
86
+ }
87
+
88
+ const costSaved = estimateTokenCost('default', tokensSaved);
89
+ const savingsRate = (tokensSaved + totalTokens) > 0
90
+ ? tokensSaved / (tokensSaved + totalTokens)
91
+ : 0;
92
+
93
+ // Trend: compare first half vs second half savings rate
94
+ const mid = Math.floor(all.length / 2);
95
+ const first = all.slice(0, mid);
96
+ const second = all.slice(mid);
97
+ const halfSavings = (half) => {
98
+ const ts = half.reduce((s, e) => s + (e.tokensSaved ?? 0), 0);
99
+ const tt = half.reduce((s, e) => s + (e.tokensEstimated ?? 0), 0);
100
+ return (ts + tt) > 0 ? ts / (ts + tt) : 0;
101
+ };
102
+ let trend = 'stable';
103
+ if (all.length >= 4) {
104
+ const delta = halfSavings(second) - halfSavings(first);
105
+ if (delta > 0.05) trend = 'improving';
106
+ else if (delta < -0.05) trend = 'degrading';
107
+ }
108
+
109
+ return {
110
+ period: `${days} days`,
111
+ totalCost, totalTokens, totalActions: all.length,
112
+ cacheHits, tokensSaved, costSaved, savingsRate,
113
+ byTier, byModel, trend,
114
+ };
115
+ }
116
+
117
+ export function formatCostReport(summary) {
118
+ const {
119
+ period, totalCost, totalTokens, totalActions,
120
+ cacheHits, tokensSaved, costSaved, savingsRate,
121
+ byTier, byModel, trend,
122
+ } = summary;
123
+
124
+ const lines = [`COST EFFICIENCY (${period})`];
125
+
126
+ const fmtK = (n) => n >= 1000 ? `${Math.round(n / 1000)}K` : String(Math.round(n));
127
+ const fmtD = (n) => `~$${n.toFixed(2)}`;
128
+
129
+ lines.push(` Total: ${fmtD(totalCost)} (${fmtK(totalTokens)} tokens, ${totalActions} actions)`);
130
+
131
+ if (cacheHits > 0) {
132
+ const pct = Math.round(savingsRate * 100);
133
+ lines.push(` Saved: ${fmtD(costSaved)} (${fmtK(tokensSaved)} tokens from ${cacheHits} cache hits)`);
134
+ lines.push(` Savings rate: ${pct}%`);
135
+ }
136
+
137
+ const tierOrder = ['recall', 'quick', 'standard', 'deep', 'ultra'];
138
+ const tierKeys = [...new Set([...tierOrder, ...Object.keys(byTier)])].filter(k => byTier[k]);
139
+ if (tierKeys.length > 0) {
140
+ lines.push('');
141
+ lines.push(' Tier breakdown:');
142
+ for (const tier of tierKeys) {
143
+ const t = byTier[tier];
144
+ const isRecall = tier === 'recall' && (t.cost < 0.001 || t.tokens === 0);
145
+ const costStr = isRecall ? '$0.00 (cache hits!)' : fmtD(t.cost);
146
+ lines.push(` ${tier.padEnd(10)}${String(t.count).padStart(4)} actions ${costStr}`);
147
+ }
148
+ }
149
+
150
+ const trendIcon = trend === 'improving' ? '↗' : trend === 'degrading' ? '↘' : '→';
151
+ lines.push('');
152
+ if (trend !== 'stable') {
153
+ const pct = Math.round(Math.abs(savingsRate) * 100);
154
+ lines.push(` Trend: ${trendIcon} ${trend} (savings rate ${trend === 'improving' ? 'up' : 'down'} vs last half)`);
155
+ } else {
156
+ lines.push(` Trend: ${trendIcon} stable`);
157
+ }
158
+
159
+ return lines.join('\n');
160
+ }
161
+
162
+ export function getEfficiencyScore(cwd = process.cwd()) {
163
+ const summary = getCostSummary(cwd, 7);
164
+ if (summary.totalActions === 0) return 50;
165
+
166
+ const TIER_WEIGHTS = { recall: 0, quick: 1, standard: 2, deep: 4, ultra: 6 };
167
+ const totalTierCost = Object.entries(summary.byTier).reduce((s, [tier, v]) => {
168
+ return s + (TIER_WEIGHTS[tier] ?? 2) * v.count;
169
+ }, 0);
170
+ const maxPossible = summary.totalActions * (TIER_WEIGHTS['ultra'] ?? 6);
171
+ const tierScore = maxPossible > 0 ? 1 - (totalTierCost / maxPossible) : 0.5;
172
+
173
+ const cacheScore = summary.savingsRate;
174
+ const trendBonus = summary.trend === 'improving' ? 10 : summary.trend === 'degrading' ? -10 : 0;
175
+
176
+ const raw = Math.round(
177
+ tierScore * 40 +
178
+ cacheScore * 40 +
179
+ 20 +
180
+ trendBonus
181
+ );
182
+
183
+ return Math.max(1, Math.min(100, raw));
184
+ }
package/src/decide.mjs CHANGED
@@ -21,6 +21,34 @@ import { getProviderScore, checkCooldown } from './health.mjs';
21
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
22
  const WORKSPACE = join(__dirname, '..');
23
23
 
24
+ // ─── Model Registry (optional, lazy-loaded) ───────────────────────────────────
25
+
26
+ /**
27
+ * Cached reference to models.mjs exports. Populated on first successful import.
28
+ * Remains null if models.mjs is unavailable — all callers fall back to
29
+ * the existing hardcoded model selection logic in that case.
30
+ */
31
+ let modelRegistry = null;
32
+ let _registryLoadAttempted = false;
33
+
34
+ /**
35
+ * Attempt to load models.mjs once. Subsequent calls return immediately.
36
+ * This is intentionally fire-and-forget: decideRoute stays synchronous and
37
+ * reads `modelRegistry` after the Promise resolves.
38
+ */
39
+ function _loadModelRegistry() {
40
+ if (_registryLoadAttempted) return;
41
+ _registryLoadAttempted = true;
42
+ import('./models.mjs').then(mod => {
43
+ modelRegistry = mod;
44
+ }).catch(() => {
45
+ // models.mjs unavailable — fall back to hardcoded logic
46
+ });
47
+ }
48
+
49
+ // Kick off the load immediately so it is ready before the first routing call.
50
+ _loadModelRegistry();
51
+
24
52
  // ─── Work Styles ─────────────────────────────────────────────────────────────
25
53
 
26
54
  /**
@@ -362,6 +390,46 @@ function pickOpenAIModel(detection, available) {
362
390
  return available[0] ?? 'gpt-4o-mini';
363
391
  }
364
392
 
393
+ /**
394
+ * Normalize a full model ID (e.g. 'claude-sonnet-4-6') to the short name used
395
+ * by the internal ranking arrays (e.g. 'sonnet'). Pass-through for names already
396
+ * in short form or OpenAI model IDs that don't need normalization.
397
+ * @param {string} model
398
+ * @param {string} provider 'claude'|'openai'
399
+ * @returns {string}
400
+ */
401
+ function toShortName(model, provider) {
402
+ if (!model) return model;
403
+ const m = model.toLowerCase();
404
+ if (provider === 'claude') {
405
+ if (m.includes('haiku')) return 'haiku';
406
+ if (m.includes('opus')) return 'opus';
407
+ if (m.includes('sonnet')) return 'sonnet';
408
+ }
409
+ // OpenAI and already-short names pass through unchanged
410
+ return model;
411
+ }
412
+
413
+ /**
414
+ * Resolve a short model name back to the best full model ID from the registry.
415
+ * Used after the internal pipeline (health downgrade, profile bias, etc.) finalizes
416
+ * the short name, to restore the full ID when the registry is available.
417
+ * @param {string} shortName e.g. 'sonnet', 'opus', 'haiku'
418
+ * @param {string} provider 'claude'|'openai'
419
+ * @param {string} tier 'search'|'execute'|'think'
420
+ * @returns {string} Full model ID, or shortName if registry unavailable
421
+ */
422
+ function toFullModelId(shortName, provider, tier) {
423
+ if (!modelRegistry) return shortName;
424
+ const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
425
+ // Map short name back to a taskType for the registry lookup
426
+ const taskType = tier === 'search' ? 'search' : tier === 'think' ? 'think' : 'execute';
427
+ const candidates = modelRegistry.getModelsForTask(taskType, registryProvider);
428
+ // Find the registry entry whose name substring matches the short name
429
+ const match = candidates.find(m => m.id.toLowerCase().includes(shortName.toLowerCase()));
430
+ return match ? match.id : shortName;
431
+ }
432
+
365
433
  function applyHealthDowngrade(model, score, provider, available, isHighStakes) {
366
434
  // score=100 healthy, score=50 degraded, score=25 probing, score=0 hot
367
435
  // If score is 0 (hot) and this isn't high-stakes, downgrade one tier
@@ -614,10 +682,10 @@ function applyCriticalRiskFloor(model, provider, available, risk) {
614
682
 
615
683
  /**
616
684
  * Main routing decision function.
617
- * @param {{ profile: object, detection: object, cwd?: string }} input
685
+ * @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object }} input
618
686
  * @returns {object} Routing decision
619
687
  */
620
- export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
688
+ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult } = {}) {
621
689
  const available = getAvailableModels(profile);
622
690
 
623
691
  // Resolve active work style
@@ -665,24 +733,104 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
665
733
  // Select base model using work style worker assignments.
666
734
  // For Claude primary: use complexWorker (opus) on deep reasoning, defaultWorker (sonnet) otherwise.
667
735
  // For OpenAI primary: mirror the same logic using GPT equivalents.
668
- let model;
669
- if (provider === 'claude') {
736
+ //
737
+ // Hardcoded fallback models (used when model registry is unavailable):
738
+ const _fallbackClaude = (() => {
670
739
  const wantOpus = needsDeepReasoning && workStyle.key !== 'fast';
671
- model = wantOpus && available.claude.includes('opus') ? 'opus' : 'sonnet';
672
- if (!available.claude.includes(model)) model = available.claude[available.claude.length - 1] ?? 'sonnet';
673
- } else {
674
- // OpenAI primary use o3 for deep reasoning in fullpower, gpt-4o otherwise
740
+ const fb = wantOpus && available.claude.includes('opus') ? 'opus' : 'sonnet';
741
+ return available.claude.includes(fb) ? fb : (available.claude[available.claude.length - 1] ?? 'sonnet');
742
+ })();
743
+ const _fallbackOpenAI = (() => {
675
744
  const wantO3 = needsDeepReasoning && workStyle.key === 'fullpower';
676
- model = wantO3 && available.openai.includes('o3') ? 'o3' : 'gpt-4o';
677
- if (!available.openai.includes(model)) model = available.openai[available.openai.length - 1] ?? 'gpt-4o';
745
+ const fb = wantO3 && available.openai.includes('o3') ? 'o3' : 'gpt-4o';
746
+ return available.openai.includes(fb) ? fb : (available.openai[available.openai.length - 1] ?? 'gpt-4o');
747
+ })();
748
+
749
+ let model;
750
+ if (modelRegistry) {
751
+ // Use registry to pick best model for the tier/provider.
752
+ // Map decide.mjs tier to registry taskType and constraints.
753
+ const registryProvider = provider === 'claude' ? 'anthropic' : 'openai';
754
+ const taskType = tier === 'search' ? 'search'
755
+ : tier === 'think' ? 'think'
756
+ : 'execute';
757
+ const constraints = {
758
+ provider: registryProvider,
759
+ ...(tier === 'search' && { preferSpeed: true }),
760
+ ...(tier === 'think' && { requireReasoning: true }),
761
+ ...(!needsDeepReasoning && workStyle.key === 'fast' && { maxCost: 'medium' }),
762
+ };
763
+ const registryResult = modelRegistry.getBestModel(taskType, constraints);
764
+ if (registryResult) {
765
+ // Registry returns full model IDs (e.g. 'claude-sonnet-4-6').
766
+ // dispatch.mjs mapToAgentModel handles both short names and full IDs.
767
+ model = registryResult.id;
768
+ } else {
769
+ // Registry found no match — use hardcoded fallback
770
+ model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
771
+ }
772
+ } else {
773
+ // Registry unavailable — use existing hardcoded selection
774
+ model = provider === 'claude' ? _fallbackClaude : _fallbackOpenAI;
678
775
  }
679
776
 
777
+ // The internal pipeline (health downgrade, profile bias, safety floor) operates on
778
+ // short model names ('haiku', 'sonnet', 'opus', 'gpt-4o', etc.) and the available[]
779
+ // arrays use the same short names. Normalize a full model ID to short name first so
780
+ // that rank lookups work correctly, then restore the full ID at the end.
781
+ model = toShortName(model, provider);
782
+
680
783
  // Apply health-based downgrade (only if score < 50 and not high-stakes)
681
784
  model = applyHealthDowngrade(model, healthScores[provider], provider, available[provider], isHighStakes);
682
785
 
683
786
  // Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
684
787
  model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
685
788
 
789
+ // Think-engine tier hint: use as a HINT to allow cheaper model when think-engine
790
+ // classifies the task as recall/quick. Never escalate — only downgrade when safe to do so.
791
+ let thinkTier = null;
792
+ try {
793
+ if (thinkResult?.tier) thinkTier = thinkResult.tier;
794
+ } catch (e) {}
795
+
796
+ if (thinkTier && !isHighStakes) {
797
+ const claudeRankAsc = ['haiku', 'sonnet', 'opus'];
798
+ const openaiRankAsc = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
799
+
800
+ if (thinkTier === 'recall' && provider === 'claude') {
801
+ // recall → haiku is fine if available
802
+ const target = 'haiku';
803
+ const currentIdx = claudeRankAsc.indexOf(model);
804
+ const targetIdx = claudeRankAsc.indexOf(target);
805
+ if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
806
+ model = target;
807
+ }
808
+ } else if (thinkTier === 'recall' && provider === 'openai') {
809
+ const target = 'gpt-4o-mini';
810
+ const currentIdx = openaiRankAsc.indexOf(model);
811
+ const targetIdx = openaiRankAsc.indexOf(target);
812
+ if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
813
+ model = target;
814
+ }
815
+ } else if (thinkTier === 'quick' && provider === 'claude') {
816
+ // quick → sonnet is sufficient
817
+ const target = 'sonnet';
818
+ const currentIdx = claudeRankAsc.indexOf(model);
819
+ const targetIdx = claudeRankAsc.indexOf(target);
820
+ if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
821
+ model = target;
822
+ }
823
+ } else if (thinkTier === 'quick' && provider === 'openai') {
824
+ const target = 'gpt-4o';
825
+ const currentIdx = openaiRankAsc.indexOf(model);
826
+ const targetIdx = openaiRankAsc.indexOf(target);
827
+ if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
828
+ model = target;
829
+ }
830
+ }
831
+ // 'standard', 'deep', 'ultra' — leave model unchanged; existing routing already picked correctly
832
+ }
833
+
686
834
  // Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
687
835
  model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
688
836
 
@@ -694,6 +842,10 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
694
842
  }
695
843
  }
696
844
 
845
+ // Restore full model ID from registry if the pipeline kept the same short name it started with.
846
+ // If the pipeline changed the model (downgrade/bias/floor), resolve the new short name to a full ID.
847
+ model = toFullModelId(model, provider, tier);
848
+
697
849
  // ── Challenger / dual-brain decision ─────────────────────────────────────
698
850
  const hasBothProviders = !!(
699
851
  profile?.providers?.claude?.enabled &&
package/src/dispatch.mjs CHANGED
@@ -702,6 +702,17 @@ async function dispatch(input = {}) {
702
702
  // that this agent call came through the official pipeline.
703
703
  prompt = _prependDispatchMarker(prompt);
704
704
 
705
+ // ── Situation brief injection ────────────────────────────────────────────────
706
+ // Prepend a compact project-state summary when provided by the pipeline.
707
+ // This gives every dispatched agent immediate context about the project reality.
708
+ const situationBrief = typeof input.situationBrief === 'string' && input.situationBrief.trim()
709
+ ? input.situationBrief.trim()
710
+ : null;
711
+ if (situationBrief) {
712
+ prompt = `--- SITUATION BRIEF ---\n${situationBrief}\n--- END BRIEF ---\n\n${prompt}`;
713
+ }
714
+ // ── End situation brief ──────────────────────────────────────────────────────
715
+
705
716
  // ── Specialist prompt injection ──────────────────────────────────────────────
706
717
  const specialist = decision.specialist && decision.specialist !== 'generic'
707
718
  ? decision.specialist
@@ -764,8 +775,26 @@ async function dispatch(input = {}) {
764
775
  }
765
776
 
766
777
  const effectiveProvider = validated.provider;
767
- const effectiveModel = validated.model ?? decision.model ?? 'sonnet';
768
- const effectiveDecision = { ...validated };
778
+ let effectiveModel = validated.model ?? decision.model ?? 'sonnet';
779
+ let effectiveDecision = { ...validated };
780
+
781
+ // modelSuggestion influence: if the pipeline provided a model suggestion from models.mjs,
782
+ // apply it when the current model is a tier default/fallback (not an explicit override).
783
+ // The suggestion is advisory — it only applies when the decision didn't pin a specific model.
784
+ if (input.modelSuggestion?.model && effectiveProvider === 'claude') {
785
+ const TIER_DEFAULTS = new Set(['haiku', 'sonnet', 'opus']);
786
+ const decisionModelExplicit = decision._explicit?.model ?? false;
787
+ const isDefault = !decisionModelExplicit && TIER_DEFAULTS.has(effectiveModel);
788
+ if (isDefault) {
789
+ const suggestedAlias = mapToAgentModel(input.modelSuggestion.model, effectiveDecision.tier ?? 'execute');
790
+ const validList = VALID_MODELS[effectiveProvider] ?? [];
791
+ if (validList.includes(suggestedAlias)) {
792
+ effectiveModel = suggestedAlias;
793
+ effectiveDecision = { ...effectiveDecision, model: suggestedAlias };
794
+ if (verbose) process.stderr.write(`\x1b[2m[dual-brain] modelSuggestion applied: ${suggestedAlias} (${input.modelSuggestion.reason})\x1b[0m\n`);
795
+ }
796
+ }
797
+ }
769
798
 
770
799
  // ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
771
800
  if (tier === 'execute' && decision.owns && !decision._force) {
@@ -1018,6 +1047,15 @@ async function dispatchDualBrain(input = {}) {
1018
1047
  // Stamp with dispatch marker so enforce-tier.mjs allows this Agent call
1019
1048
  prompt = _prependDispatchMarker(prompt);
1020
1049
 
1050
+ // ── Situation brief injection ────────────────────────────────────────────────
1051
+ const _dualBrainBrief = typeof input.situationBrief === 'string' && input.situationBrief.trim()
1052
+ ? input.situationBrief.trim()
1053
+ : null;
1054
+ if (_dualBrainBrief) {
1055
+ prompt = `--- SITUATION BRIEF ---\n${_dualBrainBrief}\n--- END BRIEF ---\n\n${prompt}`;
1056
+ }
1057
+ // ── End situation brief ──────────────────────────────────────────────────────
1058
+
1021
1059
  // Feature 1: Validate both sub-decisions before spawning anything
1022
1060
  const rt = await detectRuntime();
1023
1061
  const tier = decision.tier ?? 'execute';