rafcode 2.2.0 → 2.4.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 (125) hide show
  1. package/CLAUDE.md +19 -4
  2. package/RAF/ahtahs-token-reaper/decisions.md +37 -0
  3. package/RAF/ahtahs-token-reaper/input.md +20 -0
  4. package/RAF/ahtahs-token-reaper/outcomes/01-extend-token-tracker-data-model.md +42 -0
  5. package/RAF/ahtahs-token-reaper/outcomes/02-accumulate-usage-in-retry-loop.md +31 -0
  6. package/RAF/ahtahs-token-reaper/outcomes/03-per-attempt-display-formatting.md +60 -0
  7. package/RAF/ahtahs-token-reaper/outcomes/04-add-model-name-to-claude-call-logs.md +57 -0
  8. package/RAF/ahtahs-token-reaper/outcomes/05-handle-invalid-config-in-raf-config.md +46 -0
  9. package/RAF/ahtahs-token-reaper/outcomes/06-fix-verbose-toggle-timer-display.md +38 -0
  10. package/RAF/ahtahs-token-reaper/plans/01-extend-token-tracker-data-model.md +36 -0
  11. package/RAF/ahtahs-token-reaper/plans/02-accumulate-usage-in-retry-loop.md +36 -0
  12. package/RAF/ahtahs-token-reaper/plans/03-per-attempt-display-formatting.md +43 -0
  13. package/RAF/ahtahs-token-reaper/plans/04-add-model-name-to-claude-call-logs.md +38 -0
  14. package/RAF/ahtahs-token-reaper/plans/05-handle-invalid-config-in-raf-config.md +36 -0
  15. package/RAF/ahtahs-token-reaper/plans/06-fix-verbose-toggle-timer-display.md +40 -0
  16. package/RAF/ahvrih-rate-forge/decisions.md +70 -0
  17. package/RAF/ahvrih-rate-forge/input.md +44 -0
  18. package/RAF/ahvrih-rate-forge/outcomes/01-remove-claude-command-config.md +58 -0
  19. package/RAF/ahvrih-rate-forge/outcomes/02-fix-mixed-attempt-cost.md +46 -0
  20. package/RAF/ahvrih-rate-forge/outcomes/03-rate-limit-estimation.md +82 -0
  21. package/RAF/ahvrih-rate-forge/outcomes/04-show-version-in-do-logs.md +45 -0
  22. package/RAF/ahvrih-rate-forge/outcomes/05-sync-main-before-worktree.md +96 -0
  23. package/RAF/ahvrih-rate-forge/outcomes/06-sync-readme-with-codebase.md +45 -0
  24. package/RAF/ahvrih-rate-forge/outcomes/07-no-session-persistence.md +26 -0
  25. package/RAF/ahvrih-rate-forge/outcomes/08-plan-execution-metadata.md +130 -0
  26. package/RAF/ahvrih-rate-forge/plans/01-remove-claude-command-config.md +36 -0
  27. package/RAF/ahvrih-rate-forge/plans/02-fix-mixed-attempt-cost.md +33 -0
  28. package/RAF/ahvrih-rate-forge/plans/03-rate-limit-estimation.md +82 -0
  29. package/RAF/ahvrih-rate-forge/plans/04-show-version-in-do-logs.md +32 -0
  30. package/RAF/ahvrih-rate-forge/plans/05-sync-main-before-worktree.md +40 -0
  31. package/RAF/ahvrih-rate-forge/plans/06-sync-readme-with-codebase.md +61 -0
  32. package/RAF/ahvrih-rate-forge/plans/07-no-session-persistence.md +28 -0
  33. package/RAF/ahvrih-rate-forge/plans/08-plan-execution-metadata.md +123 -0
  34. package/README.md +27 -7
  35. package/dist/commands/config.d.ts.map +1 -1
  36. package/dist/commands/config.js +24 -7
  37. package/dist/commands/config.js.map +1 -1
  38. package/dist/commands/do.d.ts.map +1 -1
  39. package/dist/commands/do.js +122 -27
  40. package/dist/commands/do.js.map +1 -1
  41. package/dist/commands/plan.d.ts.map +1 -1
  42. package/dist/commands/plan.js +79 -3
  43. package/dist/commands/plan.js.map +1 -1
  44. package/dist/core/claude-runner.d.ts +6 -6
  45. package/dist/core/claude-runner.d.ts.map +1 -1
  46. package/dist/core/claude-runner.js +9 -10
  47. package/dist/core/claude-runner.js.map +1 -1
  48. package/dist/core/failure-analyzer.d.ts.map +1 -1
  49. package/dist/core/failure-analyzer.js +3 -3
  50. package/dist/core/failure-analyzer.js.map +1 -1
  51. package/dist/core/pull-request.d.ts.map +1 -1
  52. package/dist/core/pull-request.js +5 -3
  53. package/dist/core/pull-request.js.map +1 -1
  54. package/dist/core/state-derivation.d.ts +5 -0
  55. package/dist/core/state-derivation.d.ts.map +1 -1
  56. package/dist/core/state-derivation.js +14 -4
  57. package/dist/core/state-derivation.js.map +1 -1
  58. package/dist/core/worktree.d.ts +32 -0
  59. package/dist/core/worktree.d.ts.map +1 -1
  60. package/dist/core/worktree.js +215 -0
  61. package/dist/core/worktree.js.map +1 -1
  62. package/dist/prompts/amend.d.ts.map +1 -1
  63. package/dist/prompts/amend.js +26 -11
  64. package/dist/prompts/amend.js.map +1 -1
  65. package/dist/prompts/planning.d.ts.map +1 -1
  66. package/dist/prompts/planning.js +26 -11
  67. package/dist/prompts/planning.js.map +1 -1
  68. package/dist/types/config.d.ts +30 -13
  69. package/dist/types/config.d.ts.map +1 -1
  70. package/dist/types/config.js +14 -10
  71. package/dist/types/config.js.map +1 -1
  72. package/dist/utils/config.d.ts +53 -4
  73. package/dist/utils/config.d.ts.map +1 -1
  74. package/dist/utils/config.js +197 -30
  75. package/dist/utils/config.js.map +1 -1
  76. package/dist/utils/frontmatter.d.ts +43 -0
  77. package/dist/utils/frontmatter.d.ts.map +1 -0
  78. package/dist/utils/frontmatter.js +85 -0
  79. package/dist/utils/frontmatter.js.map +1 -0
  80. package/dist/utils/name-generator.d.ts.map +1 -1
  81. package/dist/utils/name-generator.js +2 -3
  82. package/dist/utils/name-generator.js.map +1 -1
  83. package/dist/utils/session-parser.d.ts +44 -0
  84. package/dist/utils/session-parser.d.ts.map +1 -0
  85. package/dist/utils/session-parser.js +122 -0
  86. package/dist/utils/session-parser.js.map +1 -0
  87. package/dist/utils/terminal-symbols.d.ts +28 -5
  88. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  89. package/dist/utils/terminal-symbols.js +77 -18
  90. package/dist/utils/terminal-symbols.js.map +1 -1
  91. package/dist/utils/token-tracker.d.ts +31 -1
  92. package/dist/utils/token-tracker.d.ts.map +1 -1
  93. package/dist/utils/token-tracker.js +94 -4
  94. package/dist/utils/token-tracker.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/commands/config.ts +26 -7
  97. package/src/commands/do.ts +157 -29
  98. package/src/commands/plan.ts +89 -2
  99. package/src/core/claude-runner.ts +16 -17
  100. package/src/core/failure-analyzer.ts +3 -3
  101. package/src/core/pull-request.ts +5 -3
  102. package/src/core/state-derivation.ts +20 -4
  103. package/src/core/worktree.ts +230 -0
  104. package/src/prompts/amend.ts +26 -11
  105. package/src/prompts/config-docs.md +91 -29
  106. package/src/prompts/planning.ts +26 -11
  107. package/src/types/config.ts +46 -21
  108. package/src/utils/config.ts +222 -33
  109. package/src/utils/frontmatter.ts +110 -0
  110. package/src/utils/name-generator.ts +2 -3
  111. package/src/utils/session-parser.ts +161 -0
  112. package/src/utils/terminal-symbols.ts +105 -18
  113. package/src/utils/token-tracker.ts +109 -4
  114. package/tests/unit/claude-runner-interactive.test.ts +8 -6
  115. package/tests/unit/claude-runner.test.ts +5 -66
  116. package/tests/unit/config-command.test.ts +84 -5
  117. package/tests/unit/config.test.ts +292 -45
  118. package/tests/unit/frontmatter.test.ts +182 -0
  119. package/tests/unit/post-execution-picker.test.ts +5 -0
  120. package/tests/unit/session-parser.test.ts +301 -0
  121. package/tests/unit/terminal-symbols.test.ts +263 -33
  122. package/tests/unit/timer-verbose-integration.test.ts +170 -0
  123. package/tests/unit/token-tracker.test.ts +653 -17
  124. package/tests/unit/validation.test.ts +6 -4
  125. package/tests/unit/worktree.test.ts +242 -0
