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.
- package/CLAUDE.md +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
|
@@ -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
|
+
}
|