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
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * profiles.mjs — Profile system for the Dual-Brain Orchestrator.
4
+ *
5
+ * Profiles configure routing posture, budget limits, and quality gate behavior.
6
+ * Active profile persists to .claude/dual-brain.profile.json.
7
+ *
8
+ * Exported API:
9
+ * PROFILES → built-in profile definitions
10
+ * getActiveProfile() → current profile name + merged settings
11
+ * setActiveProfile(name) → switch profile, returns success/error
12
+ * getProfileOverrides(key) → profile-driven overrides for a specific system
13
+ */
14
+
15
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
16
+ import { dirname, join } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
21
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
22
+
23
+ const ALIASES = {
24
+ // auto
25
+ 'auto': 'auto', 'adaptive': 'auto', 'smart': 'auto', 'default': 'auto', 'normal': 'auto',
26
+ // balanced
27
+ 'balanced': 'balanced', 'even': 'balanced', 'equal': 'balanced',
28
+ // cost-saver
29
+ 'cost-saver': 'cost-saver', 'cheap': 'cost-saver', 'save': 'cost-saver', 'conservative': 'cost-saver', 'frugal': 'cost-saver', 'budget': 'cost-saver', 'fast': 'cost-saver', 'quick': 'cost-saver',
30
+ // quality-first
31
+ 'quality-first': 'quality-first', 'aggressive': 'quality-first', 'quality': 'quality-first', 'max': 'quality-first', 'full': 'quality-first', 'both': 'quality-first', 'careful': 'quality-first', 'thorough': 'quality-first', 'safe': 'quality-first',
32
+ };
33
+
34
+ function resolveProfileName(input) {
35
+ if (!input) return null;
36
+ const cleaned = input.toLowerCase().trim()
37
+ .replace(/^(go|be|use|switch to|set|mode)\s+/i, '')
38
+ .replace(/\s+mode$/i, '');
39
+ return ALIASES[cleaned] || null;
40
+ }
41
+
42
+ const PROFILES = {
43
+ auto: {
44
+ description: 'Adapts routing based on task risk, provider health, and outcomes',
45
+ routing: {
46
+ prefer_provider: 'auto',
47
+ think_threshold: 'adaptive',
48
+ gpt_dispatch_bias: 0,
49
+ },
50
+ budgets: {
51
+ session_warn_usd: 5.00,
52
+ session_limit_usd: 10.00,
53
+ daily_warn_usd: 20.00,
54
+ daily_limit_usd: 50.00,
55
+ },
56
+ quality_gate: {
57
+ sensitivity_floor: 'medium',
58
+ dual_brain_minimum: 'high',
59
+ },
60
+ tier_overrides: null,
61
+ },
62
+
63
+ balanced: {
64
+ description: 'Auto-routes by complexity, uses both providers evenly',
65
+ routing: {
66
+ prefer_provider: 'auto',
67
+ think_threshold: 'normal',
68
+ gpt_dispatch_bias: 0,
69
+ },
70
+ budgets: {
71
+ session_warn_usd: 5.00,
72
+ session_limit_usd: 10.00,
73
+ daily_warn_usd: 20.00,
74
+ daily_limit_usd: 50.00,
75
+ },
76
+ quality_gate: {
77
+ sensitivity_floor: 'medium',
78
+ dual_brain_minimum: 'high',
79
+ },
80
+ tier_overrides: null,
81
+ },
82
+
83
+ 'cost-saver': {
84
+ description: 'Conservative — fewer GPT dispatches, sticks to Claude',
85
+ routing: {
86
+ prefer_provider: 'cheapest',
87
+ think_threshold: 'strict',
88
+ gpt_dispatch_bias: -20,
89
+ },
90
+ budgets: {
91
+ session_warn_usd: 2.00,
92
+ session_limit_usd: 5.00,
93
+ daily_warn_usd: 8.00,
94
+ daily_limit_usd: 20.00,
95
+ },
96
+ quality_gate: {
97
+ sensitivity_floor: 'high',
98
+ dual_brain_minimum: 'critical',
99
+ },
100
+ tier_overrides: {
101
+ promote_execute_to_think: false,
102
+ demote_think_to_execute: true,
103
+ },
104
+ },
105
+
106
+ 'quality-first': {
107
+ description: 'Aggressive — maximizes both subscriptions, dual-brain for medium+',
108
+ routing: {
109
+ prefer_provider: 'most-capable',
110
+ think_threshold: 'relaxed',
111
+ gpt_dispatch_bias: 10,
112
+ },
113
+ budgets: {
114
+ session_warn_usd: 15.00,
115
+ session_limit_usd: 30.00,
116
+ daily_warn_usd: 50.00,
117
+ daily_limit_usd: 100.00,
118
+ },
119
+ quality_gate: {
120
+ sensitivity_floor: 'low',
121
+ dual_brain_minimum: 'medium',
122
+ },
123
+ tier_overrides: {
124
+ promote_execute_to_think: true,
125
+ demote_think_to_execute: false,
126
+ },
127
+ },
128
+ };
129
+
130
+ function loadProfileFile() {
131
+ try {
132
+ return JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function loadConfig() {
139
+ try {
140
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ function getActiveProfile() {
147
+ const saved = loadProfileFile();
148
+ const name = saved?.active || 'auto';
149
+ const profile = PROFILES[name] || PROFILES.auto;
150
+ const customOverrides = saved?.custom_overrides || {};
151
+
152
+ return {
153
+ name: PROFILES[name] ? name : 'auto',
154
+ ...profile,
155
+ budgets: { ...profile.budgets, ...customOverrides.budgets },
156
+ routing: { ...profile.routing, ...customOverrides.routing },
157
+ switched_at: saved?.switched_at || null,
158
+ };
159
+ }
160
+
161
+ function setActiveProfile(name, customOverrides = null) {
162
+ let resolved = name;
163
+ if (!PROFILES[resolved]) {
164
+ const alias = resolveProfileName(name);
165
+ if (alias) {
166
+ resolved = alias;
167
+ } else {
168
+ const aliasHint = Object.entries(ALIASES)
169
+ .filter(([k, v]) => k !== v)
170
+ .map(([k, v]) => `${k} → ${v}`)
171
+ .join(', ');
172
+ return { ok: false, error: `Unknown profile: ${name}. Available: ${Object.keys(PROFILES).join(', ')}. Aliases: ${aliasHint}` };
173
+ }
174
+ }
175
+
176
+ const data = {
177
+ active: resolved,
178
+ switched_at: new Date().toISOString(),
179
+ };
180
+ if (customOverrides) data.custom_overrides = customOverrides;
181
+
182
+ try {
183
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
184
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
185
+ renameSync(tmp, PROFILE_FILE);
186
+ return { ok: true, profile: PROFILES[resolved], resolvedName: resolved };
187
+ } catch (err) {
188
+ return { ok: false, error: `Failed to write profile: ${err.message}` };
189
+ }
190
+ }
191
+
192
+ function setBudgetOverrides(sessionLimit, dailyLimit) {
193
+ const saved = loadProfileFile() || { active: 'balanced' };
194
+ saved.custom_overrides = saved.custom_overrides || {};
195
+ saved.custom_overrides.budgets = {};
196
+
197
+ if (sessionLimit != null) {
198
+ saved.custom_overrides.budgets.session_warn_usd = sessionLimit * 0.6;
199
+ saved.custom_overrides.budgets.session_limit_usd = sessionLimit;
200
+ }
201
+ if (dailyLimit != null) {
202
+ saved.custom_overrides.budgets.daily_warn_usd = dailyLimit * 0.6;
203
+ saved.custom_overrides.budgets.daily_limit_usd = dailyLimit;
204
+ }
205
+
206
+ saved.switched_at = saved.switched_at || new Date().toISOString();
207
+
208
+ try {
209
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
210
+ writeFileSync(tmp, JSON.stringify(saved, null, 2) + '\n');
211
+ renameSync(tmp, PROFILE_FILE);
212
+ return { ok: true };
213
+ } catch (err) {
214
+ return { ok: false, error: err.message };
215
+ }
216
+ }
217
+
218
+ function getProfileOverrides(system) {
219
+ const profile = getActiveProfile();
220
+
221
+ switch (system) {
222
+ case 'enforce-tier':
223
+ return {
224
+ think_threshold: profile.routing.think_threshold,
225
+ tier_overrides: profile.tier_overrides,
226
+ gpt_dispatch_bias: profile.routing.gpt_dispatch_bias,
227
+ };
228
+
229
+ case 'budget-balancer':
230
+ return {
231
+ budgets: profile.budgets,
232
+ prefer_provider: profile.routing.prefer_provider,
233
+ };
234
+
235
+ case 'quality-gate':
236
+ return {
237
+ sensitivity_floor: profile.quality_gate.sensitivity_floor,
238
+ dual_brain_minimum: profile.quality_gate.dual_brain_minimum,
239
+ };
240
+
241
+ default:
242
+ return {};
243
+ }
244
+ }
245
+
246
+ export {
247
+ PROFILES,
248
+ ALIASES,
249
+ resolveProfileName,
250
+ getActiveProfile,
251
+ setActiveProfile,
252
+ setBudgetOverrides,
253
+ getProfileOverrides,
254
+ };
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * quality-gate.mjs — Config-driven quality gate for the dual-brain orchestrator.
4
+ *
5
+ * Usage: node .claude/hooks/quality-gate.mjs
6
+ * Output: Always valid JSON to stdout, always exits 0.
7
+ *
8
+ * Logic:
9
+ * 1. Read orchestrator.json → quality_gate config
10
+ * 2. If disabled, output { "gate": "disabled" } and exit
11
+ * 3. Get changed files via `git diff --name-only HEAD` + `git ls-files --others --exclude-standard`
12
+ * 4. Filter by trigger_extensions, exclude skip_patterns
13
+ * 5. If no qualifying files → { "gate": "pass", "reason": "no qualifying code changes" }
14
+ * 6. Otherwise run dual-brain-review.mjs, save result to .claude/reviews/<timestamp>.json
15
+ * 7. Output { "gate": "reviewed", "files": [...], "issues_found": bool, "review_path": "..." }
16
+ */
17
+
18
+ import { createHash } from 'crypto';
19
+ import { spawnSync } from 'child_process';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
21
+ import { dirname, extname, join, resolve } from 'path';
22
+ import { fileURLToPath } from 'url';
23
+
24
+ import { getProfileOverrides as _getProfileOverrides } from './profiles.mjs';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const ORCHESTRATOR_CONFIG = resolve(__dirname, '..', 'orchestrator.json');
28
+ const REVIEWS_DIR = resolve(__dirname, '..', 'reviews');
29
+ const DUAL_BRAIN = resolve(__dirname, 'dual-brain-review.mjs');
30
+
31
+ const RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
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
+
67
+ function loadProfileGateSettings() {
68
+ try {
69
+ return _getProfileOverrides('quality-gate');
70
+ } catch {
71
+ return { sensitivity_floor: 'medium', dual_brain_minimum: 'high' };
72
+ }
73
+ }
74
+
75
+ function riskMeetsFloor(risk, floor) {
76
+ return RISK_LEVELS.indexOf(risk) >= RISK_LEVELS.indexOf(floor);
77
+ }
78
+
79
+ function exit(obj) {
80
+ process.stdout.write(JSON.stringify(obj) + '\n');
81
+ process.exit(0);
82
+ }
83
+
84
+ function runGit(args) {
85
+ try {
86
+ const proc = spawnSync('git', args, {
87
+ encoding: 'utf8',
88
+ stdio: ['pipe', 'pipe', 'pipe'],
89
+ timeout: 10_000,
90
+ });
91
+ return proc.status === 0 ? proc.stdout : '';
92
+ } catch {
93
+ return '';
94
+ }
95
+ }
96
+
97
+ function scoreSensitivity(files, config) {
98
+ const sensitivePaths = config?.dual_thinking?.sensitive_paths || [
99
+ 'auth', 'security', 'middleware/auth', 'payment', 'billing',
100
+ 'migration', 'schema', 'permissions', 'secrets', 'crypto',
101
+ 'api/public', '.env'
102
+ ];
103
+
104
+ let score = 0;
105
+ const reasons = [];
106
+
107
+ for (const file of files) {
108
+ const lower = file.toLowerCase();
109
+
110
+ // Check sensitive paths
111
+ for (const sp of sensitivePaths) {
112
+ if (lower.includes(sp)) {
113
+ score += 30;
114
+ reasons.push(`sensitive path: ${sp} in ${file}`);
115
+ break;
116
+ }
117
+ }
118
+
119
+ // Database/migration files
120
+ if (/migrat|schema|\.sql/i.test(lower)) {
121
+ score += 25;
122
+ reasons.push(`database change: ${file}`);
123
+ }
124
+
125
+ // Config/env files
126
+ if (/\.env|config.*\.(ts|js|json)|docker|ci|\.yml|\.yaml/i.test(lower)) {
127
+ score += 15;
128
+ reasons.push(`config/infra change: ${file}`);
129
+ }
130
+
131
+ // Dependency changes
132
+ if (/package\.json|requirements\.txt|go\.mod|Cargo\.toml/i.test(lower)) {
133
+ score += 20;
134
+ reasons.push(`dependency change: ${file}`);
135
+ }
136
+ }
137
+
138
+ // Scale by number of files
139
+ if (files.length > 10) {
140
+ score += 15;
141
+ reasons.push(`large changeset: ${files.length} files`);
142
+ }
143
+
144
+ // Determine risk level
145
+ let risk, gate;
146
+ if (score >= 50) {
147
+ risk = 'critical';
148
+ gate = 'dual-brain-required';
149
+ } else if (score >= 30) {
150
+ risk = 'high';
151
+ gate = 'dual-brain-recommended';
152
+ } else if (score >= 10) {
153
+ risk = 'medium';
154
+ gate = 'single-review';
155
+ } else {
156
+ risk = 'low';
157
+ gate = 'self-check';
158
+ }
159
+
160
+ return { score, risk, gate, reasons };
161
+ }
162
+
163
+ function matchesSkipPattern(filePath, patterns) {
164
+ const segments = filePath.split('/');
165
+ const basename = segments[segments.length - 1];
166
+ const isTestFile = /\.(test|spec)\.(js|ts|tsx|jsx|mjs)$/.test(basename);
167
+ const isTestDirectory = segments.some(seg => seg === '__tests__' || seg === '__mocks__');
168
+
169
+ if (isTestFile || isTestDirectory) {
170
+ return true;
171
+ }
172
+
173
+ return patterns.some(p => {
174
+ if (p.startsWith('.')) return basename.endsWith(p); // extension match
175
+ return segments.some(seg => seg === p || seg.startsWith(p + '.')); // exact segment match
176
+ });
177
+ }
178
+
179
+ function getChangedFiles() {
180
+ const tracked = runGit(['diff', '--name-only', 'HEAD']) || '';
181
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard']) || '';
182
+ const all = [...new Set([
183
+ ...tracked.split('\n').filter(Boolean),
184
+ ...untracked.split('\n').filter(Boolean),
185
+ ])];
186
+ return all;
187
+ }
188
+
189
+ function main() {
190
+ // 1. Load config
191
+ let config;
192
+ try {
193
+ config = JSON.parse(readFileSync(ORCHESTRATOR_CONFIG, 'utf8'));
194
+ } catch {
195
+ exit({ gate: 'pass', reason: 'orchestrator.json not found or invalid' });
196
+ }
197
+
198
+ const gate = config?.quality_gate ?? {};
199
+
200
+ // 2. Check enabled flag
201
+ if (gate.enabled === false) {
202
+ exit({ gate: 'disabled' });
203
+ }
204
+
205
+ const triggerExtensions = gate.trigger_extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.py'];
206
+ const skipPatterns = gate.skip_patterns ?? ['test', '__tests__', 'spec', '.md'];
207
+
208
+ // 3. Get changed files (tracked diffs + untracked new files)
209
+ const allFiles = getChangedFiles();
210
+
211
+ // 4. Filter files
212
+ const qualifyingFiles = allFiles.filter(f => {
213
+ const ext = extname(f);
214
+ if (!triggerExtensions.includes(ext)) return false;
215
+ if (matchesSkipPattern(f, skipPatterns)) return false;
216
+ return true;
217
+ });
218
+
219
+ // 5. No qualifying files
220
+ if (qualifyingFiles.length === 0) {
221
+ exit({ gate: 'pass', reason: 'no qualifying code changes' });
222
+ }
223
+
224
+ // 5a. Score sensitivity BEFORE running any external review
225
+ const sensitivity = scoreSensitivity(qualifyingFiles, config);
226
+
227
+ // 5b. Apply profile-driven sensitivity floor
228
+ const profileGate = loadProfileGateSettings();
229
+ if (!riskMeetsFloor(sensitivity.risk, profileGate.sensitivity_floor)) {
230
+ exit({
231
+ gate: 'pass',
232
+ risk: sensitivity.risk,
233
+ sensitivity_score: sensitivity.score,
234
+ sensitivity_reasons: sensitivity.reasons,
235
+ reason: `${sensitivity.risk} risk — below profile floor (${profileGate.sensitivity_floor})`,
236
+ profile_floor: profileGate.sensitivity_floor,
237
+ files: qualifyingFiles,
238
+ ...computeApproval(sensitivity.risk, profileGate),
239
+ });
240
+ }
241
+
242
+ // 6. Run dual-brain review (medium / high / critical)
243
+ let reviewResult = {};
244
+ try {
245
+ const proc = spawnSync(process.execPath, [DUAL_BRAIN], {
246
+ encoding: 'utf8',
247
+ stdio: ['pipe', 'pipe', 'pipe'],
248
+ timeout: 120_000,
249
+ });
250
+ const stdout = (proc.stdout || '').trim();
251
+ if (stdout) {
252
+ reviewResult = JSON.parse(stdout);
253
+ } else {
254
+ reviewResult = {
255
+ review: 'dual-brain-review produced no output',
256
+ error: true,
257
+ };
258
+ }
259
+ } catch (err) {
260
+ reviewResult = {
261
+ review: `Failed to run dual-brain-review: ${err?.message ?? String(err)}`,
262
+ error: true,
263
+ };
264
+ }
265
+
266
+ // Compute diff hash
267
+ const diff = runGit(['diff', 'HEAD']);
268
+ const diffHash = createHash('sha256').update(diff).digest('hex').slice(0, 8);
269
+
270
+ // Build review record (includes sensitivity info)
271
+ const timestamp = new Date().toISOString();
272
+ const record = {
273
+ timestamp,
274
+ files_changed: qualifyingFiles,
275
+ diff_hash: diffHash,
276
+ risk: sensitivity.risk,
277
+ sensitivity_score: sensitivity.score,
278
+ sensitivity_reasons: sensitivity.reasons,
279
+ model: reviewResult.model ?? 'unknown',
280
+ review: reviewResult.review ?? '',
281
+ issues_found: reviewResult.issues_found ?? false,
282
+ };
283
+
284
+ // 7. Save to .claude/reviews/<timestamp>.json
285
+ mkdirSync(REVIEWS_DIR, { recursive: true });
286
+ const safeTs = timestamp.replace(/[:.]/g, '-');
287
+ const reviewFile = join(REVIEWS_DIR, `${safeTs}.json`);
288
+ try {
289
+ writeFileSync(reviewFile, JSON.stringify(record, null, 2) + '\n', 'utf8');
290
+ } catch {
291
+ // Non-fatal: still output summary
292
+ }
293
+
294
+ // 8. Determine gate status from review result + sensitivity tier
295
+ const reviewUnavailable =
296
+ reviewResult.skip_reason === 'no_gpt_auth' ||
297
+ reviewResult.error === true ||
298
+ !reviewResult.review;
299
+
300
+ // Profile can lower the dual-brain threshold
301
+ const needsDualBrain = riskMeetsFloor(sensitivity.risk, profileGate.dual_brain_minimum);
302
+
303
+ let gateStatus;
304
+ if (sensitivity.gate === 'dual-brain-required' || (needsDualBrain && sensitivity.risk === 'critical')) {
305
+ gateStatus = 'needs_dual_think';
306
+ } else if (reviewUnavailable) {
307
+ gateStatus = 'needs_human_review';
308
+ } else if (reviewResult.issues_found) {
309
+ gateStatus = 'issues_found';
310
+ } else if (needsDualBrain) {
311
+ gateStatus = 'reviewed';
312
+ } else {
313
+ gateStatus = sensitivity.gate === 'dual-brain-recommended' ? 'reviewed' : 'pass';
314
+ }
315
+
316
+ // 9. Build output object — common fields first
317
+ const approval = computeApproval(sensitivity.risk, profileGate);
318
+ const output = {
319
+ gate: gateStatus,
320
+ risk: sensitivity.risk,
321
+ sensitivity_score: sensitivity.score,
322
+ sensitivity_reasons: sensitivity.reasons,
323
+ files: qualifyingFiles,
324
+ issues_found: Boolean(reviewResult.issues_found),
325
+ review_unavailable: reviewUnavailable,
326
+ review_path: reviewFile,
327
+ model: reviewResult.model || null,
328
+ auth_type: reviewResult.auth_type || null,
329
+ approval_recommendation: approval.approval_recommendation,
330
+ approval_message: approval.approval_message,
331
+ };
332
+
333
+ // High risk: recommend dual-brain-think in addition
334
+ if (sensitivity.gate === 'dual-brain-recommended') {
335
+ output.dual_thinking_recommended = true;
336
+ }
337
+
338
+ // Critical risk: add strong warning
339
+ if (sensitivity.gate === 'dual-brain-required') {
340
+ output.warning =
341
+ 'Critical sensitivity detected. Dual-brain review + explicit user approval strongly recommended before merging.';
342
+ output.reasons = sensitivity.reasons;
343
+ }
344
+
345
+ exit(output);
346
+ }
347
+
348
+ try {
349
+ main();
350
+ } catch (err) {
351
+ process.stdout.write(
352
+ JSON.stringify({ gate: 'error', error: err?.message ?? String(err) }) + '\n'
353
+ );
354
+ process.exit(0);
355
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * risk-classifier.mjs — File-path risk classification for adaptive routing.
4
+ *
5
+ * Export: classifyRisk(paths) → { level, reason }
6
+ */
7
+
8
+ const PATTERNS = [
9
+ { level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
10
+ { level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
11
+ { level: 'medium', regex: /\b(test|spec|\.test\.|\.spec\.|shared|util[s]?|lib\/|public[-_]?api|integrat|config|\.config\.)\b/i, label: 'shared/tested code' },
12
+ { level: 'low', regex: /\b(readme|\.md$|docs?\/|comment|format|lint|\.prettierrc|local[-_]?script|internal[-_]?only|changelog)\b/i, label: 'docs/formatting' },
13
+ ];
14
+
15
+ const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
16
+
17
+ function classifyRisk(paths) {
18
+ if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
19
+
20
+ let highest = { level: 'low', reason: 'no matching risk patterns' };
21
+
22
+ for (const p of paths) {
23
+ for (const pattern of PATTERNS) {
24
+ if (pattern.regex.test(p) && LEVEL_ORDER[pattern.level] > LEVEL_ORDER[highest.level]) {
25
+ highest = { level: pattern.level, reason: `${pattern.label}: ${p}` };
26
+ if (pattern.level === 'critical') return highest;
27
+ }
28
+ }
29
+ }
30
+
31
+ return highest;
32
+ }
33
+
34
+ function extractPaths(text) {
35
+ if (!text) return [];
36
+ const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
37
+ if (!matches) return [];
38
+ return matches.map(m => m.trim().replace(/^["'`]/, ''));
39
+ }
40
+
41
+ export { classifyRisk, extractPaths };