dual-brain 4.0.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/hooks/atomic-write.mjs +107 -0
- package/hooks/cost-logger.mjs +22 -0
- package/hooks/decision-ledger.mjs +50 -1
- package/hooks/enforce-tier.mjs +22 -9
- package/hooks/failure-detector.mjs +1 -0
- package/hooks/quality-gate.mjs +38 -0
- package/hooks/summary-checkpoint.mjs +186 -4
- package/hooks/test-orchestrator.mjs +105 -20
- package/hooks/vibe-router.mjs +135 -10
- package/install.mjs +123 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,6 +124,24 @@ npx dual-brain status # check current profile and provider health
|
|
|
124
124
|
- **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical work.
|
|
125
125
|
- **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews.
|
|
126
126
|
|
|
127
|
+
## Troubleshooting
|
|
128
|
+
|
|
129
|
+
**Hooks not firing** -- Run `node .claude/hooks/health-check.mjs`. Check that `.claude/settings.json` has the hook entries. Re-run `npx dual-brain` to re-register.
|
|
130
|
+
|
|
131
|
+
**Codex/GPT features unavailable** -- Run `codex --version` and `codex login`. If Codex CLI isn't installed: `npm i -g @openai/codex`. Re-run `npx dual-brain` to detect.
|
|
132
|
+
|
|
133
|
+
**Auth expired** -- Run `claude login` for Claude, `codex login` for OpenAI. Re-run `npx dual-brain` to re-detect.
|
|
134
|
+
|
|
135
|
+
**Duplicate warnings every time** -- Normal during agent waves (3+ agents in 90s). The system auto-suppresses. If persistent with single agents, check for identical task descriptions.
|
|
136
|
+
|
|
137
|
+
**Budget warnings too aggressive/too lenient** -- Switch profile: `npx dual-brain mode cost-saver` or `npx dual-brain mode quality-first`. Or set custom limits with `npx dual-brain budget <session$> [daily$]`.
|
|
138
|
+
|
|
139
|
+
**Corrupt state / weird behavior** -- Remove state files and re-run: `rm .claude/dual-brain.profile.json .claude/hooks/dual-brain.*.json 2>/dev/null; npx dual-brain`
|
|
140
|
+
|
|
141
|
+
**Multiple Claude Code sessions** -- State files may have brief write conflicts. Each session tracks independently. Use a single session for best results.
|
|
142
|
+
|
|
143
|
+
**Uninstall** -- `npx dual-brain --uninstall` removes hooks from settings.json and cleans state files.
|
|
144
|
+
|
|
127
145
|
## Requirements
|
|
128
146
|
|
|
129
147
|
- Node 20+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atomic-write.mjs — Atomic file operations for the dual-brain orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Prevents race conditions in read-modify-write patterns under multi-session use.
|
|
5
|
+
* No dependencies — uses only Node.js builtins.
|
|
6
|
+
*
|
|
7
|
+
* Exported API:
|
|
8
|
+
* atomicWriteJSON(filePath, data) → write JSON atomically via tmp+rename
|
|
9
|
+
* lockedReadModifyWrite(filePath, modifyFn, default) → locked read-modify-write cycle
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
13
|
+
import { constants } from 'fs';
|
|
14
|
+
|
|
15
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
16
|
+
const STALE_LOCK_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write JSON data to filePath using tmp-file + rename.
|
|
20
|
+
* Tmp file is in the same directory to avoid cross-device rename issues.
|
|
21
|
+
*/
|
|
22
|
+
export function atomicWriteJSON(filePath, data) {
|
|
23
|
+
const tmp = filePath + '.tmp.' + process.pid;
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
25
|
+
renameSync(tmp, filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Acquire a .lock file using O_EXCL for atomic creation.
|
|
30
|
+
* Returns true if lock acquired, false otherwise.
|
|
31
|
+
* Steals stale locks (older than STALE_LOCK_MS).
|
|
32
|
+
*/
|
|
33
|
+
function acquireLock(lockPath) {
|
|
34
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
35
|
+
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
try {
|
|
38
|
+
const fd = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
39
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== 'EEXIST') throw err;
|
|
44
|
+
|
|
45
|
+
// Check for stale lock
|
|
46
|
+
try {
|
|
47
|
+
const stat = statSync(lockPath);
|
|
48
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
49
|
+
// Stale lock — process likely died, steal it
|
|
50
|
+
try { unlinkSync(lockPath); } catch {}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Lock disappeared between our check — retry
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Wait briefly before retrying
|
|
59
|
+
const waitMs = 10 + Math.floor(Math.random() * 20);
|
|
60
|
+
const end = Date.now() + waitMs;
|
|
61
|
+
while (Date.now() < end) { /* spin */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function releaseLock(lockPath) {
|
|
68
|
+
try { unlinkSync(lockPath); } catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Locked read-modify-write cycle.
|
|
73
|
+
*
|
|
74
|
+
* 1. Acquire .lock file (O_EXCL atomic creation)
|
|
75
|
+
* 2. Read current JSON (or use defaultValue if missing/corrupt)
|
|
76
|
+
* 3. Call modifyFn(currentData) → newData
|
|
77
|
+
* 4. Atomic write newData via tmp+rename
|
|
78
|
+
* 5. Release lock
|
|
79
|
+
*
|
|
80
|
+
* @param {string} filePath — JSON file to modify
|
|
81
|
+
* @param {function} modifyFn — (currentData) => newData
|
|
82
|
+
* @param {*} defaultValue — used if file doesn't exist or is corrupt
|
|
83
|
+
*/
|
|
84
|
+
export function lockedReadModifyWrite(filePath, modifyFn, defaultValue = {}) {
|
|
85
|
+
const lockPath = filePath + '.lock';
|
|
86
|
+
const locked = acquireLock(lockPath);
|
|
87
|
+
|
|
88
|
+
if (!locked) {
|
|
89
|
+
// Timeout — fall through without lock (better than hanging)
|
|
90
|
+
// This matches the previous unlocked behavior as a degraded fallback
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
let current;
|
|
95
|
+
try {
|
|
96
|
+
current = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
97
|
+
} catch {
|
|
98
|
+
current = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const updated = modifyFn(current);
|
|
102
|
+
atomicWriteJSON(filePath, updated);
|
|
103
|
+
return updated;
|
|
104
|
+
} finally {
|
|
105
|
+
if (locked) releaseLock(lockPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/hooks/cost-logger.mjs
CHANGED
|
@@ -272,6 +272,28 @@ async function main() {
|
|
|
272
272
|
} catch {}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// Record outcomes (success + failure) to decision ledger for routing feedback
|
|
276
|
+
if (toolName === 'Agent') {
|
|
277
|
+
try {
|
|
278
|
+
const { computePromptHash } = await import('./failure-detector.mjs');
|
|
279
|
+
const { recordDecision, recordOutcome } = await import('./decision-ledger.mjs');
|
|
280
|
+
const promptHash = computePromptHash(toolInput);
|
|
281
|
+
const decisionId = recordDecision({
|
|
282
|
+
tier,
|
|
283
|
+
provider: detectProvider(model),
|
|
284
|
+
model,
|
|
285
|
+
prompt_hash: promptHash,
|
|
286
|
+
profile: loadActiveProfile(),
|
|
287
|
+
session_id: SESSION_ID,
|
|
288
|
+
});
|
|
289
|
+
recordOutcome(decisionId, {
|
|
290
|
+
success: status !== 'error',
|
|
291
|
+
actual_input_tokens: inputTokens,
|
|
292
|
+
actual_output_tokens: outputTokens,
|
|
293
|
+
});
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
|
|
275
297
|
const budgetMsg = await checkBudget();
|
|
276
298
|
|
|
277
299
|
// PostToolUse hooks must emit a JSON object to stdout
|
|
@@ -204,6 +204,55 @@ function getInsights(opts = {}) {
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* getOutcomeStats — lightweight aggregation for the routing hot path.
|
|
209
|
+
*
|
|
210
|
+
* Returns success rates by tier and provider over the last 24 hours,
|
|
211
|
+
* plus flags for any tier with < 50% success (with ≥ 5 outcomes).
|
|
212
|
+
*/
|
|
213
|
+
function getOutcomeStats() {
|
|
214
|
+
const { decisions, outcomes } = loadLedger();
|
|
215
|
+
const merged = mergeDecisionsWithOutcomes(decisions, outcomes);
|
|
216
|
+
|
|
217
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
218
|
+
const recent = merged.filter(d => d.outcome && d.timestamp >= cutoff);
|
|
219
|
+
|
|
220
|
+
const byTier = {};
|
|
221
|
+
const byProvider = {};
|
|
222
|
+
|
|
223
|
+
for (const d of recent) {
|
|
224
|
+
// Tier stats
|
|
225
|
+
const t = d.tier || 'execute';
|
|
226
|
+
if (!byTier[t]) byTier[t] = { total: 0, success: 0 };
|
|
227
|
+
byTier[t].total++;
|
|
228
|
+
if (d.outcome.success) byTier[t].success++;
|
|
229
|
+
|
|
230
|
+
// Provider stats
|
|
231
|
+
const p = d.provider || 'claude';
|
|
232
|
+
if (!byProvider[p]) byProvider[p] = { total: 0, success: 0 };
|
|
233
|
+
byProvider[p].total++;
|
|
234
|
+
if (d.outcome.success) byProvider[p].success++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Flag underperforming tiers (< 50% success with ≥ 5 outcomes)
|
|
238
|
+
const underperforming = [];
|
|
239
|
+
for (const [tier, stats] of Object.entries(byTier)) {
|
|
240
|
+
if (stats.total >= 5) {
|
|
241
|
+
const rate = Math.round((stats.success / stats.total) * 100);
|
|
242
|
+
if (rate < 50) {
|
|
243
|
+
underperforming.push({ tier, rate, total: stats.total });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
by_tier: byTier,
|
|
250
|
+
by_provider: byProvider,
|
|
251
|
+
total_outcomes: recent.length,
|
|
252
|
+
underperforming,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
208
257
|
|
|
209
258
|
function printInsights() {
|
|
@@ -296,4 +345,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
296
345
|
}
|
|
297
346
|
}
|
|
298
347
|
|
|
299
|
-
export { recordDecision, recordOutcome, getInsights, loadLedger };
|
|
348
|
+
export { recordDecision, recordOutcome, getInsights, getOutcomeStats, loadLedger };
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, appendFileSync
|
|
2
|
+
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
3
3
|
import { dirname, resolve, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
6
6
|
import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
7
|
+
import { getOutcomeStats } from './decision-ledger.mjs';
|
|
8
|
+
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
7
9
|
|
|
8
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
@@ -17,7 +19,7 @@ function detectBurst() {
|
|
|
17
19
|
try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
|
|
18
20
|
if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
|
|
19
21
|
state.count++;
|
|
20
|
-
try {
|
|
22
|
+
try { atomicWriteJSON(BURST_FILE, state); } catch {}
|
|
21
23
|
return state.count >= 3;
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -90,9 +92,7 @@ function logRecommendation(event) {
|
|
|
90
92
|
summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
|
|
91
93
|
}
|
|
92
94
|
summary.updated_at = new Date().toISOString();
|
|
93
|
-
|
|
94
|
-
writeFileSync(tmp, JSON.stringify(summary, null, 2) + '\n');
|
|
95
|
-
renameSync(tmp, summaryFile);
|
|
95
|
+
atomicWriteJSON(summaryFile, summary);
|
|
96
96
|
} catch {}
|
|
97
97
|
|
|
98
98
|
// Sync ledger write (append-only, fast)
|
|
@@ -254,10 +254,12 @@ try {
|
|
|
254
254
|
|
|
255
255
|
// Balance hint — populated after tier is fully resolved
|
|
256
256
|
let balanceHint = null;
|
|
257
|
+
// Outcome advisory — populated after tier is fully resolved
|
|
258
|
+
let outcomeAdvisory = null;
|
|
257
259
|
|
|
258
|
-
// Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
|
|
260
|
+
// Helper to prepend optional warnings (duplicate + drift + balance + outcome + auto) before a message
|
|
259
261
|
const prependWarnings = (msg) => {
|
|
260
|
-
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
262
|
+
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean);
|
|
261
263
|
return parts.join('\n\n');
|
|
262
264
|
};
|
|
263
265
|
|
|
@@ -349,6 +351,17 @@ try {
|
|
|
349
351
|
}
|
|
350
352
|
}
|
|
351
353
|
|
|
354
|
+
// Outcome stats advisory — best-effort, suppressed in burst mode
|
|
355
|
+
if (!burstMode) {
|
|
356
|
+
try {
|
|
357
|
+
const stats = getOutcomeStats();
|
|
358
|
+
const tierIssue = stats.underperforming.find(u => u.tier === tier);
|
|
359
|
+
if (tierIssue) {
|
|
360
|
+
outcomeAdvisory = `Heads up — ${tierIssue.tier} tasks have been struggling lately (${tierIssue.rate}% success over ${tierIssue.total} recent outcomes). Consider escalating to a higher tier.`;
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
|
|
352
365
|
const expected = preferredModel(config, tier);
|
|
353
366
|
|
|
354
367
|
if (tier === 'think') {
|
|
@@ -363,7 +376,7 @@ try {
|
|
|
363
376
|
followed: true,
|
|
364
377
|
profile: profileName,
|
|
365
378
|
});
|
|
366
|
-
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
379
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
|
|
367
380
|
if (onlyWarnings) {
|
|
368
381
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
369
382
|
} else {
|
|
@@ -394,7 +407,7 @@ try {
|
|
|
394
407
|
followed: true,
|
|
395
408
|
profile: profileName,
|
|
396
409
|
});
|
|
397
|
-
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
410
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint, outcomeAdvisory].filter(Boolean).join('\n\n');
|
|
398
411
|
if (onlyWarnings) {
|
|
399
412
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
400
413
|
} else {
|
|
@@ -12,6 +12,7 @@ import { createHash } from 'crypto';
|
|
|
12
12
|
import { readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
13
13
|
import { dirname, join } from 'path';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
|
+
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
package/hooks/quality-gate.mjs
CHANGED
|
@@ -30,6 +30,40 @@ const DUAL_BRAIN = resolve(__dirname, 'dual-brain-review.mjs');
|
|
|
30
30
|
|
|
31
31
|
const RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
|
|
32
32
|
|
|
33
|
+
const APPROVAL_MAP = {
|
|
34
|
+
low: { recommendation: 'self_check', message: 'Low risk — self-check is sufficient' },
|
|
35
|
+
medium: { recommendation: 'review_recommended', message: 'Medium risk — a code review would catch edge cases' },
|
|
36
|
+
high: { recommendation: 'dual_brain_review', message: 'High risk — recommending dual-brain review for safety' },
|
|
37
|
+
critical: { recommendation: 'user_approval_needed', message: 'Critical risk — this needs your explicit approval before merging' },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute approval recommendation from risk level + profile overrides.
|
|
42
|
+
* Profile escalation: if dual_brain_minimum is at or below the current risk,
|
|
43
|
+
* escalate the recommendation by one tier (e.g. medium → dual_brain_review
|
|
44
|
+
* under quality-first where dual_brain_minimum is 'medium').
|
|
45
|
+
*/
|
|
46
|
+
function computeApproval(risk, profileGate) {
|
|
47
|
+
let effectiveRisk = risk;
|
|
48
|
+
|
|
49
|
+
// Profile escalation: when dual_brain_minimum <= risk and the base
|
|
50
|
+
// recommendation would be below dual_brain_review, escalate one level.
|
|
51
|
+
const riskIdx = RISK_LEVELS.indexOf(risk);
|
|
52
|
+
const dualBrainIdx = RISK_LEVELS.indexOf(profileGate.dual_brain_minimum);
|
|
53
|
+
if (dualBrainIdx >= 0 && riskIdx >= dualBrainIdx && riskIdx < RISK_LEVELS.length - 1) {
|
|
54
|
+
const baseRec = APPROVAL_MAP[risk].recommendation;
|
|
55
|
+
if (baseRec !== 'dual_brain_review' && baseRec !== 'user_approval_needed') {
|
|
56
|
+
effectiveRisk = RISK_LEVELS[riskIdx + 1];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const entry = APPROVAL_MAP[effectiveRisk] || APPROVAL_MAP[risk];
|
|
61
|
+
return {
|
|
62
|
+
approval_recommendation: entry.recommendation,
|
|
63
|
+
approval_message: entry.message,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
function loadProfileGateSettings() {
|
|
34
68
|
try {
|
|
35
69
|
return _getProfileOverrides('quality-gate');
|
|
@@ -189,6 +223,7 @@ function main() {
|
|
|
189
223
|
reason: `${sensitivity.risk} risk — below profile floor (${profileGate.sensitivity_floor})`,
|
|
190
224
|
profile_floor: profileGate.sensitivity_floor,
|
|
191
225
|
files: qualifyingFiles,
|
|
226
|
+
...computeApproval(sensitivity.risk, profileGate),
|
|
192
227
|
});
|
|
193
228
|
}
|
|
194
229
|
|
|
@@ -267,6 +302,7 @@ function main() {
|
|
|
267
302
|
}
|
|
268
303
|
|
|
269
304
|
// 9. Build output object — common fields first
|
|
305
|
+
const approval = computeApproval(sensitivity.risk, profileGate);
|
|
270
306
|
const output = {
|
|
271
307
|
gate: gateStatus,
|
|
272
308
|
risk: sensitivity.risk,
|
|
@@ -278,6 +314,8 @@ function main() {
|
|
|
278
314
|
review_path: reviewFile,
|
|
279
315
|
model: reviewResult.model || null,
|
|
280
316
|
auth_type: reviewResult.auth_type || null,
|
|
317
|
+
approval_recommendation: approval.approval_recommendation,
|
|
318
|
+
approval_message: approval.approval_message,
|
|
281
319
|
};
|
|
282
320
|
|
|
283
321
|
// High risk: recommend dual-brain-think in addition
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
* getTokenAverages() → moving averages of actual tokens by tier
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { execSync as _execSync } from 'child_process';
|
|
20
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
21
|
import { dirname, join } from 'path';
|
|
21
22
|
import { fileURLToPath } from 'url';
|
|
23
|
+
import { atomicWriteJSON } from './atomic-write.mjs';
|
|
22
24
|
|
|
23
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
@@ -65,15 +67,23 @@ function emptySummary() {
|
|
|
65
67
|
dual_brain_useful: false,
|
|
66
68
|
balance_posture: 'no activity yet',
|
|
67
69
|
},
|
|
70
|
+
|
|
71
|
+
// Session handoff fields — enriched checkpoint for cross-session continuity
|
|
72
|
+
session_handoff: {
|
|
73
|
+
gate_passed: [], // completed milestones/tasks this session
|
|
74
|
+
evidence: [], // concrete evidence: commit hashes, file paths, PR URLs
|
|
75
|
+
pickup_prompt: 'none recorded', // one-sentence continuation prompt
|
|
76
|
+
friction: [], // problems encountered during the session
|
|
77
|
+
cross_workstream_patterns: [], // generalizable lessons beyond this task
|
|
78
|
+
},
|
|
68
79
|
};
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
|
|
72
83
|
|
|
84
|
+
/** @deprecated Use atomicWriteJSON directly. Kept as re-export for backward compat. */
|
|
73
85
|
function atomicWrite(path, data) {
|
|
74
|
-
|
|
75
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
76
|
-
renameSync(tmp, path);
|
|
86
|
+
atomicWriteJSON(path, data);
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
function readSummary(date) {
|
|
@@ -158,6 +168,57 @@ function applyEntry(summary, entry) {
|
|
|
158
168
|
avg.avg_output += (entry.output_tokens - avg.avg_output) / avg.count;
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
// Session handoff: auto-populate from entry metadata
|
|
172
|
+
if (!summary.session_handoff) {
|
|
173
|
+
summary.session_handoff = {
|
|
174
|
+
gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
|
|
175
|
+
friction: [], cross_workstream_patterns: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Track completed gates/milestones from quality-gate or review results
|
|
180
|
+
if (entry.type === 'gate_result' && entry.gate === 'pass') {
|
|
181
|
+
summary.session_handoff.gate_passed.push({
|
|
182
|
+
what: entry.reason || 'quality gate passed',
|
|
183
|
+
ts,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Track evidence: file paths from execute-tier entries, commit hashes, PR URLs
|
|
188
|
+
if (tier === 'execute' && entry.files_changed) {
|
|
189
|
+
const files = Array.isArray(entry.files_changed) ? entry.files_changed : [entry.files_changed];
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
if (!summary.session_handoff.evidence.includes(f)) {
|
|
192
|
+
summary.session_handoff.evidence.push(f);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (entry.commit_hash) {
|
|
197
|
+
const ref = `commit:${entry.commit_hash}`;
|
|
198
|
+
if (!summary.session_handoff.evidence.includes(ref)) {
|
|
199
|
+
summary.session_handoff.evidence.push(ref);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (entry.pr_url) {
|
|
203
|
+
if (!summary.session_handoff.evidence.includes(entry.pr_url)) {
|
|
204
|
+
summary.session_handoff.evidence.push(entry.pr_url);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Track friction: failures, escalations, retries
|
|
209
|
+
if (entry.type === 'failure' || entry.escalated || entry.retry) {
|
|
210
|
+
summary.session_handoff.friction.push({
|
|
211
|
+
what: entry.error || entry.reason || 'unknown failure',
|
|
212
|
+
tier,
|
|
213
|
+
provider,
|
|
214
|
+
ts,
|
|
215
|
+
});
|
|
216
|
+
// Keep friction list bounded
|
|
217
|
+
if (summary.session_handoff.friction.length > 50) {
|
|
218
|
+
summary.session_handoff.friction = summary.session_handoff.friction.slice(-50);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
161
222
|
// Codex latencies
|
|
162
223
|
if (entry.codex_startup_ms != null) {
|
|
163
224
|
summary.codex_latencies.push({
|
|
@@ -237,6 +298,125 @@ function getAdaptiveCodexThreshold(date) {
|
|
|
237
298
|
};
|
|
238
299
|
}
|
|
239
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Update a specific session handoff field.
|
|
303
|
+
* Valid keys: gate_passed, evidence, pickup_prompt, friction, cross_workstream_patterns
|
|
304
|
+
*
|
|
305
|
+
* For array fields, `value` is appended (string or object).
|
|
306
|
+
* For pickup_prompt, `value` replaces the current string.
|
|
307
|
+
*/
|
|
308
|
+
function updateHandoff(key, value, date) {
|
|
309
|
+
const arrayFields = ['gate_passed', 'evidence', 'friction', 'cross_workstream_patterns'];
|
|
310
|
+
const validKeys = [...arrayFields, 'pickup_prompt'];
|
|
311
|
+
if (!validKeys.includes(key)) return;
|
|
312
|
+
|
|
313
|
+
const summary = readSummary(date);
|
|
314
|
+
if (!summary.session_handoff) {
|
|
315
|
+
summary.session_handoff = {
|
|
316
|
+
gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
|
|
317
|
+
friction: [], cross_workstream_patterns: [],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (key === 'pickup_prompt') {
|
|
322
|
+
summary.session_handoff.pickup_prompt = String(value);
|
|
323
|
+
} else if (arrayFields.includes(key)) {
|
|
324
|
+
if (!Array.isArray(summary.session_handoff[key])) {
|
|
325
|
+
summary.session_handoff[key] = [];
|
|
326
|
+
}
|
|
327
|
+
summary.session_handoff[key].push(value);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
summary.updated_at = new Date().toISOString();
|
|
331
|
+
atomicWrite(summaryPath(date), summary);
|
|
332
|
+
return summary;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Generate a full session checkpoint for handoff.
|
|
337
|
+
*
|
|
338
|
+
* Auto-enriches evidence from git state (changed files, HEAD commit)
|
|
339
|
+
* and builds a pickup prompt if none was set manually.
|
|
340
|
+
*/
|
|
341
|
+
function generateCheckpoint(date) {
|
|
342
|
+
const summary = readSummary(date);
|
|
343
|
+
|
|
344
|
+
if (!summary.session_handoff) {
|
|
345
|
+
summary.session_handoff = {
|
|
346
|
+
gate_passed: [], evidence: [], pickup_prompt: 'none recorded',
|
|
347
|
+
friction: [], cross_workstream_patterns: [],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const handoff = summary.session_handoff;
|
|
352
|
+
|
|
353
|
+
// Auto-enrich evidence from git if available
|
|
354
|
+
try {
|
|
355
|
+
// Current HEAD commit
|
|
356
|
+
const head = _execSync('git rev-parse --short HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
357
|
+
if (head) {
|
|
358
|
+
const ref = `commit:${head}`;
|
|
359
|
+
if (!handoff.evidence.includes(ref)) {
|
|
360
|
+
handoff.evidence.push(ref);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Changed files in working tree
|
|
365
|
+
const diff = _execSync('git diff --name-only HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
366
|
+
if (diff) {
|
|
367
|
+
for (const f of diff.split('\n').filter(Boolean)) {
|
|
368
|
+
const ref = `changed:${f}`;
|
|
369
|
+
if (!handoff.evidence.includes(ref)) {
|
|
370
|
+
handoff.evidence.push(ref);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Current branch
|
|
376
|
+
const branch = _execSync('git branch --show-current 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
377
|
+
if (branch) {
|
|
378
|
+
handoff.evidence.push(`branch:${branch}`);
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// Git not available — skip enrichment
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Auto-generate pickup_prompt if not manually set
|
|
385
|
+
if (handoff.pickup_prompt === 'none recorded' && summary.totals.calls > 0) {
|
|
386
|
+
const topTier = Object.entries(summary.totals.by_tier)
|
|
387
|
+
.sort(([, a], [, b]) => b - a)[0];
|
|
388
|
+
const tierLabel = topTier ? topTier[0] : 'mixed';
|
|
389
|
+
const fileCount = handoff.evidence.filter(e => e.startsWith('changed:')).length;
|
|
390
|
+
const frictionCount = handoff.friction.length;
|
|
391
|
+
|
|
392
|
+
let prompt = `Session had ${summary.totals.calls} calls (mostly ${tierLabel})`;
|
|
393
|
+
if (fileCount > 0) prompt += `, ${fileCount} files modified`;
|
|
394
|
+
if (frictionCount > 0) prompt += `, ${frictionCount} friction points to review`;
|
|
395
|
+
prompt += '.';
|
|
396
|
+
handoff.pickup_prompt = prompt;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Build the checkpoint object
|
|
400
|
+
const checkpoint = {
|
|
401
|
+
version: 1,
|
|
402
|
+
generated_at: new Date().toISOString(),
|
|
403
|
+
date: summary.date,
|
|
404
|
+
|
|
405
|
+
// Existing summary data
|
|
406
|
+
totals: summary.totals,
|
|
407
|
+
session_insights: summary.session_insights,
|
|
408
|
+
|
|
409
|
+
// New handoff fields
|
|
410
|
+
gate_passed: handoff.gate_passed,
|
|
411
|
+
evidence: handoff.evidence,
|
|
412
|
+
pickup_prompt: handoff.pickup_prompt,
|
|
413
|
+
friction: handoff.friction,
|
|
414
|
+
cross_workstream_patterns: handoff.cross_workstream_patterns,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
return checkpoint;
|
|
418
|
+
}
|
|
419
|
+
|
|
240
420
|
export {
|
|
241
421
|
readSummary,
|
|
242
422
|
updateSummary,
|
|
@@ -246,5 +426,7 @@ export {
|
|
|
246
426
|
getTokenAverages,
|
|
247
427
|
getAdaptiveCodexThreshold,
|
|
248
428
|
updateSessionInsight,
|
|
429
|
+
updateHandoff,
|
|
430
|
+
generateCheckpoint,
|
|
249
431
|
atomicWrite,
|
|
250
432
|
};
|
|
@@ -313,31 +313,116 @@ test('orchestrator.json: dual_thinking configured', () => {
|
|
|
313
313
|
return true;
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
// ─── Test 15: profile consistency
|
|
316
|
+
// ─── Test 15: profile consistency (behavioral) ─────────────────────────────
|
|
317
317
|
test('profiles: consistent across modules', () => {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
const script = `
|
|
319
|
+
import { PROFILES, getActiveProfile } from './profiles.mjs';
|
|
320
|
+
const results = { errors: [] };
|
|
321
|
+
|
|
322
|
+
// 1. All 4 profiles exist
|
|
323
|
+
const expected = ['auto', 'balanced', 'cost-saver', 'quality-first'];
|
|
324
|
+
for (const name of expected) {
|
|
325
|
+
if (!PROFILES[name]) results.errors.push('missing profile: ' + name);
|
|
326
|
+
}
|
|
323
327
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
+
// 2. Each profile has required fields
|
|
329
|
+
const requiredFields = ['description', 'routing', 'budgets', 'quality_gate'];
|
|
330
|
+
const routingFields = ['prefer_provider', 'think_threshold', 'gpt_dispatch_bias'];
|
|
331
|
+
const budgetFields = ['session_warn_usd', 'session_limit_usd', 'daily_warn_usd', 'daily_limit_usd'];
|
|
328
332
|
|
|
329
|
-
|
|
330
|
-
|
|
333
|
+
for (const name of expected) {
|
|
334
|
+
const p = PROFILES[name];
|
|
335
|
+
if (!p) continue;
|
|
336
|
+
for (const f of requiredFields) {
|
|
337
|
+
if (!p[f]) results.errors.push(name + ' missing field: ' + f);
|
|
338
|
+
}
|
|
339
|
+
for (const f of routingFields) {
|
|
340
|
+
if (p.routing[f] === undefined) results.errors.push(name + ' routing missing: ' + f);
|
|
341
|
+
}
|
|
342
|
+
for (const f of budgetFields) {
|
|
343
|
+
if (typeof p.budgets[f] !== 'number' || p.budgets[f] <= 0)
|
|
344
|
+
results.errors.push(name + ' budget not positive number: ' + f + '=' + p.budgets[f]);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
331
347
|
|
|
348
|
+
// 3. getActiveProfile returns a valid profile
|
|
349
|
+
const active = getActiveProfile();
|
|
350
|
+
if (!active.name) results.errors.push('getActiveProfile missing name');
|
|
351
|
+
if (!active.routing) results.errors.push('getActiveProfile missing routing');
|
|
352
|
+
if (!active.budgets) results.errors.push('getActiveProfile missing budgets');
|
|
353
|
+
|
|
354
|
+
process.stdout.write(JSON.stringify(results));
|
|
355
|
+
`;
|
|
356
|
+
const proc = spawnSync(process.execPath, [
|
|
357
|
+
'--input-type=module',
|
|
358
|
+
'-e', script,
|
|
359
|
+
], { encoding: 'utf8', timeout: 5000, cwd: HOOKS });
|
|
360
|
+
|
|
361
|
+
if (proc.status !== 0) return `profiles script failed: ${proc.stderr}`;
|
|
362
|
+
let results;
|
|
363
|
+
try { results = JSON.parse(proc.stdout.trim()); } catch { return `output not JSON: ${proc.stdout}`; }
|
|
364
|
+
if (results.errors.length > 0) return results.errors.join('; ');
|
|
332
365
|
return true;
|
|
333
366
|
});
|
|
334
367
|
|
|
335
|
-
// ─── Test 16: failure-detector
|
|
336
|
-
test('failure-detector:
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
368
|
+
// ─── Test 16: failure-detector API contract (behavioral) ────────────────────
|
|
369
|
+
test('failure-detector: API contract', () => {
|
|
370
|
+
const LEDGER = resolve(HOOKS, 'decision-ledger.jsonl');
|
|
371
|
+
const backup = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8') : null;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// Start with clean ledger
|
|
375
|
+
writeFileSync(LEDGER, '', 'utf8');
|
|
376
|
+
|
|
377
|
+
const script = `
|
|
378
|
+
import { computePromptHash, checkFailureLoop, recordFailure } from './failure-detector.mjs';
|
|
379
|
+
const results = { errors: [] };
|
|
380
|
+
|
|
381
|
+
// 1. computePromptHash returns 12-char hex string
|
|
382
|
+
const hash = computePromptHash({ prompt: 'test prompt', description: 'test desc' });
|
|
383
|
+
if (typeof hash !== 'string') results.errors.push('hash not a string: ' + typeof hash);
|
|
384
|
+
else if (hash.length !== 12) results.errors.push('hash length not 12: ' + hash.length);
|
|
385
|
+
else if (!/^[0-9a-f]{12}$/.test(hash)) results.errors.push('hash not hex: ' + hash);
|
|
386
|
+
|
|
387
|
+
// 2. checkFailureLoop returns { isLoop, score } shape (before any failures)
|
|
388
|
+
const check1 = checkFailureLoop(hash);
|
|
389
|
+
if (typeof check1 !== 'object' || check1 === null) results.errors.push('checkFailureLoop did not return object');
|
|
390
|
+
else {
|
|
391
|
+
if (typeof check1.isLoop !== 'boolean' && typeof check1.isLoop !== 'undefined')
|
|
392
|
+
// isLoop should be boolean
|
|
393
|
+
results.errors.push('isLoop not boolean: ' + typeof check1.isLoop);
|
|
394
|
+
if (!('weightedScore' in check1 || 'score' in check1))
|
|
395
|
+
results.errors.push('checkFailureLoop missing score field');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 3. recordFailure is callable without throwing
|
|
399
|
+
try {
|
|
400
|
+
recordFailure(hash, 'execute', 'test_reason');
|
|
401
|
+
} catch (e) {
|
|
402
|
+
results.errors.push('recordFailure threw: ' + e.message);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 4. After recording failures, checkFailureLoop detects them
|
|
406
|
+
recordFailure(hash, 'execute', 'test_reason_2');
|
|
407
|
+
const check2 = checkFailureLoop(hash);
|
|
408
|
+
if (check2.count < 2) results.errors.push('expected count >= 2 after 2 recordFailure calls, got: ' + check2.count);
|
|
409
|
+
|
|
410
|
+
process.stdout.write(JSON.stringify(results));
|
|
411
|
+
`;
|
|
412
|
+
const proc = spawnSync(process.execPath, [
|
|
413
|
+
'--input-type=module',
|
|
414
|
+
'-e', script,
|
|
415
|
+
], { encoding: 'utf8', timeout: 5000, cwd: HOOKS });
|
|
416
|
+
|
|
417
|
+
if (proc.status !== 0) return `failure-detector script failed: ${proc.stderr}`;
|
|
418
|
+
let results;
|
|
419
|
+
try { results = JSON.parse(proc.stdout.trim()); } catch { return `output not JSON: ${proc.stdout}`; }
|
|
420
|
+
if (results.errors.length > 0) return results.errors.join('; ');
|
|
421
|
+
return true;
|
|
422
|
+
} finally {
|
|
423
|
+
if (backup !== null) writeFileSync(LEDGER, backup, 'utf8');
|
|
424
|
+
else try { writeFileSync(LEDGER, '', 'utf8'); } catch {}
|
|
425
|
+
}
|
|
341
426
|
});
|
|
342
427
|
|
|
343
428
|
// ─── Test 17: enforce-tier: malformed stdin ─────────────────────────────────
|
|
@@ -642,7 +727,7 @@ test('adaptive: cost-logger records Agent errors', () => {
|
|
|
642
727
|
let entry;
|
|
643
728
|
try { entry = JSON.parse(newEntry); } catch { return `last line not valid JSON: ${newEntry}`; }
|
|
644
729
|
if (entry.success !== false) return `expected success=false, got: ${entry.success}`;
|
|
645
|
-
if (entry.type !== '
|
|
730
|
+
if (entry.type !== 'outcome') return `expected type=outcome, got: ${entry.type}`;
|
|
646
731
|
return true;
|
|
647
732
|
} finally {
|
|
648
733
|
if (backup !== null) writeFileSync(LEDGER, backup, 'utf8');
|
|
@@ -799,7 +884,7 @@ test('hooks: output files use dual-brain-namespaced paths', () => {
|
|
|
799
884
|
const src = readFileSync(resolve(__dirname, hookFile), 'utf8');
|
|
800
885
|
|
|
801
886
|
// Find all file paths the hook writes to (writeFileSync / appendFileSync targets)
|
|
802
|
-
const writeTargets = [...src.matchAll(/(?:writeFileSync|appendFileSync|renameSync)\(\s*([^,)]+)/g)].map(m => m[1].trim());
|
|
887
|
+
const writeTargets = [...src.matchAll(/(?:writeFileSync|appendFileSync|renameSync|atomicWriteJSON)\(\s*([^,)]+)/g)].map(m => m[1].trim());
|
|
803
888
|
|
|
804
889
|
if (writeTargets.length === 0) return `${hookFile}: no write targets found`;
|
|
805
890
|
|
package/hooks/vibe-router.mjs
CHANGED
|
@@ -140,6 +140,46 @@ function determineQualityGates(tasks) {
|
|
|
140
140
|
return [...gates];
|
|
141
141
|
}
|
|
142
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
|
+
|
|
143
183
|
// ─── Complexity + Wave Recommendation ──────────────────────────────────────
|
|
144
184
|
|
|
145
185
|
function determineComplexity(tasks) {
|
|
@@ -157,18 +197,102 @@ function determineComplexity(tasks) {
|
|
|
157
197
|
return 'simple';
|
|
158
198
|
}
|
|
159
199
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
219
|
|
|
163
|
-
//
|
|
164
|
-
// were used), we already split them but keep sequential recommendation.
|
|
165
|
-
// For now, check if tasks share the same tier — parallel is fine for independent work.
|
|
166
|
-
const tiers = new Set(tasks.map(t => t.tier));
|
|
220
|
+
// 2. Check for high/critical risk tasks
|
|
167
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
|
+
}
|
|
168
294
|
|
|
169
|
-
|
|
170
|
-
if (tiers.size === 1 && complexity !== 'complex') return 'parallel';
|
|
171
|
-
return 'parallel';
|
|
295
|
+
return { wave: 'sequential', reasons };
|
|
172
296
|
}
|
|
173
297
|
|
|
174
298
|
// ─── Summary Generation ────────────────────────────────────────────────────
|
|
@@ -229,7 +353,7 @@ function routeVibe(utterance) {
|
|
|
229
353
|
const profileHint = detectProfileHint(utterance);
|
|
230
354
|
const qualityGates = determineQualityGates(tasks);
|
|
231
355
|
const complexity = determineComplexity(tasks);
|
|
232
|
-
const wave = determineWave(tasks, complexity);
|
|
356
|
+
const { wave, reasons } = determineWave(tasks, complexity, utterance);
|
|
233
357
|
const summary = generateSummary(tasks, complexity, wave, qualityGates, profileHint);
|
|
234
358
|
|
|
235
359
|
return {
|
|
@@ -238,6 +362,7 @@ function routeVibe(utterance) {
|
|
|
238
362
|
profile_hint: profileHint,
|
|
239
363
|
quality_gates: qualityGates,
|
|
240
364
|
wave_recommendation: wave,
|
|
365
|
+
wave_reasons: reasons,
|
|
241
366
|
summary,
|
|
242
367
|
};
|
|
243
368
|
}
|
package/install.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* npx dual-brain --dry-run # detect only, don't install
|
|
9
9
|
* npx dual-brain --help
|
|
10
10
|
*/
|
|
11
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
11
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
12
12
|
import { dirname, join, resolve } from 'path';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
@@ -55,6 +55,7 @@ if (flag('--help') || flag('-h')) {
|
|
|
55
55
|
--force Overwrite all existing config
|
|
56
56
|
--dry-run Detect environment only
|
|
57
57
|
--json Output detection as JSON
|
|
58
|
+
--uninstall Remove dual-brain hooks and state files
|
|
58
59
|
--help Show this help
|
|
59
60
|
|
|
60
61
|
🎛️ Routing modes:
|
|
@@ -697,9 +698,130 @@ function cmdExplain() {
|
|
|
697
698
|
console.log('');
|
|
698
699
|
}
|
|
699
700
|
|
|
701
|
+
// ─── Uninstall ─────────────────────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
function cmdUninstall() {
|
|
704
|
+
const workspace = resolve(process.cwd());
|
|
705
|
+
const claudeDir = join(workspace, '.claude');
|
|
706
|
+
const hooksDir = join(claudeDir, 'hooks');
|
|
707
|
+
const actions = [];
|
|
708
|
+
|
|
709
|
+
// 1. Remove dual-brain hooks from settings.json
|
|
710
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
711
|
+
if (existsSync(settingsPath)) {
|
|
712
|
+
try {
|
|
713
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
714
|
+
const DUAL_BRAIN_CMDS = [
|
|
715
|
+
'node .claude/hooks/enforce-tier.mjs',
|
|
716
|
+
'node .claude/hooks/cost-logger.mjs',
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
if (settings.hooks) {
|
|
720
|
+
let removedCount = 0;
|
|
721
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
722
|
+
const before = settings.hooks[event].length;
|
|
723
|
+
settings.hooks[event] = settings.hooks[event].filter(entry =>
|
|
724
|
+
!entry.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
725
|
+
);
|
|
726
|
+
removedCount += before - settings.hooks[event].length;
|
|
727
|
+
|
|
728
|
+
// Clean up empty arrays
|
|
729
|
+
if (settings.hooks[event].length === 0) {
|
|
730
|
+
delete settings.hooks[event];
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Clean up empty hooks object
|
|
735
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
736
|
+
delete settings.hooks;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
740
|
+
if (removedCount > 0) {
|
|
741
|
+
actions.push(`✓ Removed ${removedCount} hook(s) from settings.json`);
|
|
742
|
+
} else {
|
|
743
|
+
actions.push('⊘ No dual-brain hooks found in settings.json');
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
actions.push('⊘ No hooks section in settings.json');
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
actions.push(`⚠ Could not parse settings.json: ${err.message}`);
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
actions.push('⊘ No settings.json found');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// 2. Remove state files
|
|
756
|
+
const stateFiles = [
|
|
757
|
+
join(claudeDir, 'dual-brain.profile.json'),
|
|
758
|
+
join(claudeDir, 'dual-brain.memory.json'),
|
|
759
|
+
join(claudeDir, '.launched'),
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
// Add date-stamped usage files and summary files
|
|
763
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
764
|
+
stateFiles.push(join(hooksDir, 'usage.jsonl'));
|
|
765
|
+
stateFiles.push(join(hooksDir, `usage-${today}.jsonl`));
|
|
766
|
+
stateFiles.push(join(hooksDir, 'decision-ledger.jsonl'));
|
|
767
|
+
stateFiles.push(join(hooksDir, '.drift-warned'));
|
|
768
|
+
stateFiles.push(join(hooksDir, '.budget-alerted'));
|
|
769
|
+
|
|
770
|
+
// Scan for any usage-*.jsonl and usage-summary-*.json files
|
|
771
|
+
try {
|
|
772
|
+
const files = readdirSync(hooksDir);
|
|
773
|
+
for (const f of files) {
|
|
774
|
+
if (f.startsWith('usage-') && f.endsWith('.jsonl')) {
|
|
775
|
+
stateFiles.push(join(hooksDir, f));
|
|
776
|
+
}
|
|
777
|
+
if (f.startsWith('usage-summary-') && f.endsWith('.json')) {
|
|
778
|
+
stateFiles.push(join(hooksDir, f));
|
|
779
|
+
}
|
|
780
|
+
if (f === 'burst-state.json' || f === 'failure-ledger.json') {
|
|
781
|
+
stateFiles.push(join(hooksDir, f));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
} catch {}
|
|
785
|
+
|
|
786
|
+
// Deduplicate
|
|
787
|
+
const uniqueFiles = [...new Set(stateFiles)];
|
|
788
|
+
|
|
789
|
+
let removedFiles = 0;
|
|
790
|
+
for (const f of uniqueFiles) {
|
|
791
|
+
try {
|
|
792
|
+
if (existsSync(f)) {
|
|
793
|
+
unlinkSync(f);
|
|
794
|
+
removedFiles++;
|
|
795
|
+
}
|
|
796
|
+
} catch {}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (removedFiles > 0) {
|
|
800
|
+
actions.push(`✓ Removed ${removedFiles} state file(s)`);
|
|
801
|
+
} else {
|
|
802
|
+
actions.push('⊘ No state files to remove');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 3. Print summary
|
|
806
|
+
console.log('');
|
|
807
|
+
console.log(` 🧠 dual-brain v${VERSION} — uninstall`);
|
|
808
|
+
console.log(' ' + '─'.repeat(40));
|
|
809
|
+
for (const a of actions) {
|
|
810
|
+
console.log(` ${a}`);
|
|
811
|
+
}
|
|
812
|
+
console.log('');
|
|
813
|
+
console.log(' Hook scripts in .claude/hooks/ were left in place');
|
|
814
|
+
console.log(' (they are part of the npm package, not your repo).');
|
|
815
|
+
console.log('');
|
|
816
|
+
console.log(' To reinstall: npx -y dual-brain');
|
|
817
|
+
console.log('');
|
|
818
|
+
}
|
|
819
|
+
|
|
700
820
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
701
821
|
|
|
702
822
|
function main() {
|
|
823
|
+
if (flag('--uninstall')) { cmdUninstall(); return; }
|
|
824
|
+
|
|
703
825
|
if (subcommand === 'status') {
|
|
704
826
|
launchPanel();
|
|
705
827
|
return;
|
package/package.json
CHANGED