claude-git-hooks 2.44.0 → 2.51.2

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/lib/config.js CHANGED
@@ -24,104 +24,23 @@
24
24
  import fs from 'fs';
25
25
  import path from 'path';
26
26
  import logger from './utils/logger.js';
27
+ import { getDefaults, resolveSection } from './utils/config-registry.js';
27
28
 
28
29
  /**
29
- * Hardcoded defaults (v3.0.0)
30
- * These are NOT user-configurable - sensible defaults that work for everyone
30
+ * Package defaults loaded from lib/defaults.json via config-registry.
31
+ * These are NOT user-configurable sensible defaults that work for everyone.
32
+ * See lib/defaults.json for the full structure and values.
31
33
  */
32
- const HARDCODED = {
33
- analysis: {
34
- maxFileSize: 1000000, // 1MB - sufficient for most files
35
- maxFiles: 30, // Reasonable limit per commit
36
- timeout: 360000, // 6 minutes - adequate for Claude API
37
- contextLines: 3, // Git default
38
- ignoreExtensions: [] // Can be set in advanced config only
39
- },
40
- commitMessage: {
41
- autoKeyword: 'auto', // Standard keyword
42
- timeout: 300000, // Use same timeout as analysis
43
- taskIdPattern: '([A-Z]{1,3}[-\\s]\\d{3,5})' // Jira/GitHub/Linear pattern
44
- },
45
- subagents: {
46
- enabled: true // Enable by default (faster analysis via orchestration)
47
- },
48
- templates: {
49
- baseDir: '.claude/prompts',
50
- analysis: 'CLAUDE_ANALYSIS_PROMPT.md',
51
- guidelines: 'CLAUDE_PRE_COMMIT.md',
52
- commitMessage: 'COMMIT_MESSAGE.md',
53
- analyzeDiff: 'ANALYZE_DIFF.md',
54
- resolution: 'CLAUDE_RESOLUTION_PROMPT.md',
55
- createGithubPR: 'CREATE_GITHUB_PR.md'
56
- },
57
- output: {
58
- outputDir: '.claude/out',
59
- debugFile: '.claude/out/debug-claude-response.json',
60
- resolutionFile: '.claude/out/claude_resolution_prompt.md',
61
- prAnalysisFile: '.claude/out/pr-analysis.json'
62
- },
63
- system: {
64
- debug: false, // Controlled by --debug flag
65
- wslCheckTimeout: 15000 // System behavior
66
- },
67
- git: {
68
- diffFilter: 'ACM' // Standard: Added, Copied, Modified
69
- },
70
- github: {
71
- enabled: true // Always enabled
72
- },
73
- prAnalysis: {
74
- model: 'sonnet',
75
- timeout: 300000, // 5 minutes
76
- inlineCategories: ['bug', 'security', 'performance', 'hotspot'],
77
- generalCategories: [
78
- 'ticket-alignment',
79
- 'scope',
80
- 'style',
81
- 'good-practice',
82
- 'extensibility',
83
- 'observability',
84
- 'documentation',
85
- 'testing'
86
- ]
87
- },
88
- linting: {
89
- enabled: true, // Run linters before Claude analysis
90
- autoFix: true, // Auto-fix and re-stage
91
- failOnError: true, // Block commit on linting errors
92
- failOnWarning: false, // Do not block on warnings
93
- timeout: 30000 // 30s per linter
94
- },
95
- claude: {
96
- defaultModel: 'sonnet' // Fallback model for SDK headless mode
97
- }
98
- };
34
+ const HARDCODED = getDefaults();
99
35
 
100
36
  /**
101
37
  * Default user-configurable values (v2.8.0)
38
+ * Derived from the github.pr section of HARDCODED.
102
39
  * Only these can be overridden in .claude/config.json
103
40
  */
