rafcode 3.2.1 → 3.8.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 (200) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/CLAUDE.md +0 -1
  3. package/RAF/41-echo-chamber/decisions.md +13 -0
  4. package/RAF/41-echo-chamber/input.md +4 -0
  5. package/RAF/41-echo-chamber/outcomes/1-update-codex-model-defaults.md +24 -0
  6. package/RAF/41-echo-chamber/outcomes/2-e2e-test-codex-provider.md +74 -0
  7. package/RAF/41-echo-chamber/plans/1-update-codex-model-defaults.md +28 -0
  8. package/RAF/41-echo-chamber/plans/2-e2e-test-codex-provider.md +103 -0
  9. package/RAF/42-patch-parade/decisions.md +29 -0
  10. package/RAF/42-patch-parade/input.md +9 -0
  11. package/RAF/42-patch-parade/outcomes/1-fix-codex-model-resolution.md +36 -0
  12. package/RAF/42-patch-parade/outcomes/2-fix-provider-aware-name-generation.md +31 -0
  13. package/RAF/42-patch-parade/outcomes/3-fix-codex-error-event-rendering.md +32 -0
  14. package/RAF/42-patch-parade/outcomes/4-update-cli-help-docs.md +28 -0
  15. package/RAF/42-patch-parade/outcomes/5-update-default-codex-models-to-gpt-5-4.md +33 -0
  16. package/RAF/42-patch-parade/outcomes/6-unify-model-config-schema.md +89 -0
  17. package/RAF/42-patch-parade/plans/1-fix-codex-model-resolution.md +35 -0
  18. package/RAF/42-patch-parade/plans/2-fix-provider-aware-name-generation.md +38 -0
  19. package/RAF/42-patch-parade/plans/3-fix-codex-error-event-rendering.md +32 -0
  20. package/RAF/42-patch-parade/plans/4-update-cli-help-docs.md +31 -0
  21. package/RAF/42-patch-parade/plans/5-update-default-codex-models-to-gpt-5-4.md +35 -0
  22. package/RAF/42-patch-parade/plans/6-unify-model-config-schema.md +46 -0
  23. package/RAF/43-swiss-army/decisions.md +34 -0
  24. package/RAF/43-swiss-army/input.md +7 -0
  25. package/RAF/43-swiss-army/outcomes/1-fix-model-validation.md +21 -0
  26. package/RAF/43-swiss-army/outcomes/2-update-commit-format.md +31 -0
  27. package/RAF/43-swiss-army/outcomes/3-wire-reasoning-effort.md +28 -0
  28. package/RAF/43-swiss-army/outcomes/4-remove-provider-flag.md +27 -0
  29. package/RAF/43-swiss-army/outcomes/5-config-wizard-validation.md +23 -0
  30. package/RAF/43-swiss-army/outcomes/6-add-fast-mode.md +32 -0
  31. package/RAF/43-swiss-army/outcomes/7-config-preset.md +31 -0
  32. package/RAF/43-swiss-army/plans/1-fix-model-validation.md +38 -0
  33. package/RAF/43-swiss-army/plans/2-update-commit-format.md +46 -0
  34. package/RAF/43-swiss-army/plans/3-wire-reasoning-effort.md +39 -0
  35. package/RAF/43-swiss-army/plans/4-remove-provider-flag.md +43 -0
  36. package/RAF/43-swiss-army/plans/5-config-wizard-validation.md +42 -0
  37. package/RAF/43-swiss-army/plans/6-add-fast-mode.md +46 -0
  38. package/RAF/43-swiss-army/plans/7-config-preset.md +51 -0
  39. package/RAF/44-config-api-change/decisions.md +22 -0
  40. package/RAF/44-config-api-change/input.md +5 -0
  41. package/RAF/44-config-api-change/outcomes/1-restructure-config-subcommands.md +19 -0
  42. package/RAF/44-config-api-change/outcomes/2-move-preset-under-config.md +17 -0
  43. package/RAF/44-config-api-change/outcomes/3-update-existing-tests-for-config-api.md +14 -0
  44. package/RAF/44-config-api-change/outcomes/4-update-config-command-docs.md +11 -0
  45. package/RAF/44-config-api-change/outcomes/5-fix-codex-name-generation.md +18 -0
  46. package/RAF/44-config-api-change/plans/1-restructure-config-subcommands.md +37 -0
  47. package/RAF/44-config-api-change/plans/2-move-preset-under-config.md +38 -0
  48. package/RAF/44-config-api-change/plans/3-update-existing-tests-for-config-api.md +38 -0
  49. package/RAF/44-config-api-change/plans/4-update-config-command-docs.md +36 -0
  50. package/RAF/44-config-api-change/plans/5-fix-codex-name-generation.md +49 -0
  51. package/RAF/45-signal-cairn/decisions.md +7 -0
  52. package/RAF/45-signal-cairn/input.md +2 -0
  53. package/RAF/45-signal-cairn/outcomes/1-rename-provider-to-harness.md +19 -0
  54. package/RAF/45-signal-cairn/outcomes/2-normalize-model-display-names.md +18 -0
  55. package/RAF/45-signal-cairn/plans/1-rename-provider-to-harness.md +40 -0
  56. package/RAF/45-signal-cairn/plans/2-normalize-model-display-names.md +41 -0
  57. package/RAF/45-signal-lantern/decisions.md +10 -0
  58. package/RAF/45-signal-lantern/input.md +2 -0
  59. package/RAF/45-signal-lantern/outcomes/1-add-effort-and-fast-to-do-model-display.md +15 -0
  60. package/RAF/45-signal-lantern/outcomes/2-capture-codex-post-run-token-usage.md +15 -0
  61. package/RAF/45-signal-lantern/outcomes/3-show-codex-token-summaries-without-fake-cost.md +14 -0
  62. package/RAF/45-signal-lantern/plans/1-add-effort-and-fast-to-do-model-display.md +38 -0
  63. package/RAF/45-signal-lantern/plans/2-capture-codex-post-run-token-usage.md +37 -0
  64. package/RAF/45-signal-lantern/plans/3-show-codex-token-summaries-without-fake-cost.md +40 -0
  65. package/RAF/46-lantern-arc/decisions.md +19 -0
  66. package/RAF/46-lantern-arc/input.md +6 -0
  67. package/RAF/46-lantern-arc/outcomes/1-remove-spark-alias.md +16 -0
  68. package/RAF/46-lantern-arc/outcomes/2-clean-up-worktree-plan-command.md +30 -0
  69. package/RAF/46-lantern-arc/outcomes/3-fix-token-usage-accumulation.md +32 -0
  70. package/RAF/46-lantern-arc/outcomes/4-display-effort-in-compact-mode.md +22 -0
  71. package/RAF/46-lantern-arc/outcomes/5-codex-fast-mode-research.md +38 -0
  72. package/RAF/46-lantern-arc/outcomes/6-optimize-llm-prompts.md +39 -0
  73. package/RAF/46-lantern-arc/plans/1-remove-spark-alias.md +38 -0
  74. package/RAF/46-lantern-arc/plans/2-clean-up-worktree-plan-command.md +33 -0
  75. package/RAF/46-lantern-arc/plans/3-fix-token-usage-accumulation.md +33 -0
  76. package/RAF/46-lantern-arc/plans/4-display-effort-in-compact-mode.md +28 -0
  77. package/RAF/46-lantern-arc/plans/5-codex-fast-mode-research.md +34 -0
  78. package/RAF/46-lantern-arc/plans/6-optimize-llm-prompts.md +48 -0
  79. package/RAF/47-signal-trim/decisions.md +13 -0
  80. package/RAF/47-signal-trim/input.md +2 -0
  81. package/RAF/47-signal-trim/plans/1-remove-cache-from-status.md +73 -0
  82. package/README.md +47 -57
  83. package/dist/commands/config.d.ts.map +1 -1
  84. package/dist/commands/config.js +47 -49
  85. package/dist/commands/config.js.map +1 -1
  86. package/dist/commands/do.d.ts +2 -0
  87. package/dist/commands/do.d.ts.map +1 -1
  88. package/dist/commands/do.js +57 -44
  89. package/dist/commands/do.js.map +1 -1
  90. package/dist/commands/plan.d.ts.map +1 -1
  91. package/dist/commands/plan.js +36 -153
  92. package/dist/commands/plan.js.map +1 -1
  93. package/dist/commands/preset.d.ts +3 -0
  94. package/dist/commands/preset.d.ts.map +1 -0
  95. package/dist/commands/preset.js +158 -0
  96. package/dist/commands/preset.js.map +1 -0
  97. package/dist/core/claude-runner.d.ts +2 -0
  98. package/dist/core/claude-runner.d.ts.map +1 -1
  99. package/dist/core/claude-runner.js +36 -12
  100. package/dist/core/claude-runner.js.map +1 -1
  101. package/dist/core/codex-runner.d.ts +1 -0
  102. package/dist/core/codex-runner.d.ts.map +1 -1
  103. package/dist/core/codex-runner.js +26 -7
  104. package/dist/core/codex-runner.js.map +1 -1
  105. package/dist/core/failure-analyzer.js +2 -1
  106. package/dist/core/failure-analyzer.js.map +1 -1
  107. package/dist/core/git.d.ts +2 -2
  108. package/dist/core/git.d.ts.map +1 -1
  109. package/dist/core/git.js +53 -3
  110. package/dist/core/git.js.map +1 -1
  111. package/dist/core/pull-request.js +3 -3
  112. package/dist/core/pull-request.js.map +1 -1
  113. package/dist/core/runner-factory.d.ts +4 -4
  114. package/dist/core/runner-factory.d.ts.map +1 -1
  115. package/dist/core/runner-factory.js +8 -8
  116. package/dist/core/runner-factory.js.map +1 -1
  117. package/dist/core/runner-interface.d.ts +1 -1
  118. package/dist/core/runner-types.d.ts +17 -4
  119. package/dist/core/runner-types.d.ts.map +1 -1
  120. package/dist/parsers/codex-stream-renderer.d.ts +7 -0
  121. package/dist/parsers/codex-stream-renderer.d.ts.map +1 -1
  122. package/dist/parsers/codex-stream-renderer.js +37 -4
  123. package/dist/parsers/codex-stream-renderer.js.map +1 -1
  124. package/dist/prompts/amend.d.ts.map +1 -1
  125. package/dist/prompts/amend.js +29 -101
  126. package/dist/prompts/amend.js.map +1 -1
  127. package/dist/prompts/execution.d.ts.map +1 -1
  128. package/dist/prompts/execution.js +17 -34
  129. package/dist/prompts/execution.js.map +1 -1
  130. package/dist/prompts/planning.d.ts.map +1 -1
  131. package/dist/prompts/planning.js +21 -120
  132. package/dist/prompts/planning.js.map +1 -1
  133. package/dist/types/config.d.ts +33 -31
  134. package/dist/types/config.d.ts.map +1 -1
  135. package/dist/types/config.js +14 -28
  136. package/dist/types/config.js.map +1 -1
  137. package/dist/utils/config.d.ts +36 -16
  138. package/dist/utils/config.d.ts.map +1 -1
  139. package/dist/utils/config.js +209 -104
  140. package/dist/utils/config.js.map +1 -1
  141. package/dist/utils/name-generator.d.ts.map +1 -1
  142. package/dist/utils/name-generator.js +25 -12
  143. package/dist/utils/name-generator.js.map +1 -1
  144. package/dist/utils/terminal-symbols.d.ts +15 -2
  145. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  146. package/dist/utils/terminal-symbols.js +36 -4
  147. package/dist/utils/terminal-symbols.js.map +1 -1
  148. package/dist/utils/token-tracker.d.ts +6 -1
  149. package/dist/utils/token-tracker.d.ts.map +1 -1
  150. package/dist/utils/token-tracker.js +84 -51
  151. package/dist/utils/token-tracker.js.map +1 -1
  152. package/dist/utils/validation.d.ts +1 -2
  153. package/dist/utils/validation.d.ts.map +1 -1
  154. package/dist/utils/validation.js +4 -25
  155. package/dist/utils/validation.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/commands/config.ts +60 -63
  158. package/src/commands/do.ts +63 -51
  159. package/src/commands/plan.ts +34 -165
  160. package/src/commands/preset.ts +186 -0
  161. package/src/core/claude-runner.ts +45 -5
  162. package/src/core/codex-runner.ts +32 -7
  163. package/src/core/failure-analyzer.ts +2 -1
  164. package/src/core/git.ts +57 -3
  165. package/src/core/pull-request.ts +3 -3
  166. package/src/core/runner-factory.ts +9 -9
  167. package/src/core/runner-interface.ts +1 -1
  168. package/src/core/runner-types.ts +17 -4
  169. package/src/parsers/codex-stream-renderer.ts +47 -4
  170. package/src/prompts/amend.ts +29 -101
  171. package/src/prompts/config-docs.md +206 -62
  172. package/src/prompts/execution.ts +17 -34
  173. package/src/prompts/planning.ts +21 -120
  174. package/src/types/config.ts +47 -58
  175. package/src/utils/config.ts +248 -115
  176. package/src/utils/name-generator.ts +29 -13
  177. package/src/utils/terminal-symbols.ts +46 -6
  178. package/src/utils/token-tracker.ts +96 -57
  179. package/src/utils/validation.ts +5 -30
  180. package/tests/unit/amend-prompt.test.ts +3 -2
  181. package/tests/unit/claude-runner-interactive.test.ts +21 -3
  182. package/tests/unit/claude-runner.test.ts +39 -0
  183. package/tests/unit/codex-runner.test.ts +163 -0
  184. package/tests/unit/codex-stream-renderer.test.ts +127 -0
  185. package/tests/unit/command-output.test.ts +57 -0
  186. package/tests/unit/commit-planning-artifacts-worktree.test.ts +24 -7
  187. package/tests/unit/commit-planning-artifacts.test.ts +26 -4
  188. package/tests/unit/config-command.test.ts +215 -303
  189. package/tests/unit/config.test.ts +319 -235
  190. package/tests/unit/dependency-integration.test.ts +27 -1
  191. package/tests/unit/do-model-display.test.ts +35 -0
  192. package/tests/unit/execution-prompt.test.ts +49 -19
  193. package/tests/unit/name-generator.test.ts +82 -12
  194. package/tests/unit/plan-command-auto-flag.test.ts +7 -10
  195. package/tests/unit/plan-command.test.ts +14 -17
  196. package/tests/unit/planning-prompt.test.ts +9 -8
  197. package/tests/unit/terminal-symbols.test.ts +94 -3
  198. package/tests/unit/token-tracker.test.ts +180 -1
  199. package/tests/unit/validation.test.ts +9 -41
  200. package/tests/unit/worktree-flag-override.test.ts +0 -186
