rafcode 2.4.1-0 → 2.5.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 (75) hide show
  1. package/CLAUDE.md +4 -4
  2. package/RAF/ahwqwq-model-whisperer/decisions.md +22 -0
  3. package/RAF/ahwqwq-model-whisperer/input.md +5 -0
  4. package/RAF/ahwqwq-model-whisperer/outcomes/01-show-model-on-task-line.md +49 -0
  5. package/RAF/ahwqwq-model-whisperer/outcomes/02-use-claude-cost-estimation.md +107 -0
  6. package/RAF/ahwqwq-model-whisperer/outcomes/03-add-plan-resume-flag.md +87 -0
  7. package/RAF/ahwqwq-model-whisperer/plans/01-show-model-on-task-line.md +45 -0
  8. package/RAF/ahwqwq-model-whisperer/plans/02-use-claude-cost-estimation.md +115 -0
  9. package/RAF/ahwqwq-model-whisperer/plans/03-add-plan-resume-flag.md +70 -0
  10. package/RAF/ahwvrz-legacy-sunset/decisions.md +10 -0
  11. package/RAF/ahwvrz-legacy-sunset/input.md +10 -0
  12. package/RAF/ahwvrz-legacy-sunset/outcomes/01-remove-migrate-command.md +30 -0
  13. package/RAF/ahwvrz-legacy-sunset/outcomes/02-fix-resume-worktree-resolution.md +62 -0
  14. package/RAF/ahwvrz-legacy-sunset/plans/01-remove-migrate-command.md +65 -0
  15. package/RAF/ahwvrz-legacy-sunset/plans/02-fix-resume-worktree-resolution.md +72 -0
  16. package/README.md +0 -17
  17. package/dist/commands/do.js +13 -15
  18. package/dist/commands/do.js.map +1 -1
  19. package/dist/commands/plan.d.ts.map +1 -1
  20. package/dist/commands/plan.js +98 -2
  21. package/dist/commands/plan.js.map +1 -1
  22. package/dist/core/claude-runner.d.ts +8 -0
  23. package/dist/core/claude-runner.d.ts.map +1 -1
  24. package/dist/core/claude-runner.js +72 -0
  25. package/dist/core/claude-runner.js.map +1 -1
  26. package/dist/index.js +0 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/parsers/stream-renderer.d.ts +2 -0
  29. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  30. package/dist/parsers/stream-renderer.js +2 -0
  31. package/dist/parsers/stream-renderer.js.map +1 -1
  32. package/dist/prompts/amend.d.ts.map +1 -1
  33. package/dist/prompts/amend.js +3 -1
  34. package/dist/prompts/amend.js.map +1 -1
  35. package/dist/prompts/planning.d.ts.map +1 -1
  36. package/dist/prompts/planning.js +4 -1
  37. package/dist/prompts/planning.js.map +1 -1
  38. package/dist/types/config.d.ts +4 -28
  39. package/dist/types/config.d.ts.map +1 -1
  40. package/dist/types/config.js +0 -24
  41. package/dist/types/config.js.map +1 -1
  42. package/dist/utils/config.d.ts +1 -26
  43. package/dist/utils/config.d.ts.map +1 -1
  44. package/dist/utils/config.js +2 -98
  45. package/dist/utils/config.js.map +1 -1
  46. package/dist/utils/terminal-symbols.d.ts +7 -16
  47. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  48. package/dist/utils/terminal-symbols.js +16 -42
  49. package/dist/utils/terminal-symbols.js.map +1 -1
  50. package/dist/utils/token-tracker.d.ts +4 -30
  51. package/dist/utils/token-tracker.d.ts.map +1 -1
  52. package/dist/utils/token-tracker.js +17 -98
  53. package/dist/utils/token-tracker.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/commands/do.ts +14 -15
  56. package/src/commands/plan.ts +112 -1
  57. package/src/core/claude-runner.ts +81 -0
  58. package/src/index.ts +0 -2
  59. package/src/parsers/stream-renderer.ts +4 -0
  60. package/src/prompts/amend.ts +3 -1
  61. package/src/prompts/config-docs.md +1 -72
  62. package/src/prompts/planning.ts +4 -1
  63. package/src/types/config.ts +4 -57
  64. package/src/utils/config.ts +2 -112
  65. package/src/utils/terminal-symbols.ts +16 -46
  66. package/src/utils/token-tracker.ts +19 -113
  67. package/tests/unit/claude-runner.test.ts +1 -0
  68. package/tests/unit/config-command.test.ts +4 -13
  69. package/tests/unit/config.test.ts +6 -148
  70. package/tests/unit/plan-resume-worktree-resolution.test.ts +153 -0
  71. package/tests/unit/stream-renderer.test.ts +82 -0
  72. package/tests/unit/terminal-symbols.test.ts +86 -124
  73. package/tests/unit/token-tracker.test.ts +159 -679
  74. package/src/commands/migrate.ts +0 -269
  75. package/tests/unit/migrate-command.test.ts +0 -197