@@ -3,17 +3,26 @@ import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
4
  import { Command } from 'commander';
5
5
  import { createConfigCommand } from '../../src/commands/config.js';
6
- import { validateConfig, ConfigValidationError } from '../../src/utils/config.js';
6
+ import {
7
+ validateConfig,
8
+ ConfigValidationError,
9
+ resolveConfig,
10
+ getModel,
11
+ resetConfigCache,
12
+ } from '../../src/utils/config.js';
13
+ import { DEFAULT_CONFIG } from '../../src/types/config.js';
7
14
 
8
15
  describe('Config Command', () => {
9
16
  let tempDir: string;
10
17
 
11
18
  beforeEach(() => {
12
19
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-config-cmd-test-'));
20
+ resetConfigCache();
13
21
  });
14
22
 
15
23
  afterEach(() => {
16
24
  fs.rmSync(tempDir, { recursive: true, force: true });
25
+ resetConfigCache();
17
26
  });
18
27
 
19
28
  describe('Command setup', () => {
@@ -55,8 +64,8 @@ describe('Config Command', () => {
55
64
  expect(() => validateConfig(config)).not.toThrow();
56
65
  });
57
66
 
58
- it('should accept valid config with effort override', () => {
59
- const config = { effort: { plan: 'low' } };
67
+ it('should accept valid config with effortMapping override', () => {
68
+ const config = { effortMapping: { low: 'haiku', medium: 'sonnet' } };
60
69
  expect(() => validateConfig(config)).not.toThrow();
61
70
  });
62
71
 
@@ -75,8 +84,8 @@ describe('Config Command', () => {
75
84
  expect(() => validateConfig(config)).toThrow(ConfigValidationError);
76
85
  });
77
86
 
78
- it('should reject config with invalid effort level', () => {
79
- const config = { effort: { plan: 'max' } };
87
+ it('should reject config with invalid effortMapping model', () => {
88
+ const config = { effortMapping: { low: 'gpt-4' } };
80
89
  expect(() => validateConfig(config)).toThrow(ConfigValidationError);
81
90
  });
82
91
 
@@ -160,4 +169,74 @@ describe('Config Command', () => {
160
169
  expect(content).toContain('"worktree": true');
161
170
  });
162
171
  });
172
+
173
+ describe('Error recovery - invalid config fallback', () => {
174
+ // These tests verify the behaviors that runConfigSession relies on for error recovery
175
+ // The config command catches errors from getModel/getEffort and falls back to defaults
176
+
177
+ it('should throw on invalid JSON when resolving config', () => {
178
+ const configPath = path.join(tempDir, 'raf.config.json');
179
+ fs.writeFileSync(configPath, '{ invalid json }}}');
180
+
181
+ expect(() => resolveConfig(configPath)).toThrow(SyntaxError);
182
+ });
183
+
184
+ it('should throw on schema validation failure when resolving config', () => {
185
+ const configPath = path.join(tempDir, 'raf.config.json');
186
+ fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
187
+
188
+ expect(() => resolveConfig(configPath)).toThrow(ConfigValidationError);
189
+ });
190
+
191
+ it('should have valid default fallback values for config scenario', () => {
192
+ // These are the values that runConfigSession uses when config loading fails
193
+ expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
194
+ // effortMapping defaults used for per-task model resolution
195
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
196
+ });
197
+
198
+ it('should be able to read raw file contents even when config is invalid JSON', () => {
199
+ // This verifies that getCurrentConfigState can still read the broken file
200
+ // so Claude can see and help fix it
201
+ const configPath = path.join(tempDir, 'raf.config.json');
202
+ const invalidContent = '{ "broken": true, }'; // trailing comma = invalid
203
+ fs.writeFileSync(configPath, invalidContent);
204
+
205
+ // File is readable even though it's invalid JSON
206
+ const content = fs.readFileSync(configPath, 'utf-8');
207
+ expect(content).toBe(invalidContent);
208
+ });
209
+
210
+ it('should be able to read raw file contents even when config fails schema validation', () => {
211
+ const configPath = path.join(tempDir, 'raf.config.json');
212
+ const invalidContent = JSON.stringify({ badKey: 'value' }, null, 2);
213
+ fs.writeFileSync(configPath, invalidContent);
214
+
215
+ // File is readable even though it fails validation
216
+ const content = fs.readFileSync(configPath, 'utf-8');
217
+ expect(JSON.parse(content)).toEqual({ badKey: 'value' });
218
+ });
219
+
220
+ it('resetConfigCache should clear the cached config', () => {
221
+ // This is used by runConfigSession to clear a broken cached config
222
+ // so subsequent operations don't fail
223
+ const configPath = path.join(tempDir, 'valid.json');
224
+ fs.writeFileSync(configPath, JSON.stringify({ timeout: 99 }));
225
+
226
+ // Load the config
227
+ const config1 = resolveConfig(configPath);
228
+ expect(config1.timeout).toBe(99);
229
+
230
+ // Write different content
231
+ fs.writeFileSync(configPath, JSON.stringify({ timeout: 120 }));
232
+
233
+ // Without reset, we'd still get cached value (but resolveConfig doesn't use cache)
234
+ // This test verifies resetConfigCache exists and can be called
235
+ resetConfigCache();
236
+
237
+ // After reset, we should get new value
238
+ const config2 = resolveConfig(configPath);
239
+ expect(config2.timeout).toBe(120);
240
+ });
241
+ });
163
242
  });
@@ -8,14 +8,19 @@ import {
8
8
  ConfigValidationError,
9
9
  resolveConfig,
10
10
  getModel,
11
- getEffort,
11
+ getEffortMapping,
12
+ resolveEffortToModel,
13
+ getModelTier,
14
+ applyModelCeiling,
12
15
  getCommitFormat,
13
16
  getCommitPrefix,
14
17
  getTimeout,
15
18
  getMaxRetries,
16
19
  getAutoCommit,
17
20
  getWorktreeDefault,
18
- getClaudeCommand,
21
+ getSyncMainBranch,
22
+ getModelShortName,
23
+ resolveFullModelId,
19
24
  resetConfigCache,
20
25
  saveConfig,
21
26
  renderCommitMessage,
@@ -89,13 +94,12 @@ describe('Config', () => {
89
94
  it('should accept a full valid config', () => {
90
95
  const config = {
91
96
  models: { plan: 'opus', execute: 'haiku' },
92
- effort: { plan: 'high', execute: 'low' },
97
+ effortMapping: { low: 'haiku', medium: 'sonnet', high: 'opus' },
93
98
  timeout: 30,
94
99
  maxRetries: 5,
95
100
  autoCommit: false,
96
101
  worktree: true,
97
102
  commitFormat: { prefix: 'MY', task: '{prefix}[{projectId}] {description}' },
98
- claudeCommand: '/usr/local/bin/claude',
99
103
  };
100
104
  expect(() => validateConfig(config)).not.toThrow();
101
105
  });
@@ -112,12 +116,16 @@ describe('Config', () => {
112
116
  expect(() => validateConfig({ unknownKey: 'value' })).toThrow('Unknown config key: unknownKey');
113
117
  });
114
118
 
119
+ it('should reject removed claudeCommand key', () => {
120
+ expect(() => validateConfig({ claudeCommand: 'claude' })).toThrow('Unknown config key: claudeCommand');
121
+ });
122
+
115
123
  it('should reject unknown model keys', () => {
116
124
  expect(() => validateConfig({ models: { unknownScenario: 'opus' } })).toThrow('Unknown config key: models.unknownScenario');
117
125
  });
118
126
 
119
- it('should reject unknown effort keys', () => {
120
- expect(() => validateConfig({ effort: { unknownScenario: 'high' } })).toThrow('Unknown config key: effort.unknownScenario');
127
+ it('should reject unknown effortMapping keys', () => {
128
+ expect(() => validateConfig({ effortMapping: { unknownLevel: 'haiku' } })).toThrow('Unknown config key: effortMapping.unknownLevel');
121
129
  });
122
130
 
123
131
  it('should reject unknown commitFormat keys', () => {
@@ -150,9 +158,9 @@ describe('Config', () => {
150
158
  expect(() => validateConfig({ models: { plan: 123 } })).toThrow('models.plan must be');
151
159
  });
152
160
 
153
- // Invalid effort values
154
- it('should reject invalid effort levels', () => {
155
- expect(() => validateConfig({ effort: { plan: 'ultra' } })).toThrow('effort.plan must be one of');
161
+ // Invalid effortMapping values
162
+ it('should reject invalid effortMapping model names', () => {
163
+ expect(() => validateConfig({ effortMapping: { low: 'invalid-model' } })).toThrow('effortMapping.low must be a short alias');
156
164
  });
157
165
 
158
166
  // Invalid types for nested objects
@@ -164,8 +172,8 @@ describe('Config', () => {
164
172
  expect(() => validateConfig({ models: ['opus'] })).toThrow('models must be an object');
165
173
  });
166
174
 
167
- it('should reject non-object effort', () => {
168
- expect(() => validateConfig({ effort: 'high' })).toThrow('effort must be an object');
175
+ it('should reject non-object effortMapping', () => {
176
+ expect(() => validateConfig({ effortMapping: 'high' })).toThrow('effortMapping must be an object');
169
177
  });
170
178
 
171
179
  it('should reject non-object commitFormat', () => {
@@ -207,17 +215,13 @@ describe('Config', () => {
207
215
  expect(() => validateConfig({ worktree: 1 })).toThrow('worktree must be a boolean');
208
216
  });
209
217
 
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');
218
+ it('should reject non-boolean syncMainBranch', () => {
219
+ expect(() => validateConfig({ syncMainBranch: 'yes' })).toThrow('syncMainBranch must be a boolean');
217
220
  });
218
221
 
219
- it('should reject non-string claudeCommand', () => {
220
- expect(() => validateConfig({ claudeCommand: 123 })).toThrow('claudeCommand must be a non-empty string');
222
+ it('should accept boolean syncMainBranch', () => {
223
+ expect(() => validateConfig({ syncMainBranch: true })).not.toThrow();
224
+ expect(() => validateConfig({ syncMainBranch: false })).not.toThrow();
221
225
  });
222
226
 
223
227
  // Non-string commitFormat values
@@ -267,13 +271,14 @@ describe('Config', () => {
267
271
  expect(config.models.failureAnalysis).toBe('haiku'); // default preserved
268
272
  });
269
273
 
270
- it('should deep-merge partial effort override', () => {
274
+ it('should deep-merge partial effortMapping override', () => {
271
275
  const configPath = path.join(tempDir, 'raf.config.json');
272
- fs.writeFileSync(configPath, JSON.stringify({ effort: { execute: 'high' } }));
276
+ fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { medium: 'opus' } }));
273
277
 
274
278
  const config = resolveConfig(configPath);
275
- expect(config.effort.execute).toBe('high');
276
- expect(config.effort.plan).toBe('high'); // default preserved
279
+ expect(config.effortMapping.medium).toBe('opus');
280
+ expect(config.effortMapping.low).toBe('haiku'); // default preserved
281
+ expect(config.effortMapping.high).toBe('opus'); // default preserved
277
282
  });
278
283
 
279
284
  it('should deep-merge partial commitFormat override', () => {
@@ -296,6 +301,19 @@ describe('Config', () => {
296
301
  expect(config.maxRetries).toBe(3); // default preserved
297
302
  });
298
303
 
304
+ it('should override syncMainBranch', () => {
305
+ const configPath = path.join(tempDir, 'raf.config.json');
306
+ fs.writeFileSync(configPath, JSON.stringify({ syncMainBranch: false }));
307
+
308
+ const config = resolveConfig(configPath);
309
+ expect(config.syncMainBranch).toBe(false);
310
+ });
311
+
312
+ it('should default syncMainBranch to true', () => {
313
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
314
+ expect(config.syncMainBranch).toBe(true);
315
+ });
316
+
299
317
  it('should throw on invalid config file', () => {
300
318
  const configPath = path.join(tempDir, 'raf.config.json');
301
319
  fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
@@ -348,11 +366,12 @@ describe('Config', () => {
348
366
  expect(config.models.execute).toBe('opus');
349
367
  });
350
368
 
351
- it('getEffort returns correct effort for scenario', () => {
369
+ it('effortMapping resolves correctly from config', () => {
352
370
  const configPath = path.join(tempDir, 'raf.config.json');
353
- fs.writeFileSync(configPath, JSON.stringify({ effort: { plan: 'low' } }));
371
+ fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { high: 'sonnet' } }));
354
372
  const config = resolveConfig(configPath);
355
- expect(config.effort.plan).toBe('low');
373
+ expect(config.effortMapping.high).toBe('sonnet');
374
+ expect(config.effortMapping.low).toBe('haiku'); // default preserved
356
375
  });
357
376
 
358
377
  it('getCommitFormat returns correct format', () => {
@@ -371,7 +390,6 @@ describe('Config', () => {
371
390
  expect(config.maxRetries).toBe(3);
372
391
  expect(config.autoCommit).toBe(true);
373
392
  expect(config.worktree).toBe(false);
374
- expect(config.claudeCommand).toBe('claude');
375
393
  });
376
394
  });
377
395
 
@@ -385,13 +403,10 @@ describe('Config', () => {
385
403
  expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
386
404
  });
387
405
 
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');
406
+ it('should have all effortMapping levels defined', () => {
407
+ expect(DEFAULT_CONFIG.effortMapping.low).toBe('haiku');
408
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
409
+ expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
395
410
  });
396
411
 
397
412
  it('should have all commit format fields defined', () => {
@@ -471,8 +486,10 @@ describe('Config', () => {
471
486
  expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
472
487
  });
473
488
 
474
- it('should default effort to match previous hardcoded values', () => {
475
- expect(DEFAULT_CONFIG.effort.execute).toBe('medium');
489
+ it('should default effortMapping to haiku/sonnet/opus', () => {
490
+ expect(DEFAULT_CONFIG.effortMapping.low).toBe('haiku');
491
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
492
+ expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
476
493
  });
477
494
 
478
495
  it('should default timeout to 60', () => {
@@ -491,10 +508,6 @@ describe('Config', () => {
491
508
  expect(DEFAULT_CONFIG.worktree).toBe(false);
492
509
  });
493
510
 
494
- it('should default claudeCommand to claude', () => {
495
- expect(DEFAULT_CONFIG.claudeCommand).toBe('claude');
496
- });
497
-
498
511
  it('should default commit format to match previous hardcoded format', () => {
499
512
  // The task format should produce the same output as the old hardcoded format
500
513
  const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
@@ -616,6 +629,49 @@ describe('Config', () => {
616
629
  });
617
630
  });
618
631
 
632
+ describe('getModelShortName', () => {
633
+ it('should return short aliases as-is', () => {
634
+ expect(getModelShortName('opus')).toBe('opus');
635
+ expect(getModelShortName('sonnet')).toBe('sonnet');
636
+ expect(getModelShortName('haiku')).toBe('haiku');
637
+ });
638
+
639
+ it('should extract family from full model IDs', () => {
640
+ expect(getModelShortName('claude-opus-4-6')).toBe('opus');
641
+ expect(getModelShortName('claude-opus-4-5-20251101')).toBe('opus');
642
+ expect(getModelShortName('claude-sonnet-4-5-20250929')).toBe('sonnet');
643
+ expect(getModelShortName('claude-sonnet-4-5')).toBe('sonnet');
644
+ expect(getModelShortName('claude-haiku-4-5-20251001')).toBe('haiku');
645
+ });
646
+
647
+ it('should return unknown model IDs as-is', () => {
648
+ expect(getModelShortName('gpt-4')).toBe('gpt-4');
649
+ expect(getModelShortName('claude-unknown-3-0')).toBe('claude-unknown-3-0');
650
+ expect(getModelShortName('')).toBe('');
651
+ expect(getModelShortName('some-random-model')).toBe('some-random-model');
652
+ });
653
+ });
654
+
655
+ describe('resolveFullModelId', () => {
656
+ it('should resolve short aliases to full model IDs', () => {
657
+ expect(resolveFullModelId('opus')).toBe('claude-opus-4-6');
658
+ expect(resolveFullModelId('sonnet')).toBe('claude-sonnet-4-5-20250929');
659
+ expect(resolveFullModelId('haiku')).toBe('claude-haiku-4-5-20251001');
660
+ });
661
+
662
+ it('should return full model IDs as-is', () => {
663
+ expect(resolveFullModelId('claude-opus-4-6')).toBe('claude-opus-4-6');
664
+ expect(resolveFullModelId('claude-sonnet-4-5-20250929')).toBe('claude-sonnet-4-5-20250929');
665
+ expect(resolveFullModelId('claude-haiku-4-5-20251001')).toBe('claude-haiku-4-5-20251001');
666
+ });
667
+
668
+ it('should return unknown model strings as-is', () => {
669
+ expect(resolveFullModelId('gpt-4')).toBe('gpt-4');
670
+ expect(resolveFullModelId('claude-unknown-3-0')).toBe('claude-unknown-3-0');
671
+ expect(resolveFullModelId('')).toBe('');
672
+ });
673
+ });
674
+
619
675
  describe('config integration - overrides work', () => {
620
676
  it('should use custom model when configured', () => {
621
677
  const configPath = path.join(tempDir, 'custom-models.json');
@@ -628,13 +684,14 @@ describe('Config', () => {
628
684
  expect(config.models.failureAnalysis).toBe('haiku');
629
685
  });
630
686
 
631
- it('should use custom effort when configured', () => {
687
+ it('should use custom effortMapping when configured', () => {
632
688
  const configPath = path.join(tempDir, 'custom-effort.json');
633
- saveConfig(configPath, { effort: { execute: 'high' } });
689
+ saveConfig(configPath, { effortMapping: { high: 'sonnet' } });
634
690
  const config = resolveConfig(configPath);
635
- expect(config.effort.execute).toBe('high');
691
+ expect(config.effortMapping.high).toBe('sonnet');
636
692
  // Others should remain at defaults
637
- expect(config.effort.plan).toBe('high');
693
+ expect(config.effortMapping.low).toBe('haiku');
694
+ expect(config.effortMapping.medium).toBe('sonnet');
638
695
  });
639
696
 
640
697
  it('should use custom commit format when configured', () => {
@@ -647,4 +704,194 @@ describe('Config', () => {
647
704
  expect(config.commitFormat.plan).toBe(DEFAULT_CONFIG.commitFormat.plan);
648
705
  });
649
706
  });
707
+
708
+ describe('validateConfig - display', () => {
709
+ it('should accept valid display config', () => {
710
+ expect(() => validateConfig({
711
+ display: {
712
+ showRateLimitEstimate: true,
713
+ showCacheTokens: false,
714
+ },
715
+ })).not.toThrow();
716
+ });
717
+
718
+ it('should accept partial display override', () => {
719
+ expect(() => validateConfig({
720
+ display: { showRateLimitEstimate: false },
721
+ })).not.toThrow();
722
+ });
723
+
724
+ it('should reject non-object display', () => {
725
+ expect(() => validateConfig({ display: 'full' })).toThrow('display must be an object');
726
+ });
727
+
728
+ it('should reject unknown display keys', () => {
729
+ expect(() => validateConfig({ display: { unknownKey: true } })).toThrow('Unknown config key: display.unknownKey');
730
+ });
731
+
732
+ it('should reject non-boolean display values', () => {
733
+ expect(() => validateConfig({ display: { showRateLimitEstimate: 'yes' } })).toThrow('display.showRateLimitEstimate must be a boolean');
734
+ });
735
+ });
736
+
737
+ describe('validateConfig - rateLimitWindow', () => {
738
+ it('should accept valid rateLimitWindow config', () => {
739
+ expect(() => validateConfig({
740
+ rateLimitWindow: { sonnetTokenCap: 100000 },
741
+ })).not.toThrow();
742
+ });
743
+
744
+ it('should reject non-object rateLimitWindow', () => {
745
+ expect(() => validateConfig({ rateLimitWindow: 88000 })).toThrow('rateLimitWindow must be an object');
746
+ });
747
+
748
+ it('should reject unknown rateLimitWindow keys', () => {
749
+ expect(() => validateConfig({ rateLimitWindow: { unknownKey: 50000 } })).toThrow('Unknown config key: rateLimitWindow.unknownKey');
750
+ });
751
+
752
+ it('should reject non-positive sonnetTokenCap', () => {
753
+ expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: 0 } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
754
+ expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: -100 } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
755
+ });
756
+
757
+ it('should reject non-number sonnetTokenCap', () => {
758
+ expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: '88000' } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
759
+ });
760
+ });
761
+
762
+ describe('resolveConfig - display and rateLimitWindow', () => {
763
+ it('should include default display when no config file', () => {
764
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
765
+ expect(config.display.showRateLimitEstimate).toBe(true);
766
+ expect(config.display.showCacheTokens).toBe(true);
767
+ });
768
+
769
+ it('should include default rateLimitWindow when no config file', () => {
770
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
771
+ expect(config.rateLimitWindow.sonnetTokenCap).toBe(88000);
772
+ });
773
+
774
+ it('should deep-merge partial display override', () => {
775
+ const configPath = path.join(tempDir, 'display.json');
776
+ fs.writeFileSync(configPath, JSON.stringify({
777
+ display: { showRateLimitEstimate: false },
778
+ }));
779
+
780
+ const config = resolveConfig(configPath);
781
+ expect(config.display.showRateLimitEstimate).toBe(false);
782
+ expect(config.display.showCacheTokens).toBe(true); // default preserved
783
+ });
784
+
785
+ it('should deep-merge partial rateLimitWindow override', () => {
786
+ const configPath = path.join(tempDir, 'rateLimit.json');
787
+ fs.writeFileSync(configPath, JSON.stringify({
788
+ rateLimitWindow: { sonnetTokenCap: 100000 },
789
+ }));
790
+
791
+ const config = resolveConfig(configPath);
792
+ expect(config.rateLimitWindow.sonnetTokenCap).toBe(100000);
793
+ });
794
+ });
795
+
796
+ describe('DEFAULT_CONFIG - display and rateLimitWindow', () => {
797
+ it('should have default display settings', () => {
798
+ expect(DEFAULT_CONFIG.display.showRateLimitEstimate).toBe(true);
799
+ expect(DEFAULT_CONFIG.display.showCacheTokens).toBe(true);
800
+ });
801
+
802
+ it('should have default rateLimitWindow settings', () => {
803
+ expect(DEFAULT_CONFIG.rateLimitWindow.sonnetTokenCap).toBe(88000);
804
+ });
805
+ });
806
+
807
+ describe('getModelTier', () => {
808
+ it('should return correct tier for short aliases', () => {
809
+ expect(getModelTier('haiku')).toBe(1);
810
+ expect(getModelTier('sonnet')).toBe(2);
811
+ expect(getModelTier('opus')).toBe(3);
812
+ });
813
+
814
+ it('should extract tier from full model IDs', () => {
815
+ expect(getModelTier('claude-haiku-4-5-20251001')).toBe(1);
816
+ expect(getModelTier('claude-sonnet-4-5-20250929')).toBe(2);
817
+ expect(getModelTier('claude-opus-4-6')).toBe(3);
818
+ });
819
+
820
+ it('should return highest tier for unknown models', () => {
821
+ expect(getModelTier('unknown-model')).toBe(3);
822
+ expect(getModelTier('claude-future-5-0')).toBe(3);
823
+ expect(getModelTier('')).toBe(3);
824
+ });
825
+ });
826
+
827
+ describe('applyModelCeiling', () => {
828
+ it('should return resolved model when below ceiling', () => {
829
+ expect(applyModelCeiling('haiku', 'sonnet')).toBe('haiku');
830
+ expect(applyModelCeiling('haiku', 'opus')).toBe('haiku');
831
+ expect(applyModelCeiling('sonnet', 'opus')).toBe('sonnet');
832
+ });
833
+
834
+ it('should return ceiling model when above ceiling', () => {
835
+ expect(applyModelCeiling('opus', 'sonnet')).toBe('sonnet');
836
+ expect(applyModelCeiling('opus', 'haiku')).toBe('haiku');
837
+ expect(applyModelCeiling('sonnet', 'haiku')).toBe('haiku');
838
+ });
839
+
840
+ it('should return resolved model when at ceiling', () => {
841
+ expect(applyModelCeiling('sonnet', 'sonnet')).toBe('sonnet');
842
+ expect(applyModelCeiling('opus', 'opus')).toBe('opus');
843
+ });
844
+
845
+ it('should work with full model IDs', () => {
846
+ expect(applyModelCeiling('claude-opus-4-6', 'sonnet')).toBe('sonnet');
847
+ expect(applyModelCeiling('claude-haiku-4-5-20251001', 'claude-opus-4-6')).toBe('claude-haiku-4-5-20251001');
848
+ });
849
+ });
850
+
851
+ describe('resolveEffortToModel', () => {
852
+ it('should resolve effort levels to default models', () => {
853
+ const configPath = path.join(tempDir, 'default.json');
854
+ // Use default config
855
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
856
+ expect(config.effortMapping.low).toBe('haiku');
857
+ expect(config.effortMapping.medium).toBe('sonnet');
858
+ expect(config.effortMapping.high).toBe('opus');
859
+ });
860
+ });
861
+
862
+ describe('validateConfig - effortMapping', () => {
863
+ it('should accept valid effortMapping config', () => {
864
+ expect(() => validateConfig({
865
+ effortMapping: {
866
+ low: 'haiku',
867
+ medium: 'sonnet',
868
+ high: 'opus',
869
+ },
870
+ })).not.toThrow();
871
+ });
872
+
873
+ it('should accept partial effortMapping override', () => {
874
+ expect(() => validateConfig({
875
+ effortMapping: { high: 'sonnet' },
876
+ })).not.toThrow();
877
+ });
878
+
879
+ it('should accept full model IDs in effortMapping', () => {
880
+ expect(() => validateConfig({
881
+ effortMapping: { low: 'claude-haiku-4-5-20251001' },
882
+ })).not.toThrow();
883
+ });
884
+
885
+ it('should reject invalid model names in effortMapping', () => {
886
+ expect(() => validateConfig({
887
+ effortMapping: { low: 'gpt-4' },
888
+ })).toThrow('effortMapping.low must be a short alias');
889
+ });
890
+
891
+ it('should reject unknown keys in effortMapping', () => {
892
+ expect(() => validateConfig({
893
+ effortMapping: { extra: 'haiku' },
894
+ })).toThrow('Unknown config key: effortMapping.extra');
895
+ });
896
+ });
650
897
  });