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
|
@@ -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 };
|