dual-brain 4.6.0 → 4.7.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 +35 -130
- package/README.md +34 -179
- package/hooks/control-panel.mjs +379 -8
- package/hooks/cost-logger.mjs +11 -53
- package/hooks/cost-report.mjs +126 -65
- package/hooks/decision-ledger.mjs +3 -53
- package/hooks/dual-brain-review.mjs +25 -261
- package/hooks/dual-brain-think.mjs +37 -300
- package/hooks/enforce-tier.mjs +93 -265
- package/hooks/failure-detector.mjs +1 -3
- package/hooks/gpt-work-dispatcher.mjs +153 -12
- package/hooks/health-check.mjs +25 -17
- package/hooks/quality-gate.mjs +11 -6
- package/hooks/risk-classifier.mjs +2 -135
- package/hooks/session-report.mjs +71 -41
- package/hooks/summary-checkpoint.mjs +8 -35
- package/hooks/test-orchestrator.mjs +31 -2080
- package/install.mjs +616 -1564
- package/orchestrator.json +96 -73
- package/package.json +2 -7
- package/hooks/agent-chains.mjs +0 -369
- package/hooks/agent-templates.mjs +0 -441
- package/hooks/atomic-write.mjs +0 -109
- package/hooks/config-validator.mjs +0 -156
- package/hooks/confirmation-policy.mjs +0 -167
- package/hooks/error-channel.mjs +0 -68
- package/hooks/ship-captain.mjs +0 -1176
- package/hooks/ship-gate.mjs +0 -971
|
@@ -9,20 +9,34 @@
|
|
|
9
9
|
* Usage as CLI:
|
|
10
10
|
* node .claude/hooks/gpt-work-dispatcher.mjs \
|
|
11
11
|
* --task "Add tests for budget-balancer.mjs" \
|
|
12
|
-
* --
|
|
12
|
+
* --tier execute \
|
|
13
13
|
* --files hooks/budget-balancer.mjs
|
|
14
14
|
*
|
|
15
15
|
* Usage as module:
|
|
16
16
|
* import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
|
|
17
|
-
* const result = await dispatchGptTask({ task, model, files, constraints, timeoutMs });
|
|
17
|
+
* const result = await dispatchGptTask({ task, model, tier, forceModel, files, constraints, timeoutMs });
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { spawnSync } from 'child_process';
|
|
21
21
|
import { appendFileSync, readFileSync } from 'fs';
|
|
22
22
|
import { dirname, join } from 'path';
|
|
23
23
|
import { fileURLToPath } from 'url';
|
|
24
24
|
|
|
25
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
27
|
+
const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
|
|
28
|
+
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
|
|
29
|
+
const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
|
|
30
|
+
const GPT_TIER_SANDBOX = {
|
|
31
|
+
search: 'read-only',
|
|
32
|
+
execute: 'danger-full-access',
|
|
33
|
+
think: 'read-only',
|
|
34
|
+
};
|
|
35
|
+
const GPT_TIER_PROMPTS = {
|
|
36
|
+
search: 'You are a READ-ONLY search agent. Do NOT edit files.',
|
|
37
|
+
execute: 'You are an execution agent. Edit files directly.',
|
|
38
|
+
think: 'You are an architecture/review agent. Analyze and recommend, do not edit unless explicitly asked.',
|
|
39
|
+
};
|
|
26
40
|
|
|
27
41
|
// ---------------------------------------------------------------------------
|
|
28
42
|
// Codex discovery — mirrors dual-brain-review.mjs
|
|
@@ -51,16 +65,66 @@ function findCodex() {
|
|
|
51
65
|
return null;
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
function isCodexAuthenticated(result) {
|
|
69
|
+
const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
|
|
70
|
+
if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
|
|
71
|
+
return result?.status === 0 ||
|
|
72
|
+
/\b(logged\s+in|authenticated|signed\s+in)\b/.test(out);
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
// ---------------------------------------------------------------------------
|
|
55
76
|
// Prompt builder
|
|
56
77
|
// ---------------------------------------------------------------------------
|
|
57
78
|
|
|
79
|
+
function normalizeTier(tier) {
|
|
80
|
+
return ['search', 'execute', 'think'].includes(tier) ? tier : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadOrchestratorConfig() {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function classifyGptTier(task) {
|
|
92
|
+
const text = [
|
|
93
|
+
task?.task,
|
|
94
|
+
...(Array.isArray(task?.constraints) ? task.constraints : []),
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' ');
|
|
98
|
+
|
|
99
|
+
if (THINK_WORDS.test(text)) return 'think';
|
|
100
|
+
if (EXECUTE_WORDS.test(text)) return 'execute';
|
|
101
|
+
if (SEARCH_WORDS.test(text)) return 'search';
|
|
102
|
+
return 'execute';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resolveGptModel(tier, config = loadOrchestratorConfig()) {
|
|
106
|
+
const normalizedTier = normalizeTier(tier);
|
|
107
|
+
if (!normalizedTier) return null;
|
|
108
|
+
|
|
109
|
+
const models = config?.subscriptions?.openai?.models ?? {};
|
|
110
|
+
for (const [model, meta] of Object.entries(models)) {
|
|
111
|
+
if (meta?.tier === normalizedTier) return model;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (normalizedTier === 'think') return 'gpt-5.5';
|
|
115
|
+
if (normalizedTier === 'search') return 'gpt-4.1-mini';
|
|
116
|
+
return 'gpt-5.4';
|
|
117
|
+
}
|
|
118
|
+
|
|
58
119
|
function buildPrompt(task) {
|
|
120
|
+
const tierInstruction = GPT_TIER_PROMPTS[task.tier] || GPT_TIER_PROMPTS.execute;
|
|
59
121
|
let prompt = `You are a GPT execution agent inside the Dual-Brain Orchestrator.
|
|
60
122
|
|
|
61
123
|
Task: ${task.task}
|
|
62
124
|
|
|
63
|
-
|
|
125
|
+
${tierInstruction}
|
|
126
|
+
|
|
127
|
+
Own this task completely.
|
|
64
128
|
|
|
65
129
|
`;
|
|
66
130
|
if (task.files?.length) {
|
|
@@ -81,13 +145,13 @@ Own this task completely. Edit files directly.
|
|
|
81
145
|
// Codex executor
|
|
82
146
|
// ---------------------------------------------------------------------------
|
|
83
147
|
|
|
84
|
-
function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
|
|
148
|
+
function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
|
|
85
149
|
const startTime = Date.now();
|
|
86
150
|
|
|
87
151
|
const proc = spawnSync(codexBin, [
|
|
88
152
|
'exec', '--json', '--ephemeral',
|
|
89
153
|
'-m', model,
|
|
90
|
-
'-s',
|
|
154
|
+
'-s', sandbox,
|
|
91
155
|
prompt,
|
|
92
156
|
], {
|
|
93
157
|
encoding: 'utf8',
|
|
@@ -154,12 +218,14 @@ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() ||
|
|
|
154
218
|
function logUsageEvent(result, task) {
|
|
155
219
|
const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
156
220
|
const entryObj = {
|
|
157
|
-
schema_version:
|
|
221
|
+
schema_version: 4,
|
|
158
222
|
timestamp: new Date().toISOString(),
|
|
159
223
|
provider: 'openai',
|
|
160
224
|
tier: task.tier || 'execute',
|
|
225
|
+
classified_tier: task.classifiedTier || task.tier || 'execute',
|
|
161
226
|
tool: 'codex-exec',
|
|
162
227
|
model: result.model,
|
|
228
|
+
model_override: task.modelOverride || null,
|
|
163
229
|
status: result.success ? 'ok' : 'error',
|
|
164
230
|
durationMs: result.durationMs,
|
|
165
231
|
codex_startup_ms: result.startupMs || null,
|
|
@@ -202,6 +268,18 @@ function logUsageEvent(result, task) {
|
|
|
202
268
|
// Main exported function
|
|
203
269
|
// ---------------------------------------------------------------------------
|
|
204
270
|
|
|
271
|
+
function tryHealCodexAuth(codexBin) {
|
|
272
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
273
|
+
if (!apiKey) return false;
|
|
274
|
+
const pipe = spawnSync(codexBin, ['login', '--with-api-key'], {
|
|
275
|
+
input: apiKey,
|
|
276
|
+
encoding: 'utf8',
|
|
277
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
278
|
+
timeout: 10000,
|
|
279
|
+
});
|
|
280
|
+
return pipe.status === 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
205
283
|
export async function dispatchGptTask(task) {
|
|
206
284
|
const codexBin = findCodex();
|
|
207
285
|
if (!codexBin) {
|
|
@@ -211,11 +289,70 @@ export async function dispatchGptTask(task) {
|
|
|
211
289
|
};
|
|
212
290
|
}
|
|
213
291
|
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
292
|
+
// Pre-flight: check auth and heal if possible
|
|
293
|
+
const loginCheck = spawnSync(codexBin, ['login', 'status'], {
|
|
294
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
|
|
295
|
+
});
|
|
296
|
+
const isAuthed = isCodexAuthenticated(loginCheck);
|
|
297
|
+
if (!isAuthed) {
|
|
298
|
+
const healed = tryHealCodexAuth(codexBin);
|
|
299
|
+
if (!healed) {
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
error: 'Codex not authenticated. Run: npx dual-brain (sign in with your ChatGPT subscription) or codex login --device-auth',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const config = loadOrchestratorConfig();
|
|
308
|
+
const classifiedTier = classifyGptTier(task);
|
|
309
|
+
const explicitTier = normalizeTier(task.tier);
|
|
310
|
+
const tier = explicitTier || classifiedTier;
|
|
311
|
+
const expectedModel = resolveGptModel(tier, config) || 'gpt-5.4';
|
|
312
|
+
|
|
313
|
+
let model = task.model || expectedModel;
|
|
314
|
+
let modelOverride = null;
|
|
315
|
+
|
|
316
|
+
if (task.model && !task.forceModel && task.model !== expectedModel) {
|
|
317
|
+
console.warn(`[gpt-work-dispatcher] Warning: task classified as "${tier}", overriding requested model "${task.model}" with "${expectedModel}". Use --force-model to bypass.`);
|
|
318
|
+
model = expectedModel;
|
|
319
|
+
modelOverride = {
|
|
320
|
+
requested: task.model,
|
|
321
|
+
effective: expectedModel,
|
|
322
|
+
forced: false,
|
|
323
|
+
reason: `tier:${tier}`,
|
|
324
|
+
};
|
|
325
|
+
} else if (!task.model) {
|
|
326
|
+
modelOverride = {
|
|
327
|
+
requested: null,
|
|
328
|
+
effective: expectedModel,
|
|
329
|
+
forced: false,
|
|
330
|
+
reason: `auto-select:${tier}`,
|
|
331
|
+
};
|
|
332
|
+
} else if (task.forceModel) {
|
|
333
|
+
modelOverride = {
|
|
334
|
+
requested: task.model,
|
|
335
|
+
effective: task.model,
|
|
336
|
+
forced: true,
|
|
337
|
+
reason: `force-model:${tier}`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const preparedTask = {
|
|
342
|
+
...task,
|
|
343
|
+
tier,
|
|
344
|
+
classifiedTier,
|
|
345
|
+
modelOverride,
|
|
346
|
+
};
|
|
347
|
+
const prompt = buildPrompt(preparedTask);
|
|
348
|
+
const sandbox = GPT_TIER_SANDBOX[tier] || GPT_TIER_SANDBOX.execute;
|
|
349
|
+
const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs, sandbox);
|
|
350
|
+
result.tier = tier;
|
|
351
|
+
result.classifiedTier = classifiedTier;
|
|
352
|
+
result.modelOverride = modelOverride;
|
|
353
|
+
result.sandbox = sandbox;
|
|
217
354
|
result.profile = loadActiveProfile();
|
|
218
|
-
logUsageEvent(result,
|
|
355
|
+
logUsageEvent(result, preparedTask);
|
|
219
356
|
return result;
|
|
220
357
|
}
|
|
221
358
|
|
|
@@ -261,6 +398,10 @@ function parseArgs(argv) {
|
|
|
261
398
|
args.timeoutMs = Number(args.timeout) * 1000;
|
|
262
399
|
delete args.timeout;
|
|
263
400
|
}
|
|
401
|
+
if (typeof args['force-model'] === 'boolean') {
|
|
402
|
+
args.forceModel = args['force-model'];
|
|
403
|
+
delete args['force-model'];
|
|
404
|
+
}
|
|
264
405
|
|
|
265
406
|
return args;
|
|
266
407
|
}
|
|
@@ -273,7 +414,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
273
414
|
const rawArgs = parseArgs(process.argv.slice(2));
|
|
274
415
|
|
|
275
416
|
if (!rawArgs.task) {
|
|
276
|
-
console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--model
|
|
417
|
+
console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--tier think|execute|search] [--model MODEL] [--force-model] [--files file1,file2] [--timeout 120]');
|
|
277
418
|
process.exit(1);
|
|
278
419
|
}
|
|
279
420
|
|
package/hooks/health-check.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Checks:
|
|
12
12
|
* 1. orchestrator.json — exists and parses as valid JSON
|
|
13
13
|
* 2. pricing_verified — exists, warn if >30 days, fail if >90 days
|
|
14
|
-
* 3. model_intelligence —
|
|
14
|
+
* 3. model_intelligence — exists and covers all subscription models
|
|
15
15
|
* 4. hook scripts — enforce-tier, cost-logger, quality-gate, dual-brain-review readable
|
|
16
16
|
* 5. usage.jsonl active — recent entries (last 15 min) indicate PostToolUse hook is wired
|
|
17
17
|
* 6. codex CLI — found on PATH or known locations; auth status checked
|
|
@@ -42,6 +42,13 @@ function check(name, status, detail) {
|
|
|
42
42
|
return { name, status, detail };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function isCodexAuthenticated(result) {
|
|
46
|
+
const output = ((result?.stdout || "") + (result?.stderr || "")).toLowerCase();
|
|
47
|
+
if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(output)) return false;
|
|
48
|
+
return result?.status === 0 ||
|
|
49
|
+
/\b(logged\s+in|authenticated|signed\s+in)\b/.test(output);
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
// ---------------------------------------------------------------------------
|
|
46
53
|
// Check implementations
|
|
47
54
|
// ---------------------------------------------------------------------------
|
|
@@ -94,7 +101,7 @@ function checkPricingVerified() {
|
|
|
94
101
|
return check("pricing_verified", STATUS.pass, `${ageDays} days ago`);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
/** 3. model_intelligence —
|
|
104
|
+
/** 3. model_intelligence — exists and has entries for at least the subscription models */
|
|
98
105
|
function checkModelIntelligence() {
|
|
99
106
|
let config;
|
|
100
107
|
try {
|
|
@@ -103,30 +110,31 @@ function checkModelIntelligence() {
|
|
|
103
110
|
return check("model_intelligence", STATUS.fail, "cannot read config");
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
for (const [providerName, provider] of Object.entries(config.subscriptions || {})) {
|
|
110
|
-
for (const [modelName, meta] of Object.entries(provider.models || {})) {
|
|
111
|
-
entryCount++;
|
|
112
|
-
if (!meta.best_for && !meta.model_id) {
|
|
113
|
-
missing.push(modelName);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
113
|
+
const mi = config.model_intelligence;
|
|
114
|
+
if (!mi || typeof mi !== "object") {
|
|
115
|
+
return check("model_intelligence", STATUS.fail, "key missing from config");
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// Collect model keys from subscriptions
|
|
119
|
+
const subscriptionModels = new Set();
|
|
120
|
+
for (const provider of Object.values(config.subscriptions || {})) {
|
|
121
|
+
for (const key of Object.keys(provider.models || {})) {
|
|
122
|
+
subscriptionModels.add(key);
|
|
123
|
+
}
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
const miKeys = Object.keys(mi);
|
|
127
|
+
const missing = [...subscriptionModels].filter((m) => !mi[m]);
|
|
128
|
+
const entryCount = miKeys.length;
|
|
129
|
+
|
|
122
130
|
if (missing.length > 0) {
|
|
123
131
|
return check(
|
|
124
132
|
"model_intelligence",
|
|
125
133
|
STATUS.warn,
|
|
126
|
-
`${entryCount} models, missing
|
|
134
|
+
`${entryCount} models, missing: ${missing.join(", ")}`
|
|
127
135
|
);
|
|
128
136
|
}
|
|
129
|
-
return check("model_intelligence", STATUS.pass, `${entryCount} models
|
|
137
|
+
return check("model_intelligence", STATUS.pass, `${entryCount} models`);
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/** 4. Hook scripts readable */
|
|
@@ -255,7 +263,7 @@ function checkCodexCli() {
|
|
|
255
263
|
|
|
256
264
|
const output = (loginResult.stdout + loginResult.stderr).toLowerCase();
|
|
257
265
|
|
|
258
|
-
if (loginResult
|
|
266
|
+
if (isCodexAuthenticated(loginResult)) {
|
|
259
267
|
return check("codex CLI", STATUS.pass, "authenticated");
|
|
260
268
|
}
|
|
261
269
|
|
package/hooks/quality-gate.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { createHash } from 'crypto';
|
|
19
|
-
import {
|
|
19
|
+
import { spawnSync } from 'child_process';
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
21
21
|
import { dirname, extname, join, resolve } from 'path';
|
|
22
22
|
import { fileURLToPath } from 'url';
|
|
@@ -81,9 +81,14 @@ function exit(obj) {
|
|
|
81
81
|
process.exit(0);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function runGit(
|
|
84
|
+
function runGit(args) {
|
|
85
85
|
try {
|
|
86
|
-
|
|
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 : '';
|
|
87
92
|
} catch {
|
|
88
93
|
return '';
|
|
89
94
|
}
|
|
@@ -165,8 +170,8 @@ function matchesSkipPattern(filePath, patterns) {
|
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
function getChangedFiles() {
|
|
168
|
-
const tracked = runGit('
|
|
169
|
-
const untracked = runGit('
|
|
173
|
+
const tracked = runGit(['diff', '--name-only', 'HEAD']) || '';
|
|
174
|
+
const untracked = runGit(['ls-files', '--others', '--exclude-standard']) || '';
|
|
170
175
|
const all = [...new Set([
|
|
171
176
|
...tracked.split('\n').filter(Boolean),
|
|
172
177
|
...untracked.split('\n').filter(Boolean),
|
|
@@ -252,7 +257,7 @@ function main() {
|
|
|
252
257
|
}
|
|
253
258
|
|
|
254
259
|
// Compute diff hash
|
|
255
|
-
const diff = runGit('
|
|
260
|
+
const diff = runGit(['diff', 'HEAD']);
|
|
256
261
|
const diffHash = createHash('sha256').update(diff).digest('hex').slice(0, 8);
|
|
257
262
|
|
|
258
263
|
// Build review record (includes sensitivity info)
|
|
@@ -2,22 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* risk-classifier.mjs — File-path risk classification for adaptive routing.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* classifyRisk(paths) → { level, reason } (static, backward-compat)
|
|
7
|
-
* classifyRiskEnhanced(filePath) → { risk, basis, details } (empirical, v4.3.0+)
|
|
8
|
-
* getGitChurn(filePath, days?) → { commits, isHot } | null
|
|
9
|
-
* getFileRiskHistory(filePath) → { total, failures, success_rate, risk_adjustment }
|
|
10
|
-
* extractPaths(text) → string[]
|
|
5
|
+
* Export: classifyRisk(paths) → { level, reason }
|
|
11
6
|
*/
|
|
12
7
|
|
|
13
|
-
import { execSync } from 'child_process';
|
|
14
|
-
import { existsSync, readFileSync } from 'fs';
|
|
15
|
-
import { dirname, join } from 'path';
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
17
|
-
|
|
18
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
|
|
20
|
-
|
|
21
8
|
const PATTERNS = [
|
|
22
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' },
|
|
23
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' },
|
|
@@ -26,7 +13,6 @@ const PATTERNS = [
|
|
|
26
13
|
];
|
|
27
14
|
|
|
28
15
|
const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
29
|
-
const LEVEL_UP = { low: 'medium', medium: 'high', high: 'critical', critical: 'critical' };
|
|
30
16
|
|
|
31
17
|
function classifyRisk(paths) {
|
|
32
18
|
if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
|
|
@@ -45,125 +31,6 @@ function classifyRisk(paths) {
|
|
|
45
31
|
return highest;
|
|
46
32
|
}
|
|
47
33
|
|
|
48
|
-
/**
|
|
49
|
-
* Count how many commits touched a file in the last N days using git log.
|
|
50
|
-
* Returns { commits, isHot: commits > 10 }, or null if git is unavailable.
|
|
51
|
-
*/
|
|
52
|
-
function getGitChurn(filePath, days = 30) {
|
|
53
|
-
try {
|
|
54
|
-
const output = execSync(
|
|
55
|
-
`git log --oneline --since="${days} days ago" -- "${filePath}"`,
|
|
56
|
-
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
57
|
-
);
|
|
58
|
-
const commits = output.split('\n').filter(Boolean).length;
|
|
59
|
-
return { commits, isHot: commits > 10 };
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Read decision-ledger.jsonl and compute success rate for entries that
|
|
67
|
-
* touched this file path or its parent directory.
|
|
68
|
-
*
|
|
69
|
-
* Returns { total, failures, success_rate, risk_adjustment } where
|
|
70
|
-
* risk_adjustment is 'escalate' if success_rate < 60% with 3+ entries,
|
|
71
|
-
* 'normal' otherwise.
|
|
72
|
-
*/
|
|
73
|
-
function getFileRiskHistory(filePath) {
|
|
74
|
-
const empty = { total: 0, failures: 0, success_rate: 100, risk_adjustment: 'normal' };
|
|
75
|
-
if (!existsSync(LEDGER_FILE)) return empty;
|
|
76
|
-
|
|
77
|
-
let raw;
|
|
78
|
-
try { raw = readFileSync(LEDGER_FILE, 'utf8'); } catch { return empty; }
|
|
79
|
-
|
|
80
|
-
// Normalize the file path and compute its parent directory prefix
|
|
81
|
-
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
82
|
-
const parentDir = normalizedPath.includes('/') ? normalizedPath.slice(0, normalizedPath.lastIndexOf('/')) : '';
|
|
83
|
-
|
|
84
|
-
let total = 0;
|
|
85
|
-
let failures = 0;
|
|
86
|
-
|
|
87
|
-
for (const line of raw.split('\n').filter(Boolean)) {
|
|
88
|
-
try {
|
|
89
|
-
const entry = JSON.parse(line);
|
|
90
|
-
if (entry.type !== 'outcome') continue;
|
|
91
|
-
|
|
92
|
-
const files = entry.files_changed || entry.files_read || [];
|
|
93
|
-
if (!Array.isArray(files)) continue;
|
|
94
|
-
|
|
95
|
-
const matches = files.some(f => {
|
|
96
|
-
const nf = String(f).replace(/\\/g, '/');
|
|
97
|
-
return nf === normalizedPath ||
|
|
98
|
-
nf.includes(normalizedPath) ||
|
|
99
|
-
(parentDir && nf.includes(parentDir));
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (!matches) continue;
|
|
103
|
-
|
|
104
|
-
total++;
|
|
105
|
-
if (entry.success === false) failures++;
|
|
106
|
-
} catch {}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (total === 0) return empty;
|
|
110
|
-
|
|
111
|
-
const success_rate = Math.round(((total - failures) / total) * 100);
|
|
112
|
-
const risk_adjustment = (success_rate < 60 && total >= 3) ? 'escalate' : 'normal';
|
|
113
|
-
|
|
114
|
-
return { total, failures, success_rate, risk_adjustment };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Enhanced risk classifier that combines static patterns with empirical data.
|
|
119
|
-
*
|
|
120
|
-
* Returns { risk, basis, details } where:
|
|
121
|
-
* risk — 'low' | 'medium' | 'high' | 'critical'
|
|
122
|
-
* basis — 'static' | 'churn' | 'history' | 'churn+history'
|
|
123
|
-
* details — { static_risk, churn_commits, history_success_rate }
|
|
124
|
-
*/
|
|
125
|
-
function classifyRiskEnhanced(filePath) {
|
|
126
|
-
// Step 1: static pattern classification
|
|
127
|
-
const staticResult = classifyRisk([filePath]);
|
|
128
|
-
let risk = staticResult.level;
|
|
129
|
-
const details = {
|
|
130
|
-
static_risk: risk,
|
|
131
|
-
churn_commits: null,
|
|
132
|
-
history_success_rate: null,
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
let bumpedByChurn = false;
|
|
136
|
-
let bumpedByHistory = false;
|
|
137
|
-
|
|
138
|
-
// Step 2: git churn check
|
|
139
|
-
const churn = getGitChurn(filePath);
|
|
140
|
-
if (churn !== null) {
|
|
141
|
-
details.churn_commits = churn.commits;
|
|
142
|
-
if (churn.isHot && risk !== 'critical') {
|
|
143
|
-
risk = LEVEL_UP[risk];
|
|
144
|
-
bumpedByChurn = true;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Step 3: file risk history check
|
|
149
|
-
const history = getFileRiskHistory(filePath);
|
|
150
|
-
details.history_success_rate = history.success_rate;
|
|
151
|
-
if (history.risk_adjustment === 'escalate' && risk !== 'critical') {
|
|
152
|
-
risk = LEVEL_UP[risk];
|
|
153
|
-
bumpedByHistory = true;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Cap at critical
|
|
157
|
-
if (LEVEL_ORDER[risk] > LEVEL_ORDER['critical']) risk = 'critical';
|
|
158
|
-
|
|
159
|
-
let basis = 'static';
|
|
160
|
-
if (bumpedByChurn && bumpedByHistory) basis = 'churn+history';
|
|
161
|
-
else if (bumpedByChurn) basis = 'churn';
|
|
162
|
-
else if (bumpedByHistory) basis = 'history';
|
|
163
|
-
|
|
164
|
-
return { risk, basis, details };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
34
|
function extractPaths(text) {
|
|
168
35
|
if (!text) return [];
|
|
169
36
|
const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
|
|
@@ -171,4 +38,4 @@ function extractPaths(text) {
|
|
|
171
38
|
return matches.map(m => m.trim().replace(/^["'`]/, ''));
|
|
172
39
|
}
|
|
173
40
|
|
|
174
|
-
export { classifyRisk,
|
|
41
|
+
export { classifyRisk, extractPaths };
|