104
41
  const defaults = {
105
- // GitHub PR configuration (user-specific)
106
42
  github: {
107
- pr: {
108
- defaultBase: 'develop', // Project default branch
109
- reviewers: [], // Project reviewers
110
- labelRules: {
111
- // Labels by preset
112
- backend: ['backend', 'java'],
113
- frontend: ['frontend', 'react'],
114
- fullstack: ['fullstack'],
115
- database: ['database', 'sql'],
116
- ai: ['ai', 'tooling'],
117
- default: []
118
- },
119
- // Auto-push configuration (v2.11.0)
120
- autoPush: true, // Auto-push unpublished branches
121
- pushConfirm: true, // Prompt for confirmation before push
122
- verifyRemote: true, // Verify remote exists before push
123
- showCommits: true // Show commit preview before push
124
- }
43
+ pr: structuredClone(HARDCODED.github.pr)
125
44
  }
126
45
  };
127
46
 
@@ -132,7 +51,7 @@ const defaults = {
132
51
  * - v2.8.0: { version: "2.8.0", preset: "...", overrides: {...} }
133
52
  * - Legacy: { preset: "...", analysis: {...}, ... } (auto-migrates with warning)
134
53
  *
135
- * Merge priority: HARDCODED < defaults < preset config < user overrides
54
+ * Merge priority: HARDCODED < remote settings.json < defaults < preset config < user overrides
136
55
  *
137
56
  * @param {string} baseDir - Base directory to search for config (default: cwd)
138
57
  * @returns {Promise<Object>} Merged configuration
@@ -194,8 +113,17 @@ const loadUserConfig = async (baseDir = process.cwd()) => {
194
113
  }
195
114
  }
196
115
 
197
- // Merge priority: HARDCODED < defaults < preset < user overrides
198
- const baseConfig = deepMerge(HARDCODED, defaults);
116
+ // Fetch remote settings.json overrides for team-policy sections
117
+ const remoteSettings = {};
118
+ const settingsSections = ['analysis', 'commitMessage', 'linting'];
119
+ const sectionResults = await Promise.all(settingsSections.map((s) => resolveSection(s)));
120
+ settingsSections.forEach((section, i) => {
121
+ if (sectionResults[i]) remoteSettings[section] = sectionResults[i];
122
+ });
123
+
124
+ // Merge priority: HARDCODED < remote settings.json < defaults < preset < user overrides
125
+ const withRemote = deepMerge(HARDCODED, remoteSettings);
126
+ const baseConfig = deepMerge(withRemote, defaults);
199
127
  const withPreset = deepMerge(baseConfig, presetConfig);
200
128
  const final = deepMerge(withPreset, userOverrides);
201
129
 
