`project.yaml` at the repo root is now the single source of truth for a project's configuration, replacing `production/stage.txt`, `production/review-mode.txt` and `.claude/docs/technical-preferences.md` (all three still read as a fallback; nothing is force-deleted). **`modes.rigor` — one question instead of six.** `minimal`, `standard` or `full` fronts six knobs: `modes.workflow`, `docs.density`, `qa.level`, `modes.story_granularity`, `modes.review_mode` and `team.size`. It defaults to `minimal`, which is a change from 1.0's behaviour: the heavier tier was measured costing several times more to reach working code without producing a better result, and returning nothing when a fresh developer inherited the project. Pin `modes.rigor: standard` to keep 1.0's process level. Also in this release: - `/settings` to view or change any setting, and `project.local.yaml` for per-developer preferences that should not be committed. - `system_overrides` to hold one system to a higher standard than the rest of the project, and `testing.strict` to set per test type whether missing evidence blocks a story or warns. - A story that changes anything the player sees is not closed until the game has been launched, observed, and a screenshot retained in `production/qa/evidence/`. A parse check is not a run. This holds at every rigor level, including `minimal`, where automated tests are waived but the look is not. - The code root is engine-specific and enforced as such: Godot `src/`, Unity `Assets/`, Unreal `Source/<Module>/`. Two of the three will not build a project laid out any other way. - Migration tooling for existing projects: `.claude/scripts/migrate-v1-config.sh` converts a 1.0 layout, non-destructively by default, with a report to review before `--finalize` deletes anything. - Ask-before-write holds regardless of personal automation settings. Upgrading from 1.0 or earlier: see UPGRADING.md. Full list of changes: CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xvzG3i7pWQg4LjyDwWMXd
14 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):
!`source .claude/hooks/yaml-helper.sh 2>/dev/null && 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.
Two consequences:
allowed-toolsdoes not gate this. It governs tools the model calls during the turn; preprocessing happens before that. A skill needs noBashgrant to useresolve_config.- The repo-wide setting
disableSkillShellExecution: truedisables it. 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 (default)
story_granularity: balanced (default)
qa.level: standard (default)
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 (default) 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. Tests X.2 and X.3 assert both directions.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.) Test X.8 asserts the six stay defaultless.
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. Test X.3 pins exactly this. 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
(X.6). 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.
This table is asserted against the helper, not maintained by hand. Test X.10 reads
_yaml_helper_defaultsthroughget_yaml_defaultand checks both directions: every knob listed here must carry the value the helper actually returns, and none of the six fronted knobs may appear at all. It exists because a table can list knobs the helper has stopped defaulting and read as correct: the structural check pairs a knob with the word "default" on a single line, so a two-cell table row is invisible to it. Editing this table without editing the helper now fails the build.
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 — use the defaults table 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.
Deliberately not fixed in v1.1. 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. Recorded here as v1.2.
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.