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
Brings the project to a clean `make lint` baseline (0 issues).
Mechanical:
- Wrap deferred resp.Body.Close() in closures (router/discovery.go,
router/probe.go) so the unchecked return surfaces as `_ = ...`.
- Apply `_ = ...` (single or multi-return blank) to test-file calls
that intentionally ignore errors: os.MkdirAll / os.WriteFile / os.Chdir
in setup paths, Close / Shutdown in teardown, Submit / Spawn / Send /
LoadDir in tests that assert on side effects.
Structural:
- engine.handleRequestTooLarge drops the unused req parameter and
rebuilds the request from compacted history (SA4009 — argument was
overwritten before first use).
- provider.ClassifyHTTPStatus and google.applyCapabilityOverrides switch
to tagged switches over the discriminator (QF1002).
- tui.app.go MouseWheel + inputMode and cmd/gnoma main slm-status use
tagged switches in place of equality chains (QF1003).
- cmd/gnoma main.go merges a var decl with its immediate assignment
(S1021).
- Three empty-branch sites (dispatcher_test, loader_test,
coordinator_test) become real assertions or get the dead `if` removed
(SA9003).
Plugins are now verified against ~/.config/gnoma/plugins.pins.toml at
load time. Each plugin's plugin.json bytes are hashed (SHA-256) and:
- recorded automatically on first load (TOFU) with a prominent warning
- compared on subsequent loads
- refused with a clear error if the hash drifted, without overwriting
the pin so the user can review and re-enrol deliberately
Pin-store I/O failures degrade to load-without-pinning rather than
locking the user out of previously-trusted plugins.
Closes audit finding C2. See ADR-003 for the decision rationale and
docs/plugins-trust.md for the end-user trust model.
Complete the remaining M8 extensibility deliverables:
- MCP client with JSON-RPC 2.0 over stdio transport, protocol
lifecycle (initialize/tools-list/tools-call), and process group
management for clean shutdown
- MCP tool adapter implementing tool.Tool with mcp__{server}__{tool}
naming convention and replace_default for swapping built-in tools
- MCP manager for multi-server orchestration with parallel startup,
tool discovery, and registry integration
- Plugin system with plugin.json manifest (name/version/capabilities),
directory-based discovery (global + project scopes with precedence),
loader that merges skills/hooks/MCP configs into existing registries,
and install/uninstall/list lifecycle manager
- Config additions: MCPServerConfig, PluginsSection with opt-in/opt-out
enabled/disabled resolution
- TUI /plugins command for listing installed plugins
- 54 tests across internal/mcp and internal/plugin packages