@@ -0,0 +1,130 @@
1
+ {
2
+ "analysis": {
3
+ "maxFileSize": 1000000,
4
+ "maxFiles": 30,
5
+ "timeout": 360000,
6
+ "contextLines": 3,
7
+ "ignoreExtensions": []
8
+ },
9
+ "commitMessage": {
10
+ "autoKeyword": "auto",
11
+ "timeout": 300000,
12
+ "taskIdPattern": "([A-Z]{1,3}[-\\s]\\d{3,5})"
13
+ },
14
+ "subagents": {
15
+ "enabled": true
16
+ },
17
+ "templates": {
18
+ "baseDir": ".claude/prompts",
19
+ "analysis": "CLAUDE_ANALYSIS_PROMPT.md",
20
+ "guidelines": "CLAUDE_PRE_COMMIT.md",
21
+ "commitMessage": "COMMIT_MESSAGE.md",
22
+ "analyzeDiff": "ANALYZE_DIFF.md",
23
+ "resolution": "CLAUDE_RESOLUTION_PROMPT.md",
24
+ "createGithubPR": "CREATE_GITHUB_PR.md"
25
+ },
26
+ "output": {
27
+ "outputDir": ".claude/out",
28
+ "debugFile": ".claude/out/debug-claude-response.json",
29
+ "resolutionFile": ".claude/out/claude_resolution_prompt.md",
30
+ "prAnalysisFile": ".claude/out/pr-analysis.json"
31
+ },
32
+ "system": {
33
+ "debug": false,
34
+ "wslCheckTimeout": 15000
35
+ },
36
+ "git": {
37
+ "diffFilter": "ACM"
38
+ },
39
+ "github": {
40
+ "enabled": true,
41
+ "pr": {
42
+ "defaultBase": "develop",
43
+ "reviewers": [],
44
+ "labelRules": {
45
+ "backend": ["backend", "java"],
46
+ "frontend": ["frontend", "react"],
47
+ "fullstack": ["fullstack"],
48
+ "database": ["database", "sql"],
49
+ "ai": ["ai", "tooling"],
50
+ "default": []
51
+ },
52
+ "autoPush": true,
53
+ "pushConfirm": true,
54
+ "verifyRemote": true,
55
+ "showCommits": true
56
+ }
57
+ },
58
+ "prAnalysis": {
59
+ "model": "sonnet",
60
+ "timeout": 300000,
61
+ "inlineCategories": ["bug", "security", "performance", "hotspot"],
62
+ "generalCategories": [
63
+ "ticket-alignment",
64
+ "scope",
65
+ "style",
66
+ "good-practice",
67
+ "extensibility",
68
+ "observability",
69
+ "documentation",
70
+ "testing"
71
+ ]
72
+ },
73
+ "linting": {
74
+ "enabled": true,
75
+ "autoFix": true,
76
+ "failOnError": true,
77
+ "failOnWarning": false,
78
+ "timeout": 30000
79
+ },
80
+ "claude": {
81
+ "defaultModel": "sonnet"
82
+ },
83
+ "models": {
84
+ "modelMap": {
85
+ "haiku": "claude-haiku-4-5-20251001",
86
+ "sonnet": "claude-sonnet-4-6",
87
+ "opus": "claude-opus-4-6"
88
+ },
89
+ "defaults": {
90
+ "analysis": "sonnet",
91
+ "orchestrator": "opus",
92
+ "judge": "sonnet",
93
+ "prAnalysis": "sonnet",
94
+ "commitMessage": "sonnet"
95
+ }
96
+ },
97
+ "tools": {
98
+ "prettier": { "enabled": true, "timeout": 30000 },
99
+ "eslint": { "enabled": true, "timeout": 30000 },
100
+ "spotless": { "enabled": true, "timeout": 120000, "perFile": true },
101
+ "sqlfluff": { "enabled": true, "timeout": 30000 }
102
+ },
103
+ "presetLinters": {
104
+ "frontend": ["prettier", "eslint"],
105
+ "backend": ["spotless"],
106
+ "fullstack": ["prettier", "eslint", "spotless"],
107
+ "database": ["sqlfluff"],
108
+ "ai": ["prettier", "eslint"],
109
+ "default": ["prettier", "eslint"]
110
+ },
111
+ "judge": {
112
+ "model": "sonnet",
113
+ "timeout": 360000
114
+ },
115
+ "orchestrator": {
116
+ "model": "opus",
117
+ "timeout": 60000,
118
+ "threshold": 3
119
+ },
120
+ "prMetadata": {
121
+ "timeout": 180000,
122
+ "maxDiffSize": 50000
123
+ },
124
+ "linear": {
125
+ "hostname": "api.linear.app",
126
+ "path": "/graphql",
127
+ "timeout": 10000,
128
+ "maxRetries": 2
129
+ }
130
+ }
@@ -19,6 +19,7 @@
19
19
  * - resolution-prompt: Issue resolution generation
20
20
  */
21
21
 
22
+ import { join } from 'path';
22
23
  import { getStagedFiles, getRepoRoot, getStagedTreeSha } from '../utils/git-operations.js';
23
24
  import { writeMarker } from '../utils/hooks-verified-marker.js';
