dual-brain 4.6.0 → 4.7.1
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 +628 -1557
- 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
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* config-validator.mjs — Validates orchestrator.json on load.
|
|
3
|
-
*
|
|
4
|
-
* Exports:
|
|
5
|
-
* validateConfig(config) → { valid: boolean, errors: string[], warnings: string[] }
|
|
6
|
-
* loadAndValidateConfig(configPath) → { config, validation }
|
|
7
|
-
*
|
|
8
|
-
* On invalid config: logs errors via error-channel.mjs, returns sensible defaults.
|
|
9
|
-
* On unknown top-level keys: warns (potential typos).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from 'fs';
|
|
13
|
-
import { logHookError } from './error-channel.mjs';
|
|
14
|
-
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Known top-level keys (anything else triggers a typo warning)
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
19
|
-
'subscriptions',
|
|
20
|
-
'tiers',
|
|
21
|
-
'routing',
|
|
22
|
-
'routing_rules',
|
|
23
|
-
'quality_gate',
|
|
24
|
-
'pricing_verified',
|
|
25
|
-
'budgets',
|
|
26
|
-
'providers',
|
|
27
|
-
'dual_thinking',
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// Sensible defaults for graceful degradation
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
const DEFAULT_CONFIG = {
|
|
34
|
-
subscriptions: {
|
|
35
|
-
claude: {
|
|
36
|
-
plan: '$100',
|
|
37
|
-
models: {
|
|
38
|
-
opus: { tier: 'think', name: 'opus', provider: 'claude' },
|
|
39
|
-
sonnet: { tier: 'execute', name: 'sonnet', provider: 'claude' },
|
|
40
|
-
haiku: { tier: 'search', name: 'haiku', provider: 'claude' },
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
tiers: {
|
|
45
|
-
search: { description: 'Read-only lookups' },
|
|
46
|
-
execute: { description: 'Implementation and edits' },
|
|
47
|
-
think: { description: 'Architecture and review' },
|
|
48
|
-
},
|
|
49
|
-
routing: {
|
|
50
|
-
strategy: 'hybrid-specialized-balanced',
|
|
51
|
-
},
|
|
52
|
-
quality_gate: {
|
|
53
|
-
enabled: true,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// Validation
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Validate an orchestrator config object.
|
|
63
|
-
* Returns { valid, errors, warnings }.
|
|
64
|
-
*/
|
|
65
|
-
export function validateConfig(config) {
|
|
66
|
-
const errors = [];
|
|
67
|
-
const warnings = [];
|
|
68
|
-
|
|
69
|
-
if (!config || typeof config !== 'object') {
|
|
70
|
-
errors.push('Config is not a valid object');
|
|
71
|
-
return { valid: false, errors, warnings };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Required top-level keys
|
|
75
|
-
const requiredKeys = ['subscriptions', 'tiers', 'routing', 'quality_gate'];
|
|
76
|
-
for (const key of requiredKeys) {
|
|
77
|
-
if (!(key in config)) {
|
|
78
|
-
errors.push(`Missing required top-level key: "${key}"`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Unknown top-level keys (typo detection)
|
|
83
|
-
for (const key of Object.keys(config)) {
|
|
84
|
-
if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
|
|
85
|
-
warnings.push(`Unknown top-level key: "${key}" — possible typo?`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Validate subscriptions structure
|
|
90
|
-
if (config.subscriptions && typeof config.subscriptions === 'object') {
|
|
91
|
-
for (const [providerName, provider] of Object.entries(config.subscriptions)) {
|
|
92
|
-
if (!provider.models || typeof provider.models !== 'object') {
|
|
93
|
-
errors.push(`subscriptions.${providerName} missing "models" object`);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
for (const [modelName, meta] of Object.entries(provider.models)) {
|
|
97
|
-
if (!meta.tier) {
|
|
98
|
-
errors.push(`subscriptions.${providerName}.models.${modelName} missing "tier"`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Validate tiers has search, execute, think
|
|
105
|
-
if (config.tiers && typeof config.tiers === 'object') {
|
|
106
|
-
for (const required of ['search', 'execute', 'think']) {
|
|
107
|
-
if (!(required in config.tiers)) {
|
|
108
|
-
errors.push(`tiers missing required tier: "${required}"`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
valid: errors.length === 0,
|
|
115
|
-
errors,
|
|
116
|
-
warnings,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Load orchestrator.json from disk, validate it, log issues, and return
|
|
122
|
-
* either the parsed config or sensible defaults on failure.
|
|
123
|
-
*/
|
|
124
|
-
export function loadAndValidateConfig(configPath) {
|
|
125
|
-
let config;
|
|
126
|
-
try {
|
|
127
|
-
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
128
|
-
} catch (err) {
|
|
129
|
-
logHookError('config-validator', 'load', err, { configPath });
|
|
130
|
-
return { config: DEFAULT_CONFIG, validation: { valid: false, errors: [`Failed to parse config: ${err.message}`], warnings: [] } };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const validation = validateConfig(config);
|
|
134
|
-
|
|
135
|
-
// Log errors
|
|
136
|
-
for (const err of validation.errors) {
|
|
137
|
-
logHookError('config-validator', 'validate', new Error(err), { configPath });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Log warnings to stderr (non-fatal)
|
|
141
|
-
for (const warn of validation.warnings) {
|
|
142
|
-
process.stderr.write(`[config-validator] WARNING: ${warn}\n`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// If invalid, merge defaults for missing keys
|
|
146
|
-
if (!validation.valid) {
|
|
147
|
-
const merged = { ...DEFAULT_CONFIG, ...config };
|
|
148
|
-
if (!config.subscriptions) merged.subscriptions = DEFAULT_CONFIG.subscriptions;
|
|
149
|
-
if (!config.tiers) merged.tiers = DEFAULT_CONFIG.tiers;
|
|
150
|
-
if (!config.routing) merged.routing = DEFAULT_CONFIG.routing;
|
|
151
|
-
if (!config.quality_gate) merged.quality_gate = DEFAULT_CONFIG.quality_gate;
|
|
152
|
-
return { config: merged, validation };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { config, validation };
|
|
156
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* confirmation-policy.mjs — Centralized confirmation policy for Ship Captain.
|
|
4
|
-
*
|
|
5
|
-
* Single source of truth for "should we prompt the user?"
|
|
6
|
-
* Controls automation level based on risk level and user flags.
|
|
7
|
-
*
|
|
8
|
-
* Exports: getConfirmationPolicy, resolveMode, aggregateRisk, formatConfirmation
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
// Risk level ordering for comparison
|
|
12
|
-
const RISK_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
13
|
-
|
|
14
|
-
// Steps that are mutations (vs. validation steps)
|
|
15
|
-
const MUTATION_STEPS = new Set(['edit', 'pr', 'push']);
|
|
16
|
-
// Steps that are validation (auto-run in careful mode too)
|
|
17
|
-
const VALIDATION_STEPS = new Set(['test', 'gate']);
|
|
18
|
-
// Heal step: a fix attempt — auto-runs unless in careful mode (where user confirms)
|
|
19
|
-
const HEAL_STEPS = new Set(['heal']);
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* getConfirmationPolicy({ risk, mode, step })
|
|
23
|
-
*
|
|
24
|
-
* @param {object} options
|
|
25
|
-
* @param {'low'|'medium'|'high'|'critical'} options.risk
|
|
26
|
-
* @param {'default'|'yolo'|'careful'|'plan-only'} options.mode
|
|
27
|
-
* @param {'plan'|'edit'|'test'|'gate'|'heal'|'pr'|'push'} options.step
|
|
28
|
-
* @returns {{ shouldConfirm: boolean, shouldBlock: boolean, reason: string }}
|
|
29
|
-
*/
|
|
30
|
-
export function getConfirmationPolicy({ risk, mode, step }) {
|
|
31
|
-
const safeMode = mode || 'default';
|
|
32
|
-
const safeRisk = risk || 'low';
|
|
33
|
-
const safeStep = step || 'plan';
|
|
34
|
-
|
|
35
|
-
// PLAN-ONLY mode: block all mutations, allow plan display; skip heal (no mutations)
|
|
36
|
-
if (safeMode === 'plan-only') {
|
|
37
|
-
if (safeStep === 'plan') {
|
|
38
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'Plan-only mode — showing plan' };
|
|
39
|
-
}
|
|
40
|
-
if (HEAL_STEPS.has(safeStep)) {
|
|
41
|
-
return {
|
|
42
|
-
shouldConfirm: false,
|
|
43
|
-
shouldBlock: true,
|
|
44
|
-
reason: 'Plan-only mode — skipping heal (no mutations)',
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
return {
|
|
48
|
-
shouldConfirm: false,
|
|
49
|
-
shouldBlock: true,
|
|
50
|
-
reason: 'Plan-only mode — no mutations',
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// YOLO mode: no confirmations for any step at any risk level; auto-heal
|
|
55
|
-
if (safeMode === 'yolo') {
|
|
56
|
-
if (safeRisk === 'critical') {
|
|
57
|
-
return {
|
|
58
|
-
shouldConfirm: false,
|
|
59
|
-
shouldBlock: false,
|
|
60
|
-
reason: '⚠ YOLO mode on critical risk surface',
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'YOLO mode — proceeding automatically' };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// CAREFUL mode: confirm every mutation/plan step; validation steps auto-run;
|
|
67
|
-
// heal step requires confirmation before each attempt
|
|
68
|
-
if (safeMode === 'careful') {
|
|
69
|
-
if (VALIDATION_STEPS.has(safeStep)) {
|
|
70
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'Validation step — auto-run' };
|
|
71
|
-
}
|
|
72
|
-
if (HEAL_STEPS.has(safeStep)) {
|
|
73
|
-
return {
|
|
74
|
-
shouldConfirm: true,
|
|
75
|
-
shouldBlock: false,
|
|
76
|
-
reason: 'Careful mode — confirming heal attempt',
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
shouldConfirm: true,
|
|
81
|
-
shouldBlock: false,
|
|
82
|
-
reason: `Careful mode — confirming ${safeStep} step`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// DEFAULT mode: risk-based policy
|
|
87
|
-
// Heal step: auto-heal for all risk levels — it's just a fix attempt, no worse than the original change
|
|
88
|
-
if (safeMode === 'default') {
|
|
89
|
-
if (HEAL_STEPS.has(safeStep)) {
|
|
90
|
-
return {
|
|
91
|
-
shouldConfirm: false,
|
|
92
|
-
shouldBlock: false,
|
|
93
|
-
reason: 'Heal step — auto-proceeding (fix attempt only)',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
if (safeRisk === 'critical') {
|
|
97
|
-
return {
|
|
98
|
-
shouldConfirm: false,
|
|
99
|
-
shouldBlock: true,
|
|
100
|
-
reason: 'Critical risk requires --yolo flag',
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
if (safeRisk === 'high') {
|
|
104
|
-
if (safeStep === 'edit' || safeStep === 'pr') {
|
|
105
|
-
return {
|
|
106
|
-
shouldConfirm: true,
|
|
107
|
-
shouldBlock: false,
|
|
108
|
-
reason: `High-risk step requires confirmation before ${safeStep}`,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'High risk — auto-proceeding for non-critical step' };
|
|
112
|
-
}
|
|
113
|
-
// low or medium: no confirmations
|
|
114
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'Low/medium risk — proceeding automatically' };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Fallback: treat unknown modes as default/no-confirm
|
|
118
|
-
return { shouldConfirm: false, shouldBlock: false, reason: 'Unknown mode — proceeding automatically' };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* resolveMode(argv) — Parse CLI flags into a mode string.
|
|
123
|
-
*
|
|
124
|
-
* @param {string[]} argv Process argv array (e.g. process.argv)
|
|
125
|
-
* @returns {'default'|'yolo'|'careful'|'plan-only'}
|
|
126
|
-
*/
|
|
127
|
-
export function resolveMode(argv) {
|
|
128
|
-
const args = argv || [];
|
|
129
|
-
if (args.includes('--yolo')) return 'yolo';
|
|
130
|
-
if (args.includes('--careful')) return 'careful';
|
|
131
|
-
if (args.includes('--plan-only') || args.includes('--dry-run')) return 'plan-only';
|
|
132
|
-
return 'default';
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* aggregateRisk(risks) — Return the highest risk level from an array.
|
|
137
|
-
*
|
|
138
|
-
* @param {string[]} risks Array of risk strings
|
|
139
|
-
* @returns {'low'|'medium'|'high'|'critical'}
|
|
140
|
-
*/
|
|
141
|
-
export function aggregateRisk(risks) {
|
|
142
|
-
if (!Array.isArray(risks) || risks.length === 0) return 'low';
|
|
143
|
-
let maxIndex = 0;
|
|
144
|
-
for (const r of risks) {
|
|
145
|
-
const idx = RISK_ORDER.indexOf(r);
|
|
146
|
-
if (idx > maxIndex) maxIndex = idx;
|
|
147
|
-
}
|
|
148
|
-
return RISK_ORDER[maxIndex];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* formatConfirmation(step, risk, reason) — Format a human-readable confirmation prompt.
|
|
153
|
-
*
|
|
154
|
-
* @param {string} step
|
|
155
|
-
* @param {string} risk
|
|
156
|
-
* @param {string} reason
|
|
157
|
-
* @returns {string}
|
|
158
|
-
*/
|
|
159
|
-
export function formatConfirmation(step, risk, reason) {
|
|
160
|
-
if (risk === 'critical') {
|
|
161
|
-
return `\u{1F534} Critical risk detected: ${reason}. This requires explicit approval. Continue? [Y/n]`;
|
|
162
|
-
}
|
|
163
|
-
if (risk === 'high') {
|
|
164
|
-
return `⚠ High-risk step: ${reason}. Continue? [Y/n]`;
|
|
165
|
-
}
|
|
166
|
-
return `\u{2139} ${reason} (step: ${step}). Continue? [Y/n]`;
|
|
167
|
-
}
|
package/hooks/error-channel.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* error-channel.mjs — Lightweight error logger for dual-brain hooks.
|
|
3
|
-
*
|
|
4
|
-
* Exports:
|
|
5
|
-
* logHookError(hookName, operation, error, context?) → append to errors.jsonl
|
|
6
|
-
* getRecentErrors(hours?) → array of recent error entries
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { appendFileSync, readFileSync, writeFileSync } from 'fs';
|
|
10
|
-
import { dirname, join } from 'path';
|
|
11
|
-
import { fileURLToPath } from 'url';
|
|
12
|
-
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const ERROR_FILE = join(__dirname, 'errors.jsonl');
|
|
15
|
-
|
|
16
|
-
const PRUNE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
17
|
-
const PRUNE_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
18
|
-
let lastPruneCheck = 0;
|
|
19
|
-
|
|
20
|
-
function maybePrune() {
|
|
21
|
-
const now = Date.now();
|
|
22
|
-
if (now - lastPruneCheck < PRUNE_INTERVAL_MS) return;
|
|
23
|
-
lastPruneCheck = now;
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const raw = readFileSync(ERROR_FILE, 'utf8');
|
|
27
|
-
const cutoff = now - PRUNE_MAX_AGE_MS;
|
|
28
|
-
const kept = raw.split('\n').filter(line => {
|
|
29
|
-
if (!line) return false;
|
|
30
|
-
try {
|
|
31
|
-
const entry = JSON.parse(line);
|
|
32
|
-
return Date.parse(entry.timestamp) >= cutoff;
|
|
33
|
-
} catch { return true; } // keep unparseable lines
|
|
34
|
-
});
|
|
35
|
-
writeFileSync(ERROR_FILE, kept.length > 0 ? kept.join('\n') + '\n' : '');
|
|
36
|
-
} catch {
|
|
37
|
-
// File doesn't exist or can't be read — nothing to prune
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function logHookError(hookName, operation, error, context = {}) {
|
|
42
|
-
const entry = JSON.stringify({
|
|
43
|
-
timestamp: new Date().toISOString(),
|
|
44
|
-
hook: hookName,
|
|
45
|
-
operation,
|
|
46
|
-
error: error?.message || String(error),
|
|
47
|
-
stack: error?.stack || null,
|
|
48
|
-
context,
|
|
49
|
-
});
|
|
50
|
-
try {
|
|
51
|
-
appendFileSync(ERROR_FILE, entry + '\n');
|
|
52
|
-
} catch {
|
|
53
|
-
// Last resort — can't even log. Silently drop.
|
|
54
|
-
}
|
|
55
|
-
maybePrune();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function getRecentErrors(hours = 24) {
|
|
59
|
-
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
|
60
|
-
try {
|
|
61
|
-
const raw = readFileSync(ERROR_FILE, 'utf8');
|
|
62
|
-
return raw.split('\n').filter(Boolean).map(line => {
|
|
63
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
64
|
-
}).filter(e => e && Date.parse(e.timestamp) >= cutoff);
|
|
65
|
-
} catch {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|