dual-brain 3.8.1 → 4.0.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.
- package/README.md +1 -1
- package/hooks/control-panel.mjs +27 -3
- package/hooks/cost-logger.mjs +2 -3
- package/hooks/enforce-tier.mjs +15 -18
- package/hooks/failure-detector.mjs +15 -1
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +35 -4
- package/hooks/test-orchestrator.mjs +67 -3
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +262 -0
- package/install.mjs +33 -15
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vibe-router.mjs — Intent compiler for vibe coding.
|
|
4
|
+
* Decomposes casual natural language into structured work orders.
|
|
5
|
+
*
|
|
6
|
+
* Export: routeVibe(utterance) → { tasks, profile_hint, quality_gates }
|
|
7
|
+
* CLI: node vibe-router.mjs "fix login bug and update the nav"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
11
|
+
|
|
12
|
+
// ─── Tier Detection Patterns ───────────────────────────────────────────────
|
|
13
|
+
// Aligned with enforce-tier.mjs SEARCH_WORDS, THINK_WORDS, and execute patterns.
|
|
14
|
+
|
|
15
|
+
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|where\s+is|list\s+files|read[-\s]?only|lookup|scan|check|look|where|what)\b/i;
|
|
16
|
+
const THINK_WORDS = /\b(review|plan|design|architect|decide|analyze|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug|evaluate|compare|assess)\b/i;
|
|
17
|
+
const EXECUTE_WORDS = /\b(fix|build|add|update|edit|implement|refactor|delete|commit|test|run|create|modify|write|change|remove|rename|move|install|deploy|migrate|convert|replace|rewrite)\b/i;
|
|
18
|
+
|
|
19
|
+
// ─── Risk Keyword Patterns ─────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const RISK_KEYWORDS = [
|
|
22
|
+
{ level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate)\b/i, label: 'security-sensitive' },
|
|
23
|
+
{ level: 'high', regex: /\b(login|payment|billing|deploy|migration|ci[-/]?cd|permission|policy|schema|api[-_]?contract)\b/i, label: 'high-impact' },
|
|
24
|
+
{ level: 'medium', regex: /\b(test|spec|config|integration|shared|util|lib)\b/i, label: 'shared/tested code' },
|
|
25
|
+
{ level: 'low', regex: /\b(readme|docs?|comment|format|lint|style|typo|changelog|nav|ui|css|color|font|margin|padding)\b/i, label: 'docs/UI' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
29
|
+
|
|
30
|
+
// ─── Task Splitting ────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const TASK_SEPARATORS = /\b(?:and\s+(?:also\s+)?|also\s+|plus\s+|then\s+|after\s+that\s+|,\s*(?:and\s+)?)/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split a casual utterance into individual task segments.
|
|
36
|
+
* Handles "and", "also", "plus", "then", "after that", and comma separators.
|
|
37
|
+
*/
|
|
38
|
+
function splitTasks(utterance) {
|
|
39
|
+
if (!utterance) return [];
|
|
40
|
+
|
|
41
|
+
const segments = utterance
|
|
42
|
+
.split(TASK_SEPARATORS)
|
|
43
|
+
.map(s => s.trim())
|
|
44
|
+
.filter(s => s.length > 2);
|
|
45
|
+
|
|
46
|
+
// If no split happened, the whole utterance is a single task
|
|
47
|
+
return segments.length === 0 ? [utterance.trim()] : segments;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Per-Task Classification ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function classifyTier(text) {
|
|
53
|
+
if (THINK_WORDS.test(text)) return 'think';
|
|
54
|
+
if (EXECUTE_WORDS.test(text)) return 'execute';
|
|
55
|
+
if (SEARCH_WORDS.test(text)) return 'search';
|
|
56
|
+
return 'execute'; // default
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classifyKeywordRisk(text) {
|
|
60
|
+
let highest = { level: 'low', reason: 'general task' };
|
|
61
|
+
|
|
62
|
+
for (const pattern of RISK_KEYWORDS) {
|
|
63
|
+
const match = text.match(pattern.regex);
|
|
64
|
+
if (match && LEVEL_ORDER[pattern.level] > LEVEL_ORDER[highest.level]) {
|
|
65
|
+
highest = { level: pattern.level, reason: `${pattern.label} (${match[0]})` };
|
|
66
|
+
if (pattern.level === 'critical') return highest;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return highest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function classifyTask(segment) {
|
|
74
|
+
const tier = classifyTier(segment);
|
|
75
|
+
|
|
76
|
+
// Check keyword-based risk
|
|
77
|
+
const keywordRisk = classifyKeywordRisk(segment);
|
|
78
|
+
|
|
79
|
+
// Check file-path-based risk (uses risk-classifier.mjs)
|
|
80
|
+
const paths = extractPaths(segment);
|
|
81
|
+
const pathRisk = classifyRisk(paths);
|
|
82
|
+
|
|
83
|
+
// Take the higher of keyword risk and path risk
|
|
84
|
+
const risk = LEVEL_ORDER[pathRisk.level] > LEVEL_ORDER[keywordRisk.level]
|
|
85
|
+
? pathRisk
|
|
86
|
+
: keywordRisk;
|
|
87
|
+
|
|
88
|
+
// Generate a clean title: capitalize first letter, trim trailing punctuation
|
|
89
|
+
const title = segment.charAt(0).toUpperCase() + segment.slice(1).replace(/[.!?]+$/, '');
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
title,
|
|
93
|
+
tier,
|
|
94
|
+
risk: risk.level,
|
|
95
|
+
reason: risk.reason,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Profile Hint Detection ────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const QUALITY_HINT_WORDS = /\b(be\s+careful|take\s+your\s+time|thorough|deep\s+dive|carefully|exhaustive|comprehensive)\b/i;
|
|
102
|
+
const COST_HINT_WORDS = /\b(quick|fast|just|quickly|rapid|simple|straightforward)\b/i;
|
|
103
|
+
|
|
104
|
+
function detectProfileHint(utterance) {
|
|
105
|
+
if (QUALITY_HINT_WORDS.test(utterance)) return 'quality-first';
|
|
106
|
+
if (COST_HINT_WORDS.test(utterance)) return 'cost-saver';
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Quality Gates ─────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function determineQualityGates(tasks) {
|
|
113
|
+
const gates = new Set();
|
|
114
|
+
|
|
115
|
+
let highestRisk = 'low';
|
|
116
|
+
for (const task of tasks) {
|
|
117
|
+
if (LEVEL_ORDER[task.risk] > LEVEL_ORDER[highestRisk]) {
|
|
118
|
+
highestRisk = task.risk;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
switch (highestRisk) {
|
|
123
|
+
case 'critical':
|
|
124
|
+
gates.add('dual_brain_required');
|
|
125
|
+
gates.add('tests');
|
|
126
|
+
gates.add('user_permission');
|
|
127
|
+
break;
|
|
128
|
+
case 'high':
|
|
129
|
+
gates.add('dual_brain_review');
|
|
130
|
+
gates.add('tests');
|
|
131
|
+
break;
|
|
132
|
+
case 'medium':
|
|
133
|
+
gates.add('tests');
|
|
134
|
+
break;
|
|
135
|
+
case 'low':
|
|
136
|
+
gates.add('self_check');
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [...gates];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Complexity + Wave Recommendation ──────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function determineComplexity(tasks) {
|
|
146
|
+
const highestRisk = tasks.reduce(
|
|
147
|
+
(max, t) => LEVEL_ORDER[t.risk] > LEVEL_ORDER[max] ? t.risk : max,
|
|
148
|
+
'low'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (tasks.length >= 4 || highestRisk === 'high' || highestRisk === 'critical') {
|
|
152
|
+
return 'complex';
|
|
153
|
+
}
|
|
154
|
+
if (tasks.length >= 2 || highestRisk === 'medium') {
|
|
155
|
+
return 'structured';
|
|
156
|
+
}
|
|
157
|
+
return 'simple';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function determineWave(tasks, complexity) {
|
|
161
|
+
if (tasks.length === 1) return 'single';
|
|
162
|
+
|
|
163
|
+
// If any task depends on another (sequential markers like "then", "after that"
|
|
164
|
+
// were used), we already split them but keep sequential recommendation.
|
|
165
|
+
// For now, check if tasks share the same tier — parallel is fine for independent work.
|
|
166
|
+
const tiers = new Set(tasks.map(t => t.tier));
|
|
167
|
+
const hasHighRisk = tasks.some(t => t.risk === 'high' || t.risk === 'critical');
|
|
168
|
+
|
|
169
|
+
if (hasHighRisk) return 'sequential'; // high-risk tasks need careful ordering
|
|
170
|
+
if (tiers.size === 1 && complexity !== 'complex') return 'parallel';
|
|
171
|
+
return 'parallel';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Summary Generation ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function generateSummary(tasks, complexity, wave, qualityGates, profileHint) {
|
|
177
|
+
const parts = [];
|
|
178
|
+
|
|
179
|
+
if (tasks.length === 1) {
|
|
180
|
+
const t = tasks[0];
|
|
181
|
+
parts.push(`Single ${t.tier} task: ${t.title} (${t.risk} risk).`);
|
|
182
|
+
} else {
|
|
183
|
+
const taskDescs = tasks.map(t => `${t.title.toLowerCase()} (${t.risk} risk, ${t.tier})`);
|
|
184
|
+
parts.push(`Split into ${tasks.length} tasks: ${taskDescs.join(' + ')}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (wave === 'parallel' && tasks.length > 1) {
|
|
188
|
+
parts.push('Recommend parallel agents.');
|
|
189
|
+
} else if (wave === 'sequential') {
|
|
190
|
+
parts.push('Recommend sequential execution.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (qualityGates.includes('dual_brain_required')) {
|
|
194
|
+
parts.push('Dual-brain review required for critical changes.');
|
|
195
|
+
} else if (qualityGates.includes('dual_brain_review')) {
|
|
196
|
+
parts.push('Dual-brain review recommended for high-risk changes.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (profileHint) {
|
|
200
|
+
parts.push(`Profile hint: ${profileHint}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parts.join(' ');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Main Entry Point ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* routeVibe(utterance) — Decompose a casual natural language utterance
|
|
210
|
+
* into structured work orders with tier, risk, and quality gate assignments.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} utterance - The user's casual description
|
|
213
|
+
* @returns {{ complexity, tasks, profile_hint, quality_gates, wave_recommendation, summary }}
|
|
214
|
+
*/
|
|
215
|
+
function routeVibe(utterance) {
|
|
216
|
+
if (!utterance || typeof utterance !== 'string' || !utterance.trim()) {
|
|
217
|
+
return {
|
|
218
|
+
complexity: 'simple',
|
|
219
|
+
tasks: [],
|
|
220
|
+
profile_hint: null,
|
|
221
|
+
quality_gates: ['self_check'],
|
|
222
|
+
wave_recommendation: 'single',
|
|
223
|
+
summary: 'No input provided.',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const segments = splitTasks(utterance);
|
|
228
|
+
const tasks = segments.map(classifyTask);
|
|
229
|
+
const profileHint = detectProfileHint(utterance);
|
|
230
|
+
const qualityGates = determineQualityGates(tasks);
|
|
231
|
+
const complexity = determineComplexity(tasks);
|
|
232
|
+
const wave = determineWave(tasks, complexity);
|
|
233
|
+
const summary = generateSummary(tasks, complexity, wave, qualityGates, profileHint);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
complexity,
|
|
237
|
+
tasks,
|
|
238
|
+
profile_hint: profileHint,
|
|
239
|
+
quality_gates: qualityGates,
|
|
240
|
+
wave_recommendation: wave,
|
|
241
|
+
summary,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export { routeVibe, splitTasks, classifyTask, detectProfileHint };
|
|
246
|
+
|
|
247
|
+
// ─── CLI ───────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
const isMain = process.argv[1] && (
|
|
250
|
+
process.argv[1].endsWith('vibe-router.mjs') ||
|
|
251
|
+
process.argv[1].endsWith('vibe-router')
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (isMain) {
|
|
255
|
+
const utterance = process.argv.slice(2).join(' ');
|
|
256
|
+
if (!utterance) {
|
|
257
|
+
console.error('Usage: node vibe-router.mjs "fix the login bug and also update the nav"');
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const result = routeVibe(utterance);
|
|
261
|
+
console.log(JSON.stringify(result, null, 2));
|
|
262
|
+
}
|
package/install.mjs
CHANGED
|
@@ -58,7 +58,8 @@ if (flag('--help') || flag('-h')) {
|
|
|
58
58
|
--help Show this help
|
|
59
59
|
|
|
60
60
|
🎛️ Routing modes:
|
|
61
|
-
|
|
61
|
+
🤖 Auto (default) Adapts routing based on risk, health, outcomes
|
|
62
|
+
⚖️ Balanced Auto-routes, uses both providers evenly
|
|
62
63
|
🛡️ Conservative Fewer GPT dispatches, sticks to Claude
|
|
63
64
|
🚀 Aggressive Maximizes both subscriptions, dual-brain for medium+
|
|
64
65
|
|
|
@@ -314,6 +315,7 @@ function generateGitignoreEntries(workspace) {
|
|
|
314
315
|
'.claude/hooks/usage-summary-*.json',
|
|
315
316
|
'.claude/hooks/decision-ledger.jsonl',
|
|
316
317
|
'.claude/.launched',
|
|
318
|
+
'.claude/dual-brain.memory.json',
|
|
317
319
|
];
|
|
318
320
|
let existing = '';
|
|
319
321
|
try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
|
|
@@ -337,6 +339,7 @@ function install(workspace, env, mode) {
|
|
|
337
339
|
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
338
340
|
'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
|
|
339
341
|
'risk-classifier.mjs', 'failure-detector.mjs',
|
|
342
|
+
'vibe-router.mjs', 'plan-generator.mjs', 'vibe-memory.mjs',
|
|
340
343
|
];
|
|
341
344
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
342
345
|
actions.push(`✓ ${HOOKS.length} hook scripts`);
|
|
@@ -453,7 +456,7 @@ const PROFILES = {
|
|
|
453
456
|
function loadProfile(workspace) {
|
|
454
457
|
try {
|
|
455
458
|
const data = JSON.parse(readFileSync(profilePath(workspace), 'utf8'));
|
|
456
|
-
const name = data.active && PROFILES[data.active] ? data.active : '
|
|
459
|
+
const name = data.active && PROFILES[data.active] ? data.active : 'auto';
|
|
457
460
|
const profile = PROFILES[name];
|
|
458
461
|
const custom = data.custom_overrides || {};
|
|
459
462
|
return {
|
|
@@ -464,7 +467,7 @@ function loadProfile(workspace) {
|
|
|
464
467
|
switched_at: data.switched_at || null,
|
|
465
468
|
};
|
|
466
469
|
} catch {
|
|
467
|
-
return { name: '
|
|
470
|
+
return { name: 'auto', ...PROFILES.auto, switched_at: null };
|
|
468
471
|
}
|
|
469
472
|
}
|
|
470
473
|
|
|
@@ -497,8 +500,8 @@ function cmdMode() {
|
|
|
497
500
|
|
|
498
501
|
if (!modeArg || modeArg === 'list') {
|
|
499
502
|
const current = loadProfile(workspace);
|
|
500
|
-
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
501
|
-
const UI_NAMES = { balanced: '
|
|
503
|
+
const PEMOJIS = { auto: '🤖', balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
504
|
+
const UI_NAMES = { auto: 'Auto (default)', balanced: 'Balanced', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
502
505
|
console.log('');
|
|
503
506
|
console.log(' 🎛️ Routing modes:');
|
|
504
507
|
console.log('');
|
|
@@ -513,13 +516,28 @@ function cmdMode() {
|
|
|
513
516
|
return;
|
|
514
517
|
}
|
|
515
518
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
519
|
+
let resolvedMode = modeArg;
|
|
520
|
+
if (!PROFILES[resolvedMode]) {
|
|
521
|
+
// Try natural language alias resolution
|
|
522
|
+
const cleaned = resolvedMode.toLowerCase().trim()
|
|
523
|
+
.replace(/^(go|be|use|switch to|set|mode)\s+/i, '')
|
|
524
|
+
.replace(/\s+mode$/i, '');
|
|
525
|
+
const MODE_ALIASES = {
|
|
526
|
+
'auto': 'auto', 'adaptive': 'auto', 'smart': 'auto', 'default': 'auto', 'normal': 'auto',
|
|
527
|
+
'balanced': 'balanced', 'even': 'balanced', 'equal': 'balanced',
|
|
528
|
+
'cost-saver': 'cost-saver', 'cheap': 'cost-saver', 'save': 'cost-saver', 'conservative': 'cost-saver', 'frugal': 'cost-saver', 'budget': 'cost-saver',
|
|
529
|
+
'quality-first': 'quality-first', 'aggressive': 'quality-first', 'quality': 'quality-first', 'max': 'quality-first', 'full': 'quality-first', 'both': 'quality-first',
|
|
530
|
+
};
|
|
531
|
+
resolvedMode = MODE_ALIASES[cleaned] || null;
|
|
532
|
+
if (!resolvedMode) {
|
|
533
|
+
console.error(` Unknown profile: ${modeArg}`);
|
|
534
|
+
console.error(` Available: ${Object.keys(PROFILES).join(', ')}`);
|
|
535
|
+
console.error(` Aliases: cheap, aggressive, quality, budget, frugal, smart, adaptive, ...`);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
520
538
|
}
|
|
521
539
|
|
|
522
|
-
const profile = PROFILES[
|
|
540
|
+
const profile = PROFILES[resolvedMode];
|
|
523
541
|
|
|
524
542
|
let customOverrides = null;
|
|
525
543
|
try {
|
|
@@ -529,12 +547,12 @@ function cmdMode() {
|
|
|
529
547
|
}
|
|
530
548
|
} catch {}
|
|
531
549
|
|
|
532
|
-
saveProfile(workspace,
|
|
550
|
+
saveProfile(workspace, resolvedMode, customOverrides);
|
|
533
551
|
|
|
534
|
-
const PEMOJIS = { balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
535
|
-
const UI_NAMES = { balanced: '
|
|
552
|
+
const PEMOJIS = { auto: '🤖', balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
|
|
553
|
+
const UI_NAMES = { auto: 'Auto (default)', balanced: 'Balanced', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
|
|
536
554
|
console.log('');
|
|
537
|
-
console.log(` ✅ Mode switched: ${PEMOJIS[
|
|
555
|
+
console.log(` ✅ Mode switched: ${PEMOJIS[resolvedMode] || ''} ${UI_NAMES[resolvedMode] || resolvedMode}`);
|
|
538
556
|
console.log(` ${profile.description}`);
|
|
539
557
|
console.log('');
|
|
540
558
|
console.log(' 🧭 Routing changes:');
|
|
@@ -586,7 +604,7 @@ function cmdBudget() {
|
|
|
586
604
|
};
|
|
587
605
|
|
|
588
606
|
const data = {
|
|
589
|
-
active: existing.active || '
|
|
607
|
+
active: existing.active || 'auto',
|
|
590
608
|
switched_at: existing.switched_at || new Date().toISOString(),
|
|
591
609
|
custom_overrides: customOverrides,
|
|
592
610
|
};
|
package/package.json
CHANGED