rafcode 2.3.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 (109) hide show
  1. package/CLAUDE.md +19 -4
  2. package/RAF/ahvrih-rate-forge/decisions.md +70 -0
  3. package/RAF/ahvrih-rate-forge/input.md +44 -0
  4. package/RAF/ahvrih-rate-forge/outcomes/01-remove-claude-command-config.md +58 -0
  5. package/RAF/ahvrih-rate-forge/outcomes/02-fix-mixed-attempt-cost.md +46 -0
  6. package/RAF/ahvrih-rate-forge/outcomes/03-rate-limit-estimation.md +82 -0
  7. package/RAF/ahvrih-rate-forge/outcomes/04-show-version-in-do-logs.md +45 -0
  8. package/RAF/ahvrih-rate-forge/outcomes/05-sync-main-before-worktree.md +96 -0
  9. package/RAF/ahvrih-rate-forge/outcomes/06-sync-readme-with-codebase.md +45 -0
  10. package/RAF/ahvrih-rate-forge/outcomes/07-no-session-persistence.md +26 -0
  11. package/RAF/ahvrih-rate-forge/outcomes/08-plan-execution-metadata.md +130 -0
  12. package/RAF/ahvrih-rate-forge/plans/01-remove-claude-command-config.md +36 -0
  13. package/RAF/ahvrih-rate-forge/plans/02-fix-mixed-attempt-cost.md +33 -0
  14. package/RAF/ahvrih-rate-forge/plans/03-rate-limit-estimation.md +82 -0
  15. package/RAF/ahvrih-rate-forge/plans/04-show-version-in-do-logs.md +32 -0
  16. package/RAF/ahvrih-rate-forge/plans/05-sync-main-before-worktree.md +40 -0
  17. package/RAF/ahvrih-rate-forge/plans/06-sync-readme-with-codebase.md +61 -0
  18. package/RAF/ahvrih-rate-forge/plans/07-no-session-persistence.md +28 -0
  19. package/RAF/ahvrih-rate-forge/plans/08-plan-execution-metadata.md +123 -0
  20. package/README.md +27 -7
  21. package/dist/commands/config.d.ts.map +1 -1
  22. package/dist/commands/config.js +1 -6
  23. package/dist/commands/config.js.map +1 -1
  24. package/dist/commands/do.d.ts.map +1 -1
  25. package/dist/commands/do.js +106 -18
  26. package/dist/commands/do.js.map +1 -1
  27. package/dist/commands/plan.d.ts.map +1 -1
  28. package/dist/commands/plan.js +77 -2
  29. package/dist/commands/plan.js.map +1 -1
  30. package/dist/core/claude-runner.d.ts +6 -6
  31. package/dist/core/claude-runner.d.ts.map +1 -1
  32. package/dist/core/claude-runner.js +9 -10
  33. package/dist/core/claude-runner.js.map +1 -1
  34. package/dist/core/failure-analyzer.d.ts.map +1 -1
  35. package/dist/core/failure-analyzer.js +3 -3
  36. package/dist/core/failure-analyzer.js.map +1 -1
  37. package/dist/core/pull-request.js +3 -3
  38. package/dist/core/pull-request.js.map +1 -1
  39. package/dist/core/state-derivation.d.ts +5 -0
  40. package/dist/core/state-derivation.d.ts.map +1 -1
  41. package/dist/core/state-derivation.js +14 -4
  42. package/dist/core/state-derivation.js.map +1 -1
  43. package/dist/core/worktree.d.ts +32 -0
  44. package/dist/core/worktree.d.ts.map +1 -1
  45. package/dist/core/worktree.js +215 -0
  46. package/dist/core/worktree.js.map +1 -1
  47. package/dist/prompts/amend.d.ts.map +1 -1
  48. package/dist/prompts/amend.js +26 -11
  49. package/dist/prompts/amend.js.map +1 -1
  50. package/dist/prompts/planning.d.ts.map +1 -1
  51. package/dist/prompts/planning.js +26 -11
  52. package/dist/prompts/planning.js.map +1 -1
  53. package/dist/types/config.d.ts +30 -13
  54. package/dist/types/config.d.ts.map +1 -1
  55. package/dist/types/config.js +14 -10
  56. package/dist/types/config.js.map +1 -1
  57. package/dist/utils/config.d.ts +47 -4
  58. package/dist/utils/config.d.ts.map +1 -1
  59. package/dist/utils/config.js +176 -30
  60. package/dist/utils/config.js.map +1 -1
  61. package/dist/utils/frontmatter.d.ts +43 -0
  62. package/dist/utils/frontmatter.d.ts.map +1 -0
  63. package/dist/utils/frontmatter.js +85 -0
  64. package/dist/utils/frontmatter.js.map +1 -0
  65. package/dist/utils/name-generator.d.ts.map +1 -1
  66. package/dist/utils/name-generator.js +2 -3
  67. package/dist/utils/name-generator.js.map +1 -1
  68. package/dist/utils/session-parser.d.ts +44 -0
  69. package/dist/utils/session-parser.d.ts.map +1 -0
  70. package/dist/utils/session-parser.js +122 -0
  71. package/dist/utils/session-parser.js.map +1 -0
  72. package/dist/utils/terminal-symbols.d.ts +22 -3
  73. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  74. package/dist/utils/terminal-symbols.js +52 -18
  75. package/dist/utils/terminal-symbols.js.map +1 -1
  76. package/dist/utils/token-tracker.d.ts +20 -0
  77. package/dist/utils/token-tracker.d.ts.map +1 -1
  78. package/dist/utils/token-tracker.js +57 -2
  79. package/dist/utils/token-tracker.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/commands/config.ts +0 -7
  82. package/src/commands/do.ts +141 -20
  83. package/src/commands/plan.ts +87 -1
  84. package/src/core/claude-runner.ts +16 -17
  85. package/src/core/failure-analyzer.ts +3 -3
  86. package/src/core/pull-request.ts +3 -3
  87. package/src/core/state-derivation.ts +20 -4
  88. package/src/core/worktree.ts +230 -0
  89. package/src/prompts/amend.ts +26 -11
  90. package/src/prompts/config-docs.md +91 -29
  91. package/src/prompts/planning.ts +26 -11
  92. package/src/types/config.ts +46 -21
  93. package/src/utils/config.ts +200 -33
  94. package/src/utils/frontmatter.ts +110 -0
  95. package/src/utils/name-generator.ts +2 -3
  96. package/src/utils/session-parser.ts +161 -0
  97. package/src/utils/terminal-symbols.ts +68 -16
  98. package/src/utils/token-tracker.ts +65 -2
  99. package/tests/unit/claude-runner-interactive.test.ts +8 -6
  100. package/tests/unit/claude-runner.test.ts +5 -66
  101. package/tests/unit/config-command.test.ts +6 -6
  102. package/tests/unit/config.test.ts +268 -45
  103. package/tests/unit/frontmatter.test.ts +182 -0
  104. package/tests/unit/post-execution-picker.test.ts +5 -0
  105. package/tests/unit/session-parser.test.ts +301 -0
  106. package/tests/unit/terminal-symbols.test.ts +142 -0
  107. package/tests/unit/token-tracker.test.ts +304 -1
  108. package/tests/unit/validation.test.ts +6 -4
  109. package/tests/unit/worktree.test.ts +242 -0