@@ -1,12 +1,7 @@
1
- import { UsageData, PricingConfig } from '../types/config.js';
2
- import { resolveModelPricingCategory, getPricingConfig, getRateLimitWindowConfig } from './config.js';
1
+ import { UsageData } from '../types/config.js';
3
2
 
4
3
  /** Cost breakdown for a single task or accumulated total. */
5
4
  export interface CostBreakdown {
6
- inputCost: number;
7
- outputCost: number;
8
- cacheReadCost: number;
9
- cacheCreateCost: number;
10
5
  totalCost: number;
11
6
  }
12
7
 
@@ -25,23 +20,11 @@ export interface TaskUsageEntry {
25
20
  * Sum multiple CostBreakdown objects into a single total.
26
21
  */
27
22
  export function sumCostBreakdowns(costs: CostBreakdown[]): CostBreakdown {
28
- const result: CostBreakdown = {
29
- inputCost: 0,
30
- outputCost: 0,
31
- cacheReadCost: 0,
32
- cacheCreateCost: 0,
33
- totalCost: 0,
34
- };
35
-
23
+ let totalCost = 0;
36
24
  for (const cost of costs) {
37
- result.inputCost += cost.inputCost;
38
- result.outputCost += cost.outputCost;
39
- result.cacheReadCost += cost.cacheReadCost;
40
- result.cacheCreateCost += cost.cacheCreateCost;
41
- result.totalCost += cost.totalCost;
25
+ totalCost += cost.totalCost;
42
26
  }
43
-
44
- return result;
27
+ return { totalCost };
45
28
  }
46
29
 
47
30
  /**
@@ -55,6 +38,7 @@ export function accumulateUsage(attempts: UsageData[]): UsageData {
55
38
  cacheReadInputTokens: 0,
56
39
  cacheCreationInputTokens: 0,
57
40
  modelUsage: {},
41
+ totalCostUsd: 0,
58
42
  };
59
43
 
60
44
  for (const attempt of attempts) {
@@ -71,39 +55,39 @@ export function accumulateUsage(attempts: UsageData[]): UsageData {
71
55
  existing.outputTokens += modelUsage.outputTokens;
72
56
  existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
73
57
  existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
58
+ existing.costUsd += modelUsage.costUsd;
74
59
  } else {
75
60
  result.modelUsage[modelId] = { ...modelUsage };
76
61
  }
77
62
  }
63
+
64
+ // Sum totalCostUsd across attempts
65
+ result.totalCostUsd += attempt.totalCostUsd;
78
66
  }
79
67
 
80
68
  return result;
81
69
  }
82
70
 
83
71
  /**
84
- * Accumulates token usage across multiple task executions and calculates costs
85
- * using configurable per-model pricing.
72
+ * Accumulates token usage across multiple task executions using Claude-provided cost data.
86
73
  */
87
74
  export class TokenTracker {
88
75
  private entries: TaskUsageEntry[] = [];
89
- private pricingConfig: PricingConfig;
90
76
 
91
- constructor(pricingConfig?: PricingConfig) {
92
- this.pricingConfig = pricingConfig ?? getPricingConfig();
77
+ constructor() {
78
+ // No pricing config needed - costs come from Claude
93
79
  }
94
80
 
95
81
  /**
96
82
  * Record usage data from a completed task.
97
83
  * Accepts an array of UsageData (one per attempt) and accumulates them.
98
- * Cost is calculated per-attempt to avoid underreporting when some attempts
99
- * have modelUsage and others only have aggregate fields.
84
+ * Costs are summed from Claude-provided totalCostUsd values.
100
85
  */
101
86
  addTask(taskId: string, attempts: UsageData[]): TaskUsageEntry {
102
87
  const usage = accumulateUsage(attempts);
103
- // Calculate cost per-attempt, then sum. This ensures attempts with only
104
- // aggregate fields use sonnet fallback pricing independently.
105
- const perAttemptCosts = attempts.map((attempt) => this.calculateCost(attempt));
106
- const cost = sumCostBreakdowns(perAttemptCosts);
88
+ // Sum costs from Claude-provided totalCostUsd
89
+ const totalCost = attempts.reduce((sum, attempt) => sum + attempt.totalCostUsd, 0);
90
+ const cost: CostBreakdown = { totalCost };
107
91
  const entry: TaskUsageEntry = { taskId, usage, cost, attempts };
108
92
  this.entries.push(entry);
109
93
  return entry;
@@ -126,12 +110,9 @@ export class TokenTracker {
126
110
  cacheReadInputTokens: 0,
127
111
  cacheCreationInputTokens: 0,
128
112
  modelUsage: {},
113
+ totalCostUsd: 0,
129
114
  };
130
115
  const totalCost: CostBreakdown = {
131
- inputCost: 0,
132
- outputCost: 0,
133
- cacheReadCost: 0,
134
- cacheCreateCost: 0,
135
116
  totalCost: 0,
136
117
  };
137
118
 
@@ -140,6 +121,7 @@ export class TokenTracker {
140
121
  totalUsage.outputTokens += entry.usage.outputTokens;
141
122
  totalUsage.cacheReadInputTokens += entry.usage.cacheReadInputTokens;
142
123
  totalUsage.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens;
124
+ totalUsage.totalCostUsd += entry.usage.totalCostUsd;
143
125
 
144
126
  // Merge per-model usage
145
127
  for (const [modelId, modelUsage] of Object.entries(entry.usage.modelUsage)) {
@@ -149,92 +131,16 @@ export class TokenTracker {
149
131
  existing.outputTokens += modelUsage.outputTokens;
150
132
  existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
151
133
  existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
134
+ existing.costUsd += modelUsage.costUsd;
152
135
  } else {
153
136
  totalUsage.modelUsage[modelId] = { ...modelUsage };
154
137
  }
155
138
  }
156
139
 
157
- totalCost.inputCost += entry.cost.inputCost;
158
- totalCost.outputCost += entry.cost.outputCost;
159
- totalCost.cacheReadCost += entry.cost.cacheReadCost;
160
- totalCost.cacheCreateCost += entry.cost.cacheCreateCost;
161
140
  totalCost.totalCost += entry.cost.totalCost;
162
141
  }
163
142
 
164
143
  return { usage: totalUsage, cost: totalCost };
165
144
  }
166
145
 
167
- /**
168
- * Calculate cost for a given UsageData using per-model pricing.
169
- * Uses per-model breakdown when available, falls back to aggregate with sonnet pricing.
170
- */
171
- calculateCost(usage: UsageData): CostBreakdown {
172
- const result: CostBreakdown = {
173
- inputCost: 0,
174
- outputCost: 0,
175
- cacheReadCost: 0,
176
- cacheCreateCost: 0,
177
- totalCost: 0,
178
- };
179
-
180
- const modelEntries = Object.entries(usage.modelUsage);
181
-
182
- if (modelEntries.length > 0) {
183
- // Use per-model breakdown for accurate pricing
184
- for (const [modelId, modelUsage] of modelEntries) {
185
- const category = resolveModelPricingCategory(modelId);
186
- const pricing = this.pricingConfig[category ?? 'sonnet'];
187
-
188
- result.inputCost += (modelUsage.inputTokens / 1_000_000) * pricing.inputPerMTok;
189
- result.outputCost += (modelUsage.outputTokens / 1_000_000) * pricing.outputPerMTok;
190
- result.cacheReadCost += (modelUsage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
191
- result.cacheCreateCost += (modelUsage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
192
- }
193
- } else {
194
- // Fallback: use aggregate totals with sonnet pricing
195
- const pricing = this.pricingConfig.sonnet;
196
- result.inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMTok;
197
- result.outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMTok;
198
- result.cacheReadCost = (usage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
199
- result.cacheCreateCost = (usage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
200
- }
201
-
202
- result.totalCost = result.inputCost + result.outputCost + result.cacheReadCost + result.cacheCreateCost;
203
- return result;
204
- }
205
-
206
- /**
207
- * Calculate the 5h rate limit window percentage for a given cost.
208
- * Converts cost to Sonnet-equivalent tokens using the configured Sonnet pricing,
209
- * then divides by the configured cap.
210
- *
211
- * @param totalCost - The total cost in dollars
212
- * @param sonnetTokenCap - Optional override for the Sonnet-equivalent token cap (defaults to config value)
213
- * @returns The percentage of the 5h window consumed (0-100+)
214
- */
215
- calculateRateLimitPercentage(totalCost: number, sonnetTokenCap?: number): number {
216
- if (totalCost === 0) return 0;
217
-
218
- // Get the configured cap or use the provided override
219
- const cap = sonnetTokenCap ?? getRateLimitWindowConfig().sonnetTokenCap;
220
-
221
- // Calculate the average Sonnet cost per token
222
- // Using the average of input and output pricing (simplified approach)
223
- const sonnetPricing = this.pricingConfig.sonnet;
224
- const avgSonnetCostPerToken = (sonnetPricing.inputPerMTok + sonnetPricing.outputPerMTok) / 2 / 1_000_000;
225
-
226
- // Convert cost to Sonnet-equivalent tokens
227
- const sonnetEquivalentTokens = totalCost / avgSonnetCostPerToken;
228
-
229
- // Calculate percentage
230
- return (sonnetEquivalentTokens / cap) * 100;
231
- }
232
-
233
- /**
234
- * Get the cumulative 5h window percentage across all recorded tasks.
235
- */
236
- getCumulativeRateLimitPercentage(sonnetTokenCap?: number): number {
237
- const totals = this.getTotals();
238
- return this.calculateRateLimitPercentage(totals.cost.totalCost, sonnetTokenCap);
239
- }
240
146
  }
@@ -736,6 +736,7 @@ describe('ClaudeRunner', () => {
736
736
  outputTokens: 500,
737
737
  cacheReadInputTokens: 200,
738
738
  cacheCreationInputTokens: 100,
739
+ costUsd: 0,
739
740
  });
740
741
  });
741
742
 
@@ -277,18 +277,10 @@ describe('Config Command', () => {
277
277
 
278
278
  it('should handle nested keys', () => {
279
279
  const configPath = path.join(tempDir, 'raf.config.json');
280
- fs.writeFileSync(configPath, JSON.stringify({ display: { showRateLimitEstimate: false } }, null, 2));
280
+ fs.writeFileSync(configPath, JSON.stringify({ display: { showCacheTokens: false } }, null, 2));
281
281
 
282
282
  const config = resolveConfig(configPath);
283
- expect(config.display.showRateLimitEstimate).toBe(false);
284
- });
285
-
286
- it('should handle deeply nested pricing keys', () => {
287
- const configPath = path.join(tempDir, 'raf.config.json');
288
- fs.writeFileSync(configPath, JSON.stringify({ pricing: { opus: { inputPerMTok: 20 } } }, null, 2));
289
-
290
- const config = resolveConfig(configPath);
291
- expect(config.pricing.opus.inputPerMTok).toBe(20);
283
+ expect(config.display.showCacheTokens).toBe(false);
292
284
  });
293
285
  });
294
286
 
@@ -401,12 +393,11 @@ describe('Config Command', () => {
401
393
  const configPath = path.join(tempDir, 'raf.config.json');
402
394
 
403
395
  // Set a nested value
404
- const userConfig = { display: { showRateLimitEstimate: false } };
396
+ const userConfig = { display: { showCacheTokens: false } };
405
397
  fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
406
398
 
407
399
  const config = resolveConfig(configPath);
408
- expect(config.display.showRateLimitEstimate).toBe(false);
409
- expect(config.display.showCacheTokens).toBe(DEFAULT_CONFIG.display.showCacheTokens);
400
+ expect(config.display.showCacheTokens).toBe(false);
410
401
  });
411
402
  });
412
403
  });
