rafcode 2.1.1 → 2.2.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.
Files changed (120) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/CLAUDE.md +59 -11
  3. package/RAF/ahslfe-config-wizard/decisions.md +34 -0
  4. package/RAF/ahslfe-config-wizard/input.md +1 -0
  5. package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
  6. package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
  7. package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
  8. package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
  9. package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
  10. package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
  11. package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
  12. package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
  13. package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
  14. package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
  15. package/RAF/ahstvo-token-tracker/decisions.md +44 -0
  16. package/RAF/ahstvo-token-tracker/input.md +3 -0
  17. package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
  18. package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
  19. package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
  20. package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
  21. package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
  22. package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
  23. package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
  24. package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
  25. package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
  26. package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
  27. package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
  28. package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
  29. package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
  30. package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
  31. package/README.md +34 -0
  32. package/dist/commands/config.d.ts +3 -0
  33. package/dist/commands/config.d.ts.map +1 -0
  34. package/dist/commands/config.js +173 -0
  35. package/dist/commands/config.js.map +1 -0
  36. package/dist/commands/do.d.ts.map +1 -1
  37. package/dist/commands/do.js +47 -6
  38. package/dist/commands/do.js.map +1 -1
  39. package/dist/commands/plan.d.ts.map +1 -1
  40. package/dist/commands/plan.js +3 -2
  41. package/dist/commands/plan.js.map +1 -1
  42. package/dist/core/claude-runner.d.ts +19 -2
  43. package/dist/core/claude-runner.d.ts.map +1 -1
  44. package/dist/core/claude-runner.js +43 -96
  45. package/dist/core/claude-runner.js.map +1 -1
  46. package/dist/core/failure-analyzer.d.ts.map +1 -1
  47. package/dist/core/failure-analyzer.js +6 -3
  48. package/dist/core/failure-analyzer.js.map +1 -1
  49. package/dist/core/git.d.ts.map +1 -1
  50. package/dist/core/git.js +10 -3
  51. package/dist/core/git.js.map +1 -1
  52. package/dist/core/pull-request.d.ts +1 -1
  53. package/dist/core/pull-request.d.ts.map +1 -1
  54. package/dist/core/pull-request.js +7 -4
  55. package/dist/core/pull-request.js.map +1 -1
  56. package/dist/index.js +2 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/parsers/stream-renderer.d.ts +16 -1
  59. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  60. package/dist/parsers/stream-renderer.js +34 -4
  61. package/dist/parsers/stream-renderer.js.map +1 -1
  62. package/dist/prompts/execution.d.ts.map +1 -1
  63. package/dist/prompts/execution.js +11 -1
  64. package/dist/prompts/execution.js.map +1 -1
  65. package/dist/types/config.d.ts +95 -4
  66. package/dist/types/config.d.ts.map +1 -1
  67. package/dist/types/config.js +63 -3
  68. package/dist/types/config.js.map +1 -1
  69. package/dist/utils/config.d.ts +59 -7
  70. package/dist/utils/config.d.ts.map +1 -1
  71. package/dist/utils/config.js +276 -21
  72. package/dist/utils/config.js.map +1 -1
  73. package/dist/utils/name-generator.d.ts +3 -7
  74. package/dist/utils/name-generator.d.ts.map +1 -1
  75. package/dist/utils/name-generator.js +75 -61
  76. package/dist/utils/name-generator.js.map +1 -1
  77. package/dist/utils/terminal-symbols.d.ts +21 -0
  78. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  79. package/dist/utils/terminal-symbols.js +62 -0
  80. package/dist/utils/terminal-symbols.js.map +1 -1
  81. package/dist/utils/token-tracker.d.ts +45 -0
  82. package/dist/utils/token-tracker.d.ts.map +1 -0
  83. package/dist/utils/token-tracker.js +107 -0
  84. package/dist/utils/token-tracker.js.map +1 -0
  85. package/dist/utils/validation.d.ts +5 -5
  86. package/dist/utils/validation.d.ts.map +1 -1
  87. package/dist/utils/validation.js +10 -6
  88. package/dist/utils/validation.js.map +1 -1
  89. package/dist/utils/verbose-toggle.d.ts +33 -0
  90. package/dist/utils/verbose-toggle.d.ts.map +1 -0
  91. package/dist/utils/verbose-toggle.js +94 -0
  92. package/dist/utils/verbose-toggle.js.map +1 -0
  93. package/package.json +1 -1
  94. package/src/commands/config.ts +204 -0
  95. package/src/commands/do.ts +56 -5
  96. package/src/commands/plan.ts +3 -2
  97. package/src/core/claude-runner.ts +59 -115
  98. package/src/core/failure-analyzer.ts +6 -3
  99. package/src/core/git.ts +10 -3
  100. package/src/core/pull-request.ts +7 -4
  101. package/src/index.ts +2 -0
  102. package/src/parsers/stream-renderer.ts +54 -4
  103. package/src/prompts/config-docs.md +331 -0
  104. package/src/prompts/execution.ts +13 -1
  105. package/src/types/config.ts +156 -7
  106. package/src/utils/config.ts +335 -21
  107. package/src/utils/name-generator.ts +84 -71
  108. package/src/utils/terminal-symbols.ts +68 -0
  109. package/src/utils/token-tracker.ts +135 -0
  110. package/src/utils/validation.ts +15 -10
  111. package/src/utils/verbose-toggle.ts +103 -0
  112. package/tests/unit/claude-runner.test.ts +171 -7
  113. package/tests/unit/config-command.test.ts +163 -0
  114. package/tests/unit/config.test.ts +608 -30
  115. package/tests/unit/name-generator.test.ts +99 -75
  116. package/tests/unit/pull-request.test.ts +2 -0
  117. package/tests/unit/stream-renderer.test.ts +83 -0
  118. package/tests/unit/terminal-symbols.test.ts +157 -0
  119. package/tests/unit/token-tracker.test.ts +352 -0
  120. package/tests/unit/verbose-toggle.test.ts +204 -0
