dual-brain 0.2.0 → 0.2.1
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 +56 -0
- package/package.json +4 -2
- package/src/awareness.mjs +17 -0
- package/src/decide.mjs +46 -2
- package/src/detect.mjs +119 -4
- package/src/dispatch.mjs +19 -0
- package/src/doctor.mjs +316 -1
- package/src/health.mjs +82 -0
- package/src/index.mjs +1 -1
- package/src/intelligence.mjs +25 -1
- package/src/pipeline.mjs +70 -6
- package/src/profile.mjs +28 -0
- package/src/replit.mjs +1210 -0
- package/src/session.mjs +285 -14
package/bin/dual-brain.mjs
CHANGED
|
@@ -902,6 +902,27 @@ async function cmdStatus(args = []) {
|
|
|
902
902
|
console.log(' unknown (could not read .claude/settings.json)');
|
|
903
903
|
}
|
|
904
904
|
|
|
905
|
+
// Replit section
|
|
906
|
+
try {
|
|
907
|
+
const replit = await import('../src/replit.mjs');
|
|
908
|
+
const env = replit.detectReplitEnvironment(cwd);
|
|
909
|
+
if (env.isReplit) {
|
|
910
|
+
console.log('\nReplit:');
|
|
911
|
+
const tools = replit.inspectReplitTools(cwd);
|
|
912
|
+
const verStr = tools.version ? `v${tools.version}` : 'unknown';
|
|
913
|
+
const capsCount = Array.isArray(tools.capabilities) ? tools.capabilities.length : 0;
|
|
914
|
+
console.log(` replit-tools : ${tools.installed ? `${verStr} (${capsCount} capabilities)` : 'not installed'}`);
|
|
915
|
+
const authStatus = replit.getAuthStatus(cwd);
|
|
916
|
+
console.log(` auth : ${authStatus.authenticated ? 'authenticated' : 'not authenticated'}${authStatus.method ? ` (${authStatus.method})` : ''}`);
|
|
917
|
+
const archive = replit.getSessionArchive(cwd);
|
|
918
|
+
const archiveCount = Array.isArray(archive) ? archive.length : (archive?.count ?? 0);
|
|
919
|
+
console.log(` session archive: ${archiveCount} session${archiveCount !== 1 ? 's' : ''}`);
|
|
920
|
+
const openaiPresent = replit.hasSecret('OPENAI_API_KEY');
|
|
921
|
+
const anthropicPresent = replit.hasSecret('ANTHROPIC_API_KEY');
|
|
922
|
+
console.log(` secrets : OPENAI_API_KEY=${openaiPresent ? 'set' : 'unset'} ANTHROPIC_API_KEY=${anthropicPresent ? 'set' : 'unset'}`);
|
|
923
|
+
}
|
|
924
|
+
} catch { /* replit.mjs not available or not in Replit — skip silently */ }
|
|
925
|
+
|
|
905
926
|
// Update check
|
|
906
927
|
try {
|
|
907
928
|
const localVer = readVersion();
|
|
@@ -1957,10 +1978,30 @@ async function mainScreen(rl, ask) {
|
|
|
1957
1978
|
}
|
|
1958
1979
|
} catch { /* non-fatal */ }
|
|
1959
1980
|
|
|
1981
|
+
// Replit awareness rows (shown only when running in Replit, max 2-3 lines)
|
|
1982
|
+
const replitAwarenessRows = [];
|
|
1983
|
+
try {
|
|
1984
|
+
const replitMod = await import('../src/replit.mjs');
|
|
1985
|
+
const replitEnv = replitMod.detectReplitEnvironment(cwd);
|
|
1986
|
+
if (replitEnv.isReplit) {
|
|
1987
|
+
const rtInfo = replitMod.inspectReplitTools(cwd);
|
|
1988
|
+
const authInfo = replitMod.getAuthStatus(cwd);
|
|
1989
|
+
const archive = replitMod.getSessionArchive(cwd);
|
|
1990
|
+
const archCount = Array.isArray(archive) ? archive.length : (archive?.count ?? 0);
|
|
1991
|
+
const secretNames = replitMod.listSecretNames();
|
|
1992
|
+
const secretCount = Array.isArray(secretNames) ? secretNames.length : 0;
|
|
1993
|
+
const verStr = rtInfo.version ? `v${rtInfo.version}` : (rtInfo.installed ? 'installed' : 'not installed');
|
|
1994
|
+
const authStr = authInfo.authenticated ? '\x1b[32m✓\x1b[0m auth' : '\x1b[2mno auth\x1b[0m';
|
|
1995
|
+
replitAwarenessRows.push(row(`\x1b[2m🔧\x1b[0m Replit replit-tools ${verStr} ${authStr}`));
|
|
1996
|
+
replitAwarenessRows.push(row(`\x1b[2m \x1b[0m ${archCount} archived session${archCount !== 1 ? 's' : ''} ${secretCount} secret${secretCount !== 1 ? 's' : ''}`));
|
|
1997
|
+
}
|
|
1998
|
+
} catch { /* replit.mjs not available — skip */ }
|
|
1999
|
+
|
|
1960
2000
|
const awarenessRows = [
|
|
1961
2001
|
row(awarenessLine1),
|
|
1962
2002
|
row(awarenessLine2),
|
|
1963
2003
|
row(awarenessLine3),
|
|
2004
|
+
...replitAwarenessRows,
|
|
1964
2005
|
];
|
|
1965
2006
|
|
|
1966
2007
|
// ── Sessions section ──────────────────────────────────────────────────────
|
|
@@ -5001,6 +5042,21 @@ async function main() {
|
|
|
5001
5042
|
}
|
|
5002
5043
|
|
|
5003
5044
|
if (cmd === 'init') {
|
|
5045
|
+
// init --replit: run Replit-specific integration setup
|
|
5046
|
+
if (args.includes('--replit')) {
|
|
5047
|
+
const cwd = process.cwd();
|
|
5048
|
+
const dryRun = args.includes('--dry-run');
|
|
5049
|
+
try {
|
|
5050
|
+
const replit = await import('../src/replit.mjs');
|
|
5051
|
+
const report = await replit.initReplitIntegration({ dryRun, cwd });
|
|
5052
|
+
console.log(replit.formatReplitReport(report));
|
|
5053
|
+
} catch (e) {
|
|
5054
|
+
console.error('replit.mjs not available yet — skipping Replit init');
|
|
5055
|
+
if (process.env.DEBUG) console.error(e.message);
|
|
5056
|
+
}
|
|
5057
|
+
return;
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5004
5060
|
if (isInteractive) {
|
|
5005
5061
|
// Run onboarding wizard then main screen
|
|
5006
5062
|
const cwd = process.cwd();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"./redact": "./src/redact.mjs",
|
|
23
23
|
"./calibration": "./src/calibration.mjs",
|
|
24
24
|
"./models": "./src/models.mjs",
|
|
25
|
-
"./prompt-intel": "./src/prompt-intel.mjs"
|
|
25
|
+
"./prompt-intel": "./src/prompt-intel.mjs",
|
|
26
|
+
"./replit": "./src/replit.mjs"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [
|
|
28
29
|
"claude-code",
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
"src/install-hooks.mjs",
|
|
84
85
|
"src/update-check.mjs",
|
|
85
86
|
"src/prompt-intel.mjs",
|
|
87
|
+
"src/replit.mjs",
|
|
86
88
|
"src/fx.mjs",
|
|
87
89
|
"bin/*.mjs",
|
|
88
90
|
"hooks/enforce-tier.mjs",
|
package/src/awareness.mjs
CHANGED
|
@@ -110,6 +110,23 @@ function scanReplitTools() {
|
|
|
110
110
|
return { installed: true, version, sessionArchivePath, capabilities };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Basic Replit environment scan used for the awareness report.
|
|
115
|
+
*
|
|
116
|
+
* NOTE: Detailed Replit integration (auth status, replit-tools capabilities,
|
|
117
|
+
* session archive, secrets listing, config planning, init flow) lives in
|
|
118
|
+
* src/replit.mjs. When that module is available, prefer its
|
|
119
|
+
* detectReplitEnvironment() over duplicating detection logic here.
|
|
120
|
+
*
|
|
121
|
+
* Usage (fail-safe):
|
|
122
|
+
* try {
|
|
123
|
+
* const { detectReplitEnvironment } = await import('./replit.mjs');
|
|
124
|
+
* const rich = detectReplitEnvironment(cwd);
|
|
125
|
+
* // rich has .isReplit, .replId, .version, .authStatus, .capabilities …
|
|
126
|
+
* } catch {
|
|
127
|
+
* // fall back to this scanReplit() result from scanEnvironment()
|
|
128
|
+
* }
|
|
129
|
+
*/
|
|
113
130
|
function scanReplit(cwd) {
|
|
114
131
|
const env = process.env;
|
|
115
132
|
const isReplit = Boolean(env.REPL_ID || env.REPL_SLUG);
|
package/src/decide.mjs
CHANGED
|
@@ -682,10 +682,10 @@ function applyCriticalRiskFloor(model, provider, available, risk) {
|
|
|
682
682
|
|
|
683
683
|
/**
|
|
684
684
|
* Main routing decision function.
|
|
685
|
-
* @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object }} input
|
|
685
|
+
* @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object, sessionContext?: object }} input
|
|
686
686
|
* @returns {object} Routing decision
|
|
687
687
|
*/
|
|
688
|
-
export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult } = {}) {
|
|
688
|
+
export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult, sessionContext = null } = {}) {
|
|
689
689
|
const available = getAvailableModels(profile);
|
|
690
690
|
|
|
691
691
|
// Resolve active work style
|
|
@@ -831,6 +831,50 @@ export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult } =
|
|
|
831
831
|
// 'standard', 'deep', 'ultra' — leave model unchanged; existing routing already picked correctly
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
// Session context: escalate or prefer model based on cross-session history
|
|
835
|
+
if (sessionContext) {
|
|
836
|
+
const sessionAttempts = Array.isArray(sessionContext.priorAttempts) ? sessionContext.priorAttempts : [];
|
|
837
|
+
const sessionFailures = sessionAttempts.filter(a => a && (a.failed || a.status === 'failed'));
|
|
838
|
+
const sessionSuccesses = sessionAttempts.filter(a => a && !a.failed && a.status !== 'failed');
|
|
839
|
+
|
|
840
|
+
// Prior failures on similar work → escalate from sonnet to opus (Claude) or gpt-4o to o3 (OpenAI)
|
|
841
|
+
if (sessionFailures.length >= 2 && !isHighStakes) {
|
|
842
|
+
if (provider === 'claude') {
|
|
843
|
+
const claudeRank = ['haiku', 'sonnet', 'opus'];
|
|
844
|
+
const currentIdx = claudeRank.indexOf(toShortName(model, 'claude'));
|
|
845
|
+
if (currentIdx !== -1 && currentIdx < claudeRank.length - 1) {
|
|
846
|
+
const escalated = claudeRank[currentIdx + 1];
|
|
847
|
+
if (available.claude.includes(escalated)) model = escalated;
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
const oaiRank = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
851
|
+
const currentIdx = oaiRank.indexOf(model);
|
|
852
|
+
if (currentIdx !== -1 && currentIdx < oaiRank.length - 1) {
|
|
853
|
+
const escalated = oaiRank[currentIdx + 1];
|
|
854
|
+
if (available.openai.includes(escalated)) model = escalated;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Prior successful approach → prefer same provider/model that worked before
|
|
860
|
+
if (sessionSuccesses.length > 0) {
|
|
861
|
+
const lastSuccess = sessionSuccesses[sessionSuccesses.length - 1];
|
|
862
|
+
if (lastSuccess.provider && lastSuccess.model && !isHighStakes) {
|
|
863
|
+
const successProvider = lastSuccess.provider;
|
|
864
|
+
const successModel = lastSuccess.model;
|
|
865
|
+
const providerEnabled = profile?.providers?.[successProvider]?.enabled;
|
|
866
|
+
const providerHealthy = (healthScores[successProvider] ?? 0) > 0;
|
|
867
|
+
if (providerEnabled && providerHealthy) {
|
|
868
|
+
const shortSuccess = toShortName(successModel, successProvider);
|
|
869
|
+
if (available[successProvider]?.includes(shortSuccess)) {
|
|
870
|
+
provider = successProvider;
|
|
871
|
+
model = shortSuccess;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
834
878
|
// Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
|
|
835
879
|
model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
|
|
836
880
|
|
package/src/detect.mjs
CHANGED
|
@@ -280,9 +280,112 @@ function classifyReasoningDepth(prompt, files = [], priorOutcomes = []) {
|
|
|
280
280
|
return { depth: 'low', signals: lowSignals.length > 0 ? lowSignals : ['no elevated signals detected'] };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
// ─── Plugin-aware detection helpers ───────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Known plugin service keywords → plugin IDs.
|
|
287
|
+
* Maps common service names and their aliases to Codex plugin directory names.
|
|
288
|
+
* Static map so detect.mjs stays self-contained (no I/O at classify time).
|
|
289
|
+
*/
|
|
290
|
+
const PLUGIN_KEYWORD_MAP = {
|
|
291
|
+
// Payments
|
|
292
|
+
stripe: 'stripe',
|
|
293
|
+
payment: 'stripe',
|
|
294
|
+
checkout: 'stripe',
|
|
295
|
+
subscription: 'stripe',
|
|
296
|
+
webhook: 'stripe',
|
|
297
|
+
// Collaboration / messaging
|
|
298
|
+
slack: 'slack',
|
|
299
|
+
teams: 'teams',
|
|
300
|
+
// Data / backend
|
|
301
|
+
supabase: 'supabase',
|
|
302
|
+
neondb: 'neon-postgres',
|
|
303
|
+
// Dev tools
|
|
304
|
+
github: 'github',
|
|
305
|
+
'pull request': 'github',
|
|
306
|
+
linear: 'linear',
|
|
307
|
+
jira: 'atlassian-rovo',
|
|
308
|
+
atlassian: 'atlassian-rovo',
|
|
309
|
+
// Comms / productivity
|
|
310
|
+
gmail: 'gmail',
|
|
311
|
+
outlook: 'outlook-email',
|
|
312
|
+
notion: 'notion',
|
|
313
|
+
'google calendar': 'google-calendar',
|
|
314
|
+
'google drive': 'google-drive',
|
|
315
|
+
// Monitoring / infra
|
|
316
|
+
sentry: 'sentry',
|
|
317
|
+
vercel: 'vercel',
|
|
318
|
+
netlify: 'netlify',
|
|
319
|
+
cloudflare: 'cloudflare',
|
|
320
|
+
// Analytics
|
|
321
|
+
amplitude: 'amplitude',
|
|
322
|
+
// Design
|
|
323
|
+
figma: 'figma',
|
|
324
|
+
canva: 'canva',
|
|
325
|
+
// CRM / sales
|
|
326
|
+
hubspot: 'hubspot',
|
|
327
|
+
pipedrive: 'pipedrive',
|
|
328
|
+
// Communication
|
|
329
|
+
sendgrid: 'sendgrid',
|
|
330
|
+
twilio: 'twilio-developer-kit',
|
|
331
|
+
// Storage
|
|
332
|
+
sharepoint: 'sharepoint',
|
|
333
|
+
box: 'box',
|
|
334
|
+
// AI / ML
|
|
335
|
+
openai: 'openai-developers',
|
|
336
|
+
'hugging face': 'hugging-face',
|
|
337
|
+
// Other
|
|
338
|
+
razorpay: 'razorpay',
|
|
339
|
+
render: 'render',
|
|
340
|
+
monday: 'monday-com',
|
|
341
|
+
asana: 'asana',
|
|
342
|
+
clickup: 'clickup',
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Detect Codex plugin IDs that match keywords in the prompt.
|
|
347
|
+
* Returns an array of matched plugin IDs (deduplicated, max 5).
|
|
348
|
+
* @param {string} prompt
|
|
349
|
+
* @returns {string[]}
|
|
350
|
+
*/
|
|
351
|
+
function detectSuggestedPlugins(prompt) {
|
|
352
|
+
if (!prompt) return [];
|
|
353
|
+
const lower = prompt.toLowerCase();
|
|
354
|
+
const matched = new Set();
|
|
355
|
+
|
|
356
|
+
// Check multi-word phrases first (longer matches take priority)
|
|
357
|
+
const sortedEntries = Object.entries(PLUGIN_KEYWORD_MAP).sort((a, b) => b[0].length - a[0].length);
|
|
358
|
+
for (const [keyword, pluginId] of sortedEntries) {
|
|
359
|
+
if (lower.includes(keyword)) {
|
|
360
|
+
matched.add(pluginId);
|
|
361
|
+
if (matched.size >= 5) break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return [...matched];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Main detection function. Input: { prompt, files?, priorFailures?, sessionContext? } */
|
|
284
369
|
function detectTask(input) {
|
|
285
|
-
const { prompt = '', files = [],
|
|
370
|
+
const { prompt = '', files = [], sessionContext = null } = input;
|
|
371
|
+
let { priorFailures = 0 } = input;
|
|
372
|
+
|
|
373
|
+
// Session context: bump priorFailures if session history shows failures on similar tasks
|
|
374
|
+
let repeatedFailure = false;
|
|
375
|
+
if (sessionContext) {
|
|
376
|
+
const sessionFailures = Array.isArray(sessionContext.priorAttempts)
|
|
377
|
+
? sessionContext.priorAttempts.filter(a => a && (a.failed || a.status === 'failed')).length
|
|
378
|
+
: 0;
|
|
379
|
+
if (sessionFailures > 0) {
|
|
380
|
+
priorFailures = Math.max(priorFailures, sessionFailures);
|
|
381
|
+
}
|
|
382
|
+
// Flag repeated_failure if riskSignals contains failure indicators
|
|
383
|
+
const riskSignals = sessionContext.riskSignals ?? [];
|
|
384
|
+
if (riskSignals.some(s => s && (s.type === 'failure' || s.failed || /fail/i.test(String(s))))) {
|
|
385
|
+
repeatedFailure = true;
|
|
386
|
+
}
|
|
387
|
+
if (sessionFailures >= 2) repeatedFailure = true;
|
|
388
|
+
}
|
|
286
389
|
|
|
287
390
|
// 1. Intent
|
|
288
391
|
const intent = classifyIntent(prompt);
|
|
@@ -299,7 +402,14 @@ function detectTask(input) {
|
|
|
299
402
|
if (regex.test(prompt)) { keywordRisk = level; break; }
|
|
300
403
|
}
|
|
301
404
|
|
|
302
|
-
|
|
405
|
+
let risk = higherRisk(pathRiskLevel, keywordRisk);
|
|
406
|
+
|
|
407
|
+
// Session context: bump risk one level if prior session attempts failed on similar tasks
|
|
408
|
+
if (repeatedFailure && LEVEL_ORDER[risk] < LEVEL_ORDER['high']) {
|
|
409
|
+
const riskLevels = ['low', 'medium', 'high', 'critical'];
|
|
410
|
+
const currentIdx = riskLevels.indexOf(risk);
|
|
411
|
+
risk = riskLevels[Math.min(currentIdx + 1, riskLevels.length - 1)];
|
|
412
|
+
}
|
|
303
413
|
const fileCount = files.length;
|
|
304
414
|
|
|
305
415
|
// 4. Complexity
|
|
@@ -342,6 +452,9 @@ function detectTask(input) {
|
|
|
342
452
|
: [];
|
|
343
453
|
const { depth: reasoningDepth, signals: reasoningSignals } = classifyReasoningDepth(prompt, files, priorOutcomes);
|
|
344
454
|
|
|
455
|
+
// 10. Suggested Codex plugins (keyword-based, static map — no I/O)
|
|
456
|
+
const suggestedPlugins = detectSuggestedPlugins(prompt);
|
|
457
|
+
|
|
345
458
|
return {
|
|
346
459
|
intent,
|
|
347
460
|
risk,
|
|
@@ -356,6 +469,8 @@ function detectTask(input) {
|
|
|
356
469
|
specialist: specialistResult,
|
|
357
470
|
reasoningDepth,
|
|
358
471
|
reasoningSignals,
|
|
472
|
+
suggestedPlugins,
|
|
473
|
+
...(repeatedFailure && { repeatedFailure: true }),
|
|
359
474
|
};
|
|
360
475
|
}
|
|
361
476
|
|
|
@@ -474,4 +589,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
|
474
589
|
console.log(JSON.stringify(result, null, 2));
|
|
475
590
|
}
|
|
476
591
|
|
|
477
|
-
export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist, classifyReasoningDepth };
|
|
592
|
+
export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths, classifySpecialist, classifyReasoningDepth, detectSuggestedPlugins };
|
package/src/dispatch.mjs
CHANGED
|
@@ -737,6 +737,25 @@ async function dispatch(input = {}) {
|
|
|
737
737
|
}
|
|
738
738
|
// ── End specialist injection ─────────────────────────────────────────────────
|
|
739
739
|
|
|
740
|
+
// ── Plugin hint injection (Codex path) ──────────────────────────────────────
|
|
741
|
+
// When dispatching to OpenAI/Codex, check if any Codex plugins match the task
|
|
742
|
+
// and append an advisory hint so the agent can choose to use them.
|
|
743
|
+
// Uses dynamic import so failure is always non-fatal.
|
|
744
|
+
const targetProvider = decision.provider ?? 'claude';
|
|
745
|
+
if (targetProvider === 'openai') {
|
|
746
|
+
try {
|
|
747
|
+
const { matchPluginsForTask } = await import('./replit.mjs');
|
|
748
|
+
const matched = matchPluginsForTask(prompt, undefined, cwd);
|
|
749
|
+
if (matched.length > 0) {
|
|
750
|
+
const pluginNames = matched.slice(0, 3).map(m => m.plugin.id).join(', ');
|
|
751
|
+
const hint = `\n\n[Available Codex plugins for this task: ${pluginNames}. Consider using the matching plugin for direct API access.]`;
|
|
752
|
+
prompt = prompt + hint;
|
|
753
|
+
if (verbose) process.stderr.write(`[dual-brain] plugin hint injected: ${pluginNames}\n`);
|
|
754
|
+
}
|
|
755
|
+
} catch { /* non-fatal — never block dispatch */ }
|
|
756
|
+
}
|
|
757
|
+
// ── End plugin hint injection ────────────────────────────────────────────────
|
|
758
|
+
|
|
740
759
|
const tier = decision.tier ?? 'execute';
|
|
741
760
|
const timeoutMs = TIER_TIMEOUT_MS[tier] ?? 120_000;
|
|
742
761
|
|