24
25
  import { filterFiles } from '../utils/file-operations.js';
@@ -150,6 +151,41 @@ const main = async () => {
150
151
  process.exit(0);
151
152
  }
152
153
 
154
+ // Library staleness check — non-blocking warning
155
+ try {
156
+ const { checkBook } = await import('../../.library/tools/staleness.js');
157
+ const { getBooksDir } = await import('../../.library/paths.js');
158
+ const booksDir = getBooksDir();
159
+ const sourceFiles = validFiles
160
+ .map(f => (typeof f === 'string' ? f : f.path))
161
+ .filter(p => p.startsWith('lib/'));
162
+
163
+ if (sourceFiles.length > 0) {
164
+ const staleBooks = [];
165
+ for (const srcPath of sourceFiles) {
166
+ const bookName = `${srcPath.replace(/^lib\/(?:.*\/)?/, '').replace(/\.js$/, '')}.md`;
167
+ const bookPath = join(booksDir, bookName);
168
+ try {
169
+ const result = await checkBook(bookPath, getRepoRoot());
170
+ if (result.status === 'stale') {
171
+ staleBooks.push(result.book);
172
+ }
173
+ } catch {
174
+ // Book doesn't exist or check failed — skip silently
175
+ }
176
+ }
177
+ if (staleBooks.length > 0) {
178
+ logger.warning(`📚 ${staleBooks.length} library book(s) will become stale after this commit`);
179
+ for (const book of staleBooks) {
180
+ logger.warning(` └─ ${book}`);
181
+ }
182
+ logger.warning(' Run: npm run library:regenerate');
183
+ }
184
+ }
185
+ } catch {
186
+ logger.warning('📚 Library staleness check unavailable — .library/ tools not found');
187
+ }
188
+
153
189
  // Step 3: Run linters (fast, deterministic — before Claude analysis)
154
190
  // Unfixable lint issues are forwarded to the judge for semantic resolution
155
191
  let unfixableLintDetails = [];
@@ -35,6 +35,7 @@ import { orchestrateBatches } from './diff-analysis-orchestrator.js';
35
35
  import { buildAnalysisPrompt } from './prompt-builder.js';
36
36
  import logger from './logger.js';
37
37
  import { recordMetric } from './metrics.js';
38
+ import { getDefaultSection, resolveSection } from './config-registry.js';
38
39
 
39
40
  /**
40
41
  * Standard file data schema used throughout the analysis pipeline
@@ -360,7 +361,7 @@ export const displayResults = (result, { silent = false } = {}) => {
360
361
  };
361
362
 
362
363
  // Minimum file count to trigger Opus orchestration instead of sequential analysis
363
- const ORCHESTRATOR_THRESHOLD = 3;
364
+ const ORCHESTRATOR_THRESHOLD = getDefaultSection('orchestrator').threshold;
364
365
 
365
366
  /**
366
367
  * Runs code analysis on files
@@ -385,12 +386,15 @@ export const runAnalysis = async (filesData, config, options = {}) => {
385
386
  return createEmptyResult();
386
387
  }
387
388
 
388
- const useOrchestrator = filesData.length >= ORCHESTRATOR_THRESHOLD;
389
+ // Resolve orchestrator threshold: remote settings.json > local defaults.json
390
+ const orchConfig = await resolveSection('orchestrator');
391
+ const threshold = orchConfig?.threshold || ORCHESTRATOR_THRESHOLD;
392
+ const useOrchestrator = filesData.length >= threshold;
389
393
 
390
394
  logger.debug('analysis-engine - runAnalysis', 'Starting analysis', {
391
395
  fileCount: filesData.length,
392
396
  useOrchestrator,
393
- threshold: ORCHESTRATOR_THRESHOLD
397
+ threshold
394
398
  });
395
399
 
396
400
  let result;
@@ -21,6 +21,7 @@ import path from 'path';
21
21
  import os from 'os';
22
22
  import logger from './logger.js';
23
23
  import config from '../config.js';
24
+ import { getDefaultSection } from './config-registry.js';
24
25
  import {
25
26
  detectClaudeError,
26
27
  formatClaudeError,
@@ -47,14 +48,17 @@ class ClaudeClientError extends Error {
47
48
 
48
49
  /**
49
50
  * Model alias map — resolves shorthand names to full SDK model IDs.
51
+ * Loaded from lib/defaults.json via config-registry, with env var overrides.
50
52
  * Env var overrides match Lumiere contract (CLAUDE_MODEL_HAIKU, CLAUDE_MODEL_SONNET, CLAUDE_MODEL_OPUS).
51
53
  * Full model IDs (e.g., 'claude-sonnet-4-6') pass through unchanged.
52
54
  */
