MODEL_VERSION_MAP now chains the legacy claude-opus-4-5 entry straight
to claude-opus-4-7 and adds an explicit claude-opus-4-6 to 4-7 bump
path, letting existing user configs upgrade on next load without an
intermediate 4-6 stop.
MODEL_TO_CATEGORY_MAP picks up claude-opus-4-7 as the canonical
unspecified-high model (prior 4-6 entry is covered by the chained
version map above, so legacy hardcoded configs still resolve).
Migration tests rewritten to reflect the chained 4-5 to 4-7 behavior
and the new 4-6 to 4-7 bump path, including the sidecar-union
scenario.
Users who auto-migrated from `openai/gpt-5.3-codex` to `openai/gpt-5.4`
and then reverted their config back to `gpt-5.3-codex` by hand had the
migration re-apply on every startup in an infinite loop. Discord bug
report pointed at the exact symptom: "i deleted the migrations and they
kept coming back".
The old migration tracking lived on the config body itself as a
`_migrations` string array. The skip-already-applied check relied on
the user not touching that field. But users hit by the unwanted
migration naturally reached for the JSON file to roll their model back,
and the natural human reaction to an incomprehensible internal field
next to their config is to delete it. That wiped the migration memory
and let the same migration re-apply at the next startup.
This PR introduces a sidecar state file that lives next to the config
as `<configPath>.migrations.json` and tracks applied migrations
outside the user's hand-editable config body. The migration pipeline:
1. Reads applied migrations from BOTH the sidecar AND the legacy
in-config `_migrations` field, unioning them. This keeps old
configs that still carry `_migrations` working without forcing
a reset.
2. Writes the updated migration set to the sidecar, never to the
config body.
3. Strips the legacy `_migrations` field out of the config body on
the first write after the sidecar takes over. Users stop seeing
the mystery internal field in their own config from that point
forward.
If the user also deletes the sidecar (explicit fresh-start gesture)
the migrations run again - that is intentional.
Tests (TDD, all new tests written before implementation):
- src/shared/migration/migrations-sidecar.test.ts - 11 unit tests
covering read/write/round-trip, malformed-payload resilience,
parent-directory creation, sorted output for stable diffs, and
non-string entry filtering.
- src/shared/migration.test.ts - 6 new integration tests under the
"migrateConfigFile with migration tracking via sidecar" block
covering: no-op path, sidecar-only write, sidecar skip after user
revert, legacy _migrations mirroring + strip, sidecar + legacy
union with dedupe, and partial-history append. Existing
"preserves existing _migrations and appends new ones" test was
rewritten to assert the new sidecar-based contract.
- Also fixes a latent test-hygiene bug: the shared
/tmp/nonexistent-path-for-test.json config path used by many
migrateConfigFile tests did not clean up its companion sidecar
between tests, letting state from one test bleed into the next.
Added afterEach that unlinks the sidecar.
Verified:
- bun test src/shared/migration/ -> 11 new sidecar tests pass
- bun test src/shared/migration.test.ts -> 82 pass, 0 fail
- bun run typecheck -> clean
- bun run script/run-ci-tests.ts -> 4458 pass, 0 fail (full suite)