@@ -1,10 +1,44 @@
1
- import { jest } from '@jest/globals';
2
1
  import * as fs from 'node:fs';
3
2
  import * as path from 'node:path';
4
3
  import * as os from 'node:os';
5
- import { getClaudeModel, getClaudeSettingsPath } from '../../src/utils/config.js';
4
+ import {
5
+ getClaudeModel,
6
+ getClaudeSettingsPath,
7
+ validateConfig,
8
+ ConfigValidationError,
9
+ resolveConfig,
10
+ getModel,
11
+ getEffort,
12
+ getCommitFormat,
13
+ getCommitPrefix,
14
+ getTimeout,
15
+ getMaxRetries,
16
+ getAutoCommit,
17
+ getWorktreeDefault,
18
+ getClaudeCommand,
19
+ resetConfigCache,
20
+ saveConfig,
21
+ renderCommitMessage,
22
+ isValidModelName,
23
+ resolveModelPricingCategory,
24
+ getPricing,
25
+ getPricingConfig,
26
+ } from '../../src/utils/config.js';
27
+ import { DEFAULT_CONFIG } from '../../src/types/config.js';
6
28
 
7
29
  describe('Config', () => {
30
+ let tempDir: string;
31
+
32
+ beforeEach(() => {
33
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-config-test-'));
34
+ resetConfigCache();
35
+ });
36
+
37
+ afterEach(() => {
38
+ fs.rmSync(tempDir, { recursive: true, force: true });
39
+ resetConfigCache();
40
+ });
41
+
8
42
  describe('getClaudeSettingsPath', () => {
9
43
  it('should return path in home directory', () => {
10
44
  const settingsPath = getClaudeSettingsPath();
@@ -13,60 +47,604 @@ describe('Config', () => {
13
47
  });
14
48
 
15
49
  describe('getClaudeModel', () => {
16
- let tempDir: string;
17
-
18
- beforeEach(() => {
19
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-config-test-'));
20
- });
21
-
22
- afterEach(() => {
23
- fs.rmSync(tempDir, { recursive: true, force: true });
24
- });
25
-
26
50
  it('should return model name from Claude settings', () => {
27
51
  const settingsPath = path.join(tempDir, 'settings.json');
28
52
  fs.writeFileSync(settingsPath, JSON.stringify({ model: 'opus' }));
29
-
30
- const result = getClaudeModel(settingsPath);
31
- expect(result).toBe('opus');
53
+ expect(getClaudeModel(settingsPath)).toBe('opus');
32
54
  });
33
55
 
34
56
  it('should return full model name if specified', () => {
35
57
  const settingsPath = path.join(tempDir, 'settings.json');
36
58
  fs.writeFileSync(settingsPath, JSON.stringify({ model: 'claude-sonnet-4-20250514' }));
37
-
38
- const result = getClaudeModel(settingsPath);
39
- expect(result).toBe('claude-sonnet-4-20250514');
59
+ expect(getClaudeModel(settingsPath)).toBe('claude-sonnet-4-20250514');
40
60
  });
41
61
 
42
62
  it('should return null if settings file does not exist', () => {
43
- const settingsPath = path.join(tempDir, 'nonexistent.json');
44
-
45
- const result = getClaudeModel(settingsPath);
46
- expect(result).toBeNull();
63
+ expect(getClaudeModel(path.join(tempDir, 'nonexistent.json'))).toBeNull();
47
64
  });
48
65
 
49
66
  it('should return null if model is not specified in settings', () => {
50
67
  const settingsPath = path.join(tempDir, 'settings.json');
51
68
  fs.writeFileSync(settingsPath, JSON.stringify({ permissions: {} }));
52
-
53
- const result = getClaudeModel(settingsPath);
54
- expect(result).toBeNull();
69
+ expect(getClaudeModel(settingsPath)).toBeNull();
55
70
  });
56
71
 
57
72
  it('should return null if settings file is invalid JSON', () => {
58
73
  const settingsPath = path.join(tempDir, 'settings.json');
59
74
  fs.writeFileSync(settingsPath, 'invalid json');
60
-
61
- const result = getClaudeModel(settingsPath);
62
- expect(result).toBeNull();
75
+ expect(getClaudeModel(settingsPath)).toBeNull();
63
76
  });
64
77
 
65
78
  it('should use default settings path when not provided', () => {
66
- // This tests the default path behavior - the actual file may or may not exist
67
79
  const result = getClaudeModel();
68
- // Just verify it doesn't throw and returns string or null
69
80
  expect(result === null || typeof result === 'string').toBe(true);
70
81
  });
71
82
  });
83
+
84
+ describe('validateConfig', () => {
85
+ it('should accept an empty object', () => {
86
+ expect(() => validateConfig({})).not.toThrow();
87
+ });
88
+
89
+ it('should accept a full valid config', () => {
90
+ const config = {
91
+ models: { plan: 'opus', execute: 'haiku' },
92
+ effort: { plan: 'high', execute: 'low' },
93
+ timeout: 30,
94
+ maxRetries: 5,
95
+ autoCommit: false,
96
+ worktree: true,
97
+ commitFormat: { prefix: 'MY', task: '{prefix}[{projectId}] {description}' },
98
+ claudeCommand: '/usr/local/bin/claude',
99
+ };
100
+ expect(() => validateConfig(config)).not.toThrow();
101
+ });
102
+
103
+ it('should reject non-object config', () => {
104
+ expect(() => validateConfig(null)).toThrow(ConfigValidationError);
105
+ expect(() => validateConfig('string')).toThrow(ConfigValidationError);
106
+ expect(() => validateConfig(42)).toThrow(ConfigValidationError);
107
+ expect(() => validateConfig([])).toThrow(ConfigValidationError);
108
+ });
109
+
110
+ // Unknown keys
111
+ it('should reject unknown top-level keys', () => {
112
+ expect(() => validateConfig({ unknownKey: 'value' })).toThrow('Unknown config key: unknownKey');
113
+ });
114
+
115
+ it('should reject unknown model keys', () => {
116
+ expect(() => validateConfig({ models: { unknownScenario: 'opus' } })).toThrow('Unknown config key: models.unknownScenario');
117
+ });
118
+
119
+ it('should reject unknown effort keys', () => {
120
+ expect(() => validateConfig({ effort: { unknownScenario: 'high' } })).toThrow('Unknown config key: effort.unknownScenario');
121
+ });
122
+
123
+ it('should reject unknown commitFormat keys', () => {
124
+ expect(() => validateConfig({ commitFormat: { unknownKey: 'val' } })).toThrow('Unknown config key: commitFormat.unknownKey');
125
+ });
126
+
127
+ // Valid full model IDs
128
+ it('should accept full model IDs', () => {
129
+ expect(() => validateConfig({ models: { plan: 'claude-opus-4-5-20251101' } })).not.toThrow();
130
+ expect(() => validateConfig({ models: { execute: 'claude-sonnet-4-5-20250929' } })).not.toThrow();
131
+ expect(() => validateConfig({ models: { failureAnalysis: 'claude-haiku-4-5-20251001' } })).not.toThrow();
132
+ });
133
+
134
+ it('should accept model IDs without date suffix', () => {
135
+ expect(() => validateConfig({ models: { plan: 'claude-sonnet-4-5' } })).not.toThrow();
136
+ expect(() => validateConfig({ models: { plan: 'claude-opus-4' } })).not.toThrow();
137
+ });
138
+
139
+ // Invalid model values
140
+ it('should reject invalid model names', () => {
141
+ expect(() => validateConfig({ models: { plan: 'gpt-4' } })).toThrow('models.plan must be');
142
+ });
143
+
144
+ it('should reject random strings as model names', () => {
145
+ expect(() => validateConfig({ models: { plan: 'random-string' } })).toThrow('models.plan must be');
146
+ expect(() => validateConfig({ models: { plan: 'not-a-model' } })).toThrow('models.plan must be');
147
+ });
148
+
149
+ it('should reject non-string model values', () => {
150
+ expect(() => validateConfig({ models: { plan: 123 } })).toThrow('models.plan must be');
151
+ });
152
+
153
+ // Invalid effort values
154
+ it('should reject invalid effort levels', () => {
155
+ expect(() => validateConfig({ effort: { plan: 'ultra' } })).toThrow('effort.plan must be one of');
156
+ });
157
+
158
+ // Invalid types for nested objects
159
+ it('should reject non-object models', () => {
160
+ expect(() => validateConfig({ models: 'opus' })).toThrow('models must be an object');
161
+ });
162
+
163
+ it('should reject array models', () => {
164
+ expect(() => validateConfig({ models: ['opus'] })).toThrow('models must be an object');
165
+ });
166
+
167
+ it('should reject non-object effort', () => {
168
+ expect(() => validateConfig({ effort: 'high' })).toThrow('effort must be an object');
169
+ });
170
+
171
+ it('should reject non-object commitFormat', () => {
172
+ expect(() => validateConfig({ commitFormat: 'test' })).toThrow('commitFormat must be an object');
173
+ });
174
+
175
+ // Invalid timeout
176
+ it('should reject non-number timeout', () => {
177
+ expect(() => validateConfig({ timeout: '60' })).toThrow('timeout must be a positive number');
178
+ });
179
+
180
+ it('should reject zero timeout', () => {
181
+ expect(() => validateConfig({ timeout: 0 })).toThrow('timeout must be a positive number');
182
+ });
183
+
184
+ it('should reject negative timeout', () => {
185
+ expect(() => validateConfig({ timeout: -1 })).toThrow('timeout must be a positive number');
186
+ });
187
+
188
+ // Invalid maxRetries
189
+ it('should reject non-integer maxRetries', () => {
190
+ expect(() => validateConfig({ maxRetries: 1.5 })).toThrow('maxRetries must be a non-negative integer');
191
+ });
192
+
193
+ it('should reject negative maxRetries', () => {
194
+ expect(() => validateConfig({ maxRetries: -1 })).toThrow('maxRetries must be a non-negative integer');
195
+ });
196
+
197
+ it('should accept zero maxRetries', () => {
198
+ expect(() => validateConfig({ maxRetries: 0 })).not.toThrow();
199
+ });
200
+
201
+ // Invalid booleans
202
+ it('should reject non-boolean autoCommit', () => {
203
+ expect(() => validateConfig({ autoCommit: 'yes' })).toThrow('autoCommit must be a boolean');
204
+ });
205
+
206
+ it('should reject non-boolean worktree', () => {
207
+ expect(() => validateConfig({ worktree: 1 })).toThrow('worktree must be a boolean');
208
+ });
209
+
210
+ // Invalid claudeCommand
211
+ it('should reject empty claudeCommand', () => {
212
+ expect(() => validateConfig({ claudeCommand: '' })).toThrow('claudeCommand must be a non-empty string');
213
+ });
214
+
215
+ it('should reject whitespace-only claudeCommand', () => {
216
+ expect(() => validateConfig({ claudeCommand: ' ' })).toThrow('claudeCommand must be a non-empty string');
217
+ });
218
+
219
+ it('should reject non-string claudeCommand', () => {
220
+ expect(() => validateConfig({ claudeCommand: 123 })).toThrow('claudeCommand must be a non-empty string');
221
+ });
222
+
223
+ // Non-string commitFormat values
224
+ it('should reject non-string commitFormat values', () => {
225
+ expect(() => validateConfig({ commitFormat: { prefix: 123 } })).toThrow('commitFormat.prefix must be a string');
226
+ });
227
+ });
228
+
229
+ describe('isValidModelName', () => {
230
+ it('should accept short aliases', () => {
231
+ expect(isValidModelName('sonnet')).toBe(true);
232
+ expect(isValidModelName('haiku')).toBe(true);
233
+ expect(isValidModelName('opus')).toBe(true);
234
+ });
235
+
236
+ it('should accept full model IDs', () => {
237
+ expect(isValidModelName('claude-sonnet-4-5-20250929')).toBe(true);
238
+ expect(isValidModelName('claude-opus-4-5-20251101')).toBe(true);
239
+ expect(isValidModelName('claude-haiku-4-5-20251001')).toBe(true);
240
+ expect(isValidModelName('claude-sonnet-4-5')).toBe(true);
241
+ expect(isValidModelName('claude-opus-4')).toBe(true);
242
+ });
243
+
244
+ it('should reject invalid strings', () => {
245
+ expect(isValidModelName('gpt-4')).toBe(false);
246
+ expect(isValidModelName('random-string')).toBe(false);
247
+ expect(isValidModelName('')).toBe(false);
248
+ expect(isValidModelName('claude-')).toBe(false);
249
+ expect(isValidModelName('claude-sonnet')).toBe(false);
250
+ expect(isValidModelName('CLAUDE-SONNET-4')).toBe(false);
251
+ });
252
+ });
253
+
254
+ describe('resolveConfig', () => {
255
+ it('should return defaults when no config file exists', () => {
256
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
257
+ expect(config).toEqual(DEFAULT_CONFIG);
258
+ });
259
+
260
+ it('should deep-merge partial models override', () => {
261
+ const configPath = path.join(tempDir, 'raf.config.json');
262
+ fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
263
+
264
+ const config = resolveConfig(configPath);
265
+ expect(config.models.plan).toBe('haiku');
266
+ expect(config.models.execute).toBe('opus'); // default preserved
267
+ expect(config.models.failureAnalysis).toBe('haiku'); // default preserved
268
+ });
269
+
270
+ it('should deep-merge partial effort override', () => {
271
+ const configPath = path.join(tempDir, 'raf.config.json');
272
+ fs.writeFileSync(configPath, JSON.stringify({ effort: { execute: 'high' } }));
273
+
274
+ const config = resolveConfig(configPath);
275
+ expect(config.effort.execute).toBe('high');
276
+ expect(config.effort.plan).toBe('high'); // default preserved
277
+ });
278
+
279
+ it('should deep-merge partial commitFormat override', () => {
280
+ const configPath = path.join(tempDir, 'raf.config.json');
281
+ fs.writeFileSync(configPath, JSON.stringify({ commitFormat: { prefix: 'MY' } }));
282
+
283
+ const config = resolveConfig(configPath);
284
+ expect(config.commitFormat.prefix).toBe('MY');
285
+ expect(config.commitFormat.task).toBe(DEFAULT_CONFIG.commitFormat.task); // default preserved
286
+ });
287
+
288
+ it('should override scalar values', () => {
289
+ const configPath = path.join(tempDir, 'raf.config.json');
290
+ fs.writeFileSync(configPath, JSON.stringify({ timeout: 120, autoCommit: false, worktree: true }));
291
+
292
+ const config = resolveConfig(configPath);
293
+ expect(config.timeout).toBe(120);
294
+ expect(config.autoCommit).toBe(false);
295
+ expect(config.worktree).toBe(true);
296
+ expect(config.maxRetries).toBe(3); // default preserved
297
+ });
298
+
299
+ it('should throw on invalid config file', () => {
300
+ const configPath = path.join(tempDir, 'raf.config.json');
301
+ fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
302
+
303
+ expect(() => resolveConfig(configPath)).toThrow(ConfigValidationError);
304
+ });
305
+
306
+ it('should deep-merge full model ID override', () => {
307
+ const configPath = path.join(tempDir, 'raf.config.json');
308
+ fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'claude-opus-4-5-20251101' } }));
309
+
310
+ const config = resolveConfig(configPath);
311
+ expect(config.models.plan).toBe('claude-opus-4-5-20251101');
312
+ expect(config.models.execute).toBe('opus'); // default preserved
313
+ });
314
+
315
+ it('should not mutate DEFAULT_CONFIG', () => {
316
+ const configPath = path.join(tempDir, 'raf.config.json');
317
+ fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
318
+
319
+ resolveConfig(configPath);
320
+ expect(DEFAULT_CONFIG.models.plan).toBe('opus');
321
+ });
322
+ });
323
+
324
+ describe('saveConfig', () => {
325
+ it('should write config to file', () => {
326
+ const configPath = path.join(tempDir, 'sub', 'raf.config.json');
327
+ saveConfig(configPath, { timeout: 90 });
328
+
329
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
330
+ expect(content).toEqual({ timeout: 90 });
331
+ });
332
+
333
+ it('should create parent directories', () => {
334
+ const configPath = path.join(tempDir, 'deep', 'nested', 'raf.config.json');
335
+ saveConfig(configPath, { autoCommit: false });
336
+
337
+ expect(fs.existsSync(configPath)).toBe(true);
338
+ });
339
+ });
340
+
341
+ describe('helper accessors', () => {
342
+ it('getModel returns correct model for scenario', () => {
343
+ const configPath = path.join(tempDir, 'raf.config.json');
344
+ fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
345
+ // Use resolveConfig directly to avoid cached global config
346
+ const config = resolveConfig(configPath);
347
+ expect(config.models.plan).toBe('haiku');
348
+ expect(config.models.execute).toBe('opus');
349
+ });
350
+
351
+ it('getEffort returns correct effort for scenario', () => {
352
+ const configPath = path.join(tempDir, 'raf.config.json');
353
+ fs.writeFileSync(configPath, JSON.stringify({ effort: { plan: 'low' } }));
354
+ const config = resolveConfig(configPath);
355
+ expect(config.effort.plan).toBe('low');
356
+ });
357
+
358
+ it('getCommitFormat returns correct format', () => {
359
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
360
+ expect(config.commitFormat.task).toBe('{prefix}[{projectId}:{taskId}] {description}');
361
+ });
362
+
363
+ it('getCommitPrefix returns prefix', () => {
364
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
365
+ expect(config.commitFormat.prefix).toBe('RAF');
366
+ });
367
+
368
+ it('scalar helpers return defaults', () => {
369
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
370
+ expect(config.timeout).toBe(60);
371
+ expect(config.maxRetries).toBe(3);
372
+ expect(config.autoCommit).toBe(true);
373
+ expect(config.worktree).toBe(false);
374
+ expect(config.claudeCommand).toBe('claude');
375
+ });
376
+ });
377
+
378
+ describe('DEFAULT_CONFIG', () => {
379
+ it('should have all model scenarios defined', () => {
380
+ expect(DEFAULT_CONFIG.models.plan).toBe('opus');
381
+ expect(DEFAULT_CONFIG.models.execute).toBe('opus');
382
+ expect(DEFAULT_CONFIG.models.nameGeneration).toBe('sonnet');
383
+ expect(DEFAULT_CONFIG.models.failureAnalysis).toBe('haiku');
384
+ expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
385
+ expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
386
+ });
387
+
388
+ it('should have all effort scenarios defined', () => {
389
+ expect(DEFAULT_CONFIG.effort.plan).toBe('high');
390
+ expect(DEFAULT_CONFIG.effort.execute).toBe('medium');
391
+ expect(DEFAULT_CONFIG.effort.nameGeneration).toBe('low');
392
+ expect(DEFAULT_CONFIG.effort.failureAnalysis).toBe('low');
393
+ expect(DEFAULT_CONFIG.effort.prGeneration).toBe('medium');
394
+ expect(DEFAULT_CONFIG.effort.config).toBe('medium');
395
+ });
396
+
397
+ it('should have all commit format fields defined', () => {
398
+ expect(DEFAULT_CONFIG.commitFormat.task).toContain('{prefix}');
399
+ expect(DEFAULT_CONFIG.commitFormat.plan).toContain('{prefix}');
400
+ expect(DEFAULT_CONFIG.commitFormat.amend).toContain('{prefix}');
401
+ expect(DEFAULT_CONFIG.commitFormat.prefix).toBe('RAF');
402
+ });
403
+ });
404
+
405
+ describe('renderCommitMessage', () => {
406
+ it('should replace all placeholders in a template', () => {
407
+ const result = renderCommitMessage('{prefix}[{projectId}:{taskId}] {description}', {
408
+ prefix: 'RAF',
409
+ projectId: '001',
410
+ taskId: '01',
411
+ description: 'Add feature',
412
+ });
413
+ expect(result).toBe('RAF[001:01] Add feature');
414
+ });
415
+
416
+ it('should leave unknown placeholders as-is', () => {
417
+ const result = renderCommitMessage('{prefix}[{unknown}]', { prefix: 'RAF' });
418
+ expect(result).toBe('RAF[{unknown}]');
419
+ });
420
+
421
+ it('should handle plan commit format', () => {
422
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.plan, {
423
+ prefix: 'RAF',
424
+ projectId: 'abc123',
425
+ projectName: 'my-project',
426
+ });
427
+ expect(result).toBe('RAF[abc123] Plan: my-project');
428
+ });
429
+
430
+ it('should handle amend commit format', () => {
431
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.amend, {
432
+ prefix: 'RAF',
433
+ projectId: 'abc123',
434
+ projectName: 'my-project',
435
+ });
436
+ expect(result).toBe('RAF[abc123] Amend: my-project');
437
+ });
438
+
439
+ it('should handle task commit format', () => {
440
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
441
+ prefix: 'RAF',
442
+ projectId: '001',
443
+ taskId: '0a',
444
+ description: 'Fix bug',
445
+ });
446
+ expect(result).toBe('RAF[001:0a] Fix bug');
447
+ });
448
+
449
+ it('should handle empty variables gracefully', () => {
450
+ const result = renderCommitMessage('{prefix}[{id}]', {});
451
+ expect(result).toBe('{prefix}[{id}]');
452
+ });
453
+
454
+ it('should handle custom prefix', () => {
455
+ const result = renderCommitMessage('{prefix}[{projectId}:{taskId}] {description}', {
456
+ prefix: 'CUSTOM',
457
+ projectId: '001',
458
+ taskId: '01',
459
+ description: 'Test',
460
+ });
461
+ expect(result).toBe('CUSTOM[001:01] Test');
462
+ });
463
+ });
464
+
465
+ describe('config integration - defaults match previous hardcoded values', () => {
466
+ it('should default models to match previous hardcoded values', () => {
467
+ expect(DEFAULT_CONFIG.models.execute).toBe('opus');
468
+ expect(DEFAULT_CONFIG.models.plan).toBe('opus');
469
+ expect(DEFAULT_CONFIG.models.nameGeneration).toBe('sonnet');
470
+ expect(DEFAULT_CONFIG.models.failureAnalysis).toBe('haiku');
471
+ expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
472
+ });
473
+
474
+ it('should default effort to match previous hardcoded values', () => {
475
+ expect(DEFAULT_CONFIG.effort.execute).toBe('medium');
476
+ });
477
+
478
+ it('should default timeout to 60', () => {
479
+ expect(DEFAULT_CONFIG.timeout).toBe(60);
480
+ });
481
+
482
+ it('should default maxRetries to 3', () => {
483
+ expect(DEFAULT_CONFIG.maxRetries).toBe(3);
484
+ });
485
+
486
+ it('should default autoCommit to true', () => {
487
+ expect(DEFAULT_CONFIG.autoCommit).toBe(true);
488
+ });
489
+
490
+ it('should default worktree to false', () => {
491
+ expect(DEFAULT_CONFIG.worktree).toBe(false);
492
+ });
493
+
494
+ it('should default claudeCommand to claude', () => {
495
+ expect(DEFAULT_CONFIG.claudeCommand).toBe('claude');
496
+ });
497
+
498
+ it('should default commit format to match previous hardcoded format', () => {
499
+ // The task format should produce the same output as the old hardcoded format
500
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
501
+ prefix: DEFAULT_CONFIG.commitFormat.prefix,
502
+ projectId: 'abcdef',
503
+ taskId: '01',
504
+ description: 'Add feature',
505
+ });
506
+ expect(result).toBe('RAF[abcdef:01] Add feature');
507
+ });
508
+
509
+ it('should default plan commit format to match previous hardcoded format', () => {
510
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.plan, {
511
+ prefix: DEFAULT_CONFIG.commitFormat.prefix,
512
+ projectId: 'abcdef',
513
+ projectName: 'my-project',
514
+ });
515
+ expect(result).toBe('RAF[abcdef] Plan: my-project');
516
+ });
517
+
518
+ it('should default amend commit format to match previous hardcoded format', () => {
519
+ const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.amend, {
520
+ prefix: DEFAULT_CONFIG.commitFormat.prefix,
521
+ projectId: 'abcdef',
522
+ projectName: 'my-project',
523
+ });
524
+ expect(result).toBe('RAF[abcdef] Amend: my-project');
525
+ });
526
+ });
527
+
528
+ describe('validateConfig - pricing', () => {
529
+ it('should accept valid pricing config', () => {
530
+ expect(() => validateConfig({
531
+ pricing: {
532
+ opus: { inputPerMTok: 15, outputPerMTok: 75 },
533
+ },
534
+ })).not.toThrow();
535
+ });
536
+
537
+ it('should accept partial pricing override', () => {
538
+ expect(() => validateConfig({
539
+ pricing: {
540
+ haiku: { outputPerMTok: 4 },
541
+ },
542
+ })).not.toThrow();
543
+ });
544
+
545
+ it('should reject non-object pricing', () => {
546
+ expect(() => validateConfig({ pricing: 'expensive' })).toThrow('pricing must be an object');
547
+ });
548
+
549
+ it('should reject unknown pricing categories', () => {
550
+ expect(() => validateConfig({ pricing: { gpt4: { inputPerMTok: 10 } } })).toThrow('Unknown config key: pricing.gpt4');
551
+ });
552
+
553
+ it('should reject non-object category value', () => {
554
+ expect(() => validateConfig({ pricing: { opus: 'expensive' } })).toThrow('pricing.opus must be an object');
555
+ });
556
+
557
+ it('should reject unknown pricing fields', () => {
558
+ expect(() => validateConfig({ pricing: { opus: { unknownField: 5 } } })).toThrow('Unknown config key: pricing.opus.unknownField');
559
+ });
560
+
561
+ it('should reject negative pricing values', () => {
562
+ expect(() => validateConfig({ pricing: { opus: { inputPerMTok: -1 } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
563
+ });
564
+
565
+ it('should reject non-number pricing values', () => {
566
+ expect(() => validateConfig({ pricing: { opus: { inputPerMTok: 'fifteen' } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
567
+ });
568
+
569
+ it('should accept zero pricing values', () => {
570
+ expect(() => validateConfig({ pricing: { haiku: { inputPerMTok: 0 } } })).not.toThrow();
571
+ });
572
+
573
+ it('should reject Infinity pricing values', () => {
574
+ expect(() => validateConfig({ pricing: { opus: { inputPerMTok: Infinity } } })).toThrow('must be a non-negative number');
575
+ });
576
+ });
577
+
578
+ describe('resolveModelPricingCategory', () => {
579
+ it('should map short aliases directly', () => {
580
+ expect(resolveModelPricingCategory('opus')).toBe('opus');
581
+ expect(resolveModelPricingCategory('sonnet')).toBe('sonnet');
582
+ expect(resolveModelPricingCategory('haiku')).toBe('haiku');
583
+ });
584
+
585
+ it('should extract family from full model IDs', () => {
586
+ expect(resolveModelPricingCategory('claude-opus-4-6')).toBe('opus');
587
+ expect(resolveModelPricingCategory('claude-sonnet-4-5-20250929')).toBe('sonnet');
588
+ expect(resolveModelPricingCategory('claude-haiku-4-5-20251001')).toBe('haiku');
589
+ });
590
+
591
+ it('should return null for unknown model families', () => {
592
+ expect(resolveModelPricingCategory('claude-unknown-3-0')).toBeNull();
593
+ expect(resolveModelPricingCategory('gpt-4')).toBeNull();
594
+ expect(resolveModelPricingCategory('')).toBeNull();
595
+ });
596
+ });
597
+
598
+ describe('resolveConfig - pricing', () => {
599
+ it('should include default pricing when no config file', () => {
600
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
601
+ expect(config.pricing.opus.inputPerMTok).toBe(15);
602
+ expect(config.pricing.sonnet.inputPerMTok).toBe(3);
603
+ expect(config.pricing.haiku.inputPerMTok).toBe(1);
604
+ });
605
+
606
+ it('should deep-merge partial pricing override', () => {
607
+ const configPath = path.join(tempDir, 'pricing.json');
608
+ fs.writeFileSync(configPath, JSON.stringify({
609
+ pricing: { opus: { inputPerMTok: 10 } },
610
+ }));
611
+
612
+ const config = resolveConfig(configPath);
613
+ expect(config.pricing.opus.inputPerMTok).toBe(10);
614
+ expect(config.pricing.opus.outputPerMTok).toBe(75); // default preserved
615
+ expect(config.pricing.sonnet.inputPerMTok).toBe(3); // default preserved
616
+ });
617
+ });
618
+
619
+ describe('config integration - overrides work', () => {
620
+ it('should use custom model when configured', () => {
621
+ const configPath = path.join(tempDir, 'custom-models.json');
622
+ saveConfig(configPath, { models: { execute: 'sonnet', plan: 'haiku' } });
623
+ const config = resolveConfig(configPath);
624
+ expect(config.models.execute).toBe('sonnet');
625
+ expect(config.models.plan).toBe('haiku');
626
+ // Others should remain at defaults
627
+ expect(config.models.nameGeneration).toBe('sonnet');
628
+ expect(config.models.failureAnalysis).toBe('haiku');
629
+ });
630
+
631
+ it('should use custom effort when configured', () => {
632
+ const configPath = path.join(tempDir, 'custom-effort.json');
633
+ saveConfig(configPath, { effort: { execute: 'high' } });
634
+ const config = resolveConfig(configPath);
635
+ expect(config.effort.execute).toBe('high');
636
+ // Others should remain at defaults
637
+ expect(config.effort.plan).toBe('high');
638
+ });
639
+
640
+ it('should use custom commit format when configured', () => {
641
+ const configPath = path.join(tempDir, 'custom-commit.json');
642
+ saveConfig(configPath, { commitFormat: { prefix: 'CUSTOM', task: '{prefix}-{taskId}: {description}' } });
643
+ const config = resolveConfig(configPath);
644
+ expect(config.commitFormat.prefix).toBe('CUSTOM');
645
+ expect(config.commitFormat.task).toBe('{prefix}-{taskId}: {description}');
646
+ // plan/amend remain at defaults
647
+ expect(config.commitFormat.plan).toBe(DEFAULT_CONFIG.commitFormat.plan);
648
+ });
649
+ });
72
650
  });