dual-brain 2.0.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.
@@ -0,0 +1,283 @@
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 { execSync, 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
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const ORCHESTRATOR_CONFIG = resolve(__dirname, '..', 'orchestrator.json');
26
+ const REVIEWS_DIR = resolve(__dirname, '..', 'reviews');
27
+ const DUAL_BRAIN = resolve(__dirname, 'dual-brain-review.mjs');
28
+
29
+ function exit(obj) {
30
+ process.stdout.write(JSON.stringify(obj) + '\n');
31
+ process.exit(0);
32
+ }
33
+
34
+ function runGit(cmd) {
35
+ try {
36
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ function scoreSensitivity(files, config) {
43
+ const sensitivePaths = config?.dual_thinking?.sensitive_paths || [
44
+ 'auth', 'security', 'middleware/auth', 'payment', 'billing',
45
+ 'migration', 'schema', 'permissions', 'secrets', 'crypto',
46
+ 'api/public', '.env'
47
+ ];
48
+
49
+ let score = 0;
50
+ const reasons = [];
51
+
52
+ for (const file of files) {
53
+ const lower = file.toLowerCase();
54
+
55
+ // Check sensitive paths
56
+ for (const sp of sensitivePaths) {
57
+ if (lower.includes(sp)) {
58
+ score += 30;
59
+ reasons.push(`sensitive path: ${sp} in ${file}`);
60
+ break;
61
+ }
62
+ }
63
+
64
+ // Database/migration files
65
+ if (/migrat|schema|\.sql/i.test(lower)) {
66
+ score += 25;
67
+ reasons.push(`database change: ${file}`);
68
+ }
69
+
70
+ // Config/env files
71
+ if (/\.env|config.*\.(ts|js|json)|docker|ci|\.yml|\.yaml/i.test(lower)) {
72
+ score += 15;
73
+ reasons.push(`config/infra change: ${file}`);
74
+ }
75
+
76
+ // Dependency changes
77
+ if (/package\.json|requirements\.txt|go\.mod|Cargo\.toml/i.test(lower)) {
78
+ score += 20;
79
+ reasons.push(`dependency change: ${file}`);
80
+ }
81
+ }
82
+
83
+ // Scale by number of files
84
+ if (files.length > 10) {
85
+ score += 15;
86
+ reasons.push(`large changeset: ${files.length} files`);
87
+ }
88
+
89
+ // Determine risk level
90
+ let risk, gate;
91
+ if (score >= 50) {
92
+ risk = 'critical';
93
+ gate = 'dual-brain-required';
94
+ } else if (score >= 30) {
95
+ risk = 'high';
96
+ gate = 'dual-brain-recommended';
97
+ } else if (score >= 10) {
98
+ risk = 'medium';
99
+ gate = 'single-review';
100
+ } else {
101
+ risk = 'low';
102
+ gate = 'self-check';
103
+ }
104
+
105
+ return { score, risk, gate, reasons };
106
+ }
107
+
108
+ function matchesSkipPattern(filePath, patterns) {
109
+ const segments = filePath.split('/');
110
+ const basename = segments[segments.length - 1];
111
+ return patterns.some(p => {
112
+ if (p.startsWith('.')) return basename.endsWith(p); // extension match
113
+ return segments.some(seg => seg === p || seg.startsWith(p + '.')); // exact segment match
114
+ });
115
+ }
116
+
117
+ function getChangedFiles() {
118
+ const tracked = runGit('git diff --name-only HEAD') || '';
119
+ const untracked = runGit('git ls-files --others --exclude-standard') || '';
120
+ const all = [...new Set([
121
+ ...tracked.split('\n').filter(Boolean),
122
+ ...untracked.split('\n').filter(Boolean),
123
+ ])];
124
+ return all;
125
+ }
126
+
127
+ function main() {
128
+ // 1. Load config
129
+ let config;
130
+ try {
131
+ config = JSON.parse(readFileSync(ORCHESTRATOR_CONFIG, 'utf8'));
132
+ } catch {
133
+ exit({ gate: 'pass', reason: 'orchestrator.json not found or invalid' });
134
+ }
135
+
136
+ const gate = config?.quality_gate ?? {};
137
+
138
+ // 2. Check enabled flag
139
+ if (gate.enabled === false) {
140
+ exit({ gate: 'disabled' });
141
+ }
142
+
143
+ const triggerExtensions = gate.trigger_extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.py'];
144
+ const skipPatterns = gate.skip_patterns ?? ['test', '__tests__', 'spec', '.md'];
145
+
146
+ // 3. Get changed files (tracked diffs + untracked new files)
147
+ const allFiles = getChangedFiles();
148
+
149
+ // 4. Filter files
150
+ const qualifyingFiles = allFiles.filter(f => {
151
+ const ext = extname(f);
152
+ if (!triggerExtensions.includes(ext)) return false;
153
+ if (matchesSkipPattern(f, skipPatterns)) return false;
154
+ return true;
155
+ });
156
+
157
+ // 5. No qualifying files
158
+ if (qualifyingFiles.length === 0) {
159
+ exit({ gate: 'pass', reason: 'no qualifying code changes' });
160
+ }
161
+
162
+ // 5a. Score sensitivity BEFORE running any external review
163
+ const sensitivity = scoreSensitivity(qualifyingFiles, config);
164
+
165
+ // 5b. Low risk — skip GPT review entirely
166
+ if (sensitivity.gate === 'self-check') {
167
+ exit({
168
+ gate: 'pass',
169
+ risk: 'low',
170
+ sensitivity_score: sensitivity.score,
171
+ sensitivity_reasons: sensitivity.reasons,
172
+ reason: 'low sensitivity — self-check only',
173
+ files: qualifyingFiles,
174
+ });
175
+ }
176
+
177
+ // 6. Run dual-brain review (medium / high / critical)
178
+ let reviewResult = {};
179
+ try {
180
+ const proc = spawnSync(process.execPath, [DUAL_BRAIN], {
181
+ encoding: 'utf8',
182
+ stdio: ['pipe', 'pipe', 'pipe'],
183
+ timeout: 120_000,
184
+ });
185
+ const stdout = (proc.stdout || '').trim();
186
+ if (stdout) {
187
+ reviewResult = JSON.parse(stdout);
188
+ } else {
189
+ reviewResult = {
190
+ review: 'dual-brain-review produced no output',
191
+ error: true,
192
+ };
193
+ }
194
+ } catch (err) {
195
+ reviewResult = {
196
+ review: `Failed to run dual-brain-review: ${err?.message ?? String(err)}`,
197
+ error: true,
198
+ };
199
+ }
200
+
201
+ // Compute diff hash
202
+ const diff = runGit('git diff HEAD');
203
+ const diffHash = createHash('sha256').update(diff).digest('hex').slice(0, 8);
204
+
205
+ // Build review record (includes sensitivity info)
206
+ const timestamp = new Date().toISOString();
207
+ const record = {
208
+ timestamp,
209
+ files_changed: qualifyingFiles,
210
+ diff_hash: diffHash,
211
+ risk: sensitivity.risk,
212
+ sensitivity_score: sensitivity.score,
213
+ sensitivity_reasons: sensitivity.reasons,
214
+ model: reviewResult.model ?? 'unknown',
215
+ review: reviewResult.review ?? '',
216
+ issues_found: reviewResult.issues_found ?? false,
217
+ };
218
+
219
+ // 7. Save to .claude/reviews/<timestamp>.json
220
+ mkdirSync(REVIEWS_DIR, { recursive: true });
221
+ const safeTs = timestamp.replace(/[:.]/g, '-');
222
+ const reviewFile = join(REVIEWS_DIR, `${safeTs}.json`);
223
+ try {
224
+ writeFileSync(reviewFile, JSON.stringify(record, null, 2) + '\n', 'utf8');
225
+ } catch {
226
+ // Non-fatal: still output summary
227
+ }
228
+
229
+ // 8. Determine gate status from review result + sensitivity tier
230
+ const reviewUnavailable =
231
+ reviewResult.skip_reason === 'no_gpt_auth' ||
232
+ reviewResult.error === true ||
233
+ !reviewResult.review;
234
+
235
+ let gateStatus;
236
+ if (sensitivity.gate === 'dual-brain-required') {
237
+ // Critical: always flag for dual-brain + user attention regardless of review outcome
238
+ gateStatus = 'needs_dual_think';
239
+ } else if (reviewUnavailable) {
240
+ gateStatus = 'needs_human_review';
241
+ } else if (reviewResult.issues_found) {
242
+ gateStatus = 'issues_found';
243
+ } else {
244
+ gateStatus = sensitivity.gate === 'dual-brain-recommended' ? 'reviewed' : 'pass';
245
+ }
246
+
247
+ // 9. Build output object — common fields first
248
+ const output = {
249
+ gate: gateStatus,
250
+ risk: sensitivity.risk,
251
+ sensitivity_score: sensitivity.score,
252
+ sensitivity_reasons: sensitivity.reasons,
253
+ files: qualifyingFiles,
254
+ issues_found: Boolean(reviewResult.issues_found),
255
+ review_unavailable: reviewUnavailable,
256
+ review_path: reviewFile,
257
+ model: reviewResult.model || null,
258
+ auth_type: reviewResult.auth_type || null,
259
+ };
260
+
261
+ // High risk: recommend dual-brain-think in addition
262
+ if (sensitivity.gate === 'dual-brain-recommended') {
263
+ output.dual_thinking_recommended = true;
264
+ }
265
+
266
+ // Critical risk: add strong warning
267
+ if (sensitivity.gate === 'dual-brain-required') {
268
+ output.warning =
269
+ 'Critical sensitivity detected. Dual-brain review + explicit user approval strongly recommended before merging.';
270
+ output.reasons = sensitivity.reasons;
271
+ }
272
+
273
+ exit(output);
274
+ }
275
+
276
+ try {
277
+ main();
278
+ } catch (err) {
279
+ process.stdout.write(
280
+ JSON.stringify({ gate: 'error', error: err?.message ?? String(err) }) + '\n'
281
+ );
282
+ process.exit(0);
283
+ }