* feat(#3023): per-phase-type model map in .planning/config.json
Adds a new `models` block to .planning/config.json with six phase-type
slots (planning / discuss / research / execution / verification /
completion). Lets users express coarse tuning ("Opus for planning,
Sonnet for the rest") without learning the agent taxonomy.
Resolution precedence (highest → lowest):
1. Per-agent `model_overrides[agent]` (full IDs; targeted exception)
2. Phase-type `models[phase_type]` (NEW; tier alias)
3. Profile table (`model_profile`) (per-agent column)
4. Runtime default
The three layers compose: `models` defaults a phase, `model_overrides`
carves an exception. Phase-type values are tier aliases (opus/sonnet/
haiku/inherit) so the runtime-resolution chain (#2517) stays correct
end-to-end without further branching.
Implementation:
- model-profiles.cjs: new AGENT_TO_PHASE_TYPE map + VALID_PHASE_TYPES
set. Each agent in MODEL_PROFILES gets one phase-type assignment;
tests assert coverage so adding a new agent without updating the
table fails CI.
- core.cjs (resolveModelInternal): inserts phase-type tier lookup
between per-agent override and profile-derived tier. Skips runtime
resolution when the resolved tier is 'inherit' (was previously gated
only on profile === 'inherit'; phase-type can now produce inherit
independently).
- core.cjs (loadConfig): pass `parsed.models` through both code paths
so resolveModelInternal can read it.
- config-schema.cjs + sdk/src/query/config-schema.ts: dynamic-pattern
validator accepts only the six known phase-types; unknown slots
rejected at config-set time.
Backward compat: configs without `models` behave exactly as today.
Tests (15 cases, all structural-IR — no stdout grep):
- Schema: AGENT_TO_PHASE_TYPE coverage, VALID_PHASE_TYPES exact match
- Resolver: phase-type alone; per-agent override beats phase-type;
phase-type beats profile; issue's full example; "inherit"; empty
block is no-op; no block is no-op
- Validation: each of the 6 slots accepted; unknown slot rejected;
bare `models` (no slot) rejected
Verification:
- 15/15 pass on new regression test
- 6808/6808 full suite (5 net new), 0 fail
- lint-no-source-grep clean across 375 test files
Closes#3023
* docs(#3023): document `models` per-phase-type config in user-facing docs
Adds `models` block coverage to the three user-facing docs that ship
with each release:
1. docs/CONFIGURATION.md
- New "Per-Phase-Type Models" section between "Per-Agent Overrides"
and "Non-Claude Runtimes" with:
* full example mixing models + model_overrides
* phase-type → agent mapping table
* resolution-precedence pseudocode
* accepted values (tier alias only)
* "When to use which" decision matrix
* validation behavior + example error
- Added `"models": {}` to the Full Schema snippet
- Added a row for `models.<phase_type>` to the config keys table
(next to model_profile_overrides for adjacency)
2. docs/FEATURES.md
- Added a row for models.<phase_type> in the Configurable Settings
table (right under model_profile)
- Cross-link to CONFIGURATION.md for the full surface
3. docs/USER-GUIDE.md
- New task-oriented "Tuning model cost by phase" section above
"Using Non-Claude Runtimes" — leads with the concrete config
and shows the override pattern (one-shot phase + targeted exception)
- Cross-link to CONFIGURATION.md
Verification:
- 29/29 pass on config-schema-docs-parity + docs-update + new feature
test (parity-check passes, so the config-schema entry I added in the
feature commit is now matched by a docs row)
- 6808/6808 full suite pass
- lint-no-source-grep clean
Doc style follows the same pattern used by the existing model_profile,
model_overrides, and model_profile_overrides sections — example-led,
table-backed, cross-referenced. Each doc surfaces the feature at the
right depth (reference / settings table / task guide).
* fix(#3023): mirror phase-type tier in resolveReasoningEffortInternal (CR Major)
CodeRabbit caught a real Codex correctness bug + 3 minor docs/test issues:
1. **Major (outside-diff)** — resolveReasoningEffortInternal in core.cjs
derived its tier exclusively from the profile table, ignoring the
models.<phase_type> override added in #3023. Failure mode on Codex:
Config: model_profile=balanced, models.execution=opus, agent=gsd-executor
resolveModelInternal: tier=opus → gpt-5.4
resolveReasoningEffortInternal: tier=sonnet → reasoning_effort=medium
↑
WRONG — should be xhigh
(opus tier on Codex)
The runtime received a mismatched (model, effort) pair. Mirrored the
phase-type lookup from resolveModelInternal so both functions derive
from the same tier source. 'inherit' phase-type returns null effort
(no runtime entry maps to 'inherit'; let runtime decide).
2. Minor — .changeset/per-phase-type-models.md `pr: TBD` → `pr: 3030`.
3. Minor (outside-diff) — model-profiles.md "Resolution Logic" section
omitted the new phase-type tier. Updated the 4-step block to a 5-step
block including `models[phase_type]` between override and profile,
plus a paragraph noting that `model` and `reasoning_effort` derive
from the same tier source.
4. Nitpick — added 2 typo-safety tests:
- models.research = "haiku3" (typo) → falls through to profile
- models.research = "openai/gpt-5" (full ID) → falls through to profile
Plus 5 new reasoning_effort tests covering the Major fix:
- exported correctly
- phase-type override flips both model AND effort to same tier
- inherit phase-type returns null effort
- per-agent override still bypasses phase-type for effort
- claude runtime ignores models.* (no effort propagation)
Verification:
- 24/24 pass on regression test (15 original + 2 typo-safety + 5 effort + 2 outside-diff related)
- 6815/6815 full suite (7 net new from 6808)
- lint-no-source-grep clean
The reasoning_effort tests are written semantically (phase-type override
must produce the SAME effort as a profile-only opus config) rather than
hard-coding tier-specific effort strings, so changes to the runtime tier
map don't break them.
* fix(#3023): phase-type override beats profile=inherit (CR Major round 2)
CodeRabbit caught another precedence inversion: when
{ model_profile: 'inherit', models: { execution: 'opus' } }
both resolvers short-circuited on `profile === 'inherit'` BEFORE the
phase-type override could be honored. Result: model returned 'inherit'
and reasoning_effort returned null — both contradicting the documented
precedence where models[phase_type] wins over model_profile.
Fix in resolveModelInternal:
- Compute tier from phase-type FIRST. If phase-type is a valid alias,
it wins. Otherwise, fall back to profile-derived tier OR 'inherit'
(when profile === 'inherit').
- Gate the runtime-resolution branch on `tier !== 'inherit'` (was
`profile !== 'inherit'`) so phase-type=opus can flip runtime mapping
on even when profile=inherit.
- Gate the inherit-return on `tier === 'inherit'` (was
`profile === 'inherit'`).
Fix in resolveReasoningEffortInternal:
- Remove the `if (profile === 'inherit') return null;` early-return.
- Compute tier from phase-type first, fall back to profile. If
phase-type is explicitly 'inherit' OR the resolved tier is 'inherit',
return null (no runtime entry maps to inherit).
Tests added (5 new):
- model: phase-type wins over profile=inherit (with explicit opus, with
haiku for one phase + planner-without-slot still inheriting)
- model: profile=inherit + no models block → all agents inherit (no
regression on existing inherit semantics)
- model: profile=inherit + models block but agent has no slot → that
agent inherits, agents with slots get phase-type tier
- effort: phase-type opus + profile=inherit → produces opus-tier
effort, NOT null (the original bug)
Verification:
- 27/27 pass on regression test (24 prior + 3 model + 1 effort)
- 6820/6820 full suite (5 net new)
- lint-no-source-grep clean
The effort test reads the expected value by running a profile-only opus
config and comparing — semantic check, not hard-coded effort string. So
runtime tier map changes don't break the test.