a9bba42c3d
The 2026-05-24 silent-corruption symptom: a `gnoma config set provider.default anthropic` call read the existing TOML into a zero-valued Config, set one field, then wrote the entire struct back. Every untouched field was serialized at its Go zero value (`mode = ""`, `max_tokens = 0`, etc.), and on the next layered load those present-but-zero fields silently shadowed higher- priority layers per TOML's "present field wins" semantics. This is Phase 1 of the 2026-05-24 config-migration plan: encoder-side only. Phases 2-5 (registry, doctor, upgrade-config, auto-migration) follow in subsequent slices. The fix is the hybrid approach the plan chose: - `,omitempty` on every string / map / slice field so absent keys aren't re-emitted. - Pointer conversion for the seven fields where the Go zero (`0`, `false`, `0.0`) is a legitimate user choice and the absent-vs-explicit-zero distinction matters: Provider.MaxTokens, Tools.MaxFileSize, Security.EntropyThreshold, Security.RedactHighEntropy, Router.ForceTwoStage, Session.MaxKeep, HookConfig.FailOpen. nil (absent) and *zero (explicit) are now distinguishable; the new Resolved() mirror substitutes Defaults() for nil so consumers see a clean concrete value. - Defaults() populates the new pointer fields with their default values so the resolver substitution is a no-op for the common case of "user didn't set it". - ResolvedConfig + Resolved() follow the ResolvedSafetySection precedent: a separate mirror type, constructed at the end of Load, with the boundary rule "raw cfg.X is internal; readers go through cfg.Resolved().X for pointer-converted fields". - setConfig now uses an atomic temp+rename write (writeAtomicTOML) so a crash mid-write can't leave a half-written config file. CLI surface: `gnoma config set [--global] <key> <value>` and `gnoma config keys` replace the dead help-string reference at cmd/gnoma/main.go:1538. All consumers of pointer-converted fields (cmd/gnoma/main.go, cmd/gnoma/profile_cmd.go, internal/hook/, internal/plugin/) move to the Resolved mirror. Test coverage: 6 resolver tests + 7 write tests + 3 CLI tests in the affected packages. Full go test ./... is green except for a pre-existing llamafile health-check timeout in internal/slm/backend_test.go that's environmental and unrelated to this change. Caveats (carried as follow-up work, not blockers): 1. Duration-typed fields (SLM.StartupTimeout, SLM.ClassifyTimeout) still emit as raw int64 even at zero. BurntSushi's encoder doesn't honor omitempty on the custom Duration type without a MarshalText method, and the existing MarshalText-less Duration type predates this fix. Cosmetic-only: 0 is the documented "use default" sentinel for both fields, so the value is semantically correct. Fix is a separate pointer-conversion PR on those two fields. 2. Pre-existing zero-spam in user config files is not auto-cleaned by a setConfig call on a different key. The user's recovery path remains: re-set the affected key (which the new omitempty + pointer semantics now rewrite correctly), or run `gnoma upgrade-config` (Phase 4). 3. BanditSection keeps the documented 0-sentinel pattern (0 = "use built-in default"). Pointer conversion was deliberately out of scope per the plan. Refs: docs/superpowers/plans/2026-05-24-config-migration.md