@@ -8,15 +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,
19
22
  getModelShortName,
23
+ resolveFullModelId,
20
24
  resetConfigCache,
21
25
  saveConfig,
22
26
  renderCommitMessage,
@@ -90,13 +94,12 @@ describe('Config', () => {
90
94
  it('should accept a full valid config', () => {
91
95
  const config = {
92
96
  models: { plan: 'opus', execute: 'haiku' },
93
- effort: { plan: 'high', execute: 'low' },
97
+ effortMapping: { low: 'haiku', medium: 'sonnet', high: 'opus' },
94
98
  timeout: 30,
95
99
  maxRetries: 5,
96
100
  autoCommit: false,
97
101
  worktree: true,
98
102
  commitFormat: { prefix: 'MY', task: '{prefix}[{projectId}] {description}' },
99
- claudeCommand: '/usr/local/bin/claude',
100
103
  };
101
104
  expect(() => validateConfig(config)).not.toThrow();
102
105
  });
@@ -113,12 +116,16 @@ describe('Config', () => {
113
116
  expect(() => validateConfig({ unknownKey: 'value' })).toThrow('Unknown config key: unknownKey');
114
117
  });
115
118
 
119
+ it('should reject removed claudeCommand key', () => {
120
+ expect(() => validateConfig({ claudeCommand: 'claude' })).toThrow('Unknown config key: claudeCommand');
121
+ });
122
+
116
123
  it('should reject unknown model keys', () => {
117
124
  expect(() => validateConfig({ models: { unknownScenario: 'opus' } })).toThrow('Unknown config key: models.unknownScenario');
118
125
  });
119
126
 
120
- it('should reject unknown effort keys', () => {
121
- 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');
122
129
  });
123
130
 
124
131
  it('should reject unknown commitFormat keys', () => {
@@ -151,9 +158,9 @@ describe('Config', () => {
151
158
  expect(() => validateConfig({ models: { plan: 123 } })).toThrow('models.plan must be');
152
159
  });
153
160
 
154
- // Invalid effort values
155
- it('should reject invalid effort levels', () => {
156
- 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');
157
164
  });
158
165
 
159
166
  // Invalid types for nested objects
@@ -165,8 +172,8 @@ describe('Config', () => {
165
172
  expect(() => validateConfig({ models: ['opus'] })).toThrow('models must be an object');
166
173
  });
167
174
 
168
- it('should reject non-object effort', () => {
169
- 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');
170
177
  });
171
178
 
172
179
  it('should reject non-object commitFormat', () => {
@@ -208,17 +215,13 @@ describe('Config', () => {
208
215
  expect(() => validateConfig({ worktree: 1 })).toThrow('worktree must be a boolean');
209
216
  });
210
217
 
211
- // Invalid claudeCommand
212
- it('should reject empty claudeCommand', () => {
213
- expect(() => validateConfig({ claudeCommand: '' })).toThrow('claudeCommand must be a non-empty string');
214
- });
215
-
216
- it('should reject whitespace-only claudeCommand', () => {
217
- 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');
218
220
  });
219
221
 
220
- it('should reject non-string claudeCommand', () => {
221
- 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();
222
225
  });
223
226
 
224
227
  // Non-string commitFormat values
@@ -268,13 +271,14 @@ describe('Config', () => {
268
271
  expect(config.models.failureAnalysis).toBe('haiku'); // default preserved
269
272
  });
270
273
 
271
- it('should deep-merge partial effort override', () => {
274
+ it('should deep-merge partial effortMapping override', () => {
272
275
  const configPath = path.join(tempDir, 'raf.config.json');
273
- fs.writeFileSync(configPath, JSON.stringify({ effort: { execute: 'high' } }));
276
+ fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { medium: 'opus' } }));
274
277
 
275
278
  const config = resolveConfig(configPath);
276
- expect(config.effort.execute).toBe('high');
277
- 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
278
282
  });
279
283
 
280
284
  it('should deep-merge partial commitFormat override', () => {
@@ -297,6 +301,19 @@ describe('Config', () => {
297
301
  expect(config.maxRetries).toBe(3); // default preserved
298
302
  });
299
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
+
300
317
  it('should throw on invalid config file', () => {
301
318
  const configPath = path.join(tempDir, 'raf.config.json');
302
319
  fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
@@ -349,11 +366,12 @@ describe('Config', () => {
349
366
  expect(config.models.execute).toBe('opus');
350
367
  });
351
368
 
352
- it('getEffort returns correct effort for scenario', () => {
369
+ it('effortMapping resolves correctly from config', () => {
353
370
  const configPath = path.join(tempDir, 'raf.config.json');
354
- fs.writeFileSync(configPath, JSON.stringify({ effort: { plan: 'low' } }));
371
+ fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { high: 'sonnet' } }));
355
372
  const config = resolveConfig(configPath);
356
- expect(config.effort.plan).toBe('low');
373
+ expect(config.effortMapping.high).toBe('sonnet');
374
+ expect(config.effortMapping.low).toBe('haiku'); // default preserved
357
375
  });
358
376
 
359
377
  it('getCommitFormat returns correct format', () => {
@@ -372,7 +390,6 @@ describe('Config', () => {
372
390
  expect(config.maxRetries).toBe(3);
373
391
  expect(config.autoCommit).toBe(true);
374
392
  expect(config.worktree).toBe(false);
375
- expect(config.claudeCommand).toBe('claude');
376
393
  });
377
394
  });
378
395
 
@@ -386,13 +403,10 @@ describe('Config', () => {
386
403
  expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
387
404
  });
388
405
 
389
- it('should have all effort scenarios defined', () => {
390
- expect(DEFAULT_CONFIG.effort.plan).toBe('high');
391
- expect(DEFAULT_CONFIG.effort.execute).toBe('medium');
392
- expect(DEFAULT_CONFIG.effort.nameGeneration).toBe('low');
393
- expect(DEFAULT_CONFIG.effort.failureAnalysis).toBe('low');
394
- expect(DEFAULT_CONFIG.effort.prGeneration).toBe('medium');
395
- 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');
396
410
  });
397
411
 
398
412
  it('should have all commit format fields defined', () => {
@@ -472,8 +486,10 @@ describe('Config', () => {
472
486
  expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
473
487
  });
474
488
 
475
- it('should default effort to match previous hardcoded values', () => {
476
- 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');
477
493
  });
478
494
 
479
495
  it('should default timeout to 60', () => {
@@ -492,10 +508,6 @@ describe('Config', () => {
492
508
  expect(DEFAULT_CONFIG.worktree).toBe(false);
493
509
  });
494
510
 
495
- it('should default claudeCommand to claude', () => {
496
- expect(DEFAULT_CONFIG.claudeCommand).toBe('claude');
497
- });
498
-
499
511
  it('should default commit format to match previous hardcoded format', () => {
500
512
  // The task format should produce the same output as the old hardcoded format
501
513
  const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
@@ -640,6 +652,26 @@ describe('Config', () => {
640
652
  });
641
653
  });
642
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
+
643
675
  describe('config integration - overrides work', () => {
644
676
  it('should use custom model when configured', () => {
645
677
  const configPath = path.join(tempDir, 'custom-models.json');
@@ -652,13 +684,14 @@ describe('Config', () => {
652
684
  expect(config.models.failureAnalysis).toBe('haiku');
653
685
  });
654
686
 
655
- it('should use custom effort when configured', () => {
687
+ it('should use custom effortMapping when configured', () => {
656
688
  const configPath = path.join(tempDir, 'custom-effort.json');
657
- saveConfig(configPath, { effort: { execute: 'high' } });
689
+ saveConfig(configPath, { effortMapping: { high: 'sonnet' } });
658
690
  const config = resolveConfig(configPath);
659
- expect(config.effort.execute).toBe('high');
691
+ expect(config.effortMapping.high).toBe('sonnet');
660
692
  // Others should remain at defaults
661
- expect(config.effort.plan).toBe('high');
693
+ expect(config.effortMapping.low).toBe('haiku');
694
+ expect(config.effortMapping.medium).toBe('sonnet');
662
695
  });
663
696
 
664
697
  it('should use custom commit format when configured', () => {
@@ -671,4 +704,194 @@ describe('Config', () => {
671
704
  expect(config.commitFormat.plan).toBe(DEFAULT_CONFIG.commitFormat.plan);
672
705
  });
673
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
+ });
674
897
  });
@@ -0,0 +1,182 @@
1
+ import { parsePlanFrontmatter } from '../../src/utils/frontmatter.js';
2
+
3
+ describe('parsePlanFrontmatter', () => {
4
+ describe('valid frontmatter', () => {
5
+ it('should parse effort field', () => {
6
+ const content = `effort: medium
7
+ ---
8
+ # Task: Test Task`;
9
+ const result = parsePlanFrontmatter(content);
10
+ expect(result.hasFrontmatter).toBe(true);
11
+ expect(result.frontmatter.effort).toBe('medium');
12
+ expect(result.warnings).toHaveLength(0);
13
+ });
14
+
15
+ it('should parse model field', () => {
16
+ const content = `model: sonnet
17
+ ---
18
+ # Task: Test Task`;
19
+ const result = parsePlanFrontmatter(content);
20
+ expect(result.hasFrontmatter).toBe(true);
21
+ expect(result.frontmatter.model).toBe('sonnet');
22
+ expect(result.warnings).toHaveLength(0);
23
+ });
24
+
25
+ it('should parse both effort and model', () => {
26
+ const content = `effort: high
27
+ model: opus
28
+ ---
29
+ # Task: Test Task`;
30
+ const result = parsePlanFrontmatter(content);
31
+ expect(result.hasFrontmatter).toBe(true);
32
+ expect(result.frontmatter.effort).toBe('high');
33
+ expect(result.frontmatter.model).toBe('opus');
34
+ });
35
+
36
+ it('should accept all effort levels', () => {
37
+ for (const level of ['low', 'medium', 'high']) {
38
+ const content = `effort: ${level}
39
+ ---
40
+ # Task: Test`;
41
+ const result = parsePlanFrontmatter(content);
42
+ expect(result.hasFrontmatter).toBe(true);
43
+ expect(result.frontmatter.effort).toBe(level);
44
+ }
45
+ });
46
+
47
+ it('should accept full model IDs', () => {
48
+ const content = `model: claude-opus-4-6
49
+ ---
50
+ # Task: Test`;
51
+ const result = parsePlanFrontmatter(content);
52
+ expect(result.hasFrontmatter).toBe(true);
53
+ expect(result.frontmatter.model).toBe('claude-opus-4-6');
54
+ });
55
+
56
+ it('should be case-insensitive for effort values', () => {
57
+ const content = `effort: MEDIUM
58
+ ---
59
+ # Task: Test`;
60
+ const result = parsePlanFrontmatter(content);
61
+ expect(result.hasFrontmatter).toBe(true);
62
+ expect(result.frontmatter.effort).toBe('medium');
63
+ });
64
+
65
+ it('should handle empty lines in frontmatter', () => {
66
+ const content = `effort: low
67
+
68
+ model: haiku
69
+ ---
70
+ # Task: Test`;
71
+ const result = parsePlanFrontmatter(content);
72
+ expect(result.hasFrontmatter).toBe(true);
73
+ expect(result.frontmatter.effort).toBe('low');
74
+ expect(result.frontmatter.model).toBe('haiku');
75
+ });
76
+ });
77
+
78
+ describe('no frontmatter', () => {
79
+ it('should return empty for content without delimiter', () => {
80
+ const content = `# Task: Test Task
81
+
82
+ ## Objective
83
+ Do something`;
84
+ const result = parsePlanFrontmatter(content);
85
+ expect(result.hasFrontmatter).toBe(false);
86
+ expect(result.frontmatter.effort).toBeUndefined();
87
+ expect(result.frontmatter.model).toBeUndefined();
88
+ });
89
+
90
+ it('should return empty for empty content', () => {
91
+ const result = parsePlanFrontmatter('');
92
+ expect(result.hasFrontmatter).toBe(false);
93
+ });
94
+
95
+ it('should return empty when markdown heading appears before delimiter', () => {
96
+ const content = `# Task: Test Task
97
+ ---
98
+ More content`;
99
+ const result = parsePlanFrontmatter(content);
100
+ expect(result.hasFrontmatter).toBe(false);
101
+ expect(result.warnings).toContain('Frontmatter section contains markdown content before closing delimiter');
102
+ });
103
+ });
104
+
105
+ describe('warnings', () => {
106
+ it('should warn on unknown frontmatter keys', () => {
107
+ const content = `effort: medium
108
+ unknownKey: value
109
+ ---
110
+ # Task: Test`;
111
+ const result = parsePlanFrontmatter(content);
112
+ expect(result.hasFrontmatter).toBe(true);
113
+ expect(result.warnings).toContain('Unknown frontmatter key: "unknownkey"');
114
+ });
115
+
116
+ it('should warn on invalid effort value', () => {
117
+ const content = `effort: invalid
118
+ ---
119
+ # Task: Test`;
120
+ const result = parsePlanFrontmatter(content);
121
+ expect(result.hasFrontmatter).toBe(false); // No valid frontmatter extracted
122
+ expect(result.warnings.some(w => w.includes('Invalid effort value'))).toBe(true);
123
+ });
124
+
125
+ it('should warn on invalid model value', () => {
126
+ const content = `model: gpt-4
127
+ ---
128
+ # Task: Test`;
129
+ const result = parsePlanFrontmatter(content);
130
+ expect(result.hasFrontmatter).toBe(false);
131
+ expect(result.warnings.some(w => w.includes('Invalid model value'))).toBe(true);
132
+ });
133
+
134
+ it('should collect multiple warnings', () => {
135
+ const content = `effort: invalid
136
+ model: gpt-4
137
+ unknownKey: value
138
+ ---
139
+ # Task: Test`;
140
+ const result = parsePlanFrontmatter(content);
141
+ expect(result.warnings.length).toBeGreaterThanOrEqual(3);
142
+ });
143
+ });
144
+
145
+ describe('edge cases', () => {
146
+ it('should handle delimiter with content after it', () => {
147
+ const content = `effort: medium
148
+ ---
149
+ # Task: Test
150
+
151
+ ## Objective
152
+ Do something
153
+
154
+ ---
155
+
156
+ More content with another delimiter`;
157
+ const result = parsePlanFrontmatter(content);
158
+ expect(result.hasFrontmatter).toBe(true);
159
+ expect(result.frontmatter.effort).toBe('medium');
160
+ });
161
+
162
+ it('should handle whitespace around values', () => {
163
+ const content = `effort: high
164
+ model: sonnet
165
+ ---
166
+ # Task: Test`;
167
+ const result = parsePlanFrontmatter(content);
168
+ expect(result.hasFrontmatter).toBe(true);
169
+ expect(result.frontmatter.effort).toBe('high');
170
+ expect(result.frontmatter.model).toBe('sonnet');
171
+ });
172
+
173
+ it('should handle tabs in whitespace', () => {
174
+ const content = `effort:\thigh
175
+ ---
176
+ # Task: Test`;
177
+ const result = parsePlanFrontmatter(content);
178
+ expect(result.hasFrontmatter).toBe(true);
179
+ expect(result.frontmatter.effort).toBe('high');
180
+ });
181
+ });
182
+ });
@@ -45,6 +45,8 @@ jest.unstable_mockModule('../../src/core/pull-request.js', () => ({
45
45
  // Mock worktree module
46
46
  const mockMergeWorktreeBranch = jest.fn();
47
47
  const mockRemoveWorktree = jest.fn();
48
+ const mockPullMainBranch = jest.fn();
49
+ const mockPushMainBranch = jest.fn();
48
50
  jest.unstable_mockModule('../../src/core/worktree.js', () => ({
49
51
  getRepoRoot: jest.fn(),
50
52
  getRepoBasename: jest.fn(),
@@ -60,6 +62,9 @@ jest.unstable_mockModule('../../src/core/worktree.js', () => ({
60
62
  branchExists: jest.fn(),
61
63
  getWorktreeProjectPath: jest.fn(),
62
64
  resolveWorktreeProjectByIdentifier: jest.fn(),
65
+ pullMainBranch: mockPullMainBranch,
66
+ pushMainBranch: mockPushMainBranch,
67
+ detectMainBranch: jest.fn(),
63
68
  }));
64
69
 
65
70
  // Import after mocking