Skill preambles read configuration with
VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
and that fallback only fires on a non-zero exit. lookup_default ended in a
catch-all that echoed "" and returned 0, so for any key missing from the table
VAR came back empty and the default written right there in the preamble was
unreachable. The skill then branched on a value it never specified: "skip
entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="".
Four keys that skills actually read had no entry and took that path:
question_tuning -> callers assume "false"
repo_mode -> callers assume "unknown"
team_mode -> callers assume "false"
transcript_ingest_mode -> callers assume "off"
Each default above is the value the call sites already substitute in their own
`|| echo` fallback, so this only makes reachable what was already intended.
The catch-all now returns non-zero. That is deliberately scoped to the
unknown-key arm alone: keys whose default is intentionally empty still exit 0,
because "" is their real answer and their callers depend on it --
cross_project_learnings ("unset triggers the first-time prompt"),
redact_repo_visibility ("empty falls through to gh/glab detection"),
salience_allowlist, user_slug_at_*. Making every empty answer an error would
have broken those.
test/gstack-config-defaults.test.ts pins the class rather than the four
instances: it parses the case arms and asserts every `gstack-config get <key>`
site in the tree is covered, so adding a read without a default fails CI. It
also pins the exit-code contract in both directions. Verified failing against
the pre-fix script, where it names exactly those four keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>