dual-brain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
package/src/health.mjs ADDED
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * health.mjs — Reactive provider health tracking for the Dual-Brain Orchestrator.
4
+ *
5
+ * Replaces budget-pressure estimation with real cooldown state persisted to
6
+ * .dualbrain/health.json. No external dependencies.
7
+ *
8
+ * Exports: getHealth, markHot, markDegraded, markHealthy, checkCooldown,
9
+ * getProviderScore, recordDispatch, getSessionStats, resetHealth
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ const HEALTH_FILE = '.dualbrain/health.json';
16
+
17
+ // Cooldown ladder in minutes: index = attempts - 1, capped at last entry
18
+ const COOLDOWN_LADDER = [5, 15, 45];
19
+ // Window in which repeated hot marks escalate the ladder (ms)
20
+ const ESCALATION_WINDOW_MS = 2 * 60 * 60 * 1000;
21
+
22
+ // ─── File I/O ────────────────────────────────────────────────────────────────
23
+
24
+ function healthPath(cwd) {
25
+ return join(cwd ?? process.cwd(), HEALTH_FILE);
26
+ }
27
+
28
+ function loadRaw(cwd) {
29
+ const p = healthPath(cwd);
30
+ if (!existsSync(p)) return { states: {}, session: null };
31
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { states: {}, session: null }; }
32
+ }
33
+
34
+ function saveRaw(data, cwd) {
35
+ const p = healthPath(cwd);
36
+ mkdirSync(join(cwd ?? process.cwd(), '.dualbrain'), { recursive: true });
37
+ writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
38
+ }
39
+
40
+ function key(provider, modelClass) {
41
+ return `${provider}:${modelClass}`;
42
+ }
43
+
44
+ // ─── Session helpers ──────────────────────────────────────────────────────────
45
+
46
+ function ensureSession(data) {
47
+ if (!data.session || typeof data.session !== 'object') {
48
+ data.session = { startedAt: new Date().toISOString(), dispatches: [] };
49
+ }
50
+ if (!Array.isArray(data.session.dispatches)) data.session.dispatches = [];
51
+ return data;
52
+ }
53
+
54
+ // ─── Exported: getHealth ─────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Return the raw health data (states + session).
58
+ * @param {string} [cwd]
59
+ * @returns {{ states: object, session: object }}
60
+ */
61
+ export function getHealth(cwd) {
62
+ return loadRaw(cwd);
63
+ }
64
+
65
+ // ─── Exported: markHot ───────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Mark a provider+model as hot (rate-limited). Escalates cooldown on repeat.
69
+ * @param {string} provider
70
+ * @param {string} modelClass
71
+ * @param {string} [cwd]
72
+ */
73
+ export function markHot(provider, modelClass, cwd) {
74
+ const data = loadRaw(cwd);
75
+ const k = key(provider, modelClass);
76
+ const existing = data.states[k] ?? {};
77
+ const now = Date.now();
78
+
79
+ // Count how many times this was already marked hot within the escalation window
80
+ let attempts = (existing.attempts ?? 0);
81
+ const sinceMs = existing.since ? now - Date.parse(existing.since) : Infinity;
82
+ if (sinceMs < ESCALATION_WINDOW_MS && existing.status === 'hot') {
83
+ attempts += 1;
84
+ } else if (existing.status !== 'hot') {
85
+ // First time hot (or was healthy/probing before): reset counter to 1
86
+ attempts = 1;
87
+ }
88
+ // Clamp to ladder length
89
+ const ladderIdx = Math.min(attempts - 1, COOLDOWN_LADDER.length - 1);
90
+ const cooldownMinutes = COOLDOWN_LADDER[ladderIdx];
91
+
92
+ data.states[k] = {
93
+ status: 'hot',
94
+ since: new Date().toISOString(),
95
+ cooldownMinutes,
96
+ attempts,
97
+ };
98
+ saveRaw(data, cwd);
99
+ }
100
+
101
+ // ─── Exported: markDegraded ──────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Signal soft degradation (slow responses, elevated errors) without full cooldown.
105
+ * @param {string} provider
106
+ * @param {string} modelClass
107
+ * @param {string} [cwd]
108
+ */
109
+ export function markDegraded(provider, modelClass, cwd) {
110
+ const data = loadRaw(cwd);
111
+ const k = key(provider, modelClass);
112
+ // Only downgrade if currently healthy or probing — never upgrade from hot
113
+ if (!data.states[k] || ['healthy', 'probing'].includes(data.states[k].status)) {
114
+ data.states[k] = { status: 'degraded', since: new Date().toISOString() };
115
+ saveRaw(data, cwd);
116
+ }
117
+ }
118
+
119
+ // ─── Exported: markHealthy ───────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Clear hot/degraded state and reset attempt counter.
123
+ * @param {string} provider
124
+ * @param {string} modelClass
125
+ * @param {string} [cwd]
126
+ */
127
+ export function markHealthy(provider, modelClass, cwd) {
128
+ const data = loadRaw(cwd);
129
+ const k = key(provider, modelClass);
130
+ data.states[k] = { status: 'healthy', since: new Date().toISOString() };
131
+ saveRaw(data, cwd);
132
+ }
133
+
134
+ // ─── Exported: checkCooldown ─────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Returns true if the cooldown for a hot provider+model has expired.
138
+ * Side-effect: transitions status from 'hot' to 'probing' when expired.
139
+ * @param {string} provider
140
+ * @param {string} modelClass
141
+ * @param {string} [cwd]
142
+ * @returns {boolean} true = cooldown expired, ready to probe
143
+ */
144
+ export function checkCooldown(provider, modelClass, cwd) {
145
+ const data = loadRaw(cwd);
146
+ const k = key(provider, modelClass);
147
+ const state = data.states[k];
148
+ if (!state || state.status !== 'hot') return true; // not hot → no cooldown
149
+
150
+ const sinceMs = Date.parse(state.since);
151
+ const cooldownMs = (state.cooldownMinutes ?? 5) * 60 * 1000;
152
+ const expired = Date.now() - sinceMs >= cooldownMs;
153
+
154
+ if (expired) {
155
+ // Transition to probing
156
+ data.states[k] = { ...state, status: 'probing', probingAt: new Date().toISOString() };
157
+ saveRaw(data, cwd);
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ // ─── Exported: getProviderScore ──────────────────────────────────────────────
164
+
165
+ /**
166
+ * Returns a 0-100 routing preference score for a provider+model.
167
+ * healthy=100, degraded=50, probing=25, hot=0
168
+ * @param {string} provider
169
+ * @param {string} modelClass
170
+ * @param {string} [cwd]
171
+ * @returns {number}
172
+ */
173
+ export function getProviderScore(provider, modelClass, cwd) {
174
+ const data = loadRaw(cwd);
175
+ const k = key(provider, modelClass);
176
+ const state = data.states[k];
177
+ if (!state) return 100;
178
+ switch (state.status) {
179
+ case 'healthy': return 100;
180
+ case 'degraded': return 50;
181
+ case 'probing': return 25;
182
+ case 'hot': return 0;
183
+ default: return 100;
184
+ }
185
+ }
186
+
187
+ // ─── Exported: recordDispatch ────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Log a successful dispatch for session tracking.
191
+ * @param {string} provider
192
+ * @param {string} modelClass
193
+ * @param {number} tokens
194
+ * @param {string} [cwd]
195
+ */
196
+ export function recordDispatch(provider, modelClass, tokens, cwd) {
197
+ const data = ensureSession(loadRaw(cwd));
198
+ data.session.dispatches.push({
199
+ provider,
200
+ model: modelClass,
201
+ tokens: tokens ?? 0,
202
+ at: new Date().toISOString(),
203
+ });
204
+ saveRaw(data, cwd);
205
+ }
206
+
207
+ // ─── Exported: getSessionStats ───────────────────────────────────────────────
208
+
209
+ /**
210
+ * Return per-provider aggregated call + token counts for the current session.
211
+ * @param {string} [cwd]
212
+ * @returns {{ [provider: string]: { calls: number, tokens: number } }}
213
+ */
214
+ export function getSessionStats(cwd) {
215
+ const { session } = loadRaw(cwd);
216
+ const stats = {};
217
+ for (const d of (session?.dispatches ?? [])) {
218
+ if (!stats[d.provider]) stats[d.provider] = { calls: 0, tokens: 0 };
219
+ stats[d.provider].calls += 1;
220
+ stats[d.provider].tokens += (d.tokens ?? 0);
221
+ }
222
+ return stats;
223
+ }
224
+
225
+ // ─── Exported: resetHealth ───────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Wipe all health state (states + session).
229
+ * @param {string} [cwd]
230
+ */
231
+ export function resetHealth(cwd) {
232
+ saveRaw({ states: {}, session: null }, cwd);
233
+ }
234
+
235
+ // ─── Remaining cooldown helper (used by status display) ──────────────────────
236
+
237
+ /**
238
+ * Returns remaining cooldown in minutes for a hot provider+model, or 0.
239
+ * @param {string} provider
240
+ * @param {string} modelClass
241
+ * @param {string} [cwd]
242
+ * @returns {number}
243
+ */
244
+ export function remainingCooldownMinutes(provider, modelClass, cwd) {
245
+ const data = loadRaw(cwd);
246
+ const k = key(provider, modelClass);
247
+ const state = data.states[k];
248
+ if (!state || state.status !== 'hot') return 0;
249
+ const elapsedMs = Date.now() - Date.parse(state.since);
250
+ const cooldownMs = (state.cooldownMinutes ?? 5) * 60 * 1000;
251
+ const remaining = cooldownMs - elapsedMs;
252
+ return remaining > 0 ? Math.ceil(remaining / 60_000) : 0;
253
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * index.mjs — Main entry point for the dual-brain package.
4
+ *
5
+ * Re-exports all public APIs from the four core modules, plus a top-level
6
+ * orchestrate() convenience function for programmatic use.
7
+ */
8
+
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions, autoRefreshToken } from './profile.mjs';
10
+ export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
+ export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
+ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
13
+ export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
+ export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
15
+ export { detectRepo, loadRepoCache, getTestCommand, getLintCommand } from './repo.mjs';
16
+ export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions, ensurePersistence, syncSessionMirror, buildSessionIndex, searchSessions, getSessionContext } from './session.mjs';
17
+ export { decompose, isSimpleTask, taskGraphToWaves } from './decompose.mjs';
18
+ export { generateBrief, compressPriorResults, listRoles } from './brief.mjs';
19
+ export { redact, redactFiles, isSecretFile } from './redact.mjs';
20
+ export { isInsideClaude, buildNativeDispatch, normalizeResult } from './dispatch.mjs';
21
+ export { box, bar, badge, menu, separator } from './tui.mjs';
22
+
23
+ // Top-level convenience function
24
+ export async function orchestrate({ prompt, files, cwd, dryRun }) {
25
+ // Import dynamically to avoid circular issues
26
+ const { ensureProfile } = await import('./profile.mjs');
27
+ const { detectTask } = await import('./detect.mjs');
28
+ const { decideRoute } = await import('./decide.mjs');
29
+ const { dispatch: run, dispatchDualBrain } = await import('./dispatch.mjs');
30
+
31
+ const profile = await ensureProfile(cwd || process.cwd(), { interactive: false });
32
+ const detection = detectTask({ prompt, files });
33
+ const decision = decideRoute({ profile, detection, cwd: cwd || process.cwd() });
34
+
35
+ if (dryRun) {
36
+ return { profile, detection, decision, result: null };
37
+ }
38
+
39
+ const result = decision.dualBrain
40
+ ? await dispatchDualBrain({ decision, prompt, files, cwd: cwd || process.cwd() })
41
+ : await run({ decision, prompt, files, cwd: cwd || process.cwd() });
42
+
43
+ return { profile, detection, decision, result };
44
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * install-hooks.mjs — Merge dual-brain PreToolUse hooks into .claude/settings.json.
3
+ *
4
+ * Exported function: installHooks(cwd)
5
+ * Returns: { installed: string[], skipped: string[] }
6
+ */
7
+
8
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const PKG_ROOT = join(__dirname, '..');
15
+
16
+ // The hook commands we want present in .claude/settings.json PreToolUse
17
+ const HEAD_GUARD_CMD = 'node .claude/hooks/head-guard.mjs';
18
+ const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
19
+
20
+ const DESIRED_HOOKS = [
21
+ { matcher: 'Edit', command: HEAD_GUARD_CMD },
22
+ { matcher: 'Write', command: HEAD_GUARD_CMD },
23
+ { matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
24
+ { matcher: 'Bash', command: HEAD_GUARD_CMD },
25
+ { matcher: 'Agent', command: ENFORCE_TIER_CMD },
26
+ ];
27
+
28
+ /**
29
+ * Install dual-brain enforcement hooks into a project's .claude/settings.json.
30
+ *
31
+ * @param {string} cwd - Project root directory (where .claude/ should live)
32
+ * @returns {{ installed: string[], skipped: string[] }}
33
+ */
34
+ export function installHooks(cwd) {
35
+ const claudeDir = join(cwd, '.claude');
36
+ const hooksDir = join(claudeDir, 'hooks');
37
+ const settingsPath = join(claudeDir, 'settings.json');
38
+
39
+ const installed = [];
40
+ const skipped = [];
41
+
42
+ // Ensure directories exist
43
+ mkdirSync(hooksDir, { recursive: true });
44
+
45
+ // Copy hook files from package into project's .claude/hooks/
46
+ const filesToCopy = [
47
+ { name: 'head-guard.mjs', exec: true },
48
+ { name: 'enforce-tier.mjs', exec: false },
49
+ ];
50
+
51
+ for (const { name, exec } of filesToCopy) {
52
+ const src = join(PKG_ROOT, 'hooks', name);
53
+ const dst = join(hooksDir, name);
54
+ if (existsSync(src)) {
55
+ cpSync(src, dst);
56
+ if (exec) {
57
+ try { chmodSync(dst, 0o755); } catch {}
58
+ }
59
+ installed.push(`hooks/${name}`);
60
+ }
61
+ }
62
+
63
+ // Read existing settings (or start fresh)
64
+ let settings = {};
65
+ try {
66
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
67
+ } catch {
68
+ // File doesn't exist or is malformed — start empty
69
+ }
70
+
71
+ // Ensure hooks.PreToolUse array exists
72
+ if (!settings.hooks) settings.hooks = {};
73
+ if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
74
+
75
+ const preToolUse = settings.hooks.PreToolUse;
76
+
77
+ // Merge: for each desired hook, add only if command is not already registered for that matcher
78
+ for (const { matcher, command } of DESIRED_HOOKS) {
79
+ const alreadyPresent = preToolUse.some(entry =>
80
+ entry.matcher === matcher &&
81
+ Array.isArray(entry.hooks) &&
82
+ entry.hooks.some(h => h.command === command)
83
+ );
84
+
85
+ if (alreadyPresent) {
86
+ skipped.push(`PreToolUse[${matcher}]`);
87
+ } else {
88
+ preToolUse.push({
89
+ matcher,
90
+ hooks: [{ type: 'command', command }],
91
+ });
92
+ installed.push(`PreToolUse[${matcher}]`);
93
+ }
94
+ }
95
+
96
+ // Write back merged settings
97
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
98
+
99
+ return { installed, skipped };
100
+ }
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * playbook.mjs — Playbook loader and executor for the Dual-Brain Orchestrator.
4
+ *
5
+ * Exports:
6
+ * loadPlaybook(intent, cwd) → playbook object | null
7
+ * listPlaybooks(cwd) → [{ name, source, stepCount }]
8
+ * executePlaybook(playbook, context) → { steps, summary, runId }
9
+ * createRunArtifact(runId, results, cwd) → artifact path
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join, dirname, basename } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ import { loadProfile } from './profile.mjs';
19
+ import { decideRoute, shouldDualBrain } from './decide.mjs';
20
+ import { dispatch, dispatchDualBrain } from './dispatch.mjs';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const BUILTIN_DIR = join(__dirname, '..', 'playbooks');
24
+ const GLOBAL_DIR = join(homedir(), '.config', 'dual-brain', 'playbooks');
25
+
26
+ // ─── Playbook resolution helpers ─────────────────────────────────────────────
27
+
28
+ function projectDir(cwd) {
29
+ return join(cwd || process.cwd(), '.dualbrain', 'playbooks');
30
+ }
31
+
32
+ function readJson(path) {
33
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
34
+ }
35
+
36
+ function playbookPath(dir, intent) {
37
+ return join(dir, `${intent}.json`);
38
+ }
39
+
40
+ // ─── Exported: loadPlaybook ───────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Find and return a playbook matching the given intent.
44
+ * Search order: project-local → global user → built-in.
45
+ * Returns null if no match found.
46
+ * @param {string} intent
47
+ * @param {string} [cwd]
48
+ * @returns {object|null}
49
+ */
50
+ export function loadPlaybook(intent, cwd) {
51
+ if (!intent) return null;
52
+
53
+ const candidates = [
54
+ { dir: projectDir(cwd), source: 'project' },
55
+ { dir: GLOBAL_DIR, source: 'global' },
56
+ { dir: BUILTIN_DIR, source: 'builtin' },
57
+ ];
58
+
59
+ for (const { dir, source } of candidates) {
60
+ const path = playbookPath(dir, intent);
61
+ if (existsSync(path)) {
62
+ const pb = readJson(path);
63
+ if (pb) return { ...pb, _source: source, _path: path };
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ // ─── Exported: listPlaybooks ──────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Return all available playbooks across all sources, deduped (project wins).
74
+ * @param {string} [cwd]
75
+ * @returns {{ name: string, source: string, stepCount: number }[]}
76
+ */
77
+ export function listPlaybooks(cwd) {
78
+ const seen = new Map(); // name → entry (first write wins: project > global > builtin)
79
+
80
+ const sources = [
81
+ { dir: projectDir(cwd), source: 'project' },
82
+ { dir: GLOBAL_DIR, source: 'global' },
83
+ { dir: BUILTIN_DIR, source: 'builtin' },
84
+ ];
85
+
86
+ for (const { dir, source } of sources) {
87
+ if (!existsSync(dir)) continue;
88
+ let files;
89
+ try { files = readdirSync(dir); } catch { continue; }
90
+
91
+ for (const file of files) {
92
+ if (!file.endsWith('.json')) continue;
93
+ const name = basename(file, '.json');
94
+ if (seen.has(name)) continue; // project-local already registered
95
+ const pb = readJson(join(dir, file));
96
+ if (!pb) continue;
97
+ seen.set(name, {
98
+ name: pb.name ?? name,
99
+ source,
100
+ stepCount: Array.isArray(pb.steps) ? pb.steps.length : 0,
101
+ });
102
+ }
103
+ }
104
+
105
+ return [...seen.values()];
106
+ }
107
+
108
+ // ─── Exported: createRunArtifact ─────────────────────────────────────────────
109
+
110
+ /**
111
+ * Persist a run manifest under .dualbrain/runs/<runId>/manifest.json.
112
+ * @param {string} runId
113
+ * @param {object[]} results — step result objects
114
+ * @param {string} [cwd]
115
+ * @returns {string} path to the manifest file
116
+ */
117
+ export function createRunArtifact(runId, results, cwd) {
118
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'runs', runId);
119
+ mkdirSync(dir, { recursive: true });
120
+ const path = join(dir, 'manifest.json');
121
+ const manifest = {
122
+ runId,
123
+ createdAt: new Date().toISOString(),
124
+ stepCount: results.length,
125
+ steps: results,
126
+ };
127
+ writeFileSync(path, JSON.stringify(manifest, null, 2));
128
+ return path;
129
+ }
130
+
131
+ // ─── Step prompt builder ──────────────────────────────────────────────────────
132
+
133
+ function buildStepPrompt(step, priorOutputs, basePrompt) {
134
+ const parts = [];
135
+
136
+ if (basePrompt) parts.push(`Context: ${basePrompt}`);
137
+
138
+ if (priorOutputs.length > 0) {
139
+ parts.push('Prior step results:');
140
+ for (const prior of priorOutputs) {
141
+ parts.push(` [${prior.stepId}] ${prior.summary ?? '(no output)'}`);
142
+ }
143
+ }
144
+
145
+ parts.push(`\nCurrent task — ${step.title}: ${step.goal}`);
146
+
147
+ if (step.output?.kind) {
148
+ parts.push(`Expected output format: ${step.output.kind}`);
149
+ }
150
+
151
+ return parts.join('\n');
152
+ }
153
+
154
+ // ─── Exported: executePlaybook ────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Execute all steps in a playbook sequentially, feeding prior outputs forward.
158
+ * @param {object} playbook
159
+ * @param {{ profile?: object, prompt?: string, files?: string[], cwd?: string, dryRun?: boolean, verbose?: boolean }} context
160
+ * @returns {Promise<{ steps: object[], summary: string, runId: string }>}
161
+ */
162
+ export async function executePlaybook(playbook, context = {}) {
163
+ const {
164
+ prompt = '',
165
+ files = [],
166
+ cwd = process.cwd(),
167
+ dryRun = false,
168
+ verbose = false,
169
+ } = context;
170
+
171
+ let { profile } = context;
172
+ if (!profile) {
173
+ try { profile = await loadProfile(cwd); } catch { profile = {}; }
174
+ }
175
+
176
+ const runId = randomUUID();
177
+ const steps = playbook.steps ?? [];
178
+ const results = [];
179
+ const priorOuts = [];
180
+
181
+ if (verbose) {
182
+ console.log(`[playbook] Starting "${playbook.name}" — ${steps.length} steps (runId: ${runId})`);
183
+ }
184
+
185
+ for (const step of steps) {
186
+ const stepPrompt = buildStepPrompt(step, priorOuts, prompt);
187
+
188
+ // Build synthetic detection that respects the step's declared tier
189
+ const detection = {
190
+ intent: step.tier === 'think' ? 'architecture'
191
+ : step.tier === 'search' ? 'search'
192
+ : 'edit',
193
+ tier: step.tier ?? 'execute',
194
+ risk: 'medium',
195
+ complexity: 'moderate',
196
+ effort: 'medium',
197
+ };
198
+
199
+ // Force dual-brain if step declares consensus:true OR risk warrants it
200
+ const forceDual = step.consensus === true || shouldDualBrain(detection, profile);
201
+ const decision = decideRoute({ profile, detection, cwd });
202
+ if (forceDual) decision.dualBrain = true;
203
+
204
+ if (verbose) {
205
+ const mode = forceDual ? 'dual-brain' : `${decision.provider}/${decision.model}`;
206
+ console.log(`[playbook] Step "${step.id}" → ${mode} (${decision.tier})`);
207
+ }
208
+
209
+ // Gate: log and continue (blocking gates are a future concern)
210
+ if (step.gate) {
211
+ console.log(`[playbook] Gate "${step.gate}" — checking (non-blocking in this version)`);
212
+ }
213
+
214
+ let result;
215
+ try {
216
+ if (forceDual) {
217
+ result = await dispatchDualBrain({ decision, prompt: stepPrompt, files, cwd, dryRun });
218
+ result = {
219
+ status: result.consensus === 'both-failed' ? 'failed' : 'completed',
220
+ summary: result.claude?.summary ?? result.openai?.summary ?? '(dual-brain)',
221
+ dualBrain: result,
222
+ };
223
+ } else {
224
+ result = await dispatch({ decision, prompt: stepPrompt, files, cwd, dryRun });
225
+ }
226
+ } catch (err) {
227
+ result = { status: 'error', summary: err.message, error: err.message };
228
+ }
229
+
230
+ const stepResult = {
231
+ stepId: step.id,
232
+ title: step.title,
233
+ tier: step.tier ?? 'execute',
234
+ dualBrain: forceDual,
235
+ status: result.status,
236
+ summary: result.summary ?? null,
237
+ error: result.error ?? null,
238
+ };
239
+
240
+ results.push(stepResult);
241
+ priorOuts.push({ stepId: step.id, summary: result.summary });
242
+
243
+ if (verbose) {
244
+ console.log(`[playbook] → ${stepResult.status}: ${stepResult.summary ?? stepResult.error}`);
245
+ }
246
+ }
247
+
248
+ const passed = results.filter(r => r.status === 'completed' || r.status === 'dry-run').length;
249
+ const failed = results.filter(r => r.status === 'failed' || r.status === 'error').length;
250
+ const summary = `Playbook "${playbook.name}" finished: ${passed}/${steps.length} steps passed${failed ? `, ${failed} failed` : ''}.`;
251
+
252
+ if (!dryRun) {
253
+ try { createRunArtifact(runId, results, cwd); } catch {}
254
+ }
255
+
256
+ return { steps: results, summary, runId };
257
+ }