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.
@@ -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
- }
@@ -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
- }