Fix release for 1.1.1: the minimal path end to end, engine commands that run on Godot, Unity and Unreal, hook warnings you can see, and verdicts that say when they could not check. Details in CHANGELOG.md; upgrade steps in UPGRADING.md (v1.1.1 → v1.1.2). Fixes #67 Fixes #131 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WuJn2FdynEybfL5KC697nJ
16 KiB
Config Resolution
How a skill learns its effective settings. One command, no prose chain.
The rule
A skill that needs config puts this near the top of its body (not frontmatter):
!`bash "${CLAUDE_SKILL_DIR}/../../hooks/yaml-helper.sh" resolve_config --keys a,b`
and pre-approves exactly that command in its own frontmatter, with its own directory name in the path:
allowed-tools: Read, …, Bash(bash "*/.claude/skills/<skill-name>/../../hooks/yaml-helper.sh" resolve_config *)
The command runs as preprocessing before the model sees the skill, and its output replaces the placeholder inline. The skill then reads the resolved values and does not re-derive them.
Skills that resolve a per-system tier pass the system name: resolve_config combat.
Why the body, not context:
!`cmd` in the skill body is the documented mechanism. Frontmatter
context: accepts only context: fork — it does not run shell. A
context: | block containing ! commands never executes; put the command in
the skill body instead.
Why exactly this command — the permission check
Claude Code permission-checks every injected command before the skill
renders. Outside auto mode, anything that does not come back "allow" — including
a command that would normally just prompt — aborts the whole invocation, and
the model never sees the skill. A read-only command such as git status, which
Claude Code approves on its own, comes back "allow" and runs without a grant. A preloaded skill (skills: on an agent) is
checked the same way at agent launch, so a failing bootstrap line stops the agent
from starting. Measured on Claude Code 2.1.281 in default
mode:
| Bootstrap form | Repo root | Subdirectory | Bash-less agent preloading it |
|---|---|---|---|
source "${CLAUDE_PROJECT_DIR:-.}/…" && resolve_config (v1.1.0) |
aborts | aborts | fails to launch |
same, with a bare Bash grant |
aborts | — | — |
bash "${CLAUDE_PROJECT_DIR}/…" + grant |
runs | aborts | — |
bash "${CLAUDE_SKILL_DIR}/../../hooks/…" + per-skill grant |
runs | runs | launches |
What that table rules out:
- No
${VAR:-x}expansion. It fails as "Contains expansion", and no grant can approve it.${CLAUDE_SKILL_DIR}and${CLAUDE_PROJECT_DIR}are fine: Claude Code substitutes them as text before the check. - No
${CLAUDE_PROJECT_DIR}for the path. In a skill it is the directory Claude Code was launched from, not the repo root, so it breaks when Claude Code is started in a subdirectory.${CLAUDE_SKILL_DIR}always names the skill's own folder. - No
source … && …compound. Each part must be approved;bash <file> resolve_configis one command.yaml-helper.shdispatches onlyresolve_configwhen executed, and finds the repo root from its own location. - The grant is required.
allowed-toolsis what pre-approves an injected command. The pattern must include the literal"after the path — a pattern ending…yaml-helper.sh *does not match. - Any non-zero exit aborts too. The entry point always exits 0; other
injected commands that can fail end in
|| true.
Auto mode can approve the old form on its own, which is how v1.1.0 shipped with
it. Test bootstrap changes in default mode, in a project without a
settings.local.json.
disableSkillShellExecution: true replaces every injected command with a
placeholder message. If a skill sees no config block, it falls back to the
defaults below.
The block
=== CCGS Resolved Config ===
review_mode: lean (production/review-mode.txt)
automation: guided (project.local.yaml)
workflow: standard (project.yaml)
docs.density: balanced (rigor:standard)
story_granularity: balanced (rigor:standard)
qa.level: standard (rigor:standard)
team.size: individual (rigor:standard)
project.stage: Systems Design (production/stage.txt)
automation_always_ask: scope_changes, file_deletions, schema_changes (default)
engine: Godot 4.6 (project.yaml)
testing.strict: logic=true integration=false visual=unset ui=unset config=unset (unset = each skill applies its own default)
system_overrides: combat=full inventory=minimal
notes: none
Values above are fully resolved (local -> yaml -> legacy -> default). Use as-is.
An inline --review flag, if passed, overrides review_mode.
=== end CCGS config ===
Every knob line ends with its provenance in parentheses. That is what makes a
wrong value diagnosable — review_mode: solo (rigor:minimal) when project.yaml says
full points straight at the problem.
Resolution chain
| Step | Source | Applies to |
|---|---|---|
| 1 | project.local.yaml |
any setting on the /settings --local whitelist (12 keys — modes.review_mode, modes.automation, modes.automation_always_ask, team.size, all five testing.strict.*, performance.enforce, features.*) |
| 2 | project.yaml |
all keys |
| 3 | legacy plain-text mirror | modes.review_mode → production/review-mode.txt; project.stage → production/stage.txt |
| 4 | modes.rigor expansion |
the six knobs rigor fronts — modes.workflow, docs.density, qa.level, modes.story_granularity, modes.review_mode, team.size |
| 5 | documented default | the table below |
An enum-invalid value does not win — the chain continues past it and the
rejected value is named on the notes: line. A typo degrades to the documented
default rather than propagating a nonsense mode into every skill.
Step 4 sits below every explicit source and above the terminal default, and that ordering is the whole back-compat guarantee. A
project.yamlthat already setsdocs.density: terseresolves exactly as it did beforerigorexisted, because step 2 answers first. Invert steps 4 and 5 andrigorbecomes a no-op; hoist step 4 above step 2 and it silently overrides settings people already have.Derived values report their provenance as
rigor:<level>, so the source vocabulary is{project.local.yaml, project.yaml, <legacy path>, rigor:<level>, default, unset}.docs.density: balanced (rigor:standard)says the value was never written down anywhere — it follows the project's rigor level and will move if that level moves.
Read scope == write whitelist. Anything
/settings --localmay write is also read during resolution. If the read scope were ever narrower,/settings --local modes.review_mode=solowould write a value that/settingsaccepted and displayed and every skill then ignored. The test suite asserts the two lists stay in step — adding a setting to one without the other reproduces that bug.This matches the spec:
effects-map.md§ "Local Override Pattern" has always stated the chain applies to every setting. The narrow read scope was an implementation gap, not a design.
A key OUTSIDE the whitelist, hand-written into
project.local.yaml, is reported rather than swallowed./settings --localrefuses to write one, but the file exists to be edited by hand, so that path needs its own guard.modes.rigor: fullwritten there is a real key with a legal value, sovalidate_yaml_enumpasses it — and then resolution never consults the local file for that path, so the setting vanishes and the user sees the default they were trying to override, with no error anywhere. Every check in the chain validated the value; nothing validated the location.
validate_local_scopenow names such keys onnotes:. It warns and never reconciles, the same shape as the stage-mirror check: it does not edit the file, does not begin honouring the key, and does not change resolution. A locked setting is locked because it decides which artifacts exist on disk, so a helper that quietly applied one would let two developers' checkouts diverge — the exact thing the whitelist prevents. The user moves the key toproject.yamlor deletes it.
schema_versionandframework.*are exempt:effects-map.mdclasses them as file metadata rather than preferences, and aproject.local.yamlcarryingschema_version: 1is correct as written.
Defaults
| Knob | Default |
|---|---|
modes.automation |
collaborative |
modes.rigor |
minimal |
modes.automation_always_ask |
scope_changes, file_deletions, schema_changes |
The six knobs rigor fronts have no terminal default at all. modes.workflow,
docs.density, qa.level, modes.story_granularity, modes.review_mode and team.size are
deliberately absent from _yaml_helper_defaults: a default there would answer at step 5 before the
expansion at step 4 ever ran, making rigor a no-op for anyone who had not also
set the sub-knob. Their effective values come from the rigor level:
modes.rigor |
modes.workflow |
docs.density |
qa.level |
modes.story_granularity |
modes.review_mode |
team.size |
|---|---|---|---|---|---|---|
minimal (default) |
minimal |
terse |
minimal |
coarse |
solo |
individual |
standard |
standard |
balanced |
standard |
balanced |
lean |
individual |
full |
full |
thorough |
full |
fine |
full |
studio |
Because rigor itself defaults to minimal, an unconfigured project resolves
these six to the lean row. That default was standard; it changed because the
heavier tier measured several times more expensive to reach working code without
producing a better result. See the rationale block above _yaml_helper_defaults
in .claude/hooks/yaml-helper.sh for the reasoning and the ordering
constraint. Raising the tier is one question in /start or one /settings
call, and settings-guidance.md § 4's upward triggers are written to fire from
this starting state. (team.size is the one knob the flip does not move:
minimal and standard both yield individual, and only full opts into the
studio roster.)
The flip DOES reach existing projects. A
project.yamlthat sets some of the six explicitly but never setsrigorkeeps its explicit values and takes the newminimalrow for the rest -- so an unsetqa.levelthat used to resolvestandardnow resolvesminimal. Pin the old behaviour by settingmodes.rigor: standardexplicitly; see UPGRADING.md.
modes.workflow is fronted, not replaced: it keeps
workflow_overrides.system_overrides, which still beats the derived value.
Setting any of the six explicitly overrides just that one and leaves
its siblings on the rigor level, which is how "comprehensive but compact"
(rigor: full + docs.density: terse) stays expressible. Two of the six —
modes.review_mode and team.size — are personal-experience knobs that also
remain overridable from project.local.yaml (that source sits above the
expansion), unlike the four on-disk knobs, which are locked.
Keep this table in step with the helper. Every knob listed here must carry the value
_yaml_helper_defaultsreturns (read it throughget_yaml_default), and none of the six fronted knobs may appear in it.
testing.strict.* has no central default on purpose. Its unset default
differs per skill by design: /smoke-check treats unset as blocking (it is a
build-health gate), while /story-done and /dev-story apply the per-story-type
table in .claude/docs/coding-standards.md. resolve_config therefore reports
only the configured state (unset where absent) and each skill applies its own
default. The script resolves sources; the skill owns policy.
engine is reported from project.yaml only. No markdown reader is built.
Skills keep their existing per-field fallback to
.claude/docs/technical-preferences.md, which also remains the sole source for
forbidden patterns and allowed libraries — those never migrated.
Failure modes
resolve_config always exits 0 and always emits a complete block. Each
condition below degrades to defaults and is named on notes::
| Condition | Behavior |
|---|---|
project.yaml absent |
legacy → defaults; notes: project.yaml absent |
project.yaml empty |
defaults; notes: project.yaml is EMPTY. Distinct from absent on purpose — a truncated write or an interrupted /setup-engine looks like this |
| Malformed YAML | unparseable keys resolve empty and fall through |
| Tab-indented lines | rejected, and the line numbers are named on notes: |
| CRLF line endings | parsed correctly (the Windows default) |
| UTF-8 BOM | parsed correctly; the BOM does not poison the first key |
| 3-space indent | parsed correctly; the parser is indent-tolerant |
| Key present, value empty | treated as unset; falls through to the next source, never to "" |
| Enum-invalid value | chain continues; rejected value named in notes: |
project.local.yaml without a base |
validate_local_yaml_base message on notes: |
Locked key in project.local.yaml |
ignored as always, but now named on notes: — local: not locally overridable, ignored — modes.rigor (move to project.yaml or delete) |
| No Python interpreter | legacy files and defaults only; explicitly noted on notes: |
| No block at all | shell preprocessing disabled (disableSkillShellExecution) — use the defaults table above |
| Bootstrap line not approved, or exits non-zero | not a degraded state — the skill never renders at all. See "Why exactly this command" above |
A skill must never treat a missing block as "config is unset in an interesting way". It means the block did not render; the defaults apply.
Two behaviours that are correct but SILENT
In both cases the resolved value is right — the chain falls through to the default — but the user is told nothing, and their explicit configuration was discarded.
| Input | What happens | Note emitted |
|---|---|---|
Duplicate key (rigor: twice in one block) |
the last occurrence wins, deterministically | none |
Wrong shape — a list where a scalar belongs (rigor: followed by - standard) |
key resolves empty, falls through to the default | none |
Why this is worth knowing rather than shrugging at. An enum-invalid value is
announced (modes.workflow: 'medium' is not a valid value … — ignored, chain continued). A structurally-invalid one is not. So a user who mistypes the value
gets told, and a user who mistypes the structure does not — and the second
mistake is the easier one to make by copy-paste. The framework has the mechanism
(notes:) and already uses it for absence, emptiness, tabs, locked keys, orphan
locals and bad enums. Shape is the gap.
Why it is not caught yet. Detecting "this key is present in the file but resolved to nothing" means changing the parser every config read in the framework goes through, and the empty-value fall-through above is a legitimate case of exactly that signature. Distinguishing "empty on purpose" from "unparseable" is real work, not a one-liner, and it lands in the most load-bearing file there is. Until then, check the structure yourself when a setting seems ignored.
Duplicate-key precedence was undocumented before this entry. Last-wins is deterministic and reproducible, so it is not a bug — but nothing said so, which meant nobody could rely on it either.
Why not inject at session start
resolve_config is also safe to call from a hook, but session-start does not
call it. The block would cost tokens on every session whether or not any skill
needs config, and it would go stale mid-session as /settings writes land. The
per-skill body call is fresher and only loads where it is used. Session start
still prints the resolved review mode and any schema errors, which is the part a
human reads.
Testing
The resolution chain, the legacy reader, enum fall-through, block shape and
every failure mode above are covered by the framework's own test suite under
tests/, along with the modes.rigor expansion and its precedence in both
directions. Two properties are asserted that are easy to lose:
- The local read scope and the
/settings --localwrite whitelist stay in step. If they drift,/settingsaccepts and displays a value that every skill then ignores. - The expansion is reachable (
/startasks about it) and legible (/settingsreports derived values rather than "(not set)"). A correct expansion nobody is asked about and no view displays would be worth nothing.