@@ -13,6 +13,10 @@ export interface TokenSummaryOptions {
13
13
  showCacheTokens?: boolean;
14
14
  }
15
15
 
16
+ function hasExactCost(cost: number | null): cost is number {
17
+ return cost !== null;
18
+ }
19
+
16
20
  /**
17
21
  * Visual symbols for terminal output using dots/symbols style.
18
22
  */
@@ -27,6 +31,11 @@ export const SYMBOLS = {
27
31
 
28
32
  export type TaskStatus = 'running' | 'completed' | 'failed' | 'pending' | 'blocked';
29
33
 
34
+ export interface ModelDisplayOptions {
35
+ effort?: string;
36
+ fast?: boolean;
37
+ }
38
+
30
39
  /**
31
40
  * Truncates a string to the specified length, adding ellipsis if needed.
32
41
  */
@@ -55,12 +64,13 @@ export function formatTaskProgress(
55
64
  name: string,
56
65
  elapsedMs?: number,
57
66
  taskId?: string,
58
- model?: string
67
+ model?: string,
68
+ modelOptions: ModelDisplayOptions = {}
59
69
  ): string {
60
70
  const symbol = SYMBOLS[status];
61
71
  const displayName = truncate(name || 'task', 40);
62
72
  const idPrefix = taskId ? `${taskId}-` : '';
63
- const modelSuffix = model ? ` (${model})` : '';
73
+ const modelSuffix = formatModelDisplay(model, modelOptions);
64
74
 
65
75
  // Show elapsed time for running tasks, completed tasks, and failed tasks
66
76
  if (elapsedMs !== undefined) {
@@ -71,6 +81,31 @@ export function formatTaskProgress(
71
81
  return `${symbol} ${idPrefix}${displayName}${modelSuffix} ${current}/${total}`;
72
82
  }
73
83
 
84
+ /**
85
+ * Formats a model label with optional effort/fast metadata.
86
+ * Examples: "sonnet", "sonnet, low", "sonnet, low, fast"
87
+ */
88
+ export function formatModelMetadata(model: string, options: ModelDisplayOptions = {}): string {
89
+ const parts = [model];
90
+ if (options.effort) {
91
+ parts.push(options.effort);
92
+ }
93
+ if (options.fast) {
94
+ parts.push('fast');
95
+ }
96
+ return parts.join(', ');
97
+ }
98
+
99
+ /**
100
+ * Formats model metadata for display surfaces that wrap the label in parentheses.
101
+ */
102
+ export function formatModelDisplay(model?: string, options: ModelDisplayOptions = {}): string {
103
+ if (!model) {
104
+ return '';
105
+ }
106
+ return ` (${formatModelMetadata(model, options)})`;
107
+ }
108
+
74
109
  /**
75
110
  * Formats a project header line.
76
111
  * @param name - Project name
@@ -148,7 +183,8 @@ export function formatNumber(n: number): string {
148
183
  * Formats a cost in USD with 2-4 decimal places.
149
184
  * Uses 2 decimals for values >= $0.01, 4 decimals for smaller values.
150
185
  */
151
- export function formatCost(cost: number): string {
186
+ export function formatCost(cost: number | null): string {
187
+ if (cost === null) return 'unavailable';
152
188
  if (cost === 0) return '$0.00';
153
189
  if (cost < 0.01) return `$${cost.toFixed(4)}`;
154
190
  return `$${cost.toFixed(2)}`;
@@ -160,7 +196,7 @@ export function formatCost(cost: number): string {
160
196
  */
161
197
  function formatTokenLine(
162
198
  usage: UsageData,
163
- costValue: number,
199
+ costValue: number | null,
164
200
  prefix: string = '',
165
201
  indent: string = ' ',
166
202
  options: TokenSummaryOptions = {}
@@ -183,7 +219,9 @@ function formatTokenLine(
183
219
  }
184
220
  }
185
221
 
186
- parts.push(`Cost: ${formatCost(costValue)}`);
222
+ if (hasExactCost(costValue)) {
223
+ parts.push(`Cost: ${formatCost(costValue)}`);
224
+ }
187
225
 
188
226
  return `${indent}${parts.join(' | ')}`;
189
227
  }
@@ -245,7 +283,9 @@ export function formatTokenTotalSummary(
245
283
  lines.push(`Cache: ${cacheParts.join(' / ')}`);
246
284
  }
247
285
 
248
- lines.push(`Total cost: ${formatCost(cost.totalCost)}`);
286
+ if (hasExactCost(cost.totalCost)) {
287
+ lines.push(`Total cost: ${formatCost(cost.totalCost)}`);
288
+ }
249
289
 
250
290
  lines.push('─────────────────────────────────────────');
251
291
  return lines.join('\n');
@@ -2,7 +2,7 @@ import { UsageData } from '../types/config.js';
2
2
 
3
3
  /** Cost breakdown for a single task or accumulated total. */
4
4
  export interface CostBreakdown {
5
- totalCost: number;
5
+ totalCost: number | null;
6
6
  }
7
7
 
8
8
  /** Per-task usage snapshot stored by the tracker. */
@@ -16,12 +16,82 @@ export interface TaskUsageEntry {
16
16
  attempts: UsageData[];
17
17
  }
18
18
 
19
+ function mergeCostUsd(existing: number | null | undefined, incoming: number | null | undefined): number | null {
20
+ if (existing === null || incoming === null || existing === undefined || incoming === undefined) {
21
+ return null;
22
+ }
23
+ return existing + incoming;
24
+ }
25
+
26
+ /**
27
+ * Merge usage data into an accumulated snapshot.
28
+ * Handles undefined input for first-event initialization.
29
+ */
30
+ export function mergeUsageData(existing: UsageData | undefined, incoming: UsageData | undefined): UsageData | undefined {
31
+ if (!incoming) {
32
+ return existing;
33
+ }
34
+
35
+ if (!existing) {
36
+ return {
37
+ inputTokens: incoming.inputTokens ?? 0,
38
+ outputTokens: incoming.outputTokens ?? 0,
39
+ cacheReadInputTokens: incoming.cacheReadInputTokens ?? 0,
40
+ cacheCreationInputTokens: incoming.cacheCreationInputTokens ?? 0,
41
+ modelUsage: Object.fromEntries(
42
+ Object.entries(incoming.modelUsage ?? {}).map(([modelId, usage]) => [
43
+ modelId,
44
+ {
45
+ inputTokens: usage.inputTokens ?? 0,
46
+ outputTokens: usage.outputTokens ?? 0,
47
+ cacheReadInputTokens: usage.cacheReadInputTokens ?? 0,
48
+ cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
49
+ costUsd: usage.costUsd ?? null,
50
+ },
51
+ ]),
52
+ ),
53
+ totalCostUsd: incoming.totalCostUsd ?? null,
54
+ };
55
+ }
56
+
57
+ const merged: UsageData = {
58
+ inputTokens: (existing.inputTokens ?? 0) + (incoming.inputTokens ?? 0),
59
+ outputTokens: (existing.outputTokens ?? 0) + (incoming.outputTokens ?? 0),
60
+ cacheReadInputTokens: (existing.cacheReadInputTokens ?? 0) + (incoming.cacheReadInputTokens ?? 0),
61
+ cacheCreationInputTokens: (existing.cacheCreationInputTokens ?? 0) + (incoming.cacheCreationInputTokens ?? 0),
62
+ modelUsage: {},
63
+ totalCostUsd: mergeCostUsd(existing.totalCostUsd, incoming.totalCostUsd),
64
+ };
65
+
66
+ const allModelIds = new Set([
67
+ ...Object.keys(existing.modelUsage ?? {}),
68
+ ...Object.keys(incoming.modelUsage ?? {}),
69
+ ]);
70
+
71
+ for (const modelId of allModelIds) {
72
+ const existingModel = existing.modelUsage?.[modelId];
73
+ const incomingModel = incoming.modelUsage?.[modelId];
74
+ merged.modelUsage[modelId] = {
75
+ inputTokens: (existingModel?.inputTokens ?? 0) + (incomingModel?.inputTokens ?? 0),
76
+ outputTokens: (existingModel?.outputTokens ?? 0) + (incomingModel?.outputTokens ?? 0),
77
+ cacheReadInputTokens: (existingModel?.cacheReadInputTokens ?? 0) + (incomingModel?.cacheReadInputTokens ?? 0),
78
+ cacheCreationInputTokens: (existingModel?.cacheCreationInputTokens ?? 0) + (incomingModel?.cacheCreationInputTokens ?? 0),
79
+ costUsd: mergeCostUsd(existingModel?.costUsd, incomingModel?.costUsd),
80
+ };
81
+ }
82
+
83
+ return merged;
84
+ }
85
+
19
86
  /**
20
87
  * Sum multiple CostBreakdown objects into a single total.
21
88
  */
22
89
  export function sumCostBreakdowns(costs: CostBreakdown[]): CostBreakdown {
23
90
  let totalCost = 0;
24
91
  for (const cost of costs) {
92
+ if (cost.totalCost === null) {
93
+ return { totalCost: null };
94
+ }
25
95
  totalCost += cost.totalCost;
26
96
  }
27
97
  return { totalCost };
@@ -32,7 +102,12 @@ export function sumCostBreakdowns(costs: CostBreakdown[]): CostBreakdown {
32
102
  * Sums all token fields and merges modelUsage maps.
33
103
  */
34
104
  export function accumulateUsage(attempts: UsageData[]): UsageData {
35
- const result: UsageData = {
105
+ let result: UsageData | undefined;
106
+ for (const attempt of attempts) {
107
+ result = mergeUsageData(result, attempt);
108
+ }
109
+
110
+ return result ?? {
36
111
  inputTokens: 0,
37
112
  outputTokens: 0,
38
113
  cacheReadInputTokens: 0,
@@ -40,32 +115,6 @@ export function accumulateUsage(attempts: UsageData[]): UsageData {
40
115
  modelUsage: {},
41
116
  totalCostUsd: 0,
42
117
  };
43
-
44
- for (const attempt of attempts) {
45
- result.inputTokens += attempt.inputTokens;
46
- result.outputTokens += attempt.outputTokens;
47
- result.cacheReadInputTokens += attempt.cacheReadInputTokens;
48
- result.cacheCreationInputTokens += attempt.cacheCreationInputTokens;
49
-
50
- // Merge per-model usage
51
- for (const [modelId, modelUsage] of Object.entries(attempt.modelUsage)) {
52
- const existing = result.modelUsage[modelId];
53
- if (existing) {
54
- existing.inputTokens += modelUsage.inputTokens;
55
- existing.outputTokens += modelUsage.outputTokens;
56
- existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
57
- existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
58
- existing.costUsd += modelUsage.costUsd;
59
- } else {
60
- result.modelUsage[modelId] = { ...modelUsage };
61
- }
62
- }
63
-
64
- // Sum totalCostUsd across attempts
65
- result.totalCostUsd += attempt.totalCostUsd;
66
- }
67
-
68
- return result;
69
118
  }
70
119
 
71
120
  /**
@@ -85,8 +134,7 @@ export class TokenTracker {
85
134
  */
86
135
  addTask(taskId: string, attempts: UsageData[]): TaskUsageEntry {
87
136
  const usage = accumulateUsage(attempts);
88
- // Sum costs from CLI-provided totalCostUsd
89
- const totalCost = attempts.reduce((sum, attempt) => sum + attempt.totalCostUsd, 0);
137
+ const totalCost = usage.totalCostUsd;
90
138
  const cost: CostBreakdown = { totalCost };
91
139
  const entry: TaskUsageEntry = { taskId, usage, cost, attempts };
92
140
  this.entries.push(entry);
@@ -104,43 +152,34 @@ export class TokenTracker {
104
152
  * Get accumulated totals across all tasks.
105
153
  */
106
154
  getTotals(): { usage: UsageData; cost: CostBreakdown } {
107
- const totalUsage: UsageData = {
108
- inputTokens: 0,
109
- outputTokens: 0,
110
- cacheReadInputTokens: 0,
111
- cacheCreationInputTokens: 0,
112
- modelUsage: {},
113
- totalCostUsd: 0,
114
- };
155
+ let totalUsage: UsageData | undefined;
115
156
  const totalCost: CostBreakdown = {
116
157
  totalCost: 0,
117
158
  };
118
159
 
119
160
  for (const entry of this.entries) {
120
- totalUsage.inputTokens += entry.usage.inputTokens;
121
- totalUsage.outputTokens += entry.usage.outputTokens;
122
- totalUsage.cacheReadInputTokens += entry.usage.cacheReadInputTokens;
123
- totalUsage.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens;
124
- totalUsage.totalCostUsd += entry.usage.totalCostUsd;
125
-
126
- // Merge per-model usage
127
- for (const [modelId, modelUsage] of Object.entries(entry.usage.modelUsage)) {
128
- const existing = totalUsage.modelUsage[modelId];
129
- if (existing) {
130
- existing.inputTokens += modelUsage.inputTokens;
131
- existing.outputTokens += modelUsage.outputTokens;
132
- existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
133
- existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
134
- existing.costUsd += modelUsage.costUsd;
161
+ totalUsage = mergeUsageData(totalUsage, entry.usage);
162
+
163
+ if (totalCost.totalCost !== null) {
164
+ if (entry.cost.totalCost === null) {
165
+ totalCost.totalCost = null;
135
166
  } else {
136
- totalUsage.modelUsage[modelId] = { ...modelUsage };
167
+ totalCost.totalCost += entry.cost.totalCost;
137
168
  }
138
169
  }
139
-
140
- totalCost.totalCost += entry.cost.totalCost;
141
170
  }
142
171
 
143
- return { usage: totalUsage, cost: totalCost };
172
+ return {
173
+ usage: totalUsage ?? {
174
+ inputTokens: 0,
175
+ outputTokens: 0,
176
+ cacheReadInputTokens: 0,
177
+ cacheCreationInputTokens: 0,
178
+ modelUsage: {},
179
+ totalCostUsd: 0,
180
+ },
181
+ cost: totalCost,
182
+ };
144
183
  }
145
184
 
146
185
  }
@@ -2,9 +2,8 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { logger } from './logger.js';
5
- import type { ClaudeModelName, ModelScenario } from '../types/config.js';
6
- import { VALID_MODEL_ALIASES } from '../types/config.js';
7
- import { getModel, isValidModelName } from './config.js';
5
+ import type { ClaudeModelName } from '../types/config.js';
6
+ import { isValidModelName } from './config.js';
8
7
 
9
8
  export interface ValidationResult {
10
9
  valid: boolean;
@@ -19,12 +18,12 @@ export function validateEnvironment(): ValidationResult {
19
18
  errors: [],
20
19
  };
21
20
 
22
- // Check CLI provider is installed
21
+ // Check that at least one supported CLI harness is installed
23
22
  try {
24
- execSync('which claude', { encoding: 'utf-8', stdio: 'pipe' });
23
+ execSync('which claude || which codex', { encoding: 'utf-8', stdio: 'pipe' });
25
24
  } catch {
26
25
  result.valid = false;
27
- result.errors.push('CLI provider not found. Please install Claude CLI or Codex CLI first.');
26
+ result.errors.push('CLI harness not found. Please install Claude CLI or Codex CLI first.');
28
27
  }
29
28
 
30
29
  // Check for git repo (warning only)
@@ -101,27 +100,3 @@ export function validateModelName(model: string): ClaudeModelName | null {
101
100
  }
102
101
  return null;
103
102
  }
104
-
105
- export function resolveModelOption(model?: string, sonnet?: boolean, scenario: ModelScenario = 'execute'): ClaudeModelName {
106
- // Check for conflicting flags
107
- if (model && sonnet) {
108
- throw new Error('Cannot specify both --model and --sonnet flags');
109
- }
110
-
111
- // --sonnet shorthand
112
- if (sonnet) {
113
- return 'sonnet';
114
- }
115
-
116
- // --model flag
117
- if (model) {
118
- const validated = validateModelName(model);
119
- if (!validated) {
120
- throw new Error(`Invalid model name: "${model}". Valid options: ${VALID_MODEL_ALIASES.join(', ')} or a full model ID (e.g., claude-sonnet-4-5-20250929), or harness-prefixed (e.g., codex/gpt-5.4)`);
121
- }
122
- return validated;
123
- }
124
-
125
- // Default from config
126
- return getModel(scenario);
127
- }
@@ -65,7 +65,7 @@ describe('Amend Prompt', () => {
65
65
  expect(systemPrompt).not.toContain('--worktree');
66
66
  });
67
67
 
68
- it('should show raf do with --worktree when worktreeMode is true', () => {
68
+ it('should show raf do without --worktree when worktreeMode is true', () => {
69
69
  const params: AmendPromptParams = {
70
70
  ...baseParams,
71
71
  worktreeMode: true,
@@ -73,7 +73,8 @@ describe('Amend Prompt', () => {
73
73
 
74
74
  const { systemPrompt } = getAmendPrompt(params);
75
75
 
76
- expect(systemPrompt).toContain('raf do <project> --worktree');
76
+ expect(systemPrompt).toContain('raf do <project>');
77
+ expect(systemPrompt).not.toContain('--worktree');
77
78
  });
78
79
 
79
80
  it('should include new task description in user message', () => {
@@ -1,5 +1,16 @@
1
1
  import { jest } from '@jest/globals';
2
2
  import { EventEmitter } from 'events';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+
7
+ const suiteHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-claude-home-'));
8
+ let mockHomeDir = suiteHomeDir;
9
+
10
+ jest.unstable_mockModule('node:os', () => ({
11
+ homedir: () => mockHomeDir,
12
+ tmpdir: () => os.tmpdir(),
13
+ }));
3
14
 
4
15
  // Create mock pty spawn before importing ClaudeRunner
5
16
  const mockPtySpawn = jest.fn();
@@ -16,6 +27,7 @@ jest.unstable_mockModule('node:child_process', () => ({
16
27
 
17
28
  // Import after mocking
18
29
  const { ClaudeRunner } = await import('../../src/core/claude-runner.js');
30
+ const { getModel, resetConfigCache } = await import('../../src/utils/config.js');
19
31
 
20
32
  describe('ClaudeRunner - runInteractive', () => {
21
33
  // Save original stdin/stdout for restoration
@@ -24,15 +36,22 @@ describe('ClaudeRunner - runInteractive', () => {
24
36
 
25
37
  beforeEach(() => {
26
38
  jest.clearAllMocks();
39
+ fs.rmSync(path.join(mockHomeDir, '.raf'), { recursive: true, force: true });
40
+ resetConfigCache();
27
41
  mockExecSync.mockReturnValue('/usr/local/bin/claude\n');
28
42
  });
29
43
 
30
44
  afterEach(() => {
45
+ resetConfigCache();
31
46
  // Restore stdin/stdout
32
47
  Object.defineProperty(process, 'stdin', { value: originalStdin });
33
48
  Object.defineProperty(process, 'stdout', { value: originalStdout });
34
49
  });
35
50
 
51
+ afterAll(() => {
52
+ fs.rmSync(suiteHomeDir, { recursive: true, force: true });
53
+ });
54
+
36
55
  /**
37
56
  * Creates a mock PTY process for testing.
38
57
  */
@@ -129,7 +148,7 @@ describe('ClaudeRunner - runInteractive', () => {
129
148
  await runPromise;
130
149
  });
131
150
 
132
- it('should use opus as default model', async () => {
151
+ it('should use the configured execute model by default', async () => {
133
152
  const mockProc = createMockPtyProcess();
134
153
  const mockStdin = createMockStdin();
135
154
  const mockStdout = createMockStdout();
@@ -144,9 +163,8 @@ describe('ClaudeRunner - runInteractive', () => {
144
163
 
145
164
  const spawnArgs = mockPtySpawn.mock.calls[0][1] as string[];
146
165
  expect(spawnArgs).toContain('--model');
147
- // Default model comes from config, could be short alias or full model ID
148
166
  const modelArgIndex = spawnArgs.indexOf('--model');
149
- expect(spawnArgs[modelArgIndex + 1]).toMatch(/^(opus|sonnet|haiku|claude-(opus|sonnet|haiku)-.+)$/);
167
+ expect(spawnArgs[modelArgIndex + 1]).toBe(getModel('execute').model);
150
168
 
151
169
  mockProc._exitCallback({ exitCode: 0 });
152
170
  await runPromise;
@@ -762,6 +762,45 @@ describe('ClaudeRunner', () => {
762
762
  expect(result.usageData!.outputTokens).toBe(800);
763
763
  });
764
764
 
765
+ it('should accumulate usageData across multiple result events', async () => {
766
+ const mockProc = createMockProcess();
767
+ mockSpawn.mockReturnValue(mockProc);
768
+
769
+ const runner = new ClaudeRunner();
770
+ const runPromise = runner.run('test prompt', { timeout: 60 });
771
+
772
+ const firstResultEvent = JSON.stringify({
773
+ type: 'result',
774
+ usage: { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 },
775
+ modelUsage: { 'claude-opus-4-6': { inputTokens: 1000, outputTokens: 500, cacheReadInputTokens: 100, cacheCreationInputTokens: 50 } },
776
+ total_cost_usd: 2.5,
777
+ });
778
+ const secondResultEvent = JSON.stringify({
779
+ type: 'result',
780
+ usage: { input_tokens: 600, output_tokens: 300, cache_read_input_tokens: 40, cache_creation_input_tokens: 20 },
781
+ modelUsage: { 'claude-opus-4-6': { inputTokens: 600, outputTokens: 300, cacheReadInputTokens: 40, cacheCreationInputTokens: 20 } },
782
+ total_cost_usd: 1.5,
783
+ });
784
+
785
+ mockProc.stdout.emit('data', Buffer.from(firstResultEvent + '\n' + secondResultEvent + '\n'));
786
+ mockProc.emit('close', 0);
787
+
788
+ const result = await runPromise;
789
+ expect(result.usageData).toBeDefined();
790
+ expect(result.usageData!.inputTokens).toBe(1600);
791
+ expect(result.usageData!.outputTokens).toBe(800);
792
+ expect(result.usageData!.cacheReadInputTokens).toBe(140);
793
+ expect(result.usageData!.cacheCreationInputTokens).toBe(70);
794
+ expect(result.usageData!.totalCostUsd).toBe(4);
795
+ expect(result.usageData!.modelUsage['claude-opus-4-6']).toEqual({
796
+ inputTokens: 1600,
797
+ outputTokens: 800,
798
+ cacheReadInputTokens: 140,
799
+ cacheCreationInputTokens: 70,
800
+ costUsd: 0,
801
+ });
802
+ });
803
+
765
804
  it('should return undefined usageData when no result event', async () => {
766
805
  const mockProc = createMockProcess();
767
806
  mockSpawn.mockReturnValue(mockProc);
@@ -0,0 +1,163 @@
1
+ import { jest } from '@jest/globals';
2
+ import { EventEmitter } from 'events';
3
+
4
+ const mockSpawn = jest.fn();
5
+ const mockExecSync = jest.fn();
6
+
7
+ const mockExistsSync = jest.fn();
8
+ const mockStatSync = jest.fn();
9
+ const mockReadFileSync = jest.fn();
10
+
11
+ const mockGetHeadCommitHash = jest.fn();
12
+ const mockGetHeadCommitMessage = jest.fn();
13
+ const mockIsFileCommittedInHead = jest.fn();
14
+
15
+ jest.unstable_mockModule('node:child_process', () => ({
16
+ spawn: mockSpawn,
17
+ execSync: mockExecSync,
18
+ }));
19
+
20
+ jest.unstable_mockModule('node-pty', () => ({
21
+ spawn: jest.fn(),
22
+ }));
23
+
24
+ jest.unstable_mockModule('node:fs', () => ({
25
+ default: {
26
+ existsSync: mockExistsSync,
27
+ statSync: mockStatSync,
28
+ readFileSync: mockReadFileSync,
29
+ },
30
+ existsSync: mockExistsSync,
31
+ statSync: mockStatSync,
32
+ readFileSync: mockReadFileSync,
33
+ }));
34
+
35
+ jest.unstable_mockModule('../../src/core/git.js', () => ({
36
+ getHeadCommitHash: mockGetHeadCommitHash,
37
+ getHeadCommitMessage: mockGetHeadCommitMessage,
38
+ isFileCommittedInHead: mockIsFileCommittedInHead,
39
+ }));
40
+
41
+ const { CodexRunner } = await import('../../src/core/codex-runner.js');
42
+
43
+ function createMockProcess() {
44
+ const stdout = new EventEmitter();
45
+ const stderr = new EventEmitter();
46
+ const proc = new EventEmitter() as any;
47
+ proc.stdout = stdout;
48
+ proc.stderr = stderr;
49
+ proc.kill = jest.fn().mockImplementation(() => {
50
+ setImmediate(() => proc.emit('close', 1));
51
+ });
52
+ return proc;
53
+ }
54
+
55
+ describe('CodexRunner', () => {
56
+ beforeEach(() => {
57
+ jest.clearAllMocks();
58
+ mockExecSync.mockReturnValue('/usr/local/bin/codex\n');
59
+ mockExistsSync.mockReturnValue(false);
60
+ });
61
+
62
+ it('returns usageData from run() when turn.completed includes usage', async () => {
63
+ const mockProc = createMockProcess();
64
+ mockSpawn.mockReturnValue(mockProc);
65
+
66
+ const runner = new CodexRunner({ model: 'gpt-5.4' });
67
+ const runPromise = runner.run('test prompt');
68
+
69
+ mockProc.stdout.emit('data', Buffer.from(`${JSON.stringify({
70
+ type: 'turn.completed',
71
+ model: 'gpt-5.4',
72
+ usage: {
73
+ input_tokens: 1000,
74
+ output_tokens: 250,
75
+ },
76
+ })}\n`));
77
+ mockProc.emit('close', 0);
78
+
79
+ const result = await runPromise;
80
+ expect(result.usageData).toEqual({
81
+ inputTokens: 1000,
82
+ outputTokens: 250,
83
+ cacheReadInputTokens: 0,
84
+ cacheCreationInputTokens: 0,
85
+ modelUsage: {
86
+ 'gpt-5.4': {
87
+ inputTokens: 1000,
88
+ outputTokens: 250,
89
+ cacheReadInputTokens: 0,
90
+ cacheCreationInputTokens: 0,
91
+ costUsd: null,
92
+ },
93
+ },
94
+ totalCostUsd: null,
95
+ });
96
+ });
97
+
98
+ it('accumulates usageData across multiple turn.completed events', async () => {
99
+ const mockProc = createMockProcess();
100
+ mockSpawn.mockReturnValue(mockProc);
101
+
102
+ const runner = new CodexRunner({ model: 'gpt-5.4' });
103
+ const runPromise = runner.run('test prompt');
104
+
105
+ const firstTurn = JSON.stringify({
106
+ type: 'turn.completed',
107
+ model: 'gpt-5.4',
108
+ usage: {
109
+ input_tokens: 1000,
110
+ output_tokens: 250,
111
+ },
112
+ });
113
+ const secondTurn = JSON.stringify({
114
+ type: 'turn.completed',
115
+ model: 'gpt-5.4',
116
+ usage: {
117
+ input_tokens: 500,
118
+ output_tokens: 150,
119
+ },
120
+ });
121
+
122
+ mockProc.stdout.emit('data', Buffer.from(firstTurn + '\n' + secondTurn + '\n'));
123
+ mockProc.emit('close', 0);
124
+
125
+ const result = await runPromise;
126
+ expect(result.usageData).toEqual({
127
+ inputTokens: 1500,
128
+ outputTokens: 400,
129
+ cacheReadInputTokens: 0,
130
+ cacheCreationInputTokens: 0,
131
+ modelUsage: {
132
+ 'gpt-5.4': {
133
+ inputTokens: 1500,
134
+ outputTokens: 400,
135
+ cacheReadInputTokens: 0,
136
+ cacheCreationInputTokens: 0,
137
+ costUsd: null,
138
+ },
139
+ },
140
+ totalCostUsd: null,
141
+ });
142
+ });
143
+
144
+ it('returns undefined usageData when no turn.completed usage event is present', async () => {
145
+ const mockProc = createMockProcess();
146
+ mockSpawn.mockReturnValue(mockProc);
147
+
148
+ const runner = new CodexRunner({ model: 'gpt-5.4' });
149
+ const runPromise = runner.run('test prompt');
150
+
151
+ mockProc.stdout.emit('data', Buffer.from(`${JSON.stringify({
152
+ type: 'item.completed',
153
+ item: {
154
+ type: 'agent_message',
155
+ text: 'Done.',
156
+ },
157
+ })}\n`));
158
+ mockProc.emit('close', 0);
159
+
160
+ const result = await runPromise;
161
+ expect(result.usageData).toBeUndefined();
162
+ });
163
+ });