rafcode 3.0.0 → 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 (235) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/CLAUDE.md +0 -1
  3. package/RAF/38-dual-wielder/decisions.md +9 -0
  4. package/RAF/38-dual-wielder/input.md +6 -1
  5. package/RAF/38-dual-wielder/outcomes/8-e2e-test-codex-provider.md +139 -0
  6. package/RAF/38-dual-wielder/plans/8-e2e-test-codex-provider.md +95 -0
  7. package/RAF/39-pathless-rover/decisions.md +16 -0
  8. package/RAF/39-pathless-rover/input.md +2 -0
  9. package/RAF/39-pathless-rover/outcomes/1-fix-codex-stream-renderer.md +21 -0
  10. package/RAF/39-pathless-rover/outcomes/2-wire-provider-flag.md +28 -0
  11. package/RAF/39-pathless-rover/outcomes/3-remove-worktree-flag-do.md +41 -0
  12. package/RAF/39-pathless-rover/outcomes/4-remove-worktree-flag-plan-amend.md +30 -0
  13. package/RAF/39-pathless-rover/outcomes/5-update-prompts-and-docs.md +26 -0
  14. package/RAF/39-pathless-rover/plans/1-fix-codex-stream-renderer.md +43 -0
  15. package/RAF/39-pathless-rover/plans/2-wire-provider-flag.md +48 -0
  16. package/RAF/39-pathless-rover/plans/3-remove-worktree-flag-do.md +41 -0
  17. package/RAF/39-pathless-rover/plans/4-remove-worktree-flag-plan-amend.md +43 -0
  18. package/RAF/39-pathless-rover/plans/5-update-prompts-and-docs.md +31 -0
  19. package/RAF/40-numeric-order-fix/decisions.md +7 -0
  20. package/RAF/40-numeric-order-fix/input.md +19 -0
  21. package/RAF/40-numeric-order-fix/outcomes/1-fix-numeric-sort-order.md +18 -0
  22. package/RAF/40-numeric-order-fix/outcomes/2-add-npm-keywords.md +10 -0
  23. package/RAF/40-numeric-order-fix/plans/1-fix-numeric-sort-order.md +48 -0
  24. package/RAF/40-numeric-order-fix/plans/2-add-npm-keywords.md +23 -0
  25. package/RAF/41-echo-chamber/decisions.md +13 -0
  26. package/RAF/41-echo-chamber/input.md +4 -0
  27. package/RAF/41-echo-chamber/outcomes/1-update-codex-model-defaults.md +24 -0
  28. package/RAF/41-echo-chamber/outcomes/2-e2e-test-codex-provider.md +74 -0
  29. package/RAF/41-echo-chamber/plans/1-update-codex-model-defaults.md +28 -0
  30. package/RAF/41-echo-chamber/plans/2-e2e-test-codex-provider.md +103 -0
  31. package/RAF/42-patch-parade/decisions.md +29 -0
  32. package/RAF/42-patch-parade/input.md +9 -0
  33. package/RAF/42-patch-parade/outcomes/1-fix-codex-model-resolution.md +36 -0
  34. package/RAF/42-patch-parade/outcomes/2-fix-provider-aware-name-generation.md +31 -0
  35. package/RAF/42-patch-parade/outcomes/3-fix-codex-error-event-rendering.md +32 -0
  36. package/RAF/42-patch-parade/outcomes/4-update-cli-help-docs.md +28 -0
  37. package/RAF/42-patch-parade/outcomes/5-update-default-codex-models-to-gpt-5-4.md +33 -0
  38. package/RAF/42-patch-parade/outcomes/6-unify-model-config-schema.md +89 -0
  39. package/RAF/42-patch-parade/plans/1-fix-codex-model-resolution.md +35 -0
  40. package/RAF/42-patch-parade/plans/2-fix-provider-aware-name-generation.md +38 -0
  41. package/RAF/42-patch-parade/plans/3-fix-codex-error-event-rendering.md +32 -0
  42. package/RAF/42-patch-parade/plans/4-update-cli-help-docs.md +31 -0
  43. package/RAF/42-patch-parade/plans/5-update-default-codex-models-to-gpt-5-4.md +35 -0
  44. package/RAF/42-patch-parade/plans/6-unify-model-config-schema.md +46 -0
  45. package/RAF/43-swiss-army/decisions.md +34 -0
  46. package/RAF/43-swiss-army/input.md +7 -0
  47. package/RAF/43-swiss-army/outcomes/1-fix-model-validation.md +21 -0
  48. package/RAF/43-swiss-army/outcomes/2-update-commit-format.md +31 -0
  49. package/RAF/43-swiss-army/outcomes/3-wire-reasoning-effort.md +28 -0
  50. package/RAF/43-swiss-army/outcomes/4-remove-provider-flag.md +27 -0
  51. package/RAF/43-swiss-army/outcomes/5-config-wizard-validation.md +23 -0
  52. package/RAF/43-swiss-army/outcomes/6-add-fast-mode.md +32 -0
  53. package/RAF/43-swiss-army/outcomes/7-config-preset.md +31 -0
  54. package/RAF/43-swiss-army/plans/1-fix-model-validation.md +38 -0
  55. package/RAF/43-swiss-army/plans/2-update-commit-format.md +46 -0
  56. package/RAF/43-swiss-army/plans/3-wire-reasoning-effort.md +39 -0
  57. package/RAF/43-swiss-army/plans/4-remove-provider-flag.md +43 -0
  58. package/RAF/43-swiss-army/plans/5-config-wizard-validation.md +42 -0
  59. package/RAF/43-swiss-army/plans/6-add-fast-mode.md +46 -0
  60. package/RAF/43-swiss-army/plans/7-config-preset.md +51 -0
  61. package/RAF/44-config-api-change/decisions.md +22 -0
  62. package/RAF/44-config-api-change/input.md +5 -0
  63. package/RAF/44-config-api-change/outcomes/1-restructure-config-subcommands.md +19 -0
  64. package/RAF/44-config-api-change/outcomes/2-move-preset-under-config.md +17 -0
  65. package/RAF/44-config-api-change/outcomes/3-update-existing-tests-for-config-api.md +14 -0
  66. package/RAF/44-config-api-change/outcomes/4-update-config-command-docs.md +11 -0
  67. package/RAF/44-config-api-change/outcomes/5-fix-codex-name-generation.md +18 -0
  68. package/RAF/44-config-api-change/plans/1-restructure-config-subcommands.md +37 -0
  69. package/RAF/44-config-api-change/plans/2-move-preset-under-config.md +38 -0
  70. package/RAF/44-config-api-change/plans/3-update-existing-tests-for-config-api.md +38 -0
  71. package/RAF/44-config-api-change/plans/4-update-config-command-docs.md +36 -0
  72. package/RAF/44-config-api-change/plans/5-fix-codex-name-generation.md +49 -0
  73. package/RAF/45-signal-cairn/decisions.md +7 -0
  74. package/RAF/45-signal-cairn/input.md +2 -0
  75. package/RAF/45-signal-cairn/outcomes/1-rename-provider-to-harness.md +19 -0
  76. package/RAF/45-signal-cairn/outcomes/2-normalize-model-display-names.md +18 -0
  77. package/RAF/45-signal-cairn/plans/1-rename-provider-to-harness.md +40 -0
  78. package/RAF/45-signal-cairn/plans/2-normalize-model-display-names.md +41 -0
  79. package/RAF/45-signal-lantern/decisions.md +10 -0
  80. package/RAF/45-signal-lantern/input.md +2 -0
  81. package/RAF/45-signal-lantern/outcomes/1-add-effort-and-fast-to-do-model-display.md +15 -0
  82. package/RAF/45-signal-lantern/outcomes/2-capture-codex-post-run-token-usage.md +15 -0
  83. package/RAF/45-signal-lantern/outcomes/3-show-codex-token-summaries-without-fake-cost.md +14 -0
  84. package/RAF/45-signal-lantern/plans/1-add-effort-and-fast-to-do-model-display.md +38 -0
  85. package/RAF/45-signal-lantern/plans/2-capture-codex-post-run-token-usage.md +37 -0
  86. package/RAF/45-signal-lantern/plans/3-show-codex-token-summaries-without-fake-cost.md +40 -0
  87. package/RAF/46-lantern-arc/decisions.md +19 -0
  88. package/RAF/46-lantern-arc/input.md +6 -0
  89. package/RAF/46-lantern-arc/outcomes/1-remove-spark-alias.md +16 -0
  90. package/RAF/46-lantern-arc/outcomes/2-clean-up-worktree-plan-command.md +30 -0
  91. package/RAF/46-lantern-arc/outcomes/3-fix-token-usage-accumulation.md +32 -0
  92. package/RAF/46-lantern-arc/outcomes/4-display-effort-in-compact-mode.md +22 -0
  93. package/RAF/46-lantern-arc/outcomes/5-codex-fast-mode-research.md +38 -0
  94. package/RAF/46-lantern-arc/outcomes/6-optimize-llm-prompts.md +39 -0
  95. package/RAF/46-lantern-arc/plans/1-remove-spark-alias.md +38 -0
  96. package/RAF/46-lantern-arc/plans/2-clean-up-worktree-plan-command.md +33 -0
  97. package/RAF/46-lantern-arc/plans/3-fix-token-usage-accumulation.md +33 -0
  98. package/RAF/46-lantern-arc/plans/4-display-effort-in-compact-mode.md +28 -0
  99. package/RAF/46-lantern-arc/plans/5-codex-fast-mode-research.md +34 -0
  100. package/RAF/46-lantern-arc/plans/6-optimize-llm-prompts.md +48 -0
  101. package/RAF/47-signal-trim/decisions.md +13 -0
  102. package/RAF/47-signal-trim/input.md +2 -0
  103. package/RAF/47-signal-trim/plans/1-remove-cache-from-status.md +73 -0
  104. package/README.md +50 -63
  105. package/dist/commands/config.d.ts.map +1 -1
  106. package/dist/commands/config.js +47 -49
  107. package/dist/commands/config.js.map +1 -1
  108. package/dist/commands/do.d.ts +2 -0
  109. package/dist/commands/do.d.ts.map +1 -1
  110. package/dist/commands/do.js +91 -230
  111. package/dist/commands/do.js.map +1 -1
  112. package/dist/commands/plan.d.ts.map +1 -1
  113. package/dist/commands/plan.js +54 -259
  114. package/dist/commands/plan.js.map +1 -1
  115. package/dist/commands/preset.d.ts +3 -0
  116. package/dist/commands/preset.d.ts.map +1 -0
  117. package/dist/commands/preset.js +158 -0
  118. package/dist/commands/preset.js.map +1 -0
  119. package/dist/core/claude-runner.d.ts +2 -0
  120. package/dist/core/claude-runner.d.ts.map +1 -1
  121. package/dist/core/claude-runner.js +36 -12
  122. package/dist/core/claude-runner.js.map +1 -1
  123. package/dist/core/codex-runner.d.ts +1 -0
  124. package/dist/core/codex-runner.d.ts.map +1 -1
  125. package/dist/core/codex-runner.js +26 -7
  126. package/dist/core/codex-runner.js.map +1 -1
  127. package/dist/core/failure-analyzer.js +2 -1
  128. package/dist/core/failure-analyzer.js.map +1 -1
  129. package/dist/core/git.d.ts +2 -2
  130. package/dist/core/git.d.ts.map +1 -1
  131. package/dist/core/git.js +53 -3
  132. package/dist/core/git.js.map +1 -1
  133. package/dist/core/project-manager.d.ts.map +1 -1
  134. package/dist/core/project-manager.js +2 -2
  135. package/dist/core/project-manager.js.map +1 -1
  136. package/dist/core/pull-request.js +5 -5
  137. package/dist/core/pull-request.js.map +1 -1
  138. package/dist/core/runner-factory.d.ts +4 -4
  139. package/dist/core/runner-factory.d.ts.map +1 -1
  140. package/dist/core/runner-factory.js +8 -8
  141. package/dist/core/runner-factory.js.map +1 -1
  142. package/dist/core/runner-interface.d.ts +1 -1
  143. package/dist/core/runner-types.d.ts +17 -4
  144. package/dist/core/runner-types.d.ts.map +1 -1
  145. package/dist/core/state-derivation.js +3 -3
  146. package/dist/core/state-derivation.js.map +1 -1
  147. package/dist/parsers/codex-stream-renderer.d.ts +28 -4
  148. package/dist/parsers/codex-stream-renderer.d.ts.map +1 -1
  149. package/dist/parsers/codex-stream-renderer.js +110 -0
  150. package/dist/parsers/codex-stream-renderer.js.map +1 -1
  151. package/dist/prompts/amend.d.ts +0 -1
  152. package/dist/prompts/amend.d.ts.map +1 -1
  153. package/dist/prompts/amend.js +31 -104
  154. package/dist/prompts/amend.js.map +1 -1
  155. package/dist/prompts/execution.d.ts.map +1 -1
  156. package/dist/prompts/execution.js +17 -34
  157. package/dist/prompts/execution.js.map +1 -1
  158. package/dist/prompts/planning.d.ts.map +1 -1
  159. package/dist/prompts/planning.js +23 -123
  160. package/dist/prompts/planning.js.map +1 -1
  161. package/dist/types/config.d.ts +33 -32
  162. package/dist/types/config.d.ts.map +1 -1
  163. package/dist/types/config.js +14 -28
  164. package/dist/types/config.js.map +1 -1
  165. package/dist/utils/config.d.ts +36 -16
  166. package/dist/utils/config.d.ts.map +1 -1
  167. package/dist/utils/config.js +209 -104
  168. package/dist/utils/config.js.map +1 -1
  169. package/dist/utils/name-generator.d.ts.map +1 -1
  170. package/dist/utils/name-generator.js +25 -12
  171. package/dist/utils/name-generator.js.map +1 -1
  172. package/dist/utils/paths.d.ts +5 -0
  173. package/dist/utils/paths.d.ts.map +1 -1
  174. package/dist/utils/paths.js +9 -0
  175. package/dist/utils/paths.js.map +1 -1
  176. package/dist/utils/terminal-symbols.d.ts +15 -2
  177. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  178. package/dist/utils/terminal-symbols.js +36 -4
  179. package/dist/utils/terminal-symbols.js.map +1 -1
  180. package/dist/utils/token-tracker.d.ts +6 -1
  181. package/dist/utils/token-tracker.d.ts.map +1 -1
  182. package/dist/utils/token-tracker.js +84 -51
  183. package/dist/utils/token-tracker.js.map +1 -1
  184. package/dist/utils/validation.d.ts +1 -2
  185. package/dist/utils/validation.d.ts.map +1 -1
  186. package/dist/utils/validation.js +4 -25
  187. package/dist/utils/validation.js.map +1 -1
  188. package/package.json +7 -2
  189. package/src/commands/config.ts +60 -63
  190. package/src/commands/do.ts +96 -262
  191. package/src/commands/plan.ts +55 -279
  192. package/src/commands/preset.ts +186 -0
  193. package/src/core/claude-runner.ts +45 -5
  194. package/src/core/codex-runner.ts +32 -7
  195. package/src/core/failure-analyzer.ts +2 -1
  196. package/src/core/git.ts +57 -3
  197. package/src/core/project-manager.ts +2 -1
  198. package/src/core/pull-request.ts +5 -5
  199. package/src/core/runner-factory.ts +9 -9
  200. package/src/core/runner-interface.ts +1 -1
  201. package/src/core/runner-types.ts +17 -4
  202. package/src/core/state-derivation.ts +3 -3
  203. package/src/parsers/codex-stream-renderer.ts +149 -4
  204. package/src/prompts/amend.ts +30 -105
  205. package/src/prompts/config-docs.md +206 -62
  206. package/src/prompts/execution.ts +17 -34
  207. package/src/prompts/planning.ts +23 -124
  208. package/src/types/config.ts +47 -59
  209. package/src/utils/config.ts +248 -115
  210. package/src/utils/name-generator.ts +29 -13
  211. package/src/utils/paths.ts +10 -0
  212. package/src/utils/terminal-symbols.ts +46 -6
  213. package/src/utils/token-tracker.ts +96 -57
  214. package/src/utils/validation.ts +5 -30
  215. package/tests/unit/amend-prompt.test.ts +3 -2
  216. package/tests/unit/claude-runner-interactive.test.ts +21 -3
  217. package/tests/unit/claude-runner.test.ts +39 -0
  218. package/tests/unit/codex-runner.test.ts +163 -0
  219. package/tests/unit/codex-stream-renderer.test.ts +127 -0
  220. package/tests/unit/command-output.test.ts +57 -0
  221. package/tests/unit/commit-planning-artifacts-worktree.test.ts +24 -7
  222. package/tests/unit/commit-planning-artifacts.test.ts +26 -4
  223. package/tests/unit/config-command.test.ts +215 -303
  224. package/tests/unit/config.test.ts +319 -235
  225. package/tests/unit/dependency-integration.test.ts +27 -1
  226. package/tests/unit/do-model-display.test.ts +35 -0
  227. package/tests/unit/execution-prompt.test.ts +49 -19
  228. package/tests/unit/name-generator.test.ts +82 -12
  229. package/tests/unit/plan-command-auto-flag.test.ts +7 -10
  230. package/tests/unit/plan-command.test.ts +14 -17
  231. package/tests/unit/planning-prompt.test.ts +9 -8
  232. package/tests/unit/terminal-symbols.test.ts +94 -3
  233. package/tests/unit/token-tracker.test.ts +180 -1
  234. package/tests/unit/validation.test.ts +9 -41
  235. package/tests/unit/worktree-flag-override.test.ts +0 -186
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
+ import { jest } from '@jest/globals';
4
5
  import {
5
6
  getClaudeModel,
6
7
  getClaudeSettingsPath,
@@ -19,12 +20,15 @@ import {
19
20
  getAutoCommit,
20
21
  getWorktreeDefault,
21
22
  getSyncMainBranch,
23
+ getModelDisplayName,
22
24
  getModelShortName,
25
+ formatModelDisplay,
23
26
  resolveFullModelId,
24
27
  resetConfigCache,
25
28
  saveConfig,
26
29
  renderCommitMessage,
27
30
  isValidModelName,
31
+ collectConfigValidationWarnings,
28
32
  } from '../../src/utils/config.js';
29
33
  import { DEFAULT_CONFIG } from '../../src/types/config.js';
30
34
 
@@ -88,10 +92,17 @@ describe('Config', () => {
88
92
  expect(() => validateConfig({})).not.toThrow();
89
93
  });
90
94
 
91
- it('should accept a full valid config', () => {
95
+ it('should accept a full valid config with model entries', () => {
92
96
  const config = {
93
- models: { plan: 'opus', execute: 'haiku' },
94
- effortMapping: { low: 'sonnet', medium: 'opus', high: 'opus' },
97
+ models: {
98
+ plan: { model: 'opus', harness: 'claude' },
99
+ execute: { model: 'haiku', harness: 'claude' },
100
+ },
101
+ effortMapping: {
102
+ low: { model: 'sonnet', harness: 'claude' },
103
+ medium: { model: 'opus', harness: 'claude' },
104
+ high: { model: 'opus', harness: 'claude' },
105
+ },
95
106
  timeout: 30,
96
107
  maxRetries: 5,
97
108
  autoCommit: false,
@@ -101,6 +112,37 @@ describe('Config', () => {
101
112
  expect(() => validateConfig(config)).not.toThrow();
102
113
  });
103
114
 
115
+ it('should accept mixed-harness model entries', () => {
116
+ const config = {
117
+ models: {
118
+ plan: { model: 'opus', harness: 'claude' },
119
+ execute: { model: 'gpt-5.4', harness: 'codex' },
120
+ },
121
+ };
122
+ expect(() => validateConfig(config)).not.toThrow();
123
+ });
124
+
125
+ it('should accept model entries with reasoningEffort', () => {
126
+ const config = {
127
+ models: {
128
+ plan: { model: 'opus', harness: 'claude', reasoningEffort: 'high' },
129
+ },
130
+ };
131
+ expect(() => validateConfig(config)).not.toThrow();
132
+ });
133
+
134
+ it('should warn when fast mode is set on codex entries', () => {
135
+ const validated = validateConfig({
136
+ models: {
137
+ execute: { model: 'gpt-5.4', harness: 'codex', fast: true },
138
+ },
139
+ });
140
+
141
+ expect(collectConfigValidationWarnings(validated)).toEqual([
142
+ 'models.execute.fast is enabled but ignored because Codex does not support fast mode',
143
+ ]);
144
+ });
145
+
104
146
  it('should reject non-object config', () => {
105
147
  expect(() => validateConfig(null)).toThrow(ConfigValidationError);
106
148
  expect(() => validateConfig('string')).toThrow(ConfigValidationError);
@@ -118,46 +160,78 @@ describe('Config', () => {
118
160
  });
119
161
 
120
162
  it('should reject unknown model keys', () => {
121
- expect(() => validateConfig({ models: { unknownScenario: 'opus' } })).toThrow('Unknown config key: models.unknownScenario');
163
+ expect(() => validateConfig({ models: { unknownScenario: { model: 'opus', harness: 'claude' } } })).toThrow('Unknown config key: models.unknownScenario');
122
164
  });
123
165
 
124
166
  it('should reject unknown effortMapping keys', () => {
125
- expect(() => validateConfig({ effortMapping: { unknownLevel: 'haiku' } })).toThrow('Unknown config key: effortMapping.unknownLevel');
167
+ expect(() => validateConfig({ effortMapping: { unknownLevel: { model: 'haiku', harness: 'claude' } } })).toThrow('Unknown config key: effortMapping.unknownLevel');
126
168
  });
127
169
 
128
170
  it('should reject unknown commitFormat keys', () => {
129
171
  expect(() => validateConfig({ commitFormat: { unknownKey: 'val' } })).toThrow('Unknown config key: commitFormat.unknownKey');
130
172
  });
131
173
 
132
- // Valid full model IDs
133
- it('should accept full model IDs', () => {
134
- expect(() => validateConfig({ models: { plan: 'claude-opus-4-5-20251101' } })).not.toThrow();
135
- expect(() => validateConfig({ models: { execute: 'claude-sonnet-4-5-20250929' } })).not.toThrow();
136
- expect(() => validateConfig({ models: { failureAnalysis: 'claude-haiku-4-5-20251001' } })).not.toThrow();
174
+ // Removed legacy keys
175
+ it('should reject removed provider key with helpful message', () => {
176
+ expect(() => validateConfig({ provider: 'claude' })).toThrow('Top-level "provider" has been removed');
177
+ });
178
+
179
+ it('should reject removed codexModels key with helpful message', () => {
180
+ expect(() => validateConfig({ codexModels: { plan: 'gpt-5.4' } })).toThrow('"codexModels" has been removed');
181
+ });
182
+
183
+ it('should reject removed codexEffortMapping key with helpful message', () => {
184
+ expect(() => validateConfig({ codexEffortMapping: { low: 'gpt-5.4' } })).toThrow('"codexEffortMapping" has been removed');
185
+ });
186
+
187
+ // Model entry validation
188
+ it('should reject string model values (old schema)', () => {
189
+ expect(() => validateConfig({ models: { plan: 'opus' } })).toThrow('must be a model entry object');
137
190
  });
138
191
 
139
- it('should accept model IDs without date suffix', () => {
140
- expect(() => validateConfig({ models: { plan: 'claude-sonnet-4-5' } })).not.toThrow();
141
- expect(() => validateConfig({ models: { plan: 'claude-opus-4' } })).not.toThrow();
192
+ it('should reject model entries missing model field', () => {
193
+ expect(() => validateConfig({ models: { plan: { harness: 'claude' } } })).toThrow('models.plan.model is required');
142
194
  });
143
195
 
144
- // Invalid model values
145
- it('should reject invalid model names', () => {
146
- expect(() => validateConfig({ models: { plan: 'gpt-4' } })).toThrow('models.plan must be');
196
+ it('should reject model entries missing harness field', () => {
197
+ expect(() => validateConfig({ models: { plan: { model: 'opus' } } })).toThrow('models.plan.harness is required');
147
198
  });
148
199
 
149
- it('should reject random strings as model names', () => {
150
- expect(() => validateConfig({ models: { plan: 'random-string' } })).toThrow('models.plan must be');
151
- expect(() => validateConfig({ models: { plan: 'not-a-model' } })).toThrow('models.plan must be');
200
+ it('should reject invalid model names in model entries', () => {
201
+ expect(() => validateConfig({ models: { plan: { model: 'invalid', harness: 'claude' } } })).toThrow('models.plan.model must be a valid model name');
152
202
  });
153
203
 
154
- it('should reject non-string model values', () => {
155
- expect(() => validateConfig({ models: { plan: 123 } })).toThrow('models.plan must be');
204
+ it('should reject invalid harness in model entries', () => {
205
+ expect(() => validateConfig({ models: { plan: { model: 'opus', harness: 'openai' } } })).toThrow('models.plan.harness must be one of');
156
206
  });
157
207
 
158
- // Invalid effortMapping values
159
- it('should reject invalid effortMapping model names', () => {
160
- expect(() => validateConfig({ effortMapping: { low: 'invalid-model' } })).toThrow('effortMapping.low must be a short alias');
208
+ it('should reject invalid reasoningEffort in model entries', () => {
209
+ expect(() => validateConfig({ models: { plan: { model: 'opus', harness: 'claude', reasoningEffort: 'ultra' } } })).toThrow('models.plan.reasoningEffort must be one of');
210
+ });
211
+
212
+ it('should reject unknown keys in model entries', () => {
213
+ expect(() => validateConfig({ models: { plan: { model: 'opus', harness: 'claude', unknown: true } } })).toThrow('Unknown config key: models.plan.unknown');
214
+ });
215
+
216
+ // Valid full model IDs in entries
217
+ it('should accept full model IDs in entries', () => {
218
+ expect(() => validateConfig({ models: { plan: { model: 'claude-opus-4-5-20251101', harness: 'claude' } } })).not.toThrow();
219
+ expect(() => validateConfig({ models: { execute: { model: 'gpt-5.4', harness: 'codex' } } })).not.toThrow();
220
+ });
221
+
222
+ // effortMapping validation
223
+ it('should reject string effortMapping values (old schema)', () => {
224
+ expect(() => validateConfig({ effortMapping: { low: 'sonnet' } })).toThrow('must be a model entry object');
225
+ });
226
+
227
+ it('should accept valid effortMapping model entries', () => {
228
+ expect(() => validateConfig({
229
+ effortMapping: {
230
+ low: { model: 'sonnet', harness: 'claude' },
231
+ medium: { model: 'opus', harness: 'claude' },
232
+ high: { model: 'gpt-5.4', harness: 'codex' },
233
+ },
234
+ })).not.toThrow();
161
235
  });
162
236
 
163
237
  // Invalid types for nested objects
@@ -242,6 +316,13 @@ describe('Config', () => {
242
316
  expect(isValidModelName('claude-opus-4')).toBe(true);
243
317
  });
244
318
 
319
+ it('should accept Codex model names', () => {
320
+ expect(isValidModelName('gpt-5.4')).toBe(true);
321
+ expect(isValidModelName('gpt-5.3-codex')).toBe(true);
322
+ expect(isValidModelName('codex')).toBe(true);
323
+ expect(isValidModelName('gpt54')).toBe(true);
324
+ });
325
+
245
326
  it('should reject invalid strings', () => {
246
327
  expect(isValidModelName('gpt-4')).toBe(false);
247
328
  expect(isValidModelName('random-string')).toBe(false);
@@ -260,22 +341,27 @@ describe('Config', () => {
260
341
 
261
342
  it('should deep-merge partial models override', () => {
262
343
  const configPath = path.join(tempDir, 'raf.config.json');
263
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
344
+ fs.writeFileSync(configPath, JSON.stringify({
345
+ models: { plan: { model: 'haiku', harness: 'claude' } },
346
+ }));
264
347
 
265
348
  const config = resolveConfig(configPath);
266
- expect(config.models.plan).toBe('haiku');
267
- expect(config.models.execute).toBe('opus'); // default preserved
268
- expect(config.models.failureAnalysis).toBe('haiku'); // default preserved
349
+ expect(config.models.plan.model).toBe('haiku');
350
+ expect(config.models.plan.harness).toBe('claude');
351
+ expect(config.models.execute.model).toBe('opus'); // default preserved
352
+ expect(config.models.failureAnalysis.model).toBe('haiku'); // default preserved
269
353
  });
270
354
 
271
355
  it('should deep-merge partial effortMapping override', () => {
272
356
  const configPath = path.join(tempDir, 'raf.config.json');
273
- fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { medium: 'opus' } }));
357
+ fs.writeFileSync(configPath, JSON.stringify({
358
+ effortMapping: { medium: { model: 'opus', harness: 'claude' } },
359
+ }));
274
360
 
275
361
  const config = resolveConfig(configPath);
276
- expect(config.effortMapping.medium).toBe('opus');
277
- expect(config.effortMapping.low).toBe('sonnet'); // default preserved
278
- expect(config.effortMapping.high).toBe('opus'); // default preserved
362
+ expect(config.effortMapping.medium.model).toBe('opus');
363
+ expect(config.effortMapping.low.model).toBe('sonnet'); // default preserved
364
+ expect(config.effortMapping.high.model).toBe('opus'); // default preserved
279
365
  });
280
366
 
281
367
  it('should deep-merge partial commitFormat override', () => {
@@ -318,21 +404,53 @@ describe('Config', () => {
318
404
  expect(() => resolveConfig(configPath)).toThrow(ConfigValidationError);
319
405
  });
320
406
 
321
- it('should deep-merge full model ID override', () => {
407
+ it('should not mutate DEFAULT_CONFIG', () => {
408
+ const configPath = path.join(tempDir, 'raf.config.json');
409
+ fs.writeFileSync(configPath, JSON.stringify({
410
+ models: { plan: { model: 'haiku', harness: 'claude' } },
411
+ }));
412
+
413
+ resolveConfig(configPath);
414
+ expect(DEFAULT_CONFIG.models.plan.model).toBe('opus');
415
+ });
416
+
417
+ it('should support Codex model entries in config', () => {
322
418
  const configPath = path.join(tempDir, 'raf.config.json');
323
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'claude-opus-4-5-20251101' } }));
419
+ fs.writeFileSync(configPath, JSON.stringify({
420
+ models: {
421
+ execute: { model: 'gpt-5.4', harness: 'codex' },
422
+ },
423
+ effortMapping: {
424
+ high: { model: 'gpt-5.4', harness: 'codex' },
425
+ },
426
+ }));
324
427
 
325
428
  const config = resolveConfig(configPath);
326
- expect(config.models.plan).toBe('claude-opus-4-5-20251101');
327
- expect(config.models.execute).toBe('opus'); // default preserved
429
+ expect(config.models.execute.model).toBe('gpt-5.4');
430
+ expect(config.models.execute.harness).toBe('codex');
431
+ expect(config.effortMapping.high.model).toBe('gpt-5.4');
432
+ expect(config.effortMapping.high.harness).toBe('codex');
433
+ // Claude defaults preserved for unoverridden entries
434
+ expect(config.models.plan.harness).toBe('claude');
328
435
  });
329
436
 
330
- it('should not mutate DEFAULT_CONFIG', () => {
437
+ it('should warn and ignore fast mode on codex entries', () => {
331
438
  const configPath = path.join(tempDir, 'raf.config.json');
332
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
439
+ fs.writeFileSync(configPath, JSON.stringify({
440
+ models: {
441
+ execute: { model: 'gpt-5.4', harness: 'codex', fast: true },
442
+ },
443
+ }));
333
444
 
334
- resolveConfig(configPath);
335
- expect(DEFAULT_CONFIG.models.plan).toBe('opus');
445
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
446
+ const config = resolveConfig(configPath);
447
+
448
+ expect(config.models.execute.fast).toBeUndefined();
449
+ expect(warnSpy).toHaveBeenCalledWith(
450
+ expect.stringContaining('models.execute.fast is enabled but ignored because Codex does not support fast mode')
451
+ );
452
+
453
+ warnSpy.mockRestore();
336
454
  });
337
455
  });
338
456
 
@@ -354,26 +472,30 @@ describe('Config', () => {
354
472
  });
355
473
 
356
474
  describe('helper accessors', () => {
357
- it('getModel returns correct model for scenario', () => {
475
+ it('getModel returns correct model entry for scenario', () => {
358
476
  const configPath = path.join(tempDir, 'raf.config.json');
359
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'haiku' } }));
360
- // Use resolveConfig directly to avoid cached global config
477
+ fs.writeFileSync(configPath, JSON.stringify({
478
+ models: { plan: { model: 'haiku', harness: 'claude' } },
479
+ }));
361
480
  const config = resolveConfig(configPath);
362
- expect(config.models.plan).toBe('haiku');
363
- expect(config.models.execute).toBe('opus');
481
+ expect(config.models.plan.model).toBe('haiku');
482
+ expect(config.models.plan.harness).toBe('claude');
483
+ expect(config.models.execute.model).toBe('opus');
364
484
  });
365
485
 
366
486
  it('effortMapping resolves correctly from config', () => {
367
487
  const configPath = path.join(tempDir, 'raf.config.json');
368
- fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { high: 'sonnet' } }));
488
+ fs.writeFileSync(configPath, JSON.stringify({
489
+ effortMapping: { high: { model: 'sonnet', harness: 'claude' } },
490
+ }));
369
491
  const config = resolveConfig(configPath);
370
- expect(config.effortMapping.high).toBe('sonnet');
371
- expect(config.effortMapping.low).toBe('sonnet'); // default preserved
492
+ expect(config.effortMapping.high.model).toBe('sonnet');
493
+ expect(config.effortMapping.low.model).toBe('sonnet'); // default preserved
372
494
  });
373
495
 
374
496
  it('getCommitFormat returns correct format', () => {
375
497
  const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
376
- expect(config.commitFormat.task).toBe('{prefix}[{projectId}:{taskId}] {description}');
498
+ expect(config.commitFormat.task).toBe('{prefix}[{projectName}:{taskId}] {description}');
377
499
  });
378
500
 
379
501
  it('getCommitPrefix returns prefix', () => {
@@ -391,19 +513,19 @@ describe('Config', () => {
391
513
  });
392
514
 
393
515
  describe('DEFAULT_CONFIG', () => {
394
- it('should have all model scenarios defined', () => {
395
- expect(DEFAULT_CONFIG.models.plan).toBe('opus');
396
- expect(DEFAULT_CONFIG.models.execute).toBe('opus');
397
- expect(DEFAULT_CONFIG.models.nameGeneration).toBe('sonnet');
398
- expect(DEFAULT_CONFIG.models.failureAnalysis).toBe('haiku');
399
- expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
400
- expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
516
+ it('should have all model scenarios defined as ModelEntry objects', () => {
517
+ expect(DEFAULT_CONFIG.models.plan).toEqual({ model: 'opus', harness: 'claude' });
518
+ expect(DEFAULT_CONFIG.models.execute).toEqual({ model: 'opus', harness: 'claude' });
519
+ expect(DEFAULT_CONFIG.models.nameGeneration).toEqual({ model: 'sonnet', harness: 'claude' });
520
+ expect(DEFAULT_CONFIG.models.failureAnalysis).toEqual({ model: 'haiku', harness: 'claude' });
521
+ expect(DEFAULT_CONFIG.models.prGeneration).toEqual({ model: 'sonnet', harness: 'claude' });
522
+ expect(DEFAULT_CONFIG.models.config).toEqual({ model: 'sonnet', harness: 'claude' });
401
523
  });
402
524
 
403
- it('should have all effortMapping levels defined', () => {
404
- expect(DEFAULT_CONFIG.effortMapping.low).toBe('sonnet');
405
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
406
- expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
525
+ it('should have all effortMapping levels defined as ModelEntry objects', () => {
526
+ expect(DEFAULT_CONFIG.effortMapping.low).toEqual({ model: 'sonnet', harness: 'claude' });
527
+ expect(DEFAULT_CONFIG.effortMapping.medium).toEqual({ model: 'opus', harness: 'claude' });
528
+ expect(DEFAULT_CONFIG.effortMapping.high).toEqual({ model: 'opus', harness: 'claude' });
407
529
  });
408
530
 
409
531
  it('should have all commit format fields defined', () => {
@@ -412,6 +534,12 @@ describe('Config', () => {
412
534
  expect(DEFAULT_CONFIG.commitFormat.amend).toContain('{prefix}');
413
535
  expect(DEFAULT_CONFIG.commitFormat.prefix).toBe('RAF');
414
536
  });
537
+
538
+ it('should not have harness, codexModels, or codexEffortMapping fields', () => {
539
+ expect('provider' in DEFAULT_CONFIG).toBe(false);
540
+ expect('codexModels' in DEFAULT_CONFIG).toBe(false);
541
+ expect('codexEffortMapping' in DEFAULT_CONFIG).toBe(false);
542
+ });
415
543
  });
416
544
 
417
545
  describe('renderCommitMessage', () => {
@@ -433,29 +561,44 @@ describe('Config', () => {
433
561
  it('should handle plan commit format', () => {
434
562
  const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.plan, {
435
563
  prefix: 'RAF',
436
- projectId: 'abc123',
564
+ projectId: 'my-project',
437
565
  projectName: 'my-project',
566
+ description: 'add auth system',
438
567
  });
439
- expect(result).toBe('RAF[abc123] Plan: my-project');
568
+ expect(result).toBe('RAF[my-project] Plan: add auth system');
440
569
  });
441
570
 
442
571
  it('should handle amend commit format', () => {
443
572
  const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.amend, {
444
573
  prefix: 'RAF',
445
- projectId: 'abc123',
574
+ projectId: 'my-project',
446
575
  projectName: 'my-project',
576
+ description: 'fix-login, add-tests',
447
577
  });
448
- expect(result).toBe('RAF[abc123] Amend: my-project');
578
+ expect(result).toBe('RAF[my-project] Amend: fix-login, add-tests');
449
579
  });
450
580
 
451
581
  it('should handle task commit format', () => {
452
582
  const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
453
583
  prefix: 'RAF',
454
- projectId: '001',
584
+ projectId: 'my-project',
585
+ projectName: 'my-project',
455
586
  taskId: '10',
456
587
  description: 'Fix bug',
457
588
  });
458
- expect(result).toBe('RAF[001:10] Fix bug');
589
+ expect(result).toBe('RAF[my-project:10] Fix bug');
590
+ });
591
+
592
+ it('should support {projectId} as backwards compat alias for projectName', () => {
593
+ // Old-style template with {projectId} should still work
594
+ const result = renderCommitMessage('{prefix}[{projectId}:{taskId}] {description}', {
595
+ prefix: 'RAF',
596
+ projectId: 'my-project',
597
+ projectName: 'my-project',
598
+ taskId: '01',
599
+ description: 'Add feature',
600
+ });
601
+ expect(result).toBe('RAF[my-project:01] Add feature');
459
602
  });
460
603
 
461
604
  it('should handle empty variables gracefully', () => {
@@ -474,67 +617,6 @@ describe('Config', () => {
474
617
  });
475
618
  });
476
619
 
477
- describe('config integration - defaults match previous hardcoded values', () => {
478
- it('should default models to match previous hardcoded values', () => {
479
- expect(DEFAULT_CONFIG.models.execute).toBe('opus');
480
- expect(DEFAULT_CONFIG.models.plan).toBe('opus');
481
- expect(DEFAULT_CONFIG.models.nameGeneration).toBe('sonnet');
482
- expect(DEFAULT_CONFIG.models.failureAnalysis).toBe('haiku');
483
- expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
484
- });
485
-
486
- it('should default effortMapping to sonnet/opus/opus', () => {
487
- expect(DEFAULT_CONFIG.effortMapping.low).toBe('sonnet');
488
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
489
- expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
490
- });
491
-
492
- it('should default timeout to 60', () => {
493
- expect(DEFAULT_CONFIG.timeout).toBe(60);
494
- });
495
-
496
- it('should default maxRetries to 3', () => {
497
- expect(DEFAULT_CONFIG.maxRetries).toBe(3);
498
- });
499
-
500
- it('should default autoCommit to true', () => {
501
- expect(DEFAULT_CONFIG.autoCommit).toBe(true);
502
- });
503
-
504
- it('should default worktree to false', () => {
505
- expect(DEFAULT_CONFIG.worktree).toBe(false);
506
- });
507
-
508
- it('should default commit format to match previous hardcoded format', () => {
509
- // The task format should produce the same output as the old hardcoded format
510
- const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.task, {
511
- prefix: DEFAULT_CONFIG.commitFormat.prefix,
512
- projectId: '3',
513
- taskId: '01',
514
- description: 'Add feature',
515
- });
516
- expect(result).toBe('RAF[3:01] Add feature');
517
- });
518
-
519
- it('should default plan commit format to match previous hardcoded format', () => {
520
- const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.plan, {
521
- prefix: DEFAULT_CONFIG.commitFormat.prefix,
522
- projectId: '3',
523
- projectName: 'my-project',
524
- });
525
- expect(result).toBe('RAF[3] Plan: my-project');
526
- });
527
-
528
- it('should default amend commit format to match previous hardcoded format', () => {
529
- const result = renderCommitMessage(DEFAULT_CONFIG.commitFormat.amend, {
530
- prefix: DEFAULT_CONFIG.commitFormat.prefix,
531
- projectId: '3',
532
- projectName: 'my-project',
533
- });
534
- expect(result).toBe('RAF[3] Amend: my-project');
535
- });
536
- });
537
-
538
620
  describe('getModelShortName', () => {
539
621
  it('should return short aliases as-is', () => {
540
622
  expect(getModelShortName('opus')).toBe('opus');
@@ -558,6 +640,36 @@ describe('Config', () => {
558
640
  });
559
641
  });
560
642
 
643
+ describe('getModelDisplayName', () => {
644
+ it('should preserve concise Claude aliases', () => {
645
+ expect(getModelDisplayName('opus')).toBe('opus');
646
+ expect(getModelDisplayName('claude-sonnet-4-5-20250929')).toBe('sonnet');
647
+ expect(getModelDisplayName('claude-haiku-4-5-20251001')).toBe('haiku');
648
+ });
649
+
650
+ it('should normalize compact Codex aliases to canonical display names', () => {
651
+ expect(getModelDisplayName('gpt54')).toBe('gpt-5.4');
652
+ expect(getModelDisplayName('gpt-5.4')).toBe('gpt-5.4');
653
+ });
654
+
655
+ it('should keep readable Codex aliases that are already concise labels', () => {
656
+ expect(getModelDisplayName('codex')).toBe('codex');
657
+ expect(getModelDisplayName('gpt-5.3-codex')).toBe('codex');
658
+ });
659
+ });
660
+
661
+ describe('formatModelDisplay', () => {
662
+ it('should include harness when requested', () => {
663
+ expect(formatModelDisplay('gpt54', 'codex', { includeHarness: true })).toBe('gpt-5.4 (codex)');
664
+ expect(formatModelDisplay('claude-sonnet-4-5-20250929', 'claude', { includeHarness: true })).toBe('sonnet (claude)');
665
+ });
666
+
667
+ it('should allow explicit full model ID display', () => {
668
+ expect(formatModelDisplay('gpt54', 'codex', { fullId: true })).toBe('gpt-5.4');
669
+ expect(formatModelDisplay('sonnet', 'claude', { fullId: true })).toBe('claude-sonnet-4-5-20250929');
670
+ });
671
+ });
672
+
561
673
  describe('resolveFullModelId', () => {
562
674
  it('should resolve short aliases to full model IDs', () => {
563
675
  expect(resolveFullModelId('opus')).toBe('claude-opus-4-6');
@@ -578,36 +690,65 @@ describe('Config', () => {
578
690
  });
579
691
  });
580
692
 
581
- describe('config integration - overrides work', () => {
582
- it('should use custom model when configured', () => {
583
- const configPath = path.join(tempDir, 'custom-models.json');
584
- saveConfig(configPath, { models: { execute: 'sonnet', plan: 'haiku' } });
585
- const config = resolveConfig(configPath);
586
- expect(config.models.execute).toBe('sonnet');
587
- expect(config.models.plan).toBe('haiku');
588
- // Others should remain at defaults
589
- expect(config.models.nameGeneration).toBe('sonnet');
590
- expect(config.models.failureAnalysis).toBe('haiku');
693
+ describe('getModelTier', () => {
694
+ it('should return correct tier for short aliases', () => {
695
+ expect(getModelTier('haiku')).toBe(1);
696
+ expect(getModelTier('sonnet')).toBe(2);
697
+ expect(getModelTier('opus')).toBe(3);
591
698
  });
592
699
 
593
- it('should use custom effortMapping when configured', () => {
594
- const configPath = path.join(tempDir, 'custom-effort.json');
595
- saveConfig(configPath, { effortMapping: { high: 'sonnet' } });
596
- const config = resolveConfig(configPath);
597
- expect(config.effortMapping.high).toBe('sonnet');
598
- // Others should remain at defaults
599
- expect(config.effortMapping.low).toBe('sonnet');
600
- expect(config.effortMapping.medium).toBe('opus');
700
+ it('should extract tier from full model IDs', () => {
701
+ expect(getModelTier('claude-haiku-4-5-20251001')).toBe(1);
702
+ expect(getModelTier('claude-sonnet-4-5-20250929')).toBe(2);
703
+ expect(getModelTier('claude-opus-4-6')).toBe(3);
601
704
  });
602
705
 
603
- it('should use custom commit format when configured', () => {
604
- const configPath = path.join(tempDir, 'custom-commit.json');
605
- saveConfig(configPath, { commitFormat: { prefix: 'CUSTOM', task: '{prefix}-{taskId}: {description}' } });
606
- const config = resolveConfig(configPath);
607
- expect(config.commitFormat.prefix).toBe('CUSTOM');
608
- expect(config.commitFormat.task).toBe('{prefix}-{taskId}: {description}');
609
- // plan/amend remain at defaults
610
- expect(config.commitFormat.plan).toBe(DEFAULT_CONFIG.commitFormat.plan);
706
+ it('should return correct tiers for Codex models', () => {
707
+ expect(getModelTier('codex')).toBe(1);
708
+ expect(getModelTier('gpt-5.3-codex')).toBe(1);
709
+ expect(getModelTier('gpt54')).toBe(2);
710
+ expect(getModelTier('gpt-5.4')).toBe(2);
711
+ });
712
+
713
+ it('should return highest tier for unknown models', () => {
714
+ expect(getModelTier('unknown-model')).toBe(3);
715
+ expect(getModelTier('claude-future-5-0')).toBe(3);
716
+ expect(getModelTier('')).toBe(3);
717
+ });
718
+ });
719
+
720
+ describe('applyModelCeiling', () => {
721
+ it('should return resolved entry when below ceiling', () => {
722
+ const resolved = { model: 'haiku', harness: 'claude' as const };
723
+ const ceiling = { model: 'sonnet', harness: 'claude' as const };
724
+ expect(applyModelCeiling(resolved, ceiling)).toEqual(resolved);
725
+ });
726
+
727
+ it('should return ceiling entry when above ceiling', () => {
728
+ const resolved = { model: 'opus', harness: 'claude' as const };
729
+ const ceiling = { model: 'sonnet', harness: 'claude' as const };
730
+ expect(applyModelCeiling(resolved, ceiling)).toEqual(ceiling);
731
+ });
732
+
733
+ it('should return resolved entry when at ceiling', () => {
734
+ const resolved = { model: 'sonnet', harness: 'claude' as const };
735
+ const ceiling = { model: 'sonnet', harness: 'claude' as const };
736
+ expect(applyModelCeiling(resolved, ceiling)).toEqual(resolved);
737
+ });
738
+
739
+ it('should work with full model IDs', () => {
740
+ const resolved = { model: 'claude-opus-4-6', harness: 'claude' as const };
741
+ const ceiling = { model: 'sonnet', harness: 'claude' as const };
742
+ expect(applyModelCeiling(resolved, ceiling)).toEqual(ceiling);
743
+ });
744
+ });
745
+
746
+ describe('resolveEffortToModel', () => {
747
+ it('should resolve effort levels to default model entries', () => {
748
+ const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
749
+ expect(config.effortMapping.low).toEqual({ model: 'sonnet', harness: 'claude' });
750
+ expect(config.effortMapping.medium).toEqual({ model: 'opus', harness: 'claude' });
751
+ expect(config.effortMapping.high).toEqual({ model: 'opus', harness: 'claude' });
611
752
  });
612
753
  });
613
754
 
@@ -662,94 +803,37 @@ describe('Config', () => {
662
803
  });
663
804
  });
664
805
 
665
- describe('getModelTier', () => {
666
- it('should return correct tier for short aliases', () => {
667
- expect(getModelTier('haiku')).toBe(1);
668
- expect(getModelTier('sonnet')).toBe(2);
669
- expect(getModelTier('opus')).toBe(3);
670
- });
671
-
672
- it('should extract tier from full model IDs', () => {
673
- expect(getModelTier('claude-haiku-4-5-20251001')).toBe(1);
674
- expect(getModelTier('claude-sonnet-4-5-20250929')).toBe(2);
675
- expect(getModelTier('claude-opus-4-6')).toBe(3);
676
- });
677
-
678
- it('should return highest tier for unknown models', () => {
679
- expect(getModelTier('unknown-model')).toBe(3);
680
- expect(getModelTier('claude-future-5-0')).toBe(3);
681
- expect(getModelTier('')).toBe(3);
682
- });
683
- });
684
-
685
- describe('applyModelCeiling', () => {
686
- it('should return resolved model when below ceiling', () => {
687
- expect(applyModelCeiling('haiku', 'sonnet')).toBe('haiku');
688
- expect(applyModelCeiling('haiku', 'opus')).toBe('haiku');
689
- expect(applyModelCeiling('sonnet', 'opus')).toBe('sonnet');
690
- });
691
-
692
- it('should return ceiling model when above ceiling', () => {
693
- expect(applyModelCeiling('opus', 'sonnet')).toBe('sonnet');
694
- expect(applyModelCeiling('opus', 'haiku')).toBe('haiku');
695
- expect(applyModelCeiling('sonnet', 'haiku')).toBe('haiku');
696
- });
697
-
698
- it('should return resolved model when at ceiling', () => {
699
- expect(applyModelCeiling('sonnet', 'sonnet')).toBe('sonnet');
700
- expect(applyModelCeiling('opus', 'opus')).toBe('opus');
701
- });
702
-
703
- it('should work with full model IDs', () => {
704
- expect(applyModelCeiling('claude-opus-4-6', 'sonnet')).toBe('sonnet');
705
- expect(applyModelCeiling('claude-haiku-4-5-20251001', 'claude-opus-4-6')).toBe('claude-haiku-4-5-20251001');
706
- });
707
- });
806
+ describe('new schema - harness-aware resolution', () => {
807
+ it('should allow mixing Claude and Codex entries across scenarios', () => {
808
+ const configPath = path.join(tempDir, 'mixed.json');
809
+ fs.writeFileSync(configPath, JSON.stringify({
810
+ models: {
811
+ plan: { model: 'opus', harness: 'claude' },
812
+ execute: { model: 'gpt-5.4', harness: 'codex' },
813
+ nameGeneration: { model: 'sonnet', harness: 'claude' },
814
+ },
815
+ }));
708
816
 
709
- describe('resolveEffortToModel', () => {
710
- it('should resolve effort levels to default models', () => {
711
- const configPath = path.join(tempDir, 'default.json');
712
- // Use default config
713
- const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
714
- expect(config.effortMapping.low).toBe('sonnet');
715
- expect(config.effortMapping.medium).toBe('opus');
716
- expect(config.effortMapping.high).toBe('opus');
817
+ const config = resolveConfig(configPath);
818
+ expect(config.models.plan.harness).toBe('claude');
819
+ expect(config.models.execute.harness).toBe('codex');
820
+ expect(config.models.nameGeneration.harness).toBe('claude');
717
821
  });
718
- });
719
822
 
720
- describe('validateConfig - effortMapping', () => {
721
- it('should accept valid effortMapping config', () => {
722
- expect(() => validateConfig({
823
+ it('should allow mixing harnesses in effortMapping', () => {
824
+ const configPath = path.join(tempDir, 'mixed-effort.json');
825
+ fs.writeFileSync(configPath, JSON.stringify({
723
826
  effortMapping: {
724
- low: 'sonnet',
725
- medium: 'opus',
726
- high: 'opus',
827
+ low: { model: 'sonnet', harness: 'claude' },
828
+ high: { model: 'gpt-5.4', harness: 'codex' },
727
829
  },
728
- })).not.toThrow();
729
- });
730
-
731
- it('should accept partial effortMapping override', () => {
732
- expect(() => validateConfig({
733
- effortMapping: { high: 'sonnet' },
734
- })).not.toThrow();
735
- });
736
-
737
- it('should accept full model IDs in effortMapping', () => {
738
- expect(() => validateConfig({
739
- effortMapping: { low: 'claude-haiku-4-5-20251001' },
740
- })).not.toThrow();
741
- });
742
-
743
- it('should reject invalid model names in effortMapping', () => {
744
- expect(() => validateConfig({
745
- effortMapping: { low: 'gpt-4' },
746
- })).toThrow('effortMapping.low must be a short alias');
747
- });
830
+ }));
748
831
 
749
- it('should reject unknown keys in effortMapping', () => {
750
- expect(() => validateConfig({
751
- effortMapping: { extra: 'haiku' },
752
- })).toThrow('Unknown config key: effortMapping.extra');
832
+ const config = resolveConfig(configPath);
833
+ expect(config.effortMapping.low.harness).toBe('claude');
834
+ expect(config.effortMapping.high.harness).toBe('codex');
835
+ // Medium preserved from defaults
836
+ expect(config.effortMapping.medium.harness).toBe('claude');
753
837
  });
754
838
  });
755
839
  });