dual-brain 3.6.0 → 3.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 +10 -1
- package/hooks/control-panel.mjs +20 -5
- package/hooks/enforce-tier.mjs +35 -6
- package/hooks/failure-detector.mjs +62 -0
- package/hooks/profiles.mjs +23 -3
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/summary-checkpoint.mjs +19 -0
- package/install.mjs +1 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -56,13 +56,22 @@ Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_re
|
|
|
56
56
|
Active profile controls routing posture, budgets, and quality gate behavior.
|
|
57
57
|
Profile persists to `.claude/dual-brain.profile.json` (gitignored).
|
|
58
58
|
|
|
59
|
-
- **
|
|
59
|
+
- **auto** (default): Adapts routing based on task risk, provider health, and outcomes. Uses file-path risk classification and failure-loop detection to auto-escalate when needed.
|
|
60
|
+
- **balanced**: Best model per tier, normal budgets, reviews at medium+ risk
|
|
60
61
|
- **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
|
|
61
62
|
- **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
|
|
62
63
|
|
|
63
64
|
Switch profiles: `npx dual-brain mode cost-saver`
|
|
64
65
|
Check status: `npx dual-brain status`
|
|
65
66
|
|
|
67
|
+
## Adaptive Routing (Auto Mode)
|
|
68
|
+
|
|
69
|
+
Auto mode classifies risk from file paths and adjusts routing in real-time:
|
|
70
|
+
|
|
71
|
+
- **Risk classification**: auth/secrets→critical, billing/migrations→high, tests/utils→medium, docs→low
|
|
72
|
+
- **Failure detection**: 2+ failures on same prompt in 2 hours → auto-escalate tier or trigger dual-brain
|
|
73
|
+
- **Provider balance**: Routes to underused provider when one subscription is hot
|
|
74
|
+
|
|
66
75
|
## Available Tools
|
|
67
76
|
|
|
68
77
|
- `node .claude/hooks/cost-report.mjs` — activity and cost estimates
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -40,12 +40,14 @@ const blue = s => e('1;38;5;33', s);
|
|
|
40
40
|
// ─── Profiles ──────────────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
const PROFILES = {
|
|
43
|
-
|
|
43
|
+
auto: { emoji: '🤖', uiLabel: 'Auto', desc: 'Adapts routing based on task risk, provider health, and outcomes' },
|
|
44
|
+
balanced: { emoji: '⚖️', uiLabel: 'Balanced', desc: 'Routes by complexity, uses both providers evenly' },
|
|
44
45
|
'cost-saver': { emoji: '🛡️', uiLabel: 'Conservative', desc: 'Fewer GPT dispatches, sticks to Claude for most work' },
|
|
45
46
|
'quality-first': { emoji: '🚀', uiLabel: 'Aggressive', desc: 'Maximizes both subscriptions, dual-brain for medium+ risk' },
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const PROFILE_BUDGETS = {
|
|
50
|
+
auto: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
49
51
|
balanced: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
|
|
50
52
|
'cost-saver': { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
|
|
51
53
|
'quality-first': { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
|
|
@@ -54,11 +56,11 @@ const PROFILE_BUDGETS = {
|
|
|
54
56
|
function loadProfile() {
|
|
55
57
|
try {
|
|
56
58
|
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
57
|
-
const name = data.active && PROFILES[data.active] ? data.active : '
|
|
59
|
+
const name = data.active && PROFILES[data.active] ? data.active : 'auto';
|
|
58
60
|
const custom = data.custom_overrides || {};
|
|
59
61
|
return { name, budgets: { ...PROFILE_BUDGETS[name], ...custom.budgets }, hasCustomBudget: !!custom.budgets };
|
|
60
62
|
} catch {
|
|
61
|
-
return { name: '
|
|
63
|
+
return { name: 'auto', budgets: PROFILE_BUDGETS.auto, hasCustomBudget: false };
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -358,7 +360,19 @@ function renderReturningMenu(providers, sessions) {
|
|
|
358
360
|
// Provider status
|
|
359
361
|
const cStat = providers.claude.authed ? '✅' : '⚠️';
|
|
360
362
|
const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
|
|
361
|
-
|
|
363
|
+
let modeStatus = pf.uiLabel;
|
|
364
|
+
if (profile.name === 'auto') {
|
|
365
|
+
if (balance.total === 0) {
|
|
366
|
+
modeStatus = 'Auto · learning your workflow';
|
|
367
|
+
} else if (balance.openai > balance.claude + 20) {
|
|
368
|
+
modeStatus = 'Auto · routing GPT for isolated work';
|
|
369
|
+
} else if (balance.claude > balance.openai + 20) {
|
|
370
|
+
modeStatus = 'Auto · Claude-primary, GPT available';
|
|
371
|
+
} else {
|
|
372
|
+
modeStatus = 'Auto · balanced routing active';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat} ${pf.emoji} ${bold(modeStatus)}`);
|
|
362
376
|
|
|
363
377
|
// Provider balance bar
|
|
364
378
|
lines.push(` ${balanceBar(balance.claude, balance.openai)}`);
|
|
@@ -415,7 +429,8 @@ function showProfilePicker(rl) {
|
|
|
415
429
|
console.log('');
|
|
416
430
|
for (const [i, [name, pf]] of Object.entries(PROFILES).entries()) {
|
|
417
431
|
const active = name === current.name ? ' ✅' : '';
|
|
418
|
-
|
|
432
|
+
const recommended = name === 'auto' && current.name !== 'auto' ? dim(' (recommended)') : '';
|
|
433
|
+
console.log(` ${bold('[' + (i + 1) + ']')} ${pf.emoji} ${pf.uiLabel.padEnd(15)} ${dim(pf.desc)}${active}${recommended}`);
|
|
419
434
|
}
|
|
420
435
|
console.log(` ${bold('[q]')} Cancel`);
|
|
421
436
|
console.log('');
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
|
|
|
3
3
|
import { createHash } from 'crypto';
|
|
4
4
|
import { dirname, resolve, join } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
7
|
+
import { checkFailureLoop } from './failure-detector.mjs';
|
|
6
8
|
|
|
7
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
|
|
@@ -12,11 +14,12 @@ const DRIFT_STATE = resolve(__dirname, '.drift-warned');
|
|
|
12
14
|
function loadProfile() {
|
|
13
15
|
try {
|
|
14
16
|
const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
|
|
15
|
-
return data.active || '
|
|
16
|
-
} catch { return '
|
|
17
|
+
return data.active || 'auto';
|
|
18
|
+
} catch { return 'auto'; }
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const PROFILE_SETTINGS = {
|
|
22
|
+
auto: { demote_think: false, promote_execute: false, bias: 0 },
|
|
20
23
|
balanced: { demote_think: false, promote_execute: false, bias: 0 },
|
|
21
24
|
'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
|
|
22
25
|
'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
|
|
@@ -231,9 +234,9 @@ try {
|
|
|
231
234
|
// Balance hint — populated after tier is fully resolved
|
|
232
235
|
let balanceHint = null;
|
|
233
236
|
|
|
234
|
-
// Helper to prepend optional warnings (duplicate + drift + balance) before a message
|
|
237
|
+
// Helper to prepend optional warnings (duplicate + drift + balance + auto) before a message
|
|
235
238
|
const prependWarnings = (msg) => {
|
|
236
|
-
const parts = [duplicateWarning, driftWarning, msg, balanceHint].filter(Boolean);
|
|
239
|
+
const parts = [duplicateWarning, driftWarning, failureMessage, msg, autoStatus, balanceHint].filter(Boolean);
|
|
237
240
|
return parts.join('\n\n');
|
|
238
241
|
};
|
|
239
242
|
|
|
@@ -277,6 +280,32 @@ try {
|
|
|
277
280
|
else tier = 'execute';
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
// Risk classification from file paths in description
|
|
284
|
+
const filePaths = extractPaths(ti.description || '');
|
|
285
|
+
const riskResult = classifyRisk(filePaths);
|
|
286
|
+
let autoStatus = null;
|
|
287
|
+
|
|
288
|
+
// Bias high/critical risk toward think tier
|
|
289
|
+
if ((riskResult.level === 'critical' || riskResult.level === 'high') && tier !== 'think') {
|
|
290
|
+
tier = 'think';
|
|
291
|
+
autoStatus = riskResult.level === 'critical'
|
|
292
|
+
? `Dual-brain: dual-brain review recommended — ${riskResult.reason.split(':')[0]} detected`
|
|
293
|
+
: `Dual-brain: promoting to think tier — ${riskResult.reason.split(':')[0]}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Failure loop detection
|
|
297
|
+
const failureCheck = checkFailureLoop(promptHash);
|
|
298
|
+
let failureMessage = null;
|
|
299
|
+
if (failureCheck.isLoop) {
|
|
300
|
+
if (failureCheck.suggestion === 'promote_tier' && tier === 'execute') {
|
|
301
|
+
tier = 'think';
|
|
302
|
+
autoStatus = 'Dual-brain: escalating to think tier — previous attempt failed';
|
|
303
|
+
} else if (failureCheck.suggestion === 'escalate_to_dual_brain') {
|
|
304
|
+
autoStatus = 'Dual-brain: dual-brain review recommended — repeated failures detected';
|
|
305
|
+
}
|
|
306
|
+
failureMessage = `**[Failure Loop]** ${failureCheck.count} failed attempts in 2hrs. Consider: \`node .claude/hooks/dual-brain-think.mjs --question "why is this failing?"\``;
|
|
307
|
+
}
|
|
308
|
+
|
|
280
309
|
// Apply profile-driven tier adjustments
|
|
281
310
|
if (profileSettings.demote_think && tier === 'think' && !THINK_WORDS.test(text)) {
|
|
282
311
|
tier = 'execute';
|
|
@@ -312,7 +341,7 @@ try {
|
|
|
312
341
|
followed: true,
|
|
313
342
|
profile: profileName,
|
|
314
343
|
});
|
|
315
|
-
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
344
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
316
345
|
if (onlyWarnings) {
|
|
317
346
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
318
347
|
} else {
|
|
@@ -344,7 +373,7 @@ try {
|
|
|
344
373
|
followed: true,
|
|
345
374
|
profile: profileName,
|
|
346
375
|
});
|
|
347
|
-
const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
|
|
376
|
+
const onlyWarnings = [duplicateWarning, driftWarning, failureMessage, autoStatus, balanceHint].filter(Boolean).join('\n\n');
|
|
348
377
|
if (onlyWarnings) {
|
|
349
378
|
process.stdout.write(JSON.stringify({ systemMessage: onlyWarnings }));
|
|
350
379
|
} else {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* failure-detector.mjs — Detects repeated failure loops for adaptive routing.
|
|
4
|
+
*
|
|
5
|
+
* Exports:
|
|
6
|
+
* checkFailureLoop(promptHash) → { isLoop, count, suggestion }
|
|
7
|
+
* recordFailure(promptHash, tier, reason) → void
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, appendFileSync } from 'fs';
|
|
11
|
+
import { dirname, join } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
|
|
16
|
+
|
|
17
|
+
function checkFailureLoop(promptHash) {
|
|
18
|
+
if (!promptHash) return { isLoop: false, count: 0, suggestion: null };
|
|
19
|
+
|
|
20
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
21
|
+
let failures = 0;
|
|
22
|
+
let lastTier = null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const lines = readFileSync(LEDGER_FILE, 'utf8').split('\n').filter(Boolean);
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
try {
|
|
28
|
+
const entry = JSON.parse(line);
|
|
29
|
+
if (entry.prompt_hash !== promptHash) continue;
|
|
30
|
+
if (Date.parse(entry.timestamp) < twoHoursAgo) continue;
|
|
31
|
+
if (entry.success === false || entry.followed === false) {
|
|
32
|
+
failures++;
|
|
33
|
+
lastTier = entry.tier;
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
if (failures < 2) return { isLoop: false, count: failures, suggestion: null };
|
|
40
|
+
|
|
41
|
+
const suggestion = lastTier === 'execute'
|
|
42
|
+
? 'promote_tier'
|
|
43
|
+
: 'escalate_to_dual_brain';
|
|
44
|
+
|
|
45
|
+
return { isLoop: true, count: failures, suggestion };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordFailure(promptHash, tier, reason) {
|
|
49
|
+
const entry = JSON.stringify({
|
|
50
|
+
type: 'failure',
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
prompt_hash: promptHash,
|
|
53
|
+
tier,
|
|
54
|
+
reason: reason || 'unknown',
|
|
55
|
+
success: false,
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
appendFileSync(LEDGER_FILE, entry + '\n');
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { checkFailureLoop, recordFailure };
|
package/hooks/profiles.mjs
CHANGED
|
@@ -21,6 +21,26 @@ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
|
|
|
21
21
|
const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
22
22
|
|
|
23
23
|
const PROFILES = {
|
|
24
|
+
auto: {
|
|
25
|
+
description: 'Adapts routing based on task risk, provider health, and outcomes',
|
|
26
|
+
routing: {
|
|
27
|
+
prefer_provider: 'auto',
|
|
28
|
+
think_threshold: 'adaptive',
|
|
29
|
+
gpt_dispatch_bias: 0,
|
|
30
|
+
},
|
|
31
|
+
budgets: {
|
|
32
|
+
session_warn_usd: 5.00,
|
|
33
|
+
session_limit_usd: 10.00,
|
|
34
|
+
daily_warn_usd: 20.00,
|
|
35
|
+
daily_limit_usd: 50.00,
|
|
36
|
+
},
|
|
37
|
+
quality_gate: {
|
|
38
|
+
sensitivity_floor: 'medium',
|
|
39
|
+
dual_brain_minimum: 'high',
|
|
40
|
+
},
|
|
41
|
+
tier_overrides: null,
|
|
42
|
+
},
|
|
43
|
+
|
|
24
44
|
balanced: {
|
|
25
45
|
description: 'Auto-routes by complexity, uses both providers evenly',
|
|
26
46
|
routing: {
|
|
@@ -106,12 +126,12 @@ function loadConfig() {
|
|
|
106
126
|
|
|
107
127
|
function getActiveProfile() {
|
|
108
128
|
const saved = loadProfileFile();
|
|
109
|
-
const name = saved?.active || '
|
|
110
|
-
const profile = PROFILES[name] || PROFILES.
|
|
129
|
+
const name = saved?.active || 'auto';
|
|
130
|
+
const profile = PROFILES[name] || PROFILES.auto;
|
|
111
131
|
const customOverrides = saved?.custom_overrides || {};
|
|
112
132
|
|
|
113
133
|
return {
|
|
114
|
-
name: PROFILES[name] ? name : '
|
|
134
|
+
name: PROFILES[name] ? name : 'auto',
|
|
115
135
|
...profile,
|
|
116
136
|
budgets: { ...profile.budgets, ...customOverrides.budgets },
|
|
117
137
|
routing: { ...profile.routing, ...customOverrides.routing },
|
|
@@ -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 };
|
|
@@ -57,6 +57,14 @@ function emptySummary() {
|
|
|
57
57
|
token_averages: {},
|
|
58
58
|
|
|
59
59
|
codex_latencies: [],
|
|
60
|
+
|
|
61
|
+
session_insights: {
|
|
62
|
+
gpt_latency_status: 'normal',
|
|
63
|
+
provider_override_count: 0,
|
|
64
|
+
failure_domains: [],
|
|
65
|
+
dual_brain_useful: false,
|
|
66
|
+
balance_posture: 'no activity yet',
|
|
67
|
+
},
|
|
60
68
|
};
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -199,6 +207,16 @@ function getTokenAverages(date) {
|
|
|
199
207
|
return summary.token_averages;
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
function updateSessionInsight(key, value, date) {
|
|
211
|
+
const validKeys = ['gpt_latency_status', 'provider_override_count', 'failure_domains', 'dual_brain_useful', 'balance_posture'];
|
|
212
|
+
if (!validKeys.includes(key)) return;
|
|
213
|
+
const summary = readSummary(date);
|
|
214
|
+
if (!summary.session_insights) summary.session_insights = {};
|
|
215
|
+
summary.session_insights[key] = value;
|
|
216
|
+
summary.updated_at = new Date().toISOString();
|
|
217
|
+
atomicWrite(summaryPath(date), summary);
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
function getAdaptiveCodexThreshold(date) {
|
|
203
221
|
const summary = readSummary(date);
|
|
204
222
|
const latencies = summary.codex_latencies || [];
|
|
@@ -227,5 +245,6 @@ export {
|
|
|
227
245
|
getPressureBuckets,
|
|
228
246
|
getTokenAverages,
|
|
229
247
|
getAdaptiveCodexThreshold,
|
|
248
|
+
updateSessionInsight,
|
|
230
249
|
atomicWrite,
|
|
231
250
|
};
|
package/install.mjs
CHANGED
|
@@ -336,6 +336,7 @@ function install(workspace, env, mode) {
|
|
|
336
336
|
'install-git-hooks.mjs', 'session-report.mjs', 'budget-balancer.mjs',
|
|
337
337
|
'gpt-work-dispatcher.mjs', 'profiles.mjs',
|
|
338
338
|
'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
|
|
339
|
+
'risk-classifier.mjs', 'failure-detector.mjs',
|
|
339
340
|
];
|
|
340
341
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
341
342
|
actions.push(`✓ ${HOOKS.length} hook scripts`);
|
package/package.json
CHANGED