mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
`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
263 lines
14 KiB
Markdown
263 lines
14 KiB
Markdown
# 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):
|
|
|
|
````markdown
|
|
!`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-tools` does **not** gate this. It governs tools the *model* calls
|
|
during the turn; preprocessing happens before that. A skill needs no `Bash`
|
|
grant to use `resolve_config`.
|
|
- The repo-wide setting `disableSkillShellExecution: true` disables 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.yaml` that
|
|
> already sets `docs.density: terse` resolves exactly as it did before `rigor`
|
|
> existed, because step 2 answers first. Invert steps 4 and 5 and `rigor` becomes
|
|
> 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 --local` may *write*
|
|
> is also *read* during resolution. If the read scope were ever narrower,
|
|
> `/settings --local modes.review_mode=solo` would write a value
|
|
> that `/settings` accepted 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 --local` refuses to
|
|
> write one, but the file exists to be edited by hand, so that path needs its own
|
|
> guard. `modes.rigor: full` written there is a real key with a legal value, so
|
|
> `validate_yaml_enum` passes 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_scope` now names such keys on `notes:`. 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 to
|
|
> `project.yaml` or deletes it.
|
|
>
|
|
> `schema_version` and `framework.*` are exempt: `effects-map.md` classes them as
|
|
> file metadata rather than preferences, and a `project.local.yaml` carrying
|
|
> `schema_version: 1` is 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.yaml` that sets some of
|
|
> the six explicitly but never sets `rigor` keeps its explicit values and takes
|
|
> the new `minimal` row for the rest -- so an unset `qa.level` that used to
|
|
> resolve `standard` now resolves `minimal`. Test **X.3** pins exactly this.
|
|
> Pin the old behaviour by setting `modes.rigor: standard` explicitly; 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_defaults` through `get_yaml_default` and 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 --local` **write** whitelist stay
|
|
in step. If they drift, `/settings` accepts and displays a value that every
|
|
skill then ignores.
|
|
- The expansion is *reachable* (`/start` asks about it) and *legible*
|
|
(`/settings` reports derived values rather than "(not set)"). A correct
|
|
expansion nobody is asked about and no view displays would be worth nothing.
|