dual-brain 0.2.16 → 0.2.18
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/CLAUDE.md +8 -6
- package/bin/dual-brain.mjs +24 -6
- package/package.json +1 -3
- package/scripts/verify-publish.mjs +40 -3
- package/src/cognitive-loop.mjs +4 -1
- package/src/continuity.mjs +7 -1
- package/src/head.mjs +107 -0
- package/hooks/task-classifier.mjs +0 -328
- package/hooks/vibe-router.mjs +0 -387
package/CLAUDE.md
CHANGED
|
@@ -7,12 +7,14 @@ This project uses dual-provider orchestration. Config: `.claude/orchestrator.jso
|
|
|
7
7
|
HEAD is the orchestration brain. Workers implement. This is enforced by architecture, not just policy.
|
|
8
8
|
|
|
9
9
|
1. **HEAD plans, workers implement.** HEAD dispatches typed task contracts via agents. HEAD never edits files, runs implementation commands, or writes code directly.
|
|
10
|
-
2. **
|
|
11
|
-
3. **
|
|
12
|
-
4. **
|
|
13
|
-
5. **
|
|
14
|
-
6. **
|
|
15
|
-
7. **
|
|
10
|
+
2. **Think before acting — always.** HEAD applies the same cognitive rigor to its own responses as it does to dispatches. Before proposing actions: assess depth, consider scope, check if the request needs thinking or just execution. Never list things to build without first determining if they should be built. This applies to conversations, not just agent calls.
|
|
11
|
+
3. **Discuss before dispatching.** Every action task starts with intent classification. Ambiguous requests get clarified. Architecture decisions get discussed.
|
|
12
|
+
4. **Typed contracts are mandatory.** Every dispatch includes: objective, scope, acceptance criteria, risk level, allowed operations. Use `src/templates.mjs` to generate prompts.
|
|
13
|
+
5. **Dangerous work requires approval.** Auth, credentials, secrets, billing, migrations, destructive git — explicit user confirmation before dispatch.
|
|
14
|
+
6. **Complete the cycle.** HEAD finishes what it starts: implement → test → commit → push → publish. Don't stop halfway and ask the user to do admin. If the system can do it, HEAD does it.
|
|
15
|
+
7. **Runtime state is source of truth.** HEAD's state machine (`src/head.mjs`) tracks phase, intent, confidence, and drift. Not CLAUDE.md text.
|
|
16
|
+
8. **Hooks enforce boundaries.** head-guard blocks HEAD from implementing. enforce-tier ensures correct routing. Telemetry hooks observe but never block.
|
|
17
|
+
9. **Subscription-only auth.** Users authenticate via `claude login` / `codex login`. No API keys.
|
|
16
18
|
|
|
17
19
|
## Quick Reference
|
|
18
20
|
|
package/bin/dual-brain.mjs
CHANGED
|
@@ -571,20 +571,38 @@ async function cmdGo(args, opts = {}) {
|
|
|
571
571
|
dispatchSpinner.succeed(`Agent dispatched: ${prompt.slice(0, 50)}`);
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
-
// ── Cognitive loop: advance
|
|
574
|
+
// ── Cognitive loop: advance through waves until done ─────────────────────────
|
|
575
575
|
if (loopDispatchMeta && result && !dryRun) {
|
|
576
576
|
try {
|
|
577
577
|
const cogLoop = await _getCognitiveLoop();
|
|
578
578
|
if (cogLoop) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
let waveId = loopDispatchMeta.nextDispatch.waveId;
|
|
580
|
+
let rawResults = [result.summary || result.output || ''];
|
|
581
|
+
let advanceResult = cogLoop.advance(rawResults, waveId, { files });
|
|
582
|
+
|
|
583
|
+
// Loop through remaining waves
|
|
584
|
+
while (advanceResult && advanceResult.phase === 'dispatch' && advanceResult.nextDispatch) {
|
|
585
|
+
if (verbose) {
|
|
586
|
+
console.log(` [cognitive-loop] Wave ${advanceResult.rationale || 'next'}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Dispatch the next wave
|
|
590
|
+
const nextAgent = advanceResult.nextDispatch.agents?.[0];
|
|
591
|
+
const nextPrompt = nextAgent?.prompt || prompt;
|
|
592
|
+
const nextResult = await runPipeline('go', nextPrompt, { files, cwd, verbose, dryRun: false });
|
|
593
|
+
|
|
594
|
+
// Advance again
|
|
595
|
+
waveId = advanceResult.nextDispatch.waveId;
|
|
596
|
+
rawResults = [nextResult.result?.summary || nextResult.result?.output || ''];
|
|
597
|
+
advanceResult = cogLoop.advance(rawResults, waveId, { files });
|
|
598
|
+
}
|
|
599
|
+
|
|
582
600
|
if (verbose && advanceResult) {
|
|
583
|
-
console.log(` [cognitive-loop]
|
|
601
|
+
console.log(` [cognitive-loop] Final: ${advanceResult.phase}, ${advanceResult.rationale || '-'}`);
|
|
584
602
|
}
|
|
585
603
|
}
|
|
586
604
|
} catch {
|
|
587
|
-
// Non-fatal — loop advance failure doesn't affect
|
|
605
|
+
// Non-fatal — loop advance failure doesn't affect completed dispatches
|
|
588
606
|
}
|
|
589
607
|
}
|
|
590
608
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -150,11 +150,9 @@
|
|
|
150
150
|
"hooks/control-panel.mjs",
|
|
151
151
|
"hooks/risk-classifier.mjs",
|
|
152
152
|
"hooks/failure-detector.mjs",
|
|
153
|
-
"hooks/vibe-router.mjs",
|
|
154
153
|
"hooks/plan-generator.mjs",
|
|
155
154
|
"hooks/vibe-memory.mjs",
|
|
156
155
|
"hooks/wave-orchestrator.mjs",
|
|
157
|
-
"hooks/task-classifier.mjs",
|
|
158
156
|
"hooks/model-registry.mjs",
|
|
159
157
|
"hooks/auto-update-wrapper.mjs",
|
|
160
158
|
"hooks/session-end.mjs",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
|
|
3
5
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
6
|
const url = `https://registry.npmjs.org/dual-brain/${version}`;
|
|
5
7
|
const maxWait = 30000;
|
|
@@ -10,17 +12,52 @@ async function check() {
|
|
|
10
12
|
const res = await fetch(url);
|
|
11
13
|
if (res.ok) {
|
|
12
14
|
console.log(`✓ dual-brain@${version} verified on registry`);
|
|
13
|
-
return;
|
|
15
|
+
return true;
|
|
14
16
|
}
|
|
15
17
|
} catch {}
|
|
16
18
|
|
|
17
19
|
if (Date.now() - start > maxWait) {
|
|
18
20
|
console.log(`⚠ dual-brain@${version} published but CDN propagation may take a moment`);
|
|
19
|
-
return;
|
|
21
|
+
return true;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
await new Promise(r => setTimeout(r, 3000));
|
|
23
25
|
return check();
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
async function commitAndPush() {
|
|
29
|
+
try {
|
|
30
|
+
// Check if there are changes to commit
|
|
31
|
+
const status = execSync('git status --porcelain', { encoding: 'utf8' }).trim();
|
|
32
|
+
if (!status) return;
|
|
33
|
+
|
|
34
|
+
// Stage all tracked + new src/hooks/bin files (not .dualbrain/ runtime state)
|
|
35
|
+
execSync('git add src/ hooks/ bin/ scripts/ package.json CLAUDE.md .claude/ .replit .gitignore tests/ 2>/dev/null || true', { encoding: 'utf8' });
|
|
36
|
+
|
|
37
|
+
// Check if anything was staged
|
|
38
|
+
const staged = execSync('git diff --cached --stat', { encoding: 'utf8' }).trim();
|
|
39
|
+
if (!staged) return;
|
|
40
|
+
|
|
41
|
+
execSync(`git commit -m "${version}: publish"`, { encoding: 'utf8' });
|
|
42
|
+
console.log(`✓ committed ${version}`);
|
|
43
|
+
|
|
44
|
+
// Push with explicit credential helper (Replit persistence)
|
|
45
|
+
const ghDir = '/home/runner/workspace/.replit-tools/.gh-persistent';
|
|
46
|
+
const credHelper = `!GH_CONFIG_DIR=${ghDir} gh auth git-credential`;
|
|
47
|
+
execSync(`git -c 'credential.https://github.com.helper=${credHelper}' push`, {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 15000,
|
|
50
|
+
env: { ...process.env, GH_CONFIG_DIR: ghDir },
|
|
51
|
+
});
|
|
52
|
+
console.log(`✓ pushed to origin`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Non-fatal — publish succeeded even if commit/push fails
|
|
55
|
+
const msg = e.message || '';
|
|
56
|
+
if (msg.includes('Authentication')) {
|
|
57
|
+
console.log(`⚠ push failed (git auth not configured) — commit is local`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const verified = await check();
|
|
63
|
+
if (verified) await commitAndPush();
|
package/src/cognitive-loop.mjs
CHANGED
|
@@ -114,12 +114,14 @@ export function enter(userMessage, context = {}) {
|
|
|
114
114
|
// Phase 1: Full cognitive pipeline
|
|
115
115
|
const turn = processTurn(headState, userMessage, context);
|
|
116
116
|
|
|
117
|
-
// Save situation for history
|
|
117
|
+
// Save situation for history (includes mode for turn-over-turn tracking)
|
|
118
|
+
const mode = turn.situation?.mode || { primary: 'work', confidence: 0.5 };
|
|
118
119
|
loopState.situationHistory.push({
|
|
119
120
|
ts: Date.now(),
|
|
120
121
|
depth: turn.depth,
|
|
121
122
|
action: turn.action.type,
|
|
122
123
|
confidence: turn.result.confidence.score,
|
|
124
|
+
mode: mode.primary,
|
|
123
125
|
});
|
|
124
126
|
|
|
125
127
|
// Surface update notice as a noticing
|
|
@@ -145,6 +147,7 @@ export function enter(userMessage, context = {}) {
|
|
|
145
147
|
surfaceNoticings: turn.result.surfaceNoticings,
|
|
146
148
|
plan: null,
|
|
147
149
|
nextDispatch: null,
|
|
150
|
+
mode,
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
|
package/src/continuity.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
+
import { load as loadNarrative } from './narrative.mjs';
|
|
12
13
|
|
|
13
14
|
// ─── Session chaining ─────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -48,7 +49,7 @@ export function generateHandoff(sessionState) {
|
|
|
48
49
|
},
|
|
49
50
|
preferences: sessionState.activePreferences || [],
|
|
50
51
|
resumeHint: sessionState.resumeHint || null,
|
|
51
|
-
narrative: sessionState.narrative || null,
|
|
52
|
+
narrative: sessionState.narrative || loadNarrative() || null,
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -169,6 +170,11 @@ export function buildResumeBrief(cwd) {
|
|
|
169
170
|
|
|
170
171
|
lines.push(`Resuming from previous session (${ageLabel}):`);
|
|
171
172
|
|
|
173
|
+
// Narrative first — most valuable context for immersion
|
|
174
|
+
if (handoff.narrative) {
|
|
175
|
+
lines.push(` Context: ${handoff.narrative.slice(0, 300)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
172
178
|
if (handoff.task) lines.push(` Task: ${handoff.task}`);
|
|
173
179
|
if (handoff.resumeHint) lines.push(` Next: ${handoff.resumeHint}`);
|
|
174
180
|
if (handoff.progress?.filesChanged?.length) {
|
package/src/head.mjs
CHANGED
|
@@ -100,6 +100,9 @@ export function perceive(message, context = {}) {
|
|
|
100
100
|
// Relationship signals — should HEAD ask, act, or advise?
|
|
101
101
|
const relationship = _assessRelationship(message, context, taskShape);
|
|
102
102
|
|
|
103
|
+
// Mode sensing — what energy is the user bringing?
|
|
104
|
+
const mode = detectMode(message, context);
|
|
105
|
+
|
|
103
106
|
return {
|
|
104
107
|
raw: message,
|
|
105
108
|
explicitAsk: message.trim(),
|
|
@@ -111,6 +114,7 @@ export function perceive(message, context = {}) {
|
|
|
111
114
|
taskShape,
|
|
112
115
|
material,
|
|
113
116
|
relationship,
|
|
117
|
+
mode,
|
|
114
118
|
|
|
115
119
|
// Depth signals for adaptive processing
|
|
116
120
|
ambiguity: taskShape.ambiguity,
|
|
@@ -125,6 +129,109 @@ export function perceive(message, context = {}) {
|
|
|
125
129
|
};
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// ── Mode Sensing ────────────────────────────────────────────────────────────
|
|
133
|
+
// Detects user energy/intent mode. Re-evaluated every turn. Never announced.
|
|
134
|
+
// Shapes HEAD's response style, not its decisions.
|
|
135
|
+
|
|
136
|
+
const MODE_EXECUTE_WORDS = /^(go|do it|ship it|fix it|run it|push|merge|deploy|yes|ok do it|lets go|make it|just do it|ship|publish)$/i;
|
|
137
|
+
const MODE_IDEATE_WORDS = /\b(what if|imagine|wouldn't it be|picture this|feels like|i feel like|sort of like|wild idea|crazy thought|could we maybe|vibe)\b/i;
|
|
138
|
+
const MODE_EXPLORE_WORDS = /\b(how does|what is|where is|why does|explain|walk me through|show me|tell me about|i don't understand|new to)\b/i;
|
|
139
|
+
const MODE_DISCUSS_WORDS = /\b(what do you think|should we|tradeoffs?|pros and cons|is it better|alternatively|option|or should|concerns?|worry|weigh)\b/i;
|
|
140
|
+
const MODE_WORK_SIGNALS = /(`[^`]+`|\.mjs|\.ts|\.js|\.py|src\/|hooks\/|bin\/|\bfunction\b|\bclass\b|\bimport\b)/;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect user's conversational mode from message signals.
|
|
144
|
+
* Returns a probability distribution with the dominant mode.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} message
|
|
147
|
+
* @param {object} context
|
|
148
|
+
* @returns {{primary: string, confidence: number, scores: object, signals: string[]}}
|
|
149
|
+
*/
|
|
150
|
+
export function detectMode(message, context = {}) {
|
|
151
|
+
const scores = { execute: 0, ideate: 0, work: 0, explore: 0, discuss: 0 };
|
|
152
|
+
const signals = [];
|
|
153
|
+
const words = message.trim().split(/\s+/);
|
|
154
|
+
const len = words.length;
|
|
155
|
+
|
|
156
|
+
// ── Length signal (most predictive single feature) ──
|
|
157
|
+
if (len <= 4) { scores.execute += 3; signals.push('very-short'); }
|
|
158
|
+
else if (len <= 10) { scores.execute += 1; scores.work += 1; }
|
|
159
|
+
else if (len >= 80) { scores.ideate += 2; signals.push('long-message'); }
|
|
160
|
+
else if (len >= 40) { scores.ideate += 1; scores.discuss += 1; }
|
|
161
|
+
|
|
162
|
+
// ── Lexical signals ──
|
|
163
|
+
if (MODE_EXECUTE_WORDS.test(message.trim())) { scores.execute += 4; signals.push('execute-word'); }
|
|
164
|
+
if (MODE_IDEATE_WORDS.test(message)) { scores.ideate += 3; signals.push('ideate-word'); }
|
|
165
|
+
if (MODE_EXPLORE_WORDS.test(message)) { scores.explore += 3; signals.push('explore-word'); }
|
|
166
|
+
if (MODE_DISCUSS_WORDS.test(message)) { scores.discuss += 4; signals.push('discuss-word'); }
|
|
167
|
+
|
|
168
|
+
// ── Specificity signal (file paths, code references) ──
|
|
169
|
+
const specificityMatches = message.match(MODE_WORK_SIGNALS);
|
|
170
|
+
if (specificityMatches) {
|
|
171
|
+
scores.work += 2;
|
|
172
|
+
signals.push('has-specifics');
|
|
173
|
+
// Specifics + imperative = work, not ideate
|
|
174
|
+
if (/\b(add|change|update|refactor|fix|remove|rename|move)\b/i.test(message)) {
|
|
175
|
+
scores.work += 2;
|
|
176
|
+
signals.push('specific-action');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Punctuation signal (only meaningful in longer messages) ──
|
|
181
|
+
const questionMarks = (message.match(/\?/g) || []).length;
|
|
182
|
+
if (questionMarks >= 2) { scores.discuss += 2; scores.explore += 1; signals.push('multi-question'); }
|
|
183
|
+
else if (questionMarks === 1 && len > 5) { scores.explore += 1; scores.discuss += 1; }
|
|
184
|
+
|
|
185
|
+
if (/\.{3}|—|–/.test(message)) { scores.ideate += 1; signals.push('ellipsis-dash'); }
|
|
186
|
+
if (/\b(maybe|might|could|perhaps|wonder)\b/i.test(message)) { scores.ideate += 1; scores.discuss += 1; signals.push('hedging'); }
|
|
187
|
+
|
|
188
|
+
// ── Contextual signals ──
|
|
189
|
+
// If prior turn was a plan/proposal and this is short, likely execute
|
|
190
|
+
if (context._priorWasProposal && len <= 15) { scores.execute += 2; signals.push('post-proposal-short'); }
|
|
191
|
+
|
|
192
|
+
// First message in session tends to be higher-level
|
|
193
|
+
if (context._isFirstTurn) { scores.explore += 1; scores.discuss += 1; }
|
|
194
|
+
|
|
195
|
+
// ── Anti-signals (prevent false positives) ──
|
|
196
|
+
// "go on" = continue explaining, not execute. But bare "continue?" = proceed
|
|
197
|
+
if (/^go on\b|^keep going/i.test(message.trim())) {
|
|
198
|
+
scores.execute -= 3;
|
|
199
|
+
scores.discuss += 2;
|
|
200
|
+
}
|
|
201
|
+
// "actually wait" / "hold on" = pumping the brakes, shifting to discuss
|
|
202
|
+
if (/^(actually|wait|hold on|hang on)/i.test(message.trim()) && questionMarks > 0) {
|
|
203
|
+
scores.execute -= 2;
|
|
204
|
+
scores.discuss += 3;
|
|
205
|
+
signals.push('pumping-brakes');
|
|
206
|
+
}
|
|
207
|
+
// "what if X breaks" = work concern, not ideation
|
|
208
|
+
if (/what if.*(break|fail|crash|error)/i.test(message)) {
|
|
209
|
+
scores.ideate -= 2;
|
|
210
|
+
scores.work += 2;
|
|
211
|
+
}
|
|
212
|
+
// "could we refactor" with file = work, not ideate
|
|
213
|
+
if (/could we.*(refactor|change|update)/i.test(message) && specificityMatches) {
|
|
214
|
+
scores.ideate -= 2;
|
|
215
|
+
scores.work += 2;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Resolve: pick dominant mode, bias toward action when uncertain ──
|
|
219
|
+
// Floor all scores at 0
|
|
220
|
+
for (const k of Object.keys(scores)) { if (scores[k] < 0) scores[k] = 0; }
|
|
221
|
+
|
|
222
|
+
const total = Object.values(scores).reduce((a, b) => a + b, 0) || 1;
|
|
223
|
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
224
|
+
const [primary, primaryScore] = sorted[0];
|
|
225
|
+
const confidence = primaryScore / total;
|
|
226
|
+
|
|
227
|
+
// If confidence is low (< 0.35), bias toward action — BUT only if discuss/ideate aren't strong
|
|
228
|
+
if (confidence < 0.35 && scores.execute > 0 && scores.discuss <= 1 && scores.ideate <= 1) {
|
|
229
|
+
return { primary: 'execute', confidence: 0.4, scores, signals: [...signals, 'low-confidence-action-bias'] };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { primary, confidence, scores, signals };
|
|
233
|
+
}
|
|
234
|
+
|
|
128
235
|
function _inferTaskShape(message, context) {
|
|
129
236
|
const lower = message.toLowerCase();
|
|
130
237
|
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* task-classifier.mjs — Analyze work descriptions and return model + effort + mode config.
|
|
4
|
-
*
|
|
5
|
-
* Uses model-registry capabilities to make informed routing decisions:
|
|
6
|
-
* - Which model (per provider) handles this task best
|
|
7
|
-
* - What effort/reasoning level to use
|
|
8
|
-
* - Whether to enable extended thinking, fast mode, extended context, web search
|
|
9
|
-
* - How to dispatch (Claude Agent vs Codex exec)
|
|
10
|
-
*
|
|
11
|
-
* Exports: classifyTask, selectModelEffort, INTENTS
|
|
12
|
-
* CLI: node hooks/task-classifier.mjs "description" [--files a,b] [--budget-pressure 0.8]
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
16
|
-
import {
|
|
17
|
-
MODEL_CAPABILITIES, getCapabilities, getDispatchConfig,
|
|
18
|
-
recommendEffort, shouldUseExtendedContext, shouldUseFastMode,
|
|
19
|
-
getBestModelFor,
|
|
20
|
-
} from './model-registry.mjs';
|
|
21
|
-
|
|
22
|
-
// ─── Intent definitions ───────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
const INTENTS = {
|
|
25
|
-
search: /\b(grep|find|locate|where is|where are|list|explore|read|look up|look for|check|what is|show me|display)\b/i,
|
|
26
|
-
explain: /\b(explain|walk me through|what does|how does|describe|summarize|understand|clarify)\b/i,
|
|
27
|
-
compare: /\b(compare|contrast|difference|versus|vs\.?|trade.?off|which is better|pros and cons|benchmark|performance)\b/i,
|
|
28
|
-
document: /\b(document|docs?|readme|jsdoc|typedoc|api docs|write docs|add docs|update docs)\b/i,
|
|
29
|
-
format: /\b(format|lint|prettier|style|indent|whitespace|typo|typos|comment[s]?|reformat)\b/i,
|
|
30
|
-
planning: /\b(plan|roadmap|strategy|prioritize|break down|decompose|prioritise)\b/i,
|
|
31
|
-
architecture: /\b(design|architect|architecture|propose|how should we|system design|system architecture)\b/i,
|
|
32
|
-
security: /\b(auth|credential|secret|token|password|encrypt|permission[s]?|vulnerability|vulnerabilities|CVE|oauth|jwt|api.?key)\b/i,
|
|
33
|
-
review: /\b(review|audit|check for issues|evaluate|assess|inspect code|code review)\b/i,
|
|
34
|
-
debug: /\b(debug|investigate|why (is|does|isn't|doesn't)|trace|diagnose|figure out|broken|not working|failing|regression)\b/i,
|
|
35
|
-
test: /\b(test[s]?|spec[s]?|add test|fix test|test coverage|unit test|e2e|integration test|jest|vitest|mocha)\b/i,
|
|
36
|
-
refactor: /\b(refactor|restructure|reorganize|reorganise|extract|split|consolidate|clean up|cleanup|dedupe|dedup)\b/i,
|
|
37
|
-
edit: /\b(fix|add|update|modify|change|rename|move|replace|write|implement|create|remove|delete|insert)\b/i,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const INTENT_PRIORITY = [
|
|
41
|
-
'security', 'architecture', 'planning', 'compare', 'review',
|
|
42
|
-
'debug', 'refactor', 'test', 'explain', 'document', 'format', 'search', 'edit',
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
// ─── Risk keyword detection (description-level) ──────────────────────────────
|
|
46
|
-
|
|
47
|
-
const RISK_KEYWORDS = [
|
|
48
|
-
{ level: 'critical', regex: /\b(auth|secret|credential|token|password|encrypt|certificate|oauth|jwt|api.?key|vulnerability|CVE)\b/i },
|
|
49
|
-
{ level: 'high', regex: /\b(billing|payment|migration|deploy|ci.?cd|security|permission|policy|schema|openapi|swagger|production|prod)\b/i },
|
|
50
|
-
{ level: 'medium', regex: /\b(test|spec|config|shared|util|lib|integration|public.?api)\b/i },
|
|
51
|
-
{ level: 'low', regex: /\b(readme|docs?|comment|format|lint|changelog|typo|whitespace)\b/i },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
55
|
-
|
|
56
|
-
function detectKeywordRisk(description) {
|
|
57
|
-
for (const { level, regex } of RISK_KEYWORDS) {
|
|
58
|
-
if (regex.test(description)) return level;
|
|
59
|
-
}
|
|
60
|
-
return 'low';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function higherRisk(a, b) {
|
|
64
|
-
return LEVEL_ORDER[a] >= LEVEL_ORDER[b] ? a : b;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ─── classifyTask ─────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
function classifyTask(description, options = {}) {
|
|
70
|
-
const { files = [], priorFailures = 0 } = options;
|
|
71
|
-
|
|
72
|
-
// 1. Intent detection
|
|
73
|
-
let intent = 'edit';
|
|
74
|
-
for (const key of INTENT_PRIORITY) {
|
|
75
|
-
if (INTENTS[key].test(description)) {
|
|
76
|
-
intent = key;
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 2. Risk detection
|
|
82
|
-
const allPaths = [...files, ...extractPaths(description)];
|
|
83
|
-
const pathRisk = allPaths.length > 0 ? classifyRisk(allPaths).level : 'low';
|
|
84
|
-
const keywordRisk = detectKeywordRisk(description);
|
|
85
|
-
const risk = higherRisk(pathRisk, keywordRisk);
|
|
86
|
-
|
|
87
|
-
// 3. File count
|
|
88
|
-
const fileCount = files.length;
|
|
89
|
-
|
|
90
|
-
// 4. Complexity detection
|
|
91
|
-
let complexity;
|
|
92
|
-
const isAmbiguous = description.length > 120 || /\b(and also|as well as|plus|additionally|also)\b/i.test(description);
|
|
93
|
-
|
|
94
|
-
if (priorFailures >= 2 || intent === 'architecture' || risk === 'critical' || fileCount >= 6 || isAmbiguous && risk === 'critical') {
|
|
95
|
-
complexity = 'complex';
|
|
96
|
-
} else if (fileCount >= 3 || intent === 'refactor' || intent === 'debug' || risk === 'high' || isAmbiguous) {
|
|
97
|
-
complexity = 'moderate';
|
|
98
|
-
} else if (fileCount <= 2 && (risk === 'low' || risk === 'medium')) {
|
|
99
|
-
if (intent === 'format' || fileCount <= 1 && risk === 'low') {
|
|
100
|
-
complexity = 'trivial';
|
|
101
|
-
} else {
|
|
102
|
-
complexity = 'simple';
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
complexity = 'moderate';
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 5. Effort selection
|
|
109
|
-
const baseEffort = { trivial: 'low', simple: 'medium', moderate: 'high', complex: 'high' }[complexity];
|
|
110
|
-
const effortOrder = ['low', 'medium', 'high', 'xhigh'];
|
|
111
|
-
|
|
112
|
-
function bumpEffort(e, n = 1) {
|
|
113
|
-
return effortOrder[Math.min(effortOrder.indexOf(e) + n, effortOrder.length - 1)];
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
let effort = baseEffort;
|
|
117
|
-
|
|
118
|
-
if (risk === 'critical' && LEVEL_ORDER[effort] < LEVEL_ORDER['high']) effort = 'high';
|
|
119
|
-
|
|
120
|
-
if (priorFailures >= 2) {
|
|
121
|
-
effort = 'xhigh';
|
|
122
|
-
} else if (priorFailures === 1) {
|
|
123
|
-
effort = bumpEffort(effort, 1);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (intent === 'format' || intent === 'search') {
|
|
127
|
-
if (LEVEL_ORDER[effort] > LEVEL_ORDER['medium']) effort = 'medium';
|
|
128
|
-
}
|
|
129
|
-
if ((intent === 'architecture' || intent === 'security') && LEVEL_ORDER[effort] < LEVEL_ORDER['high']) {
|
|
130
|
-
effort = 'high';
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 6. Reason
|
|
134
|
-
const reasons = [];
|
|
135
|
-
if (fileCount > 0) reasons.push(`${fileCount} file(s)`);
|
|
136
|
-
if (risk !== 'low') reasons.push(`${risk} risk`);
|
|
137
|
-
if (priorFailures > 0) reasons.push(`${priorFailures} prior failure(s)`);
|
|
138
|
-
reasons.push(`intent=${intent}, complexity=${complexity}`);
|
|
139
|
-
const reason = reasons.join('; ');
|
|
140
|
-
|
|
141
|
-
return { intent, risk, complexity, fileCount, effort, reason };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ─── selectModelEffort ────────────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
function selectModelEffort(taskProfile, options = {}) {
|
|
147
|
-
const { budgetPressure = 0, userBudgetTier = '$100', isIterating = false, estimatedTokens = 0 } = options;
|
|
148
|
-
const { intent, risk, effort, complexity } = taskProfile;
|
|
149
|
-
|
|
150
|
-
// ── Intent classification for routing ──
|
|
151
|
-
const thinkIntents = ['architecture', 'security', 'review', 'planning', 'compare'];
|
|
152
|
-
const searchIntents = ['search', 'format', 'explain'];
|
|
153
|
-
const lightIntents = ['document', 'explain', 'format', 'search'];
|
|
154
|
-
|
|
155
|
-
const needsOpus = thinkIntents.includes(intent)
|
|
156
|
-
|| risk === 'critical'
|
|
157
|
-
|| effort === 'xhigh';
|
|
158
|
-
|
|
159
|
-
const needsHaiku = searchIntents.includes(intent) && effort === 'low';
|
|
160
|
-
|
|
161
|
-
let claudeModel = needsOpus ? 'opus' : needsHaiku ? 'haiku' : 'sonnet';
|
|
162
|
-
|
|
163
|
-
// ── Claude effort (from registry, null-safe for haiku) ──
|
|
164
|
-
const caps = getCapabilities(claudeModel);
|
|
165
|
-
let claudeEffort = caps?.reasoning?.effortLevels
|
|
166
|
-
? (recommendEffort(claudeModel, complexity, risk) || effort)
|
|
167
|
-
: null;
|
|
168
|
-
|
|
169
|
-
// ── Claude modes ──
|
|
170
|
-
const claudeModes = {
|
|
171
|
-
extendedThinking: caps?.reasoning?.extendedThinking
|
|
172
|
-
&& (complexity === 'moderate' || complexity === 'complex')
|
|
173
|
-
&& !lightIntents.includes(intent),
|
|
174
|
-
fastMode: shouldUseFastMode(claudeModel, isIterating),
|
|
175
|
-
extendedContext: shouldUseExtendedContext(claudeModel, estimatedTokens),
|
|
176
|
-
ultrathink: claudeModel === 'opus'
|
|
177
|
-
&& (risk === 'critical' || (complexity === 'complex' && thinkIntents.includes(intent))),
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// ── OpenAI model selection (all models reachable) ──
|
|
181
|
-
let openaiModel;
|
|
182
|
-
if (needsOpus) {
|
|
183
|
-
openaiModel = 'gpt-5.5';
|
|
184
|
-
} else if (searchIntents.includes(intent) && effort === 'low') {
|
|
185
|
-
openaiModel = 'gpt-4.1-mini';
|
|
186
|
-
} else if (['edit', 'test', 'document'].includes(intent) && ['simple', 'trivial'].includes(complexity)) {
|
|
187
|
-
openaiModel = 'gpt-4.1';
|
|
188
|
-
} else if (intent === 'explain' && complexity !== 'trivial') {
|
|
189
|
-
openaiModel = 'gpt-5.2';
|
|
190
|
-
} else if (['edit', 'document'].includes(intent) && complexity === 'moderate') {
|
|
191
|
-
openaiModel = 'gpt-5.3-codex';
|
|
192
|
-
} else if (intent === 'test' && complexity === 'moderate') {
|
|
193
|
-
openaiModel = 'gpt-5.4-mini';
|
|
194
|
-
} else if (['refactor', 'debug'].includes(intent)) {
|
|
195
|
-
openaiModel = complexity === 'complex' ? 'gpt-5.4' : 'gpt-5.3-codex';
|
|
196
|
-
} else {
|
|
197
|
-
openaiModel = 'gpt-5.4';
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ── OpenAI effort (from registry) ──
|
|
201
|
-
let openaiEffort = recommendEffort(openaiModel, complexity, risk) || effort;
|
|
202
|
-
|
|
203
|
-
// ── OpenAI modes ──
|
|
204
|
-
const openaiCaps = getCapabilities(openaiModel);
|
|
205
|
-
const openaiModes = {
|
|
206
|
-
webSearch: openaiCaps?.modes?.webSearch ?? false,
|
|
207
|
-
sandbox: openaiCaps?.modes?.sandbox?.[
|
|
208
|
-
thinkIntents.includes(intent) ? 'think' : searchIntents.includes(intent) ? 'search' : 'execute'
|
|
209
|
-
] ?? 'danger-full-access',
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
// ── Outcome learning override ──
|
|
213
|
-
// If we have enough empirical data, let it influence model selection
|
|
214
|
-
const empiricalClaude = getBestModelFor(intent, 'claude', { minSamples: 5 });
|
|
215
|
-
if (empiricalClaude && empiricalClaude.successRate !== null && empiricalClaude.successRate > 0.8) {
|
|
216
|
-
const caps = getCapabilities(empiricalClaude.model);
|
|
217
|
-
if (caps && !caps.avoidFor?.includes(intent)) {
|
|
218
|
-
claudeModel = empiricalClaude.model;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const empiricalOpenai = getBestModelFor(intent, 'openai', { minSamples: 5 });
|
|
223
|
-
if (empiricalOpenai && empiricalOpenai.successRate !== null && empiricalOpenai.successRate > 0.8) {
|
|
224
|
-
const caps = getCapabilities(empiricalOpenai.model);
|
|
225
|
-
if (caps && !caps.avoidFor?.includes(intent)) {
|
|
226
|
-
openaiModel = empiricalOpenai.model;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ── Budget pressure adjustments ──
|
|
231
|
-
const reasons = [];
|
|
232
|
-
const isHighStakes = risk === 'critical' || risk === 'high';
|
|
233
|
-
const openaiModelRank = [
|
|
234
|
-
'gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini',
|
|
235
|
-
'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.5',
|
|
236
|
-
];
|
|
237
|
-
|
|
238
|
-
if (budgetPressure > 0.9 && !isHighStakes) {
|
|
239
|
-
claudeModel = claudeModel === 'opus' ? 'sonnet' : 'haiku';
|
|
240
|
-
const oaiIdx = openaiModelRank.indexOf(openaiModel);
|
|
241
|
-
openaiModel = openaiModelRank[Math.max(0, oaiIdx - 2)] || 'gpt-4.1-mini';
|
|
242
|
-
claudeModes.fastMode = false;
|
|
243
|
-
claudeModes.extendedContext = false;
|
|
244
|
-
claudeModes.extendedThinking = false;
|
|
245
|
-
reasons.push('near limit, aggressive downgrade for non-critical task');
|
|
246
|
-
} else if (budgetPressure > 0.7 && !isHighStakes) {
|
|
247
|
-
claudeModel = claudeModel === 'opus' ? 'sonnet' : claudeModel === 'sonnet' ? 'haiku' : 'haiku';
|
|
248
|
-
const oaiIdx = openaiModelRank.indexOf(openaiModel);
|
|
249
|
-
openaiModel = openaiModelRank[Math.max(0, oaiIdx - 1)] || 'gpt-4.1-mini';
|
|
250
|
-
claudeModes.fastMode = false;
|
|
251
|
-
reasons.push('downgraded due to budget pressure');
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Recalculate efforts after potential model change
|
|
255
|
-
const newCaps = getCapabilities(claudeModel);
|
|
256
|
-
claudeEffort = newCaps?.reasoning?.effortLevels
|
|
257
|
-
? (recommendEffort(claudeModel, complexity, risk) || effort)
|
|
258
|
-
: null;
|
|
259
|
-
openaiEffort = recommendEffort(openaiModel, complexity, risk) || effort;
|
|
260
|
-
|
|
261
|
-
// ── Preferred provider (think→claude, isolated execute→openai) ──
|
|
262
|
-
const preferred = thinkIntents.includes(intent) ? 'claude' : 'openai';
|
|
263
|
-
|
|
264
|
-
// ── Dual-brain recommendation ──
|
|
265
|
-
const dualBrain = risk === 'critical'
|
|
266
|
-
|| (thinkIntents.includes(intent) && (complexity === 'complex' || complexity === 'moderate'))
|
|
267
|
-
|| intent === 'security'
|
|
268
|
-
|| (intent === 'review' && risk !== 'low')
|
|
269
|
-
|| (intent === 'refactor' && risk === 'critical');
|
|
270
|
-
|
|
271
|
-
if (reasons.length === 0) {
|
|
272
|
-
reasons.push(`${claudeModel}/${openaiModel} matched to ${intent} @ ${complexity} complexity`);
|
|
273
|
-
}
|
|
274
|
-
if (empiricalClaude?.successRate !== null) reasons.push(`claude empirical: ${empiricalClaude.model} ${Math.round(empiricalClaude.successRate * 100)}%`);
|
|
275
|
-
if (empiricalOpenai?.successRate !== null) reasons.push(`openai empirical: ${empiricalOpenai.model} ${Math.round(empiricalOpenai.successRate * 100)}%`);
|
|
276
|
-
|
|
277
|
-
return {
|
|
278
|
-
claude: {
|
|
279
|
-
model: claudeModel,
|
|
280
|
-
effort: claudeEffort,
|
|
281
|
-
modes: claudeModes,
|
|
282
|
-
dispatch: getDispatchConfig(claudeModel),
|
|
283
|
-
},
|
|
284
|
-
openai: {
|
|
285
|
-
model: openaiModel,
|
|
286
|
-
effort: openaiEffort,
|
|
287
|
-
modes: openaiModes,
|
|
288
|
-
dispatch: getDispatchConfig(openaiModel),
|
|
289
|
-
},
|
|
290
|
-
preferred,
|
|
291
|
-
dualBrain,
|
|
292
|
-
reason: reasons.join('; '),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
297
|
-
|
|
298
|
-
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
299
|
-
const args = process.argv.slice(2);
|
|
300
|
-
const description = args.find(a => !a.startsWith('--')) || '';
|
|
301
|
-
const filesArg = args.find(a => a.startsWith('--files=')) || args[args.indexOf('--files') + 1];
|
|
302
|
-
const budgetArg = args.find(a => a.startsWith('--budget-pressure=')) || args[args.indexOf('--budget-pressure') + 1];
|
|
303
|
-
const failuresArg = args.find(a => a.startsWith('--failures=')) || args[args.indexOf('--failures') + 1];
|
|
304
|
-
|
|
305
|
-
const files = (filesArg && !filesArg.startsWith('--'))
|
|
306
|
-
? filesArg.replace(/^--files=/, '').split(',').map(f => f.trim())
|
|
307
|
-
: [];
|
|
308
|
-
|
|
309
|
-
const budgetPressure = budgetArg
|
|
310
|
-
? parseFloat(budgetArg.replace(/^--budget-pressure=/, ''))
|
|
311
|
-
: 0;
|
|
312
|
-
|
|
313
|
-
const priorFailures = failuresArg
|
|
314
|
-
? parseInt(failuresArg.replace(/^--failures=/, ''), 10)
|
|
315
|
-
: 0;
|
|
316
|
-
|
|
317
|
-
if (!description) {
|
|
318
|
-
console.error('Usage: node hooks/task-classifier.mjs "task description" [--files a,b] [--budget-pressure 0.8] [--failures 1]');
|
|
319
|
-
process.exit(1);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const profile = classifyTask(description, { files, priorFailures });
|
|
323
|
-
const selection = selectModelEffort(profile, { budgetPressure });
|
|
324
|
-
|
|
325
|
-
console.log(JSON.stringify({ profile, selection }, null, 2));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export { classifyTask, selectModelEffort, INTENTS };
|
package/hooks/vibe-router.mjs
DELETED
|
@@ -1,387 +0,0 @@
|
|
|
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
|
-
// ─── Ordered Language Detection ───────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
const DEPENDENCY_MARKERS = /\b(then|after\s+that|once\s+\S+\s+is\s+done|before|first|next|finally|afterwards|subsequently|followed\s+by|depends?\s+on|requires?)\b/i;
|
|
146
|
-
|
|
147
|
-
// ─── Subsystem Detection ─────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
const SUBSYSTEM_PATTERNS = [
|
|
150
|
-
{ key: 'auth', regex: /\b(auth|login|sign[-\s]?in|sign[-\s]?up|session|credential|password|oauth|jwt|token)\b/i },
|
|
151
|
-
{ key: 'billing', regex: /\b(billing|payment|subscription|invoice|charge|stripe|pricing)\b/i },
|
|
152
|
-
{ key: 'api', regex: /\b(api|endpoint|route|controller|handler|middleware|rest|graphql)\b/i },
|
|
153
|
-
{ key: 'ui', regex: /\b(ui|nav|button|page|component|layout|style|css|modal|form|menu|sidebar|header|footer|dashboard)\b/i },
|
|
154
|
-
{ key: 'db', regex: /\b(database|db|schema|migration|model|query|table|column|index|sql|prisma|sequelize|knex)\b/i },
|
|
155
|
-
{ key: 'infra', regex: /\b(deploy|ci|cd|docker|k8s|terraform|infra|pipeline|build|config|env)\b/i },
|
|
156
|
-
{ key: 'test', regex: /\b(test|spec|fixture|mock|stub|assert|coverage)\b/i },
|
|
157
|
-
{ key: 'docs', regex: /\b(doc|readme|changelog|guide|tutorial|comment)\b/i },
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
function detectSubsystems(text) {
|
|
161
|
-
const subs = new Set();
|
|
162
|
-
for (const pat of SUBSYSTEM_PATTERNS) {
|
|
163
|
-
if (pat.regex.test(text)) subs.add(pat.key);
|
|
164
|
-
}
|
|
165
|
-
return subs;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ─── Risk Domain Extraction ──────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
function getRiskDomains(task) {
|
|
171
|
-
const domains = new Set();
|
|
172
|
-
// Use subsystem as risk domain
|
|
173
|
-
const subs = detectSubsystems(task.title);
|
|
174
|
-
for (const s of subs) domains.add(s);
|
|
175
|
-
// Also include explicit risk reason label
|
|
176
|
-
if (task.reason) {
|
|
177
|
-
const match = task.reason.match(/^([^(]+)/);
|
|
178
|
-
if (match) domains.add(match[1].trim().toLowerCase());
|
|
179
|
-
}
|
|
180
|
-
return domains;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ─── Complexity + Wave Recommendation ──────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
function determineComplexity(tasks) {
|
|
186
|
-
const highestRisk = tasks.reduce(
|
|
187
|
-
(max, t) => LEVEL_ORDER[t.risk] > LEVEL_ORDER[max] ? t.risk : max,
|
|
188
|
-
'low'
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
if (tasks.length >= 4 || highestRisk === 'high' || highestRisk === 'critical') {
|
|
192
|
-
return 'complex';
|
|
193
|
-
}
|
|
194
|
-
if (tasks.length >= 2 || highestRisk === 'medium') {
|
|
195
|
-
return 'structured';
|
|
196
|
-
}
|
|
197
|
-
return 'simple';
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* determineWave — Sequential by default, parallel only when tasks are truly independent.
|
|
202
|
-
*
|
|
203
|
-
* Returns { wave, reasons } where reasons is an array of reason codes:
|
|
204
|
-
* shared_surface — tasks likely touch same files
|
|
205
|
-
* high_risk — risky work should be sequential for review
|
|
206
|
-
* dependency_marker — ordered language detected in utterance
|
|
207
|
-
* same_subsystem — tasks in same domain/subsystem
|
|
208
|
-
* independent — truly independent, safe for parallel
|
|
209
|
-
*/
|
|
210
|
-
function determineWave(tasks, complexity, utterance) {
|
|
211
|
-
if (tasks.length === 1) return { wave: 'single', reasons: [] };
|
|
212
|
-
|
|
213
|
-
const reasons = [];
|
|
214
|
-
|
|
215
|
-
// 1. Check for ordered language in the original utterance
|
|
216
|
-
if (utterance && DEPENDENCY_MARKERS.test(utterance)) {
|
|
217
|
-
reasons.push('dependency_marker');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 2. Check for high/critical risk tasks
|
|
221
|
-
const hasHighRisk = tasks.some(t => t.risk === 'high' || t.risk === 'critical');
|
|
222
|
-
if (hasHighRisk) {
|
|
223
|
-
reasons.push('high_risk');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 3. Check for overlapping subsystems between tasks
|
|
227
|
-
const taskSubsystems = tasks.map(t => detectSubsystems(t.title));
|
|
228
|
-
let hasSharedSubsystem = false;
|
|
229
|
-
for (let i = 0; i < taskSubsystems.length; i++) {
|
|
230
|
-
for (let j = i + 1; j < taskSubsystems.length; j++) {
|
|
231
|
-
for (const sub of taskSubsystems[i]) {
|
|
232
|
-
if (taskSubsystems[j].has(sub)) {
|
|
233
|
-
hasSharedSubsystem = true;
|
|
234
|
-
break;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (hasSharedSubsystem) break;
|
|
238
|
-
}
|
|
239
|
-
if (hasSharedSubsystem) break;
|
|
240
|
-
}
|
|
241
|
-
if (hasSharedSubsystem) {
|
|
242
|
-
reasons.push('same_subsystem');
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// 4. Check for overlapping file paths / shared surface area
|
|
246
|
-
const taskPaths = tasks.map(t => extractPaths(t.title));
|
|
247
|
-
let hasSharedPaths = false;
|
|
248
|
-
for (let i = 0; i < taskPaths.length; i++) {
|
|
249
|
-
for (let j = i + 1; j < taskPaths.length; j++) {
|
|
250
|
-
for (const p of taskPaths[i]) {
|
|
251
|
-
// Check if any path from task j shares a directory prefix or exact match
|
|
252
|
-
for (const q of taskPaths[j]) {
|
|
253
|
-
if (p === q || p.startsWith(q + '/') || q.startsWith(p + '/') ||
|
|
254
|
-
p.split('/').slice(0, -1).join('/') === q.split('/').slice(0, -1).join('/')) {
|
|
255
|
-
hasSharedPaths = true;
|
|
256
|
-
break;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
if (hasSharedPaths) break;
|
|
260
|
-
}
|
|
261
|
-
if (hasSharedPaths) break;
|
|
262
|
-
}
|
|
263
|
-
if (hasSharedPaths) break;
|
|
264
|
-
}
|
|
265
|
-
if (hasSharedPaths) {
|
|
266
|
-
reasons.push('shared_surface');
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// 5. Check for shared risk domains
|
|
270
|
-
const taskDomains = tasks.map(t => getRiskDomains(t));
|
|
271
|
-
let hasSharedDomain = false;
|
|
272
|
-
for (let i = 0; i < taskDomains.length; i++) {
|
|
273
|
-
for (let j = i + 1; j < taskDomains.length; j++) {
|
|
274
|
-
for (const d of taskDomains[i]) {
|
|
275
|
-
if (taskDomains[j].has(d)) {
|
|
276
|
-
hasSharedDomain = true;
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (hasSharedDomain) break;
|
|
281
|
-
}
|
|
282
|
-
if (hasSharedDomain) break;
|
|
283
|
-
}
|
|
284
|
-
// Only add same_subsystem if not already added (risk domains overlap with subsystems)
|
|
285
|
-
if (hasSharedDomain && !reasons.includes('same_subsystem')) {
|
|
286
|
-
reasons.push('same_subsystem');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Decision: parallel ONLY when no sequential reasons found
|
|
290
|
-
if (reasons.length === 0) {
|
|
291
|
-
reasons.push('independent');
|
|
292
|
-
return { wave: 'parallel', reasons };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return { wave: 'sequential', reasons };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ─── Summary Generation ────────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
function generateSummary(tasks, complexity, wave, qualityGates, profileHint) {
|
|
301
|
-
const parts = [];
|
|
302
|
-
|
|
303
|
-
if (tasks.length === 1) {
|
|
304
|
-
const t = tasks[0];
|
|
305
|
-
parts.push(`Single ${t.tier} task: ${t.title} (${t.risk} risk).`);
|
|
306
|
-
} else {
|
|
307
|
-
const taskDescs = tasks.map(t => `${t.title.toLowerCase()} (${t.risk} risk, ${t.tier})`);
|
|
308
|
-
parts.push(`Split into ${tasks.length} tasks: ${taskDescs.join(' + ')}.`);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (wave === 'parallel' && tasks.length > 1) {
|
|
312
|
-
parts.push('Recommend parallel agents.');
|
|
313
|
-
} else if (wave === 'sequential') {
|
|
314
|
-
parts.push('Recommend sequential execution.');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (qualityGates.includes('dual_brain_required')) {
|
|
318
|
-
parts.push('Dual-brain review required for critical changes.');
|
|
319
|
-
} else if (qualityGates.includes('dual_brain_review')) {
|
|
320
|
-
parts.push('Dual-brain review recommended for high-risk changes.');
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (profileHint) {
|
|
324
|
-
parts.push(`Profile hint: ${profileHint}.`);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return parts.join(' ');
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ─── Main Entry Point ──────────────────────────────────────────────────────
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* routeVibe(utterance) — Decompose a casual natural language utterance
|
|
334
|
-
* into structured work orders with tier, risk, and quality gate assignments.
|
|
335
|
-
*
|
|
336
|
-
* @param {string} utterance - The user's casual description
|
|
337
|
-
* @returns {{ complexity, tasks, profile_hint, quality_gates, wave_recommendation, summary }}
|
|
338
|
-
*/
|
|
339
|
-
function routeVibe(utterance) {
|
|
340
|
-
if (!utterance || typeof utterance !== 'string' || !utterance.trim()) {
|
|
341
|
-
return {
|
|
342
|
-
complexity: 'simple',
|
|
343
|
-
tasks: [],
|
|
344
|
-
profile_hint: null,
|
|
345
|
-
quality_gates: ['self_check'],
|
|
346
|
-
wave_recommendation: 'single',
|
|
347
|
-
summary: 'No input provided.',
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const segments = splitTasks(utterance);
|
|
352
|
-
const tasks = segments.map(classifyTask);
|
|
353
|
-
const profileHint = detectProfileHint(utterance);
|
|
354
|
-
const qualityGates = determineQualityGates(tasks);
|
|
355
|
-
const complexity = determineComplexity(tasks);
|
|
356
|
-
const { wave, reasons } = determineWave(tasks, complexity, utterance);
|
|
357
|
-
const summary = generateSummary(tasks, complexity, wave, qualityGates, profileHint);
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
complexity,
|
|
361
|
-
tasks,
|
|
362
|
-
profile_hint: profileHint,
|
|
363
|
-
quality_gates: qualityGates,
|
|
364
|
-
wave_recommendation: wave,
|
|
365
|
-
wave_reasons: reasons,
|
|
366
|
-
summary,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export { routeVibe, splitTasks, classifyTask, detectProfileHint };
|
|
371
|
-
|
|
372
|
-
// ─── CLI ───────────────────────────────────────────────────────────────────
|
|
373
|
-
|
|
374
|
-
const isMain = process.argv[1] && (
|
|
375
|
-
process.argv[1].endsWith('vibe-router.mjs') ||
|
|
376
|
-
process.argv[1].endsWith('vibe-router')
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
if (isMain) {
|
|
380
|
-
const utterance = process.argv.slice(2).join(' ');
|
|
381
|
-
if (!utterance) {
|
|
382
|
-
console.error('Usage: node vibe-router.mjs "fix the login bug and also update the nav"');
|
|
383
|
-
process.exit(1);
|
|
384
|
-
}
|
|
385
|
-
const result = routeVibe(utterance);
|
|
386
|
-
console.log(JSON.stringify(result, null, 2));
|
|
387
|
-
}
|