@@ -25,9 +25,6 @@ import {
25
25
  saveConfig,
26
26
  renderCommitMessage,
27
27
  isValidModelName,
28
- resolveModelPricingCategory,
29
- getPricing,
30
- getPricingConfig,
31
28
  } from '../../src/utils/config.js';
32
29
  import { DEFAULT_CONFIG } from '../../src/types/config.js';
33
30
 
@@ -538,97 +535,6 @@ describe('Config', () => {
538
535
  });
539
536
  });
540
537
 
541
- describe('validateConfig - pricing', () => {
542
- it('should accept valid pricing config', () => {
543
- expect(() => validateConfig({
544
- pricing: {
545
- opus: { inputPerMTok: 15, outputPerMTok: 75 },
546
- },
547
- })).not.toThrow();
548
- });
549
-
550
- it('should accept partial pricing override', () => {
551
- expect(() => validateConfig({
552
- pricing: {
553
- haiku: { outputPerMTok: 4 },
554
- },
555
- })).not.toThrow();
556
- });
557
-
558
- it('should reject non-object pricing', () => {
559
- expect(() => validateConfig({ pricing: 'expensive' })).toThrow('pricing must be an object');
560
- });
561
-
562
- it('should reject unknown pricing categories', () => {
563
- expect(() => validateConfig({ pricing: { gpt4: { inputPerMTok: 10 } } })).toThrow('Unknown config key: pricing.gpt4');
564
- });
565
-
566
- it('should reject non-object category value', () => {
567
- expect(() => validateConfig({ pricing: { opus: 'expensive' } })).toThrow('pricing.opus must be an object');
568
- });
569
-
570
- it('should reject unknown pricing fields', () => {
571
- expect(() => validateConfig({ pricing: { opus: { unknownField: 5 } } })).toThrow('Unknown config key: pricing.opus.unknownField');
572
- });
573
-
574
- it('should reject negative pricing values', () => {
575
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: -1 } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
576
- });
577
-
578
- it('should reject non-number pricing values', () => {
579
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: 'fifteen' } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
580
- });
581
-
582
- it('should accept zero pricing values', () => {
583
- expect(() => validateConfig({ pricing: { haiku: { inputPerMTok: 0 } } })).not.toThrow();
584
- });
585
-
586
- it('should reject Infinity pricing values', () => {
587
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: Infinity } } })).toThrow('must be a non-negative number');
588
- });
589
- });
590
-
591
- describe('resolveModelPricingCategory', () => {
592
- it('should map short aliases directly', () => {
593
- expect(resolveModelPricingCategory('opus')).toBe('opus');
594
- expect(resolveModelPricingCategory('sonnet')).toBe('sonnet');
595
- expect(resolveModelPricingCategory('haiku')).toBe('haiku');
596
- });
597
-
598
- it('should extract family from full model IDs', () => {
599
- expect(resolveModelPricingCategory('claude-opus-4-6')).toBe('opus');
600
- expect(resolveModelPricingCategory('claude-sonnet-4-5-20250929')).toBe('sonnet');
601
- expect(resolveModelPricingCategory('claude-haiku-4-5-20251001')).toBe('haiku');
602
- });
603
-
604
- it('should return null for unknown model families', () => {
605
- expect(resolveModelPricingCategory('claude-unknown-3-0')).toBeNull();
606
- expect(resolveModelPricingCategory('gpt-4')).toBeNull();
607
- expect(resolveModelPricingCategory('')).toBeNull();
608
- });
609
- });
610
-
611
- describe('resolveConfig - pricing', () => {
612
- it('should include default pricing when no config file', () => {
613
- const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
614
- expect(config.pricing.opus.inputPerMTok).toBe(15);
615
- expect(config.pricing.sonnet.inputPerMTok).toBe(3);
616
- expect(config.pricing.haiku.inputPerMTok).toBe(1);
617
- });
618
-
619
- it('should deep-merge partial pricing override', () => {
620
- const configPath = path.join(tempDir, 'pricing.json');
621
- fs.writeFileSync(configPath, JSON.stringify({
622
- pricing: { opus: { inputPerMTok: 10 } },
623
- }));
624
-
625
- const config = resolveConfig(configPath);
626
- expect(config.pricing.opus.inputPerMTok).toBe(10);
627
- expect(config.pricing.opus.outputPerMTok).toBe(75); // default preserved
628
- expect(config.pricing.sonnet.inputPerMTok).toBe(3); // default preserved
629
- });
630
- });
631
-
632
538
  describe('getModelShortName', () => {
633
539
  it('should return short aliases as-is', () => {
634
540
  expect(getModelShortName('opus')).toBe('opus');
@@ -709,7 +615,6 @@ describe('Config', () => {
709
615
  it('should accept valid display config', () => {
710
616
  expect(() => validateConfig({
711
617
  display: {
712
- showRateLimitEstimate: true,
713
618
  showCacheTokens: false,
714
619
  },
715
620
  })).not.toThrow();
@@ -717,7 +622,7 @@ describe('Config', () => {
717
622
 
718
623
  it('should accept partial display override', () => {
719
624
  expect(() => validateConfig({
720
- display: { showRateLimitEstimate: false },
625
+ display: { showCacheTokens: false },
721
626
  })).not.toThrow();
722
627
  });
723
628
 
@@ -730,78 +635,31 @@ describe('Config', () => {
730
635
  });
731
636
 
732
637
  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');
638
+ expect(() => validateConfig({ display: { showCacheTokens: 'yes' } })).toThrow('display.showCacheTokens must be a boolean');
759
639
  });
760
640
  });
761
641
 
762
- describe('resolveConfig - display and rateLimitWindow', () => {
642
+ describe('resolveConfig - display', () => {
763
643
  it('should include default display when no config file', () => {
764
644
  const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
765
- expect(config.display.showRateLimitEstimate).toBe(true);
766
645
  expect(config.display.showCacheTokens).toBe(true);
767
646
  });
768
647
 
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
648
  it('should deep-merge partial display override', () => {
775
649
  const configPath = path.join(tempDir, 'display.json');
776
650
  fs.writeFileSync(configPath, JSON.stringify({
777
- display: { showRateLimitEstimate: false },
651
+ display: { showCacheTokens: false },
778
652
  }));
779
653
 
780
654
  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);
655
+ expect(config.display.showCacheTokens).toBe(false);
793
656
  });
794
657
  });
795
658
 
796
- describe('DEFAULT_CONFIG - display and rateLimitWindow', () => {
659
+ describe('DEFAULT_CONFIG - display', () => {
797
660
  it('should have default display settings', () => {
798
- expect(DEFAULT_CONFIG.display.showRateLimitEstimate).toBe(true);
799
661
  expect(DEFAULT_CONFIG.display.showCacheTokens).toBe(true);
800
662
  });
801
-
802
- it('should have default rateLimitWindow settings', () => {
803
- expect(DEFAULT_CONFIG.rateLimitWindow.sonnetTokenCap).toBe(88000);
804
- });
805
663
  });
806
664
 
807
665
  describe('getModelTier', () => {
@@ -0,0 +1,153 @@
1
+ import { jest } from '@jest/globals';
2
+
3
+ /**
4
+ * Tests for worktree resolution in the --resume command.
5
+ *
6
+ * When `raf plan --resume <identifier>` is run, it should:
7
+ * 1. Search worktrees first
8
+ * 2. Fall back to main repo if not found in worktree
9
+ * 3. Auto-detect worktree mode without requiring --worktree flag
10
+ */
11
+
12
+ describe('Plan Resume - Worktree Resolution Logic', () => {
13
+ describe('resolution flow logic', () => {
14
+ it('should prioritize worktree over main repo when both exist', () => {
15
+ // This test verifies the conceptual resolution order used in runResumeCommand:
16
+ // 1. Try worktree resolution via resolveWorktreeProjectByIdentifier()
17
+ // 2. If found and valid → use worktree path and worktree root as CWD
18
+ // 3. If not found → fall back to main repo resolution
19
+
20
+ // Simulating the flow:
21
+ const worktreeFound = true;
22
+ const worktreeValid = true;
23
+
24
+ let useWorktree = false;
25
+ let useMainRepo = false;
26
+
27
+ // Step 1: Try worktree
28
+ if (worktreeFound && worktreeValid) {
29
+ useWorktree = true;
30
+ }
31
+
32
+ // Step 2: Fall back to main repo if worktree not used
33
+ if (!useWorktree) {
34
+ useMainRepo = true;
35
+ }
36
+
37
+ expect(useWorktree).toBe(true);
38
+ expect(useMainRepo).toBe(false);
39
+ });
40
+
41
+ it('should fall back to main repo when worktree is invalid', () => {
42
+ const worktreeFound = true;
43
+ const worktreeValid = false;
44
+
45
+ let useWorktree = false;
46
+ let useMainRepo = false;
47
+
48
+ if (worktreeFound && worktreeValid) {
49
+ useWorktree = true;
50
+ }
51
+
52
+ if (!useWorktree) {
53
+ useMainRepo = true;
54
+ }
55
+
56
+ expect(useWorktree).toBe(false);
57
+ expect(useMainRepo).toBe(true);
58
+ });
59
+
60
+ it('should fall back to main repo when worktree not found', () => {
61
+ const worktreeFound = false;
62
+
63
+ let useWorktree = false;
64
+ let useMainRepo = false;
65
+
66
+ if (worktreeFound) {
67
+ useWorktree = true;
68
+ }
69
+
70
+ if (!useWorktree) {
71
+ useMainRepo = true;
72
+ }
73
+
74
+ expect(useWorktree).toBe(false);
75
+ expect(useMainRepo).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('resumeCwd determination', () => {
80
+ it('should set resumeCwd to worktree root when project found in valid worktree', () => {
81
+ // Simulated values
82
+ const worktreeRoot = '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset';
83
+ const projectPath = '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset/RAF/ahwvrz-legacy-sunset';
84
+
85
+ const worktreeValid = true;
86
+ let resumeCwd: string | undefined;
87
+
88
+ if (worktreeValid) {
89
+ resumeCwd = worktreeRoot;
90
+ }
91
+
92
+ expect(resumeCwd).toBe(worktreeRoot);
93
+ });
94
+
95
+ it('should set resumeCwd to project path when using main repo', () => {
96
+ const mainRepoProjectPath = '/Users/user/myapp/RAF/ahwvrz-legacy-sunset';
97
+
98
+ const worktreeFound = false;
99
+ let resumeCwd: string | undefined;
100
+
101
+ if (!worktreeFound) {
102
+ resumeCwd = mainRepoProjectPath;
103
+ }
104
+
105
+ expect(resumeCwd).toBe(mainRepoProjectPath);
106
+ });
107
+ });
108
+
109
+ describe('variable initialization', () => {
110
+ it('should handle undefined variables correctly when worktree is found', () => {
111
+ let projectPath: string | undefined;
112
+ let resumeCwd: string | undefined;
113
+ let folderName: string | undefined;
114
+
115
+ // Simulate worktree resolution
116
+ const worktreeResolution = {
117
+ folder: 'ahwvrz-legacy-sunset',
118
+ worktreeRoot: '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset',
119
+ };
120
+
121
+ if (worktreeResolution) {
122
+ folderName = worktreeResolution.folder;
123
+ projectPath = `/path/to/${folderName}`;
124
+ resumeCwd = worktreeResolution.worktreeRoot;
125
+ }
126
+
127
+ expect(folderName).toBeDefined();
128
+ expect(projectPath).toBeDefined();
129
+ expect(resumeCwd).toBeDefined();
130
+ });
131
+
132
+ it('should handle undefined variables correctly when falling back to main repo', () => {
133
+ let projectPath: string | undefined;
134
+ let resumeCwd: string | undefined;
135
+ let folderName: string | undefined;
136
+
137
+ // Worktree resolution returns null
138
+ const worktreeResolution = null;
139
+
140
+ // Skip worktree assignment since it's null
141
+ if (!projectPath && !worktreeResolution) {
142
+ // Main repo resolution
143
+ projectPath = '/Users/user/myapp/RAF/ahwvrz-legacy-sunset';
144
+ folderName = 'ahwvrz-legacy-sunset';
145
+ resumeCwd = projectPath;
146
+ }
147
+
148
+ expect(folderName).toBeDefined();
149
+ expect(projectPath).toBeDefined();
150
+ expect(resumeCwd).toBeDefined();
151
+ });
152
+ });
153
+ });