53
- const MODEL_ALIASES = {
54
- haiku: process.env.CLAUDE_MODEL_HAIKU || 'claude-haiku-4-5-20251001',
55
- sonnet: process.env.CLAUDE_MODEL_SONNET || 'claude-sonnet-4-6',
56
- opus: process.env.CLAUDE_MODEL_OPUS || 'claude-opus-4-6'
57
- };
55
+ const _localModelMap = getDefaultSection('models').modelMap;
56
+ const MODEL_ALIASES = Object.fromEntries(
57
+ Object.entries(_localModelMap).map(([alias, fullId]) => [
58
+ alias,
59
+ process.env[`CLAUDE_MODEL_${alias.toUpperCase()}`] || fullId
60
+ ])
61
+ );
58
62
 
59
63
  /**
60
64
  * Resolves a model alias to a full model ID.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * File: config-registry.js
3
+ * Purpose: Unified configuration reader with local defaults + remote overrides
4
+ *
5
+ * Design:
6
+ * - Reads lib/defaults.json synchronously at import time (ships with the package)
7
+ * - Fetches remote overrides from git-hooks-config via remote-config.js (async, cached)
8
+ * - Merge priority: local defaults < remote overrides
9
+ * - Consumers higher in the chain (presets, user .claude/config.json) merge on top separately
10
+ *
11
+ * Section-to-remote-file mapping:
12
+ * models → models.json (root)
13
+ * tools → tools.json (.tools key)
14
+ * prAnalysis → categories.json (.inlineCategories, .generalCategories → merged into prAnalysis)
15
+ * presetLinters → formatters.json (.presetTools)
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+ import logger from './logger.js';
22
+ import { fetchRemoteConfig } from './remote-config.js';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = path.dirname(__filename);
26
+
27
+ /** Path to the package defaults file */
28
+ const DEFAULTS_PATH = path.resolve(__dirname, '..', 'defaults.json');
29
+
30
+ /**
31
+ * Helper: create a mapping entry that reads a section key from settings.json.
32
+ * @param {string} sectionKey - Key within settings.json (e.g., 'analysis', 'judge')
33
+ * @returns {{ file: string, extract: (remote: Object) => Object }}
34
+ */
35
+ const _settingsEntry = (sectionKey) => ({
36
+ file: 'settings.json',
37
+ extract: (remote) => remote?.[sectionKey] || {}
38
+ });
39
+
40
+ /**
41
+ * Maps defaults.json section names to remote config files and their extraction logic.
42
+ * Each entry defines:
43
+ * - file: the remote JSON filename to fetch
44
+ * - extract: function that transforms the remote JSON into the shape expected for deep-merge
45
+ * @type {Object<string, { file: string, extract: (remote: Object) => Object }>}
46
+ */
47
+ const SECTION_REMOTE_MAP = {
48
+ models: {
49
+ file: 'models.json',
50
+ extract: (remote) => remote
51
+ },
52
+ tools: {
53
+ file: 'tools.json',
54
+ extract: (remote) => remote?.tools || {}
55
+ },
56
+ prAnalysis: {
57
+ file: 'categories.json',
58
+ extract: (remote) => {
59
+ const result = {};
60
+ if (remote?.inlineCategories) result.inlineCategories = remote.inlineCategories;
61
+ if (remote?.generalCategories) result.generalCategories = remote.generalCategories;
62
+ return result;
63
+ }
64
+ },
65
+ presetLinters: {
66
+ file: 'formatters.json',
67
+ extract: (remote) => remote?.presetTools || {}
68
+ },
69
+ // Team-policy sections — all read from settings.json
70
+ analysis: _settingsEntry('analysis'),
71
+ commitMessage: _settingsEntry('commitMessage'),
72
+ judge: _settingsEntry('judge'),
73
+ orchestrator: _settingsEntry('orchestrator'),
74
+ prMetadata: _settingsEntry('prMetadata'),
75
+ linting: _settingsEntry('linting')
76
+ };
77
+
78
+ // ── Sync: local defaults (read once at import time) ────────────────────────
79
+
80
+ let _defaults = null;
81
+
82
+ /**
83
+ * Load defaults.json synchronously. Called once at module init.
84
+ * @returns {Object} Parsed defaults
85
+ * @throws {Error} If defaults.json is missing or invalid (fatal — package is broken)
86
+ */
87
+ function _loadDefaults() {
88
+ if (_defaults !== null) return _defaults;
89
+
90
+ try {
91
+ const raw = fs.readFileSync(DEFAULTS_PATH, 'utf8');
92
+ _defaults = JSON.parse(raw);
93
+ logger.debug('config-registry', 'Loaded defaults.json', {
94
+ sections: Object.keys(_defaults)
95
+ });
96
+ return _defaults;
97
+ } catch (err) {
98
+ throw new Error(
99
+ `Failed to load package defaults from ${DEFAULTS_PATH}: ${err.message}. ` +
100
+ 'This indicates a broken installation — reinstall claude-git-hooks.'
101
+ );
102
+ }
103
+ }
104
+
105
+ // Load immediately on import
106
+ _loadDefaults();
107
+
108
+ /**
109
+ * Returns the full defaults object (sync, no I/O after init).
110
+ * @returns {Object} Complete defaults from lib/defaults.json
111
+ */
112
+ export function getDefaults() {
113
+ return _defaults;
114
+ }
115
+
116
+ /**
117
+ * Returns a single section from defaults (sync).
118
+ * @param {string} sectionName - Top-level key in defaults.json (e.g., 'models', 'tools', 'judge')
119
+ * @returns {Object|undefined} The section value, or undefined if not found
120
+ */
121
+ export function getDefaultSection(sectionName) {
122
+ return _defaults?.[sectionName];
123
+ }
124
+
125
+ // ── Async: remote overrides (fetched on demand, cached) ────────────────────
126
+
127
+ /**
128
+ * In-memory cache for resolved sections (local + remote merged).
129
+ * @type {Map<string, Object>}
130
+ */
131
+ const _resolvedCache = new Map();
132
+
133
+ /**
134
+ * Deep-merge two objects (source overrides target).
135
+ * Arrays are replaced wholesale (not concatenated).
136
+ * @param {Object} target - Base object
137
+ * @param {Object} source - Override object
138
+ * @returns {Object} Merged result
139
+ */
140
+ function _deepMerge(target, source) {
141
+ const result = { ...target };
142
+ for (const key in source) {
143
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
144
+ result[key] = _deepMerge(target[key] || {}, source[key]);
145
+ } else {
146
+ result[key] = source[key];
147
+ }
148
+ }
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Resolve a config section by merging local defaults with remote overrides.
154
+ *
155
+ * If the section has a mapping in SECTION_REMOTE_MAP, the corresponding remote
156
+ * file is fetched and merged (remote > local). If fetch fails, local defaults
157
+ * are returned. Results are cached per process.
158
+ *
159
+ * @param {string} sectionName - Top-level key in defaults.json
160
+ * @returns {Promise<Object>} Merged section (local defaults + remote overrides)
161
+ */
162
+ export async function resolveSection(sectionName) {
163
+ if (_resolvedCache.has(sectionName)) {
164
+ return _resolvedCache.get(sectionName);
165
+ }
166
+
167
+ const localSection = getDefaultSection(sectionName);
168
+ if (localSection === undefined) {
169
+ logger.debug('config-registry - resolveSection', 'Section not found in defaults', {
170
+ sectionName
171
+ });
172
+ return undefined;
173
+ }
174
+
175
+ const mapping = SECTION_REMOTE_MAP[sectionName];
176
+ if (!mapping) {
177
+ // No remote file for this section — return local as-is
178
+ _resolvedCache.set(sectionName, localSection);
179
+ return localSection;
180
+ }
181
+
182
+ const remoteData = await fetchRemoteConfig(mapping.file);
183
+ if (!remoteData) {
184
+ logger.debug('config-registry - resolveSection', 'Remote unavailable, using local defaults', {
185
+ sectionName,
186
+ remoteFile: mapping.file
187
+ });
188
+ _resolvedCache.set(sectionName, localSection);
189
+ return localSection;
190
+ }
191
+
192
+ const remoteOverrides = mapping.extract(remoteData);
193
+ const merged = _deepMerge(localSection, remoteOverrides);
194
+
195
+ logger.debug('config-registry - resolveSection', 'Merged local + remote', {
196
+ sectionName,
197
+ remoteFile: mapping.file,
198
+ localKeys: Object.keys(localSection),
199
+ remoteKeys: Object.keys(remoteOverrides)
200
+ });
201
+
202
+ _resolvedCache.set(sectionName, merged);
203
+ return merged;
204
+ }
205
+
206
+ // ── Convenience resolvers ──────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Resolve a model alias to a full model ID, with remote override support.
210
+ * Priority: env var > remote models.json > local defaults.json
211
+ *
212
+ * @param {string} alias - Model alias ('sonnet', 'opus', 'haiku') or full ID
213
+ * @returns {Promise<string>} Full model ID
214
+ */
215
+ export async function resolveModelAlias(alias) {
216
+ const modelsConfig = await resolveSection('models');
217
+ const modelMap = modelsConfig?.modelMap || {};
218
+
219
+ // Env var override (highest priority)
220
+ const envKey = `CLAUDE_MODEL_${alias.toUpperCase()}`;
221
+ if (process.env[envKey]) {
222
+ return process.env[envKey];
223
+ }
224
+
225
+ return modelMap[alias] || alias;
226
+ }
227
+
228
+ /**
229
+ * Resolve configuration for a specific linter tool, with remote override support.
230
+ * Merges local defaults.tools[toolName] with remote tools.json tools[toolName].
231
+ *
232
+ * @param {string} toolName - Tool name ('prettier', 'eslint', 'spotless', 'sqlfluff')
233
+ * @returns {Promise<Object|undefined>} Tool config (enabled, timeout, perFile) or undefined
234
+ */
235
+ export async function resolveToolConfig(toolName) {
236
+ const toolsConfig = await resolveSection('tools');
237
+ return toolsConfig?.[toolName];
238
+ }
239
+
240
+ // ── Test helpers ───────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Reset all caches. Test helper — not intended for production use.
244
+ */
245
+ export function _resetRegistry() {
246
+ _resolvedCache.clear();
247
+ _defaults = null;
248
+ _loadDefaults();
249
+ }
250
+
251
+ /**
252
+ * Reset resolved cache only (keeps defaults loaded).
253
+ * Useful for testing remote merge behavior without re-reading defaults.json.
254
+ */
255
+ export function _resetResolvedCache() {
256
+ _resolvedCache.clear();
257
+ }