Release 1.1.0 — one config file, and a process weight you choose

`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
This commit is contained in:
Donchitos
2026-09-23 12:25:11 +10:00
co-authored by Claude Opus 5
parent 984023ddac
commit d05699707f
291 changed files with 24374 additions and 7740 deletions
+262
View File
@@ -0,0 +1,262 @@
# 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.