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.
- package/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- 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
|
+
}
|
package/src/playbook.mjs
ADDED
|
@@ -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
|
+
}
|