mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
Fix release for 1.1.2: the 14 longest skills keep their later phases after a compaction, a question nobody answered is never taken as an answer, settings read the same from a subfolder, the commit check works for a project in a subfolder of a larger repository, every skill opens with a title, and the shipped allow rules no longer approve deleting a branch or writing files without asking. Details in CHANGELOG.md; upgrade steps in UPGRADING.md (v1.1.2 → v1.1.3). Refs #83 Refs #133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185en4t76fPn7315q4c3nko
296 lines
16 KiB
Markdown
296 lines
16 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
|
|
!`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:
|
|
|
|
````yaml
|
|
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. In default
|
|
mode, the bootstrap forms behave as follows:
|
|
|
|
| Bootstrap form | Repo root | Subdirectory | Bash-less agent preloading it |
|
|
|---|---|---|---|
|
|
| `source "${CLAUDE_PROJECT_DIR:-.}/…" && resolve_config` | 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_config` is one command. `yaml-helper.sh` dispatches only
|
|
`resolve_config` when executed, and finds the repo root from its own location.
|
|
- **The grant is required.** `allowed-tools` is 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.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.
|
|
>
|
|
> 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 costs more 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.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`.
|
|
> 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.
|
|
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_defaults` returns (read it through `get_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 |
|
|
| A task notice where the block should be ("Command did not complete within its 120s timeout and was moved to the background") | the settings line ran past Claude Code's 120-second limit for a skill's `!` line, because every process start was slowed — usually by antivirus real-time scanning of the project folder. The settings exist: read each key yourself from `project.local.yaml`, then `project.yaml`, then 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. The exception is
|
|
that task notice: the settings line ran out of time, not the settings, so read
|
|
the files as that row says instead of taking the defaults. If it keeps
|
|
happening, exclude the project folder from real-time scanning.
|
|
|
|
### Two inputs that resolve without a note
|
|
|
|
In both cases `resolve_config` follows a fixed rule and emits no note, so it does
|
|
not tell you that something you wrote was not used.
|
|
|
|
| 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** |
|
|
|
|
An enum-*invalid* value is announced (`modes.workflow: 'medium' is not a valid
|
|
value … — ignored, chain continued`). A wrong shape is not: the key is present
|
|
but resolves to nothing, which is also what a key with an empty value does on
|
|
purpose (the "Key present, value empty" row above), and `resolve_config` does not
|
|
tell the two apart. When a setting seems ignored, check the structure of its key
|
|
in the file: one scalar on the key's own line, the key written once per block,
|
|
and no list under it. `/settings` shows the value that is actually in effect.
|
|
|
|
The last-wins rule for a duplicate key is fixed, so you can rely on it.
|
|
|
|
## 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.
|