dual-brain 0.2.15 → 0.2.17

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.
@@ -504,29 +504,48 @@ async function cmdGo(args, opts = {}) {
504
504
  } catch { /* non-fatal */ }
505
505
  }
506
506
 
507
- // ── Cognitive loop: enhance prompt with debrief + preventions if available ──
507
+ // ── Cognitive loop: drive dispatch decisions ──────────────────────────────
508
508
  let loopEnhancedPrompt = prompt;
509
509
  let loopDispatchMeta = null;
510
510
  try {
511
511
  const cogLoop = await _getCognitiveLoop();
512
512
  if (cogLoop) {
513
513
  const loopResult = cogLoop.enter(prompt, { files });
514
+
515
+ if (loopResult.phase === 'readonly') {
516
+ console.log('\n⚠ Another dual-brain session is active. This session is read-only.');
517
+ return;
518
+ }
519
+
514
520
  if (loopResult.phase === 'dispatch' && loopResult.nextDispatch) {
515
521
  loopDispatchMeta = loopResult;
516
- // Append debrief instructions and preventions from first agent to prompt
522
+ // Use the full envelope prompt (includes context, preventions, debrief)
517
523
  const firstAgent = loopResult.nextDispatch.agents?.[0];
518
- if (firstAgent) {
519
- const extras = [];
520
- if (firstAgent.preventions) extras.push(firstAgent.preventions);
521
- if (firstAgent.debriefInstruction) extras.push(firstAgent.debriefInstruction);
522
- if (extras.length > 0) {
523
- loopEnhancedPrompt = prompt + '\n\n' + extras.join('\n\n');
524
- }
524
+ if (firstAgent?.prompt) {
525
+ loopEnhancedPrompt = firstAgent.prompt;
525
526
  }
526
527
  if (verbose && loopResult.plan) {
527
528
  const wc = loopResult.plan.waves?.length || 0;
528
529
  console.log(` [cognitive-loop] Plan: ${wc} wave(s), phase: ${loopResult.phase}`);
529
530
  }
531
+ } else if (loopResult.phase === 'blocked') {
532
+ console.log(`\n⚠ Dispatch blocked: ${loopResult.suggestion || 'readiness check failed'}`);
533
+ if (loopResult.surfaceNoticings?.length) {
534
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
535
+ }
536
+ return;
537
+ } else if (loopResult.phase === 'respond') {
538
+ // HEAD decided no dispatch needed — show rationale
539
+ if (loopResult.rationale) console.log(`\n${loopResult.rationale}`);
540
+ if (loopResult.surfaceNoticings?.length) {
541
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
542
+ }
543
+ return;
544
+ }
545
+
546
+ // Surface noticings (includes update notices, diagnostics)
547
+ if (loopResult.surfaceNoticings?.length && verbose) {
548
+ loopResult.surfaceNoticings.forEach(n => console.log(` → ${n}`));
530
549
  }
531
550
  }
532
551
  } catch {
@@ -552,20 +571,38 @@ async function cmdGo(args, opts = {}) {
552
571
  dispatchSpinner.succeed(`Agent dispatched: ${prompt.slice(0, 50)}`);
553
572
  }
554
573
 
555
- // ── Cognitive loop: advance after dispatch completes ─────────────────────────
574
+ // ── Cognitive loop: advance through waves until done ─────────────────────────
556
575
  if (loopDispatchMeta && result && !dryRun) {
557
576
  try {
558
577
  const cogLoop = await _getCognitiveLoop();
559
578
  if (cogLoop) {
560
- const waveId = loopDispatchMeta.nextDispatch.waveId;
561
- const rawResults = [result.summary || result.output || ''];
562
- const advanceResult = cogLoop.advance(rawResults, waveId, { files });
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
+
563
600
  if (verbose && advanceResult) {
564
- console.log(` [cognitive-loop] Next phase: ${advanceResult.phase}, rationale: ${advanceResult.rationale || '-'}`);
601
+ console.log(` [cognitive-loop] Final: ${advanceResult.phase}, ${advanceResult.rationale || '-'}`);
565
602
  }
566
603
  }
567
604
  } catch {
568
- // Non-fatal — loop advance failure doesn't affect the completed dispatch
605
+ // Non-fatal — loop advance failure doesn't affect completed dispatches
569
606
  }
570
607
  }
571
608
 
@@ -1,59 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // auto-update-wrapper.mjs — PostToolUse hook (Node.js entry point).
3
- // Runs once per session, checks for dual-brain updates, installs silently.
4
- // The parent exits immediately; all npm work happens in a detached child.
2
+ // auto-update-wrapper.mjs — PostToolUse hook.
3
+ // Checks for updates once per session. If found, installs immediately (no wait,
4
+ // no question) and writes a notice so HEAD can mention it naturally.
5
+ // "Oh, I updated to 0.2.16" — not a prompt, just awareness.
5
6
 
6
- import { spawn } from 'child_process';
7
+ import { spawnSync } from 'child_process';
7
8
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
9
  import { join, dirname, resolve } from 'path';
9
10
  import { fileURLToPath } from 'url';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const WORKSPACE = resolve(__dirname, '..');
13
- const STATE_DIR = join(WORKSPACE, '.dualbrain');
14
- const LOCK_FILE = join(STATE_DIR, '.update-checked');
15
- const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
13
+ const WORKSPACE = resolve(__dirname, '..');
14
+ const STATE_DIR = join(WORKSPACE, '.dualbrain');
15
+ const LOCK_FILE = join(STATE_DIR, '.update-checked');
16
+ const NOTICE_FILE = join(STATE_DIR, '.update-notice');
17
+ const SESSION_TTL = 30 * 60 * 1000; // Once per 30 min session
16
18
 
17
- // ── 1. Already checked recently? ─────────────────────────────────────────────
19
+ // ── 1. Already checked this session? ─────────────────────────────────────────
18
20
  if (existsSync(LOCK_FILE)) {
19
21
  try {
20
22
  const lastCheck = parseInt(readFileSync(LOCK_FILE, 'utf8').trim(), 10);
21
- if (Number.isFinite(lastCheck) && Date.now() - lastCheck < TWENTY_FOUR_HOURS) {
23
+ if (Number.isFinite(lastCheck) && Date.now() - lastCheck < SESSION_TTL) {
24
+ // Output empty JSON (no hook action)
25
+ process.stdout.write('{}\n');
22
26
  process.exit(0);
23
27
  }
24
- } catch {
25
- // Corrupt lock file — proceed with check
26
- }
28
+ } catch {}
27
29
  }
28
30
 
29
- // ── 2. Write lock BEFORE spawning (prevents concurrent session races) ─────────
31
+ // ── 2. Mark as checked ───────────────────────────────────────────────────────
30
32
  try {
31
33
  mkdirSync(STATE_DIR, { recursive: true });
32
34
  writeFileSync(LOCK_FILE, String(Date.now()));
33
35
  } catch {
34
- process.exit(0); // Can't write state — bail silently
36
+ process.stdout.write('{}\n');
37
+ process.exit(0);
35
38
  }
36
39
 
37
- // ── 3. Resolve local version ─────────────────────────────────────────────────
40
+ // ── 3. Get local version ─────────────────────────────────────────────────────
38
41
  let localVersion = '';
39
42
  try {
40
43
  const pkg = JSON.parse(readFileSync(join(WORKSPACE, 'package.json'), 'utf8'));
41
44
  localVersion = pkg.version || '';
42
45
  } catch {
46
+ process.stdout.write('{}\n');
43
47
  process.exit(0);
44
48
  }
45
49
 
46
- if (!localVersion) process.exit(0);
47
-
48
- // ── 4. Detach background worker — parent returns immediately ─────────────────
49
- // The worker script is inlined as a node -e string to avoid needing a temp file.
50
- const workerScript = `
51
- import { spawnSync } from 'child_process';
52
- import { readFileSync } from 'fs';
53
-
54
- const localVersion = ${JSON.stringify(localVersion)};
50
+ if (!localVersion) {
51
+ process.stdout.write('{}\n');
52
+ process.exit(0);
53
+ }
55
54
 
56
- // 3-second timeout npm check
55
+ // ── 4. Check registry (fast, 3s timeout) ─────────────────────────────────────
57
56
  const npmResult = spawnSync('npm', ['view', 'dual-brain', 'version'], {
58
57
  encoding: 'utf8',
59
58
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -61,42 +60,41 @@ const npmResult = spawnSync('npm', ['view', 'dual-brain', 'version'], {
61
60
  });
62
61
 
63
62
  const latestVersion = (npmResult.stdout || '').trim();
64
- if (!latestVersion) process.exit(0);
65
-
66
- // Compare: is latest strictly greater than local?
67
- function versionGt(a, b) {
68
- const ap = a.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
69
- const bp = b.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
70
- const len = Math.max(ap.length, bp.length);
71
- for (let i = 0; i < len; i++) {
72
- const diff = (bp[i] || 0) - (ap[i] || 0);
73
- if (diff !== 0) return diff > 0;
74
- }
75
- return false;
63
+ if (!latestVersion || !_isNewer(localVersion, latestVersion)) {
64
+ process.stdout.write('{}\n');
65
+ process.exit(0);
76
66
  }
77
67
 
78
- if (!versionGt(localVersion, latestVersion)) process.exit(0);
68
+ // ── 5. Install immediately — no detach, no background, just do it ────────────
69
+ process.stderr.write(`[dual-brain] updating ${localVersion} → ${latestVersion}\n`);
79
70
 
80
- // Newer version found print notice then install
81
- process.stderr.write('dual-brain: updating v' + localVersion + ' → ' + latestVersion + '...\\n');
82
-
83
- spawnSync('npx', ['-y', 'dual-brain@latest', '--quiet'], {
84
- stdio: 'ignore',
85
- timeout: 120000,
71
+ const installResult = spawnSync('npm', ['install', '-g', `dual-brain@${latestVersion}`], {
72
+ encoding: 'utf8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ timeout: 30000,
86
75
  });
87
- `;
88
76
 
89
- const child = spawn(
90
- process.execPath,
91
- ['--input-type=module'],
92
- {
93
- detached: true,
94
- stdio: ['pipe', 'ignore', 'ignore'],
95
- }
96
- );
97
-
98
- child.stdin.write(workerScript);
99
- child.stdin.end();
100
- child.unref(); // Let parent exit without waiting
77
+ if (installResult.status === 0) {
78
+ // Write notice for HEAD to pick up
79
+ const notice = { from: localVersion, to: latestVersion, ts: Date.now() };
80
+ writeFileSync(NOTICE_FILE, JSON.stringify(notice));
81
+ process.stderr.write(`[dual-brain] updated to ${latestVersion}\n`);
82
+ } else {
83
+ process.stderr.write(`[dual-brain] update failed, continuing with ${localVersion}\n`);
84
+ }
101
85
 
86
+ process.stdout.write('{}\n');
102
87
  process.exit(0);
88
+
89
+ // ── Helpers ──────────────────────────────────────────────────────────────────
90
+
91
+ function _isNewer(local, remote) {
92
+ const lp = local.split('.').map(Number);
93
+ const rp = remote.split('.').map(Number);
94
+ for (let i = 0; i < Math.max(lp.length, rp.length); i++) {
95
+ const diff = (rp[i] || 0) - (lp[i] || 0);
96
+ if (diff > 0) return true;
97
+ if (diff < 0) return false;
98
+ }
99
+ return false;
100
+ }
@@ -285,7 +285,7 @@ function detectStuckLoop(state) {
285
285
  }
286
286
 
287
287
  for (const [sig, count] of signatures) {
288
- if (count >= 3) {
288
+ if (count >= 5) {
289
289
  return {
290
290
  ts: Date.now(),
291
291
  type: 'stuck-loop',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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",
@@ -86,6 +86,9 @@ export function enter(userMessage, context = {}) {
86
86
  const headState = loadState();
87
87
  const loopState = loadLoopState();
88
88
 
89
+ // Check if we just auto-updated — surface it naturally
90
+ _checkUpdateNotice(context);
91
+
89
92
  // Immersion: load memory tiers so HEAD is "in the song"
90
93
  const memory = memoryTiers.assemble({
91
94
  userMessage,
@@ -119,6 +122,12 @@ export function enter(userMessage, context = {}) {
119
122
  confidence: turn.result.confidence.score,
120
123
  });
121
124
 
125
+ // Surface update notice as a noticing
126
+ if (context._updateNotice) {
127
+ turn.result.surfaceNoticings = turn.result.surfaceNoticings || [];
128
+ turn.result.surfaceNoticings.unshift(context._updateNotice);
129
+ }
130
+
122
131
  // Narrative evolution: update HEAD's running understanding
123
132
  _evolveNarrative(turn, userMessage, context);
124
133
 
@@ -504,6 +513,22 @@ function _postWaveReflection(waveSummary, loopState) {
504
513
  narrative.evolve(parts.join(' '));
505
514
  }
506
515
 
516
+ /**
517
+ * Check for update notice and surface it to HEAD's awareness.
518
+ */
519
+ function _checkUpdateNotice(context) {
520
+ try {
521
+ const noticeFile = join(LOOP_STATE_DIR, '.update-notice');
522
+ if (!existsSync(noticeFile)) return;
523
+ const notice = JSON.parse(readFileSync(noticeFile, 'utf8'));
524
+ if (Date.now() - notice.ts < 5 * 60 * 1000) {
525
+ context._updateNotice = `Updated dual-brain ${notice.from} → ${notice.to}`;
526
+ }
527
+ // Clear it after reading (one-shot)
528
+ try { writeFileSync(noticeFile, ''); } catch {}
529
+ } catch {}
530
+ }
531
+
507
532
  // ── Query functions ─────────────────────────────────────────────────────────
508
533
 
509
534
  export function getActivePlan() {
@@ -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
 
@@ -32,13 +33,13 @@ import { join } from 'node:path';
32
33
  */
33
34
  export function generateHandoff(sessionState) {
34
35
  return {
35
- version: 1,
36
+ version: 2,
36
37
  timestamp: new Date().toISOString(),
37
38
  task: sessionState.taskDescription || null,
38
39
  progress: {
39
40
  filesChanged: (sessionState.filesChanged || []).slice(0, 20),
40
41
  testsRun: sessionState.testsRun || [],
41
- decisions: (sessionState.decisions || []).slice(0, 5), // most recent routing decisions
42
+ decisions: (sessionState.decisions || []).slice(0, 5),
42
43
  },
43
44
  unresolved: (sessionState.unresolved || []).slice(0, 5),
44
45
  routing: {
@@ -48,6 +49,7 @@ export function generateHandoff(sessionState) {
48
49
  },
49
50
  preferences: sessionState.activePreferences || [],
50
51
  resumeHint: sessionState.resumeHint || null,
52
+ narrative: sessionState.narrative || loadNarrative() || null,
51
53
  };
52
54
  }
53
55
 
@@ -168,6 +170,11 @@ export function buildResumeBrief(cwd) {
168
170
 
169
171
  lines.push(`Resuming from previous session (${ageLabel}):`);
170
172
 
173
+ // Narrative first — most valuable context for immersion
174
+ if (handoff.narrative) {
175
+ lines.push(` Context: ${handoff.narrative.slice(0, 300)}`);
176
+ }
177
+
171
178
  if (handoff.task) lines.push(` Task: ${handoff.task}`);
172
179
  if (handoff.resumeHint) lines.push(` Next: ${handoff.resumeHint}`);
173
180
  if (handoff.progress?.filesChanged?.length) {
@@ -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 };
@@ -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
- }