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.
- package/CLAUDE.md +19 -4
- package/RAF/ahvrih-rate-forge/decisions.md +70 -0
- package/RAF/ahvrih-rate-forge/input.md +44 -0
- package/RAF/ahvrih-rate-forge/outcomes/01-remove-claude-command-config.md +58 -0
- package/RAF/ahvrih-rate-forge/outcomes/02-fix-mixed-attempt-cost.md +46 -0
- package/RAF/ahvrih-rate-forge/outcomes/03-rate-limit-estimation.md +82 -0
- package/RAF/ahvrih-rate-forge/outcomes/04-show-version-in-do-logs.md +45 -0
- package/RAF/ahvrih-rate-forge/outcomes/05-sync-main-before-worktree.md +96 -0
- package/RAF/ahvrih-rate-forge/outcomes/06-sync-readme-with-codebase.md +45 -0
- package/RAF/ahvrih-rate-forge/outcomes/07-no-session-persistence.md +26 -0
- package/RAF/ahvrih-rate-forge/outcomes/08-plan-execution-metadata.md +130 -0
- package/RAF/ahvrih-rate-forge/plans/01-remove-claude-command-config.md +36 -0
- package/RAF/ahvrih-rate-forge/plans/02-fix-mixed-attempt-cost.md +33 -0
- package/RAF/ahvrih-rate-forge/plans/03-rate-limit-estimation.md +82 -0
- package/RAF/ahvrih-rate-forge/plans/04-show-version-in-do-logs.md +32 -0
- package/RAF/ahvrih-rate-forge/plans/05-sync-main-before-worktree.md +40 -0
- package/RAF/ahvrih-rate-forge/plans/06-sync-readme-with-codebase.md +61 -0
- package/RAF/ahvrih-rate-forge/plans/07-no-session-persistence.md +28 -0
- package/RAF/ahvrih-rate-forge/plans/08-plan-execution-metadata.md +123 -0
- package/README.md +27 -7
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +1 -6
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +106 -18
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +77 -2
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts +6 -6
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +9 -10
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/failure-analyzer.d.ts.map +1 -1
- package/dist/core/failure-analyzer.js +3 -3
- package/dist/core/failure-analyzer.js.map +1 -1
- package/dist/core/pull-request.js +3 -3
- package/dist/core/pull-request.js.map +1 -1
- package/dist/core/state-derivation.d.ts +5 -0
- package/dist/core/state-derivation.d.ts.map +1 -1
- package/dist/core/state-derivation.js +14 -4
- package/dist/core/state-derivation.js.map +1 -1
- package/dist/core/worktree.d.ts +32 -0
- package/dist/core/worktree.d.ts.map +1 -1
- package/dist/core/worktree.js +215 -0
- package/dist/core/worktree.js.map +1 -1
- package/dist/prompts/amend.d.ts.map +1 -1
- package/dist/prompts/amend.js +26 -11
- package/dist/prompts/amend.js.map +1 -1
- package/dist/prompts/planning.d.ts.map +1 -1
- package/dist/prompts/planning.js +26 -11
- package/dist/prompts/planning.js.map +1 -1
- package/dist/types/config.d.ts +30 -13
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +14 -10
- package/dist/types/config.js.map +1 -1
- package/dist/utils/config.d.ts +47 -4
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +176 -30
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +43 -0
- package/dist/utils/frontmatter.d.ts.map +1 -0
- package/dist/utils/frontmatter.js +85 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/dist/utils/name-generator.d.ts.map +1 -1
- package/dist/utils/name-generator.js +2 -3
- package/dist/utils/name-generator.js.map +1 -1
- package/dist/utils/session-parser.d.ts +44 -0
- package/dist/utils/session-parser.d.ts.map +1 -0
- package/dist/utils/session-parser.js +122 -0
- package/dist/utils/session-parser.js.map +1 -0
- package/dist/utils/terminal-symbols.d.ts +22 -3
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +52 -18
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/dist/utils/token-tracker.d.ts +20 -0
- package/dist/utils/token-tracker.d.ts.map +1 -1
- package/dist/utils/token-tracker.js +57 -2
- package/dist/utils/token-tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/config.ts +0 -7
- package/src/commands/do.ts +141 -20
- package/src/commands/plan.ts +87 -1
- package/src/core/claude-runner.ts +16 -17
- package/src/core/failure-analyzer.ts +3 -3
- package/src/core/pull-request.ts +3 -3
- package/src/core/state-derivation.ts +20 -4
- package/src/core/worktree.ts +230 -0
- package/src/prompts/amend.ts +26 -11
- package/src/prompts/config-docs.md +91 -29
- package/src/prompts/planning.ts +26 -11
- package/src/types/config.ts +46 -21
- package/src/utils/config.ts +200 -33
- package/src/utils/frontmatter.ts +110 -0
- package/src/utils/name-generator.ts +2 -3
- package/src/utils/session-parser.ts +161 -0
- package/src/utils/terminal-symbols.ts +68 -16
- package/src/utils/token-tracker.ts +65 -2
- package/tests/unit/claude-runner-interactive.test.ts +8 -6
- package/tests/unit/claude-runner.test.ts +5 -66
- package/tests/unit/config-command.test.ts +6 -6
- package/tests/unit/config.test.ts +268 -45
- package/tests/unit/frontmatter.test.ts +182 -0
- package/tests/unit/post-execution-picker.test.ts +5 -0
- package/tests/unit/session-parser.test.ts +301 -0
- package/tests/unit/terminal-symbols.test.ts +142 -0
- package/tests/unit/token-tracker.test.ts +304 -1
- package/tests/unit/validation.test.ts +6 -4
- package/tests/unit/worktree.test.ts +242 -0
|
@@ -8,15 +8,19 @@ import {
|
|
|
8
8
|
ConfigValidationError,
|
|
9
9
|
resolveConfig,
|
|
10
10
|
getModel,
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
expect(() => validateConfig({
|
|
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
|
|
155
|
-
it('should reject invalid
|
|
156
|
-
expect(() => validateConfig({
|
|
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
|
|
169
|
-
expect(() => validateConfig({
|
|
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
|
-
|
|
212
|
-
|
|
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
|
|
221
|
-
expect(() => validateConfig({
|
|
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
|
|
274
|
+
it('should deep-merge partial effortMapping override', () => {
|
|
272
275
|
const configPath = path.join(tempDir, 'raf.config.json');
|
|
273
|
-
fs.writeFileSync(configPath, JSON.stringify({
|
|
276
|
+
fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { medium: 'opus' } }));
|
|
274
277
|
|
|
275
278
|
const config = resolveConfig(configPath);
|
|
276
|
-
expect(config.
|
|
277
|
-
expect(config.
|
|
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('
|
|
369
|
+
it('effortMapping resolves correctly from config', () => {
|
|
353
370
|
const configPath = path.join(tempDir, 'raf.config.json');
|
|
354
|
-
fs.writeFileSync(configPath, JSON.stringify({
|
|
371
|
+
fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { high: 'sonnet' } }));
|
|
355
372
|
const config = resolveConfig(configPath);
|
|
356
|
-
expect(config.
|
|
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
|
|
390
|
-
expect(DEFAULT_CONFIG.
|
|
391
|
-
expect(DEFAULT_CONFIG.
|
|
392
|
-
expect(DEFAULT_CONFIG.
|
|
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
|
|
476
|
-
expect(DEFAULT_CONFIG.
|
|
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
|
|
687
|
+
it('should use custom effortMapping when configured', () => {
|
|
656
688
|
const configPath = path.join(tempDir, 'custom-effort.json');
|
|
657
|
-
saveConfig(configPath, {
|
|
689
|
+
saveConfig(configPath, { effortMapping: { high: 'sonnet' } });
|
|
658
690
|
const config = resolveConfig(configPath);
|
|
659
|
-
expect(config.
|
|
691
|
+
expect(config.effortMapping.high).toBe('sonnet');
|
|
660
692
|
// Others should remain at defaults
|
|
661
|
-
expect(config.
|
|
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
|