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
@@ -8,16 +8,17 @@ import {
8
8
  UserConfig,
9
9
  VALID_MODEL_ALIASES,
10
10
  VALID_CODEX_MODEL_ALIASES,
11
- VALID_HARNESS_PROVIDERS,
11
+ VALID_HARNESSES,
12
12
  FULL_MODEL_ID_PATTERN,
13
- ClaudeModelName,
14
13
  TaskEffortLevel,
15
14
  ModelScenario,
15
+ ModelEntry,
16
16
  CommitFormatType,
17
17
  DisplayConfig,
18
18
  EffortMappingConfig,
19
- HarnessProvider,
19
+ HarnessName,
20
20
  } from '../types/config.js';
21
+ import { logger } from './logger.js';
21
22
 
22
23
  const CONFIG_DIR = path.join(os.homedir(), '.raf');
23
24
  const CONFIG_FILENAME = 'raf.config.json';
@@ -36,11 +37,18 @@ export function getClaudeSettingsPath(): string {
36
37
  // ---- Validation ----
37
38
 
38
39
  const VALID_TOP_LEVEL_KEYS = new Set<string>([
39
- 'provider', 'models', 'effortMapping', 'codexModels', 'codexEffortMapping',
40
+ 'models', 'effortMapping',
40
41
  'timeout', 'maxRetries', 'autoCommit',
41
42
  'worktree', 'syncMainBranch', 'commitFormat', 'display',
42
43
  ]);
43
44
 
45
+ /** Keys that were removed in the schema migration. Rejected with a helpful error. */
46
+ const REMOVED_KEYS: Record<string, string> = {
47
+ provider: 'Top-level "provider" has been removed. Use "harness" inside each "models" and "effortMapping" entry instead.',
48
+ codexModels: '"codexModels" has been removed. Use "models" with harness-aware entries (e.g. { "model": "gpt-5.4", "harness": "codex" }) instead.',
49
+ codexEffortMapping: '"codexEffortMapping" has been removed. Use "effortMapping" with harness-aware entries instead.',
50
+ };
51
+
44
52
  const VALID_MODEL_KEYS = new Set<string>([
45
53
  'plan', 'execute', 'nameGeneration', 'failureAnalysis', 'prGeneration', 'config',
46
54
  ]);
@@ -51,6 +59,10 @@ const VALID_COMMIT_FORMAT_KEYS = new Set<string>(['task', 'plan', 'amend', 'pref
51
59
 
52
60
  const VALID_DISPLAY_KEYS = new Set<string>(['showCacheTokens']);
53
61
 
62
+ const VALID_MODEL_ENTRY_KEYS = new Set<string>(['model', 'harness', 'reasoningEffort', 'fast']);
63
+
64
+ const VALID_REASONING_EFFORTS = new Set<string>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
65
+
54
66
  export class ConfigValidationError extends Error {
55
67
  constructor(message: string) {
56
68
  super(message);
@@ -66,7 +78,7 @@ function checkUnknownKeys(obj: Record<string, unknown>, validKeys: Set<string>,
66
78
  }
67
79
  }
68
80
 
69
- /** Regex for raw Codex model IDs (e.g., `gpt-5.4`, `gpt-5.3-codex-spark`). Requires dot-separated version. */
81
+ /** Regex for raw Codex model IDs (e.g., `gpt-5.4`, `gpt-5.3-codex`). Requires dot-separated version. */
70
82
  const CODEX_MODEL_ID_PATTERN = /^gpt-\d+\.\d+(-.+)*$/;
71
83
 
72
84
  /**
@@ -74,19 +86,19 @@ const CODEX_MODEL_ID_PATTERN = /^gpt-\d+\.\d+(-.+)*$/;
74
86
  * Accepts:
75
87
  * - Claude short aliases: sonnet, haiku, opus
76
88
  * - Claude full IDs: claude-opus-4-6
77
- * - Codex short aliases: spark, codex, gpt54
78
- * - Raw Codex model IDs: gpt-5.4, gpt-5.3-codex-spark
89
+ * - Codex short aliases: codex, gpt54
90
+ * - Raw Codex model IDs: gpt-5.4, gpt-5.3-codex
79
91
  * - Harness-prefixed format: claude/opus, codex/gpt-5.4
80
92
  */
81
93
  export function isValidModelName(value: string): boolean {
82
- // Harness-prefixed format: provider/model
94
+ // Harness-prefixed format: harness/model
83
95
  const prefixMatch = value.match(/^(claude|codex)\/(.+)$/);
84
96
  if (prefixMatch) {
85
- const [, provider, model] = prefixMatch;
86
- if (provider === 'claude') {
97
+ const [, harness, model] = prefixMatch;
98
+ if (harness === 'claude') {
87
99
  return (VALID_MODEL_ALIASES as readonly string[]).includes(model!) || FULL_MODEL_ID_PATTERN.test(model!);
88
100
  }
89
- if (provider === 'codex') {
101
+ if (harness === 'codex') {
90
102
  return (VALID_CODEX_MODEL_ALIASES as readonly string[]).includes(model!) || CODEX_MODEL_ID_PATTERN.test(model!);
91
103
  }
92
104
  }
@@ -105,25 +117,97 @@ export function isValidModelName(value: string): boolean {
105
117
  }
106
118
 
107
119
  /**
108
- * Parse a model spec string into its provider and model components.
109
- * - `codex/gpt-5.4` -> { provider: 'codex', model: 'gpt-5.4' }
110
- * - `claude/opus` -> { provider: 'claude', model: 'opus' }
111
- * - `opus` -> { provider: 'claude', model: 'opus' } (defaults to claude)
112
- * - `gpt-5.4` -> { provider: 'codex', model: 'gpt-5.4' } (inferred from model format)
120
+ * Parse a model spec string into its harness and model components.
121
+ * - `codex/gpt-5.4` -> { harness: 'codex', model: 'gpt-5.4' }
122
+ * - `claude/opus` -> { harness: 'claude', model: 'opus' }
123
+ * - `opus` -> { harness: 'claude', model: 'opus' } (defaults to claude)
124
+ * - `gpt-5.4` -> { harness: 'codex', model: 'gpt-5.4' } (inferred from model format)
113
125
  */
114
- export function parseModelSpec(modelSpec: string): { provider: HarnessProvider; model: string } {
126
+ export function parseModelSpec(modelSpec: string): { harness: HarnessName; model: string } {
115
127
  const prefixMatch = modelSpec.match(/^(claude|codex)\/(.+)$/);
116
128
  if (prefixMatch) {
117
- return { provider: prefixMatch[1] as HarnessProvider, model: prefixMatch[2]! };
129
+ return { harness: prefixMatch[1] as HarnessName, model: prefixMatch[2]! };
118
130
  }
119
131
 
120
- // Infer provider from model format
132
+ // Infer harness from model format
121
133
  if ((VALID_CODEX_MODEL_ALIASES as readonly string[]).includes(modelSpec) || CODEX_MODEL_ID_PATTERN.test(modelSpec)) {
122
- return { provider: 'codex', model: modelSpec };
134
+ return { harness: 'codex', model: modelSpec };
123
135
  }
124
136
 
125
137
  // Default to claude
126
- return { provider: 'claude', model: modelSpec };
138
+ return { harness: 'claude', model: modelSpec };
139
+ }
140
+
141
+ /**
142
+ * Validate a model entry object: { model, harness, reasoningEffort? }
143
+ */
144
+ function validateModelEntry(obj: unknown, prefix: string): void {
145
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
146
+ throw new ConfigValidationError(`${prefix} must be a model entry object (e.g. { "model": "opus", "harness": "claude" })`);
147
+ }
148
+ const entry = obj as Record<string, unknown>;
149
+ checkUnknownKeys(entry, VALID_MODEL_ENTRY_KEYS, prefix);
150
+
151
+ if (entry.model === undefined) {
152
+ throw new ConfigValidationError(`${prefix}.model is required`);
153
+ }
154
+ if (typeof entry.model !== 'string' || !isValidModelName(entry.model)) {
155
+ throw new ConfigValidationError(
156
+ `${prefix}.model must be a valid model name (e.g. opus, sonnet, gpt-5.4, claude-opus-4-6)`
157
+ );
158
+ }
159
+
160
+ if (entry.harness === undefined) {
161
+ throw new ConfigValidationError(`${prefix}.harness is required`);
162
+ }
163
+ if (typeof entry.harness !== 'string' || !(VALID_HARNESSES as readonly string[]).includes(entry.harness)) {
164
+ throw new ConfigValidationError(`${prefix}.harness must be one of: ${VALID_HARNESSES.join(', ')}`);
165
+ }
166
+
167
+ if (entry.reasoningEffort !== undefined) {
168
+ if (typeof entry.reasoningEffort !== 'string' || !VALID_REASONING_EFFORTS.has(entry.reasoningEffort)) {
169
+ throw new ConfigValidationError(`${prefix}.reasoningEffort must be one of: none, minimal, low, medium, high, xhigh, max`);
170
+ }
171
+ }
172
+
173
+ if (entry.fast !== undefined) {
174
+ if (typeof entry.fast !== 'boolean') {
175
+ throw new ConfigValidationError(`${prefix}.fast must be a boolean`);
176
+ }
177
+ }
178
+ }
179
+
180
+ function getCodexFastWarning(prefix: string): string {
181
+ return `${prefix}.fast is enabled but ignored because Codex does not support fast mode`;
182
+ }
183
+
184
+ function collectModelEntryWarnings(obj: unknown, prefix: string, warnings: string[]): void {
185
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
186
+ return;
187
+ }
188
+
189
+ const entry = obj as Record<string, unknown>;
190
+ if (entry.harness === 'codex' && entry.fast === true) {
191
+ warnings.push(getCodexFastWarning(prefix));
192
+ }
193
+ }
194
+
195
+ export function collectConfigValidationWarnings(config: UserConfig): string[] {
196
+ const warnings: string[] = [];
197
+
198
+ if (config.models && typeof config.models === 'object') {
199
+ for (const [key, value] of Object.entries(config.models)) {
200
+ collectModelEntryWarnings(value, `models.${key}`, warnings);
201
+ }
202
+ }
203
+
204
+ if (config.effortMapping && typeof config.effortMapping === 'object') {
205
+ for (const [key, value] of Object.entries(config.effortMapping)) {
206
+ collectModelEntryWarnings(value, `effortMapping.${key}`, warnings);
207
+ }
208
+ }
209
+
210
+ return warnings;
127
211
  }
128
212
 
129
213
  export function validateConfig(config: unknown): UserConfig {
@@ -132,15 +216,16 @@ export function validateConfig(config: unknown): UserConfig {
132
216
  }
133
217
 
134
218
  const obj = config as Record<string, unknown>;
135
- checkUnknownKeys(obj, VALID_TOP_LEVEL_KEYS, '');
136
219
 
137
- // provider
138
- if (obj.provider !== undefined) {
139
- if (typeof obj.provider !== 'string' || !(VALID_HARNESS_PROVIDERS as readonly string[]).includes(obj.provider)) {
140
- throw new ConfigValidationError(`provider must be one of: ${VALID_HARNESS_PROVIDERS.join(', ')}`);
220
+ // Check for removed keys first (helpful error messages)
221
+ for (const [key, message] of Object.entries(REMOVED_KEYS)) {
222
+ if (key in obj) {
223
+ throw new ConfigValidationError(message);
141
224
  }
142
225
  }
143
226
 
227
+ checkUnknownKeys(obj, VALID_TOP_LEVEL_KEYS, '');
228
+
144
229
  // models
145
230
  if (obj.models !== undefined) {
146
231
  if (typeof obj.models !== 'object' || obj.models === null || Array.isArray(obj.models)) {
@@ -149,11 +234,7 @@ export function validateConfig(config: unknown): UserConfig {
149
234
  const models = obj.models as Record<string, unknown>;
150
235
  checkUnknownKeys(models, VALID_MODEL_KEYS, 'models');
151
236
  for (const [key, val] of Object.entries(models)) {
152
- if (typeof val !== 'string' || !isValidModelName(val)) {
153
- throw new ConfigValidationError(
154
- `models.${key} must be a short alias (${VALID_MODEL_ALIASES.join(', ')}) or a full model ID (e.g., claude-sonnet-4-5-20250929)`
155
- );
156
- }
237
+ validateModelEntry(val, `models.${key}`);
157
238
  }
158
239
  }
159
240
 
@@ -165,43 +246,7 @@ export function validateConfig(config: unknown): UserConfig {
165
246
  const effortMapping = obj.effortMapping as Record<string, unknown>;
166
247
  checkUnknownKeys(effortMapping, VALID_EFFORT_MAPPING_KEYS, 'effortMapping');
167
248
  for (const [key, val] of Object.entries(effortMapping)) {
168
- if (typeof val !== 'string' || !isValidModelName(val)) {
169
- throw new ConfigValidationError(
170
- `effortMapping.${key} must be a short alias (${VALID_MODEL_ALIASES.join(', ')}) or a full model ID (e.g., claude-sonnet-4-5-20250929)`
171
- );
172
- }
173
- }
174
- }
175
-
176
- // codexModels
177
- if (obj.codexModels !== undefined) {
178
- if (typeof obj.codexModels !== 'object' || obj.codexModels === null || Array.isArray(obj.codexModels)) {
179
- throw new ConfigValidationError('codexModels must be an object');
180
- }
181
- const codexModels = obj.codexModels as Record<string, unknown>;
182
- checkUnknownKeys(codexModels, VALID_MODEL_KEYS, 'codexModels');
183
- for (const [key, val] of Object.entries(codexModels)) {
184
- if (typeof val !== 'string' || !isValidModelName(val)) {
185
- throw new ConfigValidationError(
186
- `codexModels.${key} must be a valid model name (e.g., gpt-5.4, gpt-5.3-codex-spark)`
187
- );
188
- }
189
- }
190
- }
191
-
192
- // codexEffortMapping
193
- if (obj.codexEffortMapping !== undefined) {
194
- if (typeof obj.codexEffortMapping !== 'object' || obj.codexEffortMapping === null || Array.isArray(obj.codexEffortMapping)) {
195
- throw new ConfigValidationError('codexEffortMapping must be an object');
196
- }
197
- const codexEffortMapping = obj.codexEffortMapping as Record<string, unknown>;
198
- checkUnknownKeys(codexEffortMapping, VALID_EFFORT_MAPPING_KEYS, 'codexEffortMapping');
199
- for (const [key, val] of Object.entries(codexEffortMapping)) {
200
- if (typeof val !== 'string' || !isValidModelName(val)) {
201
- throw new ConfigValidationError(
202
- `codexEffortMapping.${key} must be a valid model name (e.g., gpt-5.4, gpt-5.3-codex-spark)`
203
- );
204
- }
249
+ validateModelEntry(val, `effortMapping.${key}`);
205
250
  }
206
251
  }
207
252
 
@@ -273,21 +318,57 @@ export function validateConfig(config: unknown): UserConfig {
273
318
 
274
319
  // ---- Deep merge ----
275
320
 
321
+ /** Deep-merge a single model entry: user override replaces the default entirely. */
322
+ function mergeModelEntry(defaultEntry: ModelEntry, override: unknown): ModelEntry {
323
+ const normalizedEntry = (entry: ModelEntry): ModelEntry => {
324
+ if (entry.harness !== 'codex') {
325
+ return entry;
326
+ }
327
+ const { fast: _ignored, ...rest } = entry;
328
+ return rest;
329
+ };
330
+
331
+ if (override && typeof override === 'object' && !Array.isArray(override)) {
332
+ const o = override as Record<string, unknown>;
333
+ return normalizedEntry({
334
+ model: typeof o.model === 'string' ? o.model : defaultEntry.model,
335
+ harness: typeof o.harness === 'string' ? (o.harness as HarnessName) : defaultEntry.harness,
336
+ ...(o.reasoningEffort !== undefined
337
+ ? { reasoningEffort: o.reasoningEffort as ModelEntry['reasoningEffort'] }
338
+ : defaultEntry.reasoningEffort !== undefined
339
+ ? { reasoningEffort: defaultEntry.reasoningEffort }
340
+ : {}),
341
+ ...(o.fast !== undefined
342
+ ? { fast: o.fast as boolean }
343
+ : defaultEntry.fast !== undefined
344
+ ? { fast: defaultEntry.fast }
345
+ : {}),
346
+ });
347
+ }
348
+ return normalizedEntry(defaultEntry);
349
+ }
350
+
276
351
  function deepMerge(defaults: RafConfig, overrides: UserConfig): RafConfig {
277
352
  const result = { ...defaults };
278
353
 
279
- if (overrides.provider !== undefined) result.provider = overrides.provider;
280
354
  if (overrides.models) {
281
- result.models = { ...defaults.models, ...overrides.models };
355
+ const m = overrides.models as Record<string, unknown>;
356
+ result.models = {
357
+ plan: m.plan ? mergeModelEntry(defaults.models.plan, m.plan) : { ...defaults.models.plan },
358
+ execute: m.execute ? mergeModelEntry(defaults.models.execute, m.execute) : { ...defaults.models.execute },
359
+ nameGeneration: m.nameGeneration ? mergeModelEntry(defaults.models.nameGeneration, m.nameGeneration) : { ...defaults.models.nameGeneration },
360
+ failureAnalysis: m.failureAnalysis ? mergeModelEntry(defaults.models.failureAnalysis, m.failureAnalysis) : { ...defaults.models.failureAnalysis },
361
+ prGeneration: m.prGeneration ? mergeModelEntry(defaults.models.prGeneration, m.prGeneration) : { ...defaults.models.prGeneration },
362
+ config: m.config ? mergeModelEntry(defaults.models.config, m.config) : { ...defaults.models.config },
363
+ };
282
364
  }
283
365
  if (overrides.effortMapping) {
284
- result.effortMapping = { ...defaults.effortMapping, ...overrides.effortMapping };
285
- }
286
- if (overrides.codexModels) {
287
- result.codexModels = { ...defaults.codexModels, ...overrides.codexModels };
288
- }
289
- if (overrides.codexEffortMapping) {
290
- result.codexEffortMapping = { ...defaults.codexEffortMapping, ...overrides.codexEffortMapping };
366
+ const e = overrides.effortMapping as Record<string, unknown>;
367
+ result.effortMapping = {
368
+ low: e.low ? mergeModelEntry(defaults.effortMapping.low, e.low) : { ...defaults.effortMapping.low },
369
+ medium: e.medium ? mergeModelEntry(defaults.effortMapping.medium, e.medium) : { ...defaults.effortMapping.medium },
370
+ high: e.high ? mergeModelEntry(defaults.effortMapping.high, e.high) : { ...defaults.effortMapping.high },
371
+ };
291
372
  }
292
373
  if (overrides.commitFormat) {
293
374
  result.commitFormat = { ...defaults.commitFormat, ...overrides.commitFormat };
@@ -316,10 +397,19 @@ export function resolveConfig(configPath?: string): RafConfig {
316
397
  if (!fs.existsSync(filePath)) {
317
398
  return {
318
399
  ...DEFAULT_CONFIG,
319
- models: { ...DEFAULT_CONFIG.models },
320
- effortMapping: { ...DEFAULT_CONFIG.effortMapping },
321
- codexModels: { ...DEFAULT_CONFIG.codexModels },
322
- codexEffortMapping: { ...DEFAULT_CONFIG.codexEffortMapping },
400
+ models: {
401
+ plan: { ...DEFAULT_CONFIG.models.plan },
402
+ execute: { ...DEFAULT_CONFIG.models.execute },
403
+ nameGeneration: { ...DEFAULT_CONFIG.models.nameGeneration },
404
+ failureAnalysis: { ...DEFAULT_CONFIG.models.failureAnalysis },
405
+ prGeneration: { ...DEFAULT_CONFIG.models.prGeneration },
406
+ config: { ...DEFAULT_CONFIG.models.config },
407
+ },
408
+ effortMapping: {
409
+ low: { ...DEFAULT_CONFIG.effortMapping.low },
410
+ medium: { ...DEFAULT_CONFIG.effortMapping.medium },
411
+ high: { ...DEFAULT_CONFIG.effortMapping.high },
412
+ },
323
413
  commitFormat: { ...DEFAULT_CONFIG.commitFormat },
324
414
  display: { ...DEFAULT_CONFIG.display },
325
415
  };
@@ -328,6 +418,9 @@ export function resolveConfig(configPath?: string): RafConfig {
328
418
  const content = fs.readFileSync(filePath, 'utf-8');
329
419
  const parsed: unknown = JSON.parse(content);
330
420
  const validated = validateConfig(parsed);
421
+ for (const warning of collectConfigValidationWarnings(validated)) {
422
+ logger.warn(`Config validation warning: ${warning}`);
423
+ }
331
424
  return deepMerge(DEFAULT_CONFIG, validated);
332
425
  }
333
426
 
@@ -365,36 +458,27 @@ export function resetConfigCache(): void {
365
458
  _cachedConfig = null;
366
459
  }
367
460
 
368
- export function getModel(scenario: ModelScenario, provider?: HarnessProvider): ClaudeModelName {
461
+ /**
462
+ * Get the model entry for a scenario.
463
+ */
464
+ export function getModel(scenario: ModelScenario): ModelEntry {
369
465
  const config = getResolvedConfig();
370
- const effectiveProvider = provider ?? config.provider;
371
- if (effectiveProvider === 'codex') {
372
- return config.codexModels[scenario];
373
- }
374
466
  return config.models[scenario];
375
467
  }
376
468
 
377
469
  /**
378
470
  * Get the full effort mapping config.
379
471
  */
380
- export function getEffortMapping(provider?: HarnessProvider): EffortMappingConfig {
472
+ export function getEffortMapping(): EffortMappingConfig {
381
473
  const config = getResolvedConfig();
382
- const effectiveProvider = provider ?? config.provider;
383
- if (effectiveProvider === 'codex') {
384
- return config.codexEffortMapping;
385
- }
386
474
  return config.effortMapping;
387
475
  }
388
476
 
389
477
  /**
390
- * Resolve a task effort level to a model name using the effort mapping config.
478
+ * Resolve a task effort level to a model entry using the effort mapping config.
391
479
  */
392
- export function resolveEffortToModel(effort: TaskEffortLevel, provider?: HarnessProvider): ClaudeModelName {
480
+ export function resolveEffortToModel(effort: TaskEffortLevel): ModelEntry {
393
481
  const config = getResolvedConfig();
394
- const effectiveProvider = provider ?? config.provider;
395
- if (effectiveProvider === 'codex') {
396
- return config.codexEffortMapping[effort];
397
- }
398
482
  return config.effortMapping[effort];
399
483
  }
400
484
 
@@ -409,14 +493,12 @@ const MODEL_TIER_ORDER: Record<string, number> = {
409
493
  opus: 3,
410
494
  };
411
495
 
412
- /** Codex model tier ordering: spark (1) < codex (2) < gpt-5.4 (3) */
496
+ /** Codex model tier ordering: codex (1) < gpt54 (2) */
413
497
  const CODEX_MODEL_TIER_ORDER: Record<string, number> = {
414
- spark: 1,
415
- 'gpt-5.3-codex-spark': 1,
416
- codex: 2,
417
- 'gpt-5.3-codex': 2,
418
- gpt54: 3,
419
- 'gpt-5.4': 3,
498
+ codex: 1,
499
+ 'gpt-5.3-codex': 1,
500
+ gpt54: 2,
501
+ 'gpt-5.4': 2,
420
502
  };
421
503
 
422
504
  /**
@@ -451,19 +533,20 @@ export function getModelTier(modelName: string): number {
451
533
  }
452
534
 
453
535
  /**
454
- * Apply ceiling to a model based on the configured models.execute ceiling.
455
- * Returns the lower-tier model between the input and the ceiling.
536
+ * Apply ceiling to a model entry based on the configured models.execute ceiling.
537
+ * Returns the lower-tier entry between the input and the ceiling.
538
+ * When the input exceeds the ceiling, the ceiling entry is returned (including its harness).
456
539
  */
457
- export function applyModelCeiling(resolvedModel: string, ceiling?: string): string {
458
- const ceilingModel = ceiling ?? getModel('execute');
459
- const resolvedTier = getModelTier(resolvedModel);
460
- const ceilingTier = getModelTier(ceilingModel);
540
+ export function applyModelCeiling(resolved: ModelEntry, ceiling?: ModelEntry): ModelEntry {
541
+ const ceilingEntry = ceiling ?? getModel('execute');
542
+ const resolvedTier = getModelTier(resolved.model);
543
+ const ceilingTier = getModelTier(ceilingEntry.model);
461
544
 
462
545
  // If resolved model is above ceiling, use ceiling instead
463
546
  if (resolvedTier > ceilingTier) {
464
- return ceilingModel;
547
+ return ceilingEntry;
465
548
  }
466
- return resolvedModel;
549
+ return resolved;
467
550
  }
468
551
 
469
552
  export function getCommitFormat(type: CommitFormatType): string {
@@ -505,11 +588,10 @@ export function getModelShortName(modelId: string): string {
505
588
  return modelId;
506
589
  }
507
590
  // Codex short aliases
508
- if (modelId === 'spark' || modelId === 'codex' || modelId === 'gpt54') {
591
+ if (modelId === 'codex' || modelId === 'gpt54') {
509
592
  return modelId;
510
593
  }
511
594
  // Codex model IDs -> short names
512
- if (modelId === 'gpt-5.3-codex-spark') return 'spark';
513
595
  if (modelId === 'gpt-5.3-codex') return 'codex';
514
596
  if (modelId === 'gpt-5.4') return 'gpt54';
515
597
  // Extract family from full Claude model ID: claude-{family}-{version}
@@ -524,6 +606,10 @@ export function getModelShortName(modelId: string): string {
524
606
  return modelId;
525
607
  }
526
608
 
609
+ function normalizeModelAlias(value: string): string {
610
+ return value.toLowerCase().replace(/^gpt-/, 'gpt').replace(/[^a-z0-9]/g, '');
611
+ }
612
+
527
613
  /**
528
614
  * Mapping of short model aliases to their current full model IDs.
529
615
  * These should match the latest Claude model versions.
@@ -532,11 +618,58 @@ const MODEL_ALIAS_TO_FULL_ID: Record<string, string> = {
532
618
  opus: 'claude-opus-4-6',
533
619
  sonnet: 'claude-sonnet-4-5-20250929',
534
620
  haiku: 'claude-haiku-4-5-20251001',
535
- spark: 'gpt-5.3-codex-spark',
536
621
  codex: 'gpt-5.3-codex',
537
622
  gpt54: 'gpt-5.4',
538
623
  };
539
624
 
625
+ function getPreferredModelDisplayLabel(alias: string): string {
626
+ const fullId = MODEL_ALIAS_TO_FULL_ID[alias];
627
+ if (!fullId) {
628
+ return alias;
629
+ }
630
+
631
+ return normalizeModelAlias(alias) === normalizeModelAlias(fullId) ? fullId : alias;
632
+ }
633
+
634
+ /**
635
+ * Get the centralized user-facing display label for a model.
636
+ * Keeps concise Claude labels, while normalizing compact aliases like gpt54 -> gpt-5.4.
637
+ */
638
+ export function getModelDisplayName(modelId: string): string {
639
+ const aliasedDisplay = getPreferredModelDisplayLabel(modelId);
640
+ if (aliasedDisplay !== modelId) {
641
+ return aliasedDisplay;
642
+ }
643
+
644
+ const matchingAliases = Object.entries(MODEL_ALIAS_TO_FULL_ID)
645
+ .filter(([, fullId]) => fullId === modelId)
646
+ .map(([alias]) => alias);
647
+ if (matchingAliases.length > 0) {
648
+ const preferredAlias = matchingAliases[matchingAliases.length - 1]!;
649
+ return getPreferredModelDisplayLabel(preferredAlias);
650
+ }
651
+
652
+ return getModelShortName(modelId);
653
+ }
654
+
655
+ interface ModelDisplayOptions {
656
+ includeHarness?: boolean;
657
+ fullId?: boolean;
658
+ }
659
+
660
+ /**
661
+ * Format a model label for user-facing logs.
662
+ * Defaults to the centralized display policy, or can opt into explicit full-ID display.
663
+ */
664
+ export function formatModelDisplay(
665
+ model: string,
666
+ harness?: HarnessName,
667
+ options: ModelDisplayOptions = {},
668
+ ): string {
669
+ const label = options.fullId ? resolveFullModelId(model) : getModelDisplayName(model);
670
+ return options.includeHarness && harness ? `${label} (${harness})` : label;
671
+ }
672
+
540
673
  /**
541
674
  * Resolve a model name to its full model ID.
542
675
  * If already a full model ID, returns as-is.
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { logger } from './logger.js';
3
3
  import { sanitizeProjectName } from './validation.js';
4
4
  import { getModel } from './config.js';
5
+ import type { HarnessName } from '../types/config.js';
5
6
 
6
7
  const NAME_GENERATION_PROMPT = `Output ONLY the kebab-case name. No introduction, no explanation, no quotes.
7
8
 
@@ -29,20 +30,35 @@ Rules:
29
30
 
30
31
  Project description:`;
31
32
 
32
- /**
33
- * Run the CLI with the given prompt and return stdout.
34
- * Uses spawn with --no-session-persistence to avoid cluttering session history.
35
- */
36
- function runClaudePrint(prompt: string): Promise<string | null> {
37
- return new Promise((resolve) => {
38
- const model = getModel('nameGeneration');
39
-
40
- const proc = spawn('claude', [
33
+ function buildNameGenerationArgs(harness: HarnessName, model: string, prompt: string): string[] {
34
+ if (harness === 'claude') {
35
+ return [
41
36
  '--model', model,
42
37
  '--no-session-persistence',
43
38
  '-p',
44
39
  prompt,
45
- ], {
40
+ ];
41
+ }
42
+
43
+ return [
44
+ 'exec',
45
+ '--skip-git-repo-check',
46
+ '--ephemeral',
47
+ '--color', 'never',
48
+ '-m', model,
49
+ prompt,
50
+ ];
51
+ }
52
+
53
+ /**
54
+ * Run the configured name-generation CLI with the given prompt and return stdout.
55
+ */
56
+ function runNameGenerationPrint(prompt: string): Promise<string | null> {
57
+ return new Promise((resolve) => {
58
+ const entry = getModel('nameGeneration');
59
+ const args = buildNameGenerationArgs(entry.harness, entry.model, prompt);
60
+
61
+ const proc = spawn(entry.harness, args, {
46
62
  stdio: ['ignore', 'pipe', 'pipe'],
47
63
  });
48
64
 
@@ -110,7 +126,7 @@ export async function generateProjectName(description: string): Promise<string>
110
126
  export async function generateProjectNames(description: string): Promise<string[]> {
111
127
  try {
112
128
  const names = await callSonnetForMultipleNames(description);
113
- if (names.length >= 3) {
129
+ if (names.length > 0) {
114
130
  logger.debug(`Generated ${names.length} project names`);
115
131
  return names;
116
132
  }
@@ -129,7 +145,7 @@ export async function generateProjectNames(description: string): Promise<string[
129
145
  */
130
146
  async function callSonnetForName(description: string): Promise<string | null> {
131
147
  const fullPrompt = `${NAME_GENERATION_PROMPT}\n${description}`;
132
- return runClaudePrint(fullPrompt);
148
+ return runNameGenerationPrint(fullPrompt);
133
149
  }
134
150
 
135
151
  /**
@@ -137,7 +153,7 @@ async function callSonnetForName(description: string): Promise<string | null> {
137
153
  */
138
154
  async function callSonnetForMultipleNames(description: string): Promise<string[]> {
139
155
  const fullPrompt = `${MULTI_NAME_GENERATION_PROMPT}\n${description}`;
140
- const result = await runClaudePrint(fullPrompt);
156
+ const result = await runNameGenerationPrint(fullPrompt);
141
157
 
142
158
  if (!result) {
143
159
  return [];