Files
Claude-Code-Game-Studios/.claude/hooks/yaml-helper.sh
T
DonchitosandClaude Opus 5 d05699707f 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
2026-09-23 12:25:11 +10:00

1548 lines
63 KiB
Bash

#!/bin/bash
# Hook helper: yaml-helper.sh
# Purpose: Shared YAML reading helper for CCGS hooks and skill orchestrators.
# Cross-platform: Windows Git Bash compatible.
#
# Provides:
# get_yaml_key <file> <dotted.path>
# Prints the scalar value at <dotted.path> in <file>, or empty string if:
# - the file does not exist or is unreadable
# - the path does not exist in the YAML
# - the path resolves to a sub-map or array (caller wanted a scalar)
# - no Python interpreter is available
# Always exits 0 — callers can safely use it under `set -e`.
#
# get_yaml_array <file> <dotted.path>
# Prints array values one per line. Handles both inline [a, b, c]
# and block-dash forms (- a / - b on subsequent indented lines).
# Empty output if not an array, not found, or file missing.
#
# get_effective_yaml_key <dotted.path>
# get_effective_yaml_array <dotted.path>
# Convenience wrappers that read project.local.yaml first, then
# fall back to project.yaml. Per-leaf override (local wins) —
# sibling keys are read independently from their own files, so the
# effective config has deep-merge semantics across leaves.
#
# validate_yaml_enum <file>
# Validates known enum-typed keys in <file> against their allowed
# value sets (12 enums hardcoded below). Prints one error line per
# invalid value to stderr. Returns 1 if any invalid value found,
# 0 otherwise. Unknown-key (typo) detection is a future sub-phase —
# enum errors are the critical-path block per the spec.
#
# validate_local_yaml_base
# Hard-error guard per spec: project.local.yaml requires a project.yaml
# base. Returns 1 with error on stderr if local exists without base.
# Uses CWD-relative paths.
#
# is_locally_overridable <dotted.path>
# Whitelist check for the /settings --local flag. Returns 0 if the
# setting is on the personal-experience whitelist (12 settings per
# effects-map.md), 1 otherwise. Locked settings cannot be locally
# overridden because they affect on-disk artifacts.
#
# validate_enum_value <dotted.path> <value>
# Single-key version of validate_yaml_enum — for /settings to check
# a pending write before committing it to disk. Returns 0 if the
# key has no enum constraint OR the value is in the enum set.
# Returns 1 (with error on stderr) if the key is enum-typed and the
# value is out of range. Skills call this before any Write/Edit.
#
# is_always_ask_category <category>
# Membership check on modes.automation_always_ask. Returns 0 if the
# category is in the configured list (or in the default list when
# unset), 1 otherwise. Skills use this in autonomous mode to know
# when to prompt anyway. Default list: scope_changes, file_deletions,
# schema_changes.
#
# log_decision <skill> <point> <options> <chosen> <reason> <category>
# Append a decision-log entry to production/session-logs/decision-log.md.
# Creates the file and directory if absent. Used by skills in
# autonomous mode to record decisions that would otherwise have
# been AskUserQuestion prompts.
#
# Python fallback chain: python -> python3 -> py
# Parses a YAML subset: nested maps + scalar leaves + arrays + comments
# + quoted strings. PyYAML is NOT required.
#
# Usage:
# source .claude/hooks/yaml-helper.sh
# review_mode=$(get_yaml_key "$_YH_ROOT/project.yaml" modes.review_mode)
# if [ -z "$review_mode" ]; then
# # fall back to legacy .txt
# review_mode=$(cat production/review-mode.txt 2>/dev/null || echo lean)
# fi
# Resolve a working Python interpreter once per shell, cache the result.
_yaml_helper_python=""
_yaml_helper_resolve_python() {
if [ -n "$_yaml_helper_python" ]; then return 0; fi
for candidate in python python3 py; do
if command -v "$candidate" >/dev/null 2>&1; then
# Require Python 3 — the parser uses py3-only open(encoding=) kwargs.
if "$candidate" -c "import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)" >/dev/null 2>&1; then
_yaml_helper_python="$candidate"
return 0
fi
fi
done
return 1
}
get_yaml_key() {
local file="$1"
local path="$2"
if [ -z "$file" ] || [ -z "$path" ]; then return 0; fi
if [ ! -f "$file" ]; then return 0; fi
if ! _yaml_helper_resolve_python; then
echo "yaml-helper: no python interpreter found (tried python, python3, py)" >&2
return 0
fi
"$_yaml_helper_python" - "$file" "$path" <<'PYEOF'
import sys, re
path_file, dotted = sys.argv[1], sys.argv[2]
keys = dotted.split('.')
def parse(lines):
root = {}
stack = [(-1, root)] # (indent, mapping)
for raw in lines:
line = raw.rstrip('\n').rstrip('\r')
stripped = line.lstrip(' ')
if not stripped.strip() or stripped.lstrip().startswith('#'):
continue
indent = len(line) - len(stripped)
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
return root
parent = stack[-1][1]
m = re.match(r'([^:#\s][^:]*?)\s*:\s*(.*)$', stripped)
if not m:
continue
key = m.group(1).strip()
value = m.group(2).strip()
# Resolve the scalar: handle comment-only, quoted, and inline-comment forms.
if value.startswith('#'):
value = ''
elif value[:1] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end >= 0:
value = value[1:end] # quoted content; any trailing comment ignored
else:
value = value[1:] # unterminated quote — take the remainder
else:
hash_pos = value.find(' #')
if hash_pos >= 0:
value = value[:hash_pos].strip()
if value == '':
new_map = {}
parent[key] = new_map
stack.append((indent, new_map))
else:
parent[key] = value
return root
try:
with open(path_file, 'r', encoding='utf-8-sig', errors='replace') as f:
data = parse(f.readlines())
except OSError:
sys.exit(0)
cur = data
for k in keys:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
sys.exit(0)
if isinstance(cur, dict):
sys.exit(0)
# Write raw UTF-8 bytes — bypasses the platform stdout encoding (cp1252 on
# Windows) so unicode values round-trip intact.
sys.stdout.buffer.write(str(cur).encode('utf-8'))
PYEOF
}
# -----------------------------------------------------------------------------
# Array reads, effective (deep-merged) reads, enum validation
# -----------------------------------------------------------------------------
get_yaml_array() {
local file="$1"
local path="$2"
if [ -z "$file" ] || [ -z "$path" ]; then return 0; fi
if [ ! -f "$file" ]; then return 0; fi
if ! _yaml_helper_resolve_python; then
echo "yaml-helper: no python interpreter found (tried python, python3, py)" >&2
return 0
fi
"$_yaml_helper_python" - "$file" "$path" <<'PYEOF'
import sys, re
path_file, dotted = sys.argv[1], sys.argv[2]
keys = dotted.split('.')
# Parser extended to recognize two YAML array forms:
# key: [a, b, c] (inline)
# key: (block-dash)
# - a
# - b
# Block-dash detection peeks ahead at indented child lines after a key
# whose own value is empty; if those children start with "- ", they form
# an array, otherwise they form a sub-map (existing behavior).
def parse(lines):
root = {}
stack = [(-1, root)]
i = 0
while i < len(lines):
raw = lines[i]
line = raw.rstrip('\n').rstrip('\r')
stripped = line.lstrip(' ')
if not stripped.strip() or stripped.lstrip().startswith('#'):
i += 1
continue
indent = len(line) - len(stripped)
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
return root
parent = stack[-1][1]
m = re.match(r'([^:#\s][^:]*?)\s*:\s*(.*)$', stripped)
if not m:
i += 1
continue
key = m.group(1).strip()
value = m.group(2).strip()
if value.startswith('#'):
value = ''
elif value[:1] == '[':
# Inline array, scanned with QUOTE AWARENESS.
#
# A comma or a closing bracket inside a quoted element is DATA, not
# a delimiter. Splitting on every comma turns one quoted entry that
# contains commas into several bogus ones -- and because each shard
# is a plausible-looking string, nothing downstream can tell that it
# happened. Values written from hand-authored prose hit this
# routinely.
parts = []
buf = ''
quote = None
idx = 1
while idx < len(value):
ch = value[idx]
if quote is not None:
if ch == quote:
quote = None
else:
buf += ch
elif ch in ('"', "'"):
quote = ch
elif ch == ']':
break
elif ch == ',':
parts.append(buf.strip())
buf = ''
else:
buf += ch
idx += 1
if buf.strip():
parts.append(buf.strip())
parent[key] = [p for p in parts if p != '']
i += 1
continue
elif value[:1] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end >= 0:
value = value[1:end]
else:
value = value[1:]
else:
hash_pos = value.find(' #')
if hash_pos >= 0:
value = value[:hash_pos].strip()
if value == '':
# Peek: block-dash list or sub-map?
j = i + 1
items = []
while j < len(lines):
pl = lines[j].rstrip('\n').rstrip('\r')
pstripped = pl.lstrip(' ')
if not pstripped.strip() or pstripped.lstrip().startswith('#'):
j += 1
continue
pindent = len(pl) - len(pstripped)
if pindent <= indent:
break
if pstripped.startswith('- '):
# INDENT IS NOT ENFORCED between items, deliberately.
#
# Requiring every item to sit at the first item's indent
# made ONE misindented entry end the list there and return
# the prefix, silently: three configured always-ask
# categories came back as one. `is_always_ask_category`
# compares exactly, so the dropped entries lost their
# prompt with nothing written anywhere -- and a partial
# list is non-empty, so the caller's "fall back to the
# defaults when unset" branch does not fire either. It
# failed toward LESS confirmation, which is the wrong
# direction for the setting that decides when to ask.
#
# Safe because this schema holds only flat scalar lists:
# any deeper `- ` line under the key is an item of it. A
# non-dash line still ends the list via the break below,
# so a sub-map after a list is unaffected.
item = pstripped[2:].strip()
if item[:1] in ('"', "'") and item[-1:] == item[:1] and len(item) >= 2:
item = item[1:-1]
else:
# Unquoted items take the same trailing-comment rule
# scalars take. Without it `- scope_changes # ask`
# parsed as the literal string INCLUDING the comment
# and matched no category -- the same silent loss as
# above, reached a different way. Quoted items are
# left alone: a `#` inside quotes is data.
hash_pos = item.find(' #')
if hash_pos >= 0:
item = item[:hash_pos].strip()
items.append(item)
j += 1
continue
break
if items:
parent[key] = items
i = j
continue
else:
new_map = {}
parent[key] = new_map
stack.append((indent, new_map))
else:
parent[key] = value
i += 1
return root
try:
with open(path_file, 'r', encoding='utf-8-sig', errors='replace') as f:
data = parse(f.readlines())
except OSError:
sys.exit(0)
cur = data
for k in keys:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
sys.exit(0)
if not isinstance(cur, list):
sys.exit(0)
for item in cur:
sys.stdout.buffer.write((str(item) + '\n').encode('utf-8'))
PYEOF
}
# Guard: project.local.yaml requires a project.yaml base.
# Per spec (effects-map.md "Creation and error handling"), the local file
# has no meaning without a base — it only overrides values from project.yaml.
# Prints a hard-error message to stderr and returns 1 if the orphan case
# is detected. Returns 0 otherwise (including when neither file exists).
# Uses CWD-relative paths — callers cd into the project root first.
# --- project root resolution -------------------------------------------------
#
# Config paths must NOT be resolved against the bare current working directory.
# Run a skill or hook from a subdirectory that way and project.yaml is
# unfindable, so EVERY setting resolves to empty -- silently, because the guard
# that reports a missing base cannot find its files either. From the repo root
# `modes.rigor` returns `full`; from `src/` it would return nothing.
#
# Being loadable from anywhere is only half the problem: the helper must also be
# able to do its job from anywhere. Fixing one without the other leaves it
# sourceable everywhere and functional only at the root.
#
# PRECEDENCE IS LOAD-BEARING, in this order:
# 1. cwd holds project.yaml -> cwd
# 2. cwd holds project.local.yaml -> cwd
# 3. CLAUDE_PROJECT_DIR has a base -> that directory
# 4. nothing found -> cwd (unchanged behaviour)
#
# AN UPWARD WALK WAS IMPLEMENTED AND REMOVED, and the reason is worth keeping.
# The intent was a fallback needing no environment variable: climb until an
# ancestor has project.yaml. It resolves the wrong tree. A project that has a
# legacy production/review-mode.txt and no project.yaml, sitting anywhere below
# another project, reads the OUTER project's project.local.yaml one directory
# up instead of its own legacy file -- the wrong answer, returned silently.
#
# That is not a test-harness artifact. Any real project nested inside another
# repo that happens to carry a project.yaml would silently inherit the outer
# project's settings, which is the same silent-wrong-answer class this whole fix
# exists to remove. A fallback that guesses is worse than one that is absent:
# rule 4 leaves an unconfigured directory reading its own legacy files, which is
# exactly what a project without project.yaml should do.
#
# CLAUDE_PROJECT_DIR is populated in both the hook environment and the
# skill-bootstrap environment, so rule 3 covers every way this framework
# actually invokes the helper.
#
# 1 and 2 come FIRST deliberately. A project nested inside another one must read
# ITSELF, and an ambient CLAUDE_PROJECT_DIR would otherwise point it at the
# outer tree. Rule 2 covers the case that is easiest to lose: a directory with a
# project.local.yaml and deliberately NO base must still raise the hard error
# for a missing base. Without it, an upward walk finds an ancestor's
# project.yaml and that error silently stops firing.
#
# Sets a variable rather than echoing a path: a $( ) per lookup would add a
# subshell to every one of validate_yaml_enum's 23 calls, and per-lookup cost of
# that kind is what pushes a hook past its 10s timeout.
_yaml_helper_set_root() {
_YH_ROOT="."
[ -f "$_YH_ROOT/project.yaml" ] && return 0
[ -f "$_YH_ROOT/project.local.yaml" ] && return 0
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -f "$CLAUDE_PROJECT_DIR/project.yaml" ]; then
_YH_ROOT="$CLAUDE_PROJECT_DIR"; return 0
fi
return 0
}
validate_local_yaml_base() {
_yaml_helper_set_root
if [ -f "$_YH_ROOT/project.local.yaml" ] && [ ! -f "$_YH_ROOT/project.yaml" ]; then
echo "project.local.yaml exists but project.yaml is missing — run /start to create one. Local overrides require a base." >&2
return 1
fi
return 0
}
# Per-leaf deep-merge: read project.local.yaml first (if present),
# fall back to project.yaml. Each leaf is resolved independently from
# its own file, so sibling keys at the same nesting level naturally
# come through from project.yaml when project.local.yaml only overrides
# specific leaves.
get_effective_yaml_key() {
_yaml_helper_set_root
local path="$1"
if [ -z "$path" ]; then return 0; fi
local val=""
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
val=$(get_yaml_key "$_YH_ROOT/project.local.yaml" "$path")
fi
if [ -z "$val" ] && [ -f "$_YH_ROOT/project.yaml" ]; then
val=$(get_yaml_key "$_YH_ROOT/project.yaml" "$path")
fi
printf '%s' "$val"
}
get_effective_yaml_array() {
_yaml_helper_set_root
local path="$1"
if [ -z "$path" ]; then return 0; fi
local val=""
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
val=$(get_yaml_array "$_YH_ROOT/project.local.yaml" "$path")
fi
if [ -z "$val" ] && [ -f "$_YH_ROOT/project.yaml" ]; then
val=$(get_yaml_array "$_YH_ROOT/project.yaml" "$path")
fi
printf '%s' "$val"
}
# Enum constants — the 12 settings whose values are constrained to a
# fixed set per effects-map.md. Format: dotted.path::value1|value2|...
# Validation: walk this list; for each key present in the file, verify
# value is in the allowed set. Out-of-set values are hard errors per
# spec (effects-map.md line 145-153).
_yaml_helper_enums="\
modes.review_mode::full|lean|solo
modes.rigor::minimal|standard|full
modes.workflow::minimal|standard|full
modes.automation::collaborative|guided|autonomous
modes.story_granularity::coarse|balanced|fine
docs.density::terse|balanced|thorough
qa.level::minimal|standard|full
team.size::individual|small|studio
performance.enforce::warn|block|off
platform.cert_tier::none|itch|steam|console
accessibility.target::none|standard|aaa
engine.name::Godot|Unity|Unreal
project.stage::Concept|Systems Design|Technical Setup|Pre-Production|Production|Polish|Release
testing.strict.logic::true|false
testing.strict.integration::true|false
testing.strict.visual::true|false
testing.strict.ui::true|false
testing.strict.config::true|false
features.session_state::on|off
platform.online::true|false
workflow_overrides.edge_cases::true|false
workflow_overrides.tuning_knobs::true|false
workflow_overrides.art_bible_strict::true|false"
# The boolean keys above are enumerated for the same reason the string keys are:
# `resolve_setting` validates every hop and DROPS a value that fails, so a key
# absent from this table accepts anything. Before they were listed,
# `testing.strict.logic: maybe` in project.local.yaml passed validation and won
# over an explicit `true` in project.yaml -- and `/story-done` treats the gate as
# BLOCKING only when the value is `true`, so a typo silently downgraded a
# blocking test gate to advisory with no error. `performance.enforce` was
# already enumerated and correctly rejected garbage, which is exactly the
# asymmetry that hides this. Any NEW boolean setting must be added here too.
#
# Two keys deliberately NOT listed as `true|false`, both of which the config
# test suite catches if they are -- add nothing here without checking it:
# - `features.session_state` is an ON/OFF enum, not a boolean. It is listed
# above as `on|off` because `session_state_enabled()` disables only on the
# literal `off`, and effects-map documents `on`/`off`. Writing `false` there
# means ENABLED, which is why validating it matters.
# - `testing.strict` (the parent) is absent because its primary shape is a MAP
# of the five type keys; the bare scalar is only a back-compat form. Its
# effects-map `**Values:**` line documents the map, so enumerating the parent
# as a scalar would force that line to misdescribe the real shape. The five
# leaves below carry the validation, which is where /story-done reads.
#
# validate_local_scope [<file>]
# The location check the value checks above cannot do: names keys in
# project.local.yaml that are NOT locally overridable, which resolution
# silently ignores. One line per key on stderr; returns 1 if any found.
# Warns, never reconciles. Defined below is_locally_overridable.
# Whitelist of locally-overridable settings (per effects-map.md
# "Whitelist — which settings can be locally overridden"). Personal-experience
# settings only — divergence between developers is safe because they don't
# affect what artifacts exist on disk.
_yaml_helper_locally_overridable="\
modes.review_mode
modes.automation
modes.automation_always_ask
team.size
testing.strict.logic
testing.strict.integration
testing.strict.visual
testing.strict.ui
testing.strict.config
performance.enforce
features.session_state
features.token_budget_warn_at"
is_locally_overridable() {
local path="$1"
[ -z "$path" ] && return 1
local whitelisted
while IFS= read -r whitelisted; do
[ -z "$whitelisted" ] && continue
if [ "$path" = "$whitelisted" ]; then return 0; fi
done <<EOF
$_yaml_helper_locally_overridable
EOF
return 1
}
# validate_local_scope [<file>]
# Names the keys in project.local.yaml that resolution will NEVER read,
# because they are not on the whitelist above. One line per key on stderr;
# returns 1 if any were found, 0 otherwise.
#
# Why this needs its own check, separate from validate_yaml_enum: that
# function validates VALUES, and a locked key hand-written into the local
# file has a perfectly legal one. `modes.rigor: strict` there passes every
# validation in the chain — correct key, correct value — and then
# resolve_setting never consults the local file for that path, so the
# setting is discarded in total silence and the user sees the default they
# were trying to override. Nothing else checks the key's LOCATION.
# /settings --local already refuses these; only the hand-edited file is
# unguarded, and hand-editing is what the file is for.
#
# WARNS, NEVER RECONCILES — the same shape as the stage-mirror check.
# It does not edit project.local.yaml, does not begin honouring
# the key, and does not alter resolution. A locked setting is locked
# because it changes which artifacts exist on disk, so a helper that
# "helpfully" applied one would let two developers' checkouts diverge —
# precisely what the whitelist exists to prevent. The fix belongs to the
# user: move the key to project.yaml, or delete it.
#
# The parser below is a third copy of the one in get_yaml_key and
# validate_yaml_enum. That duplication is deliberate and matches the
# established shape of this file: these helpers are sourced individually
# and must agree on YAML edge cases exactly, and a shared copy that drifted
# would make two functions disagree about what a file says.
validate_local_scope() {
local file="${1:-project.local.yaml}"
[ -f "$file" ] || return 0
if ! _yaml_helper_resolve_python; then return 0; fi
local found key rc=0
found=$("$_yaml_helper_python" - "$file" "$_yaml_helper_locally_overridable" <<'PYEOF'
import sys, re
path_file, allowed_table = sys.argv[1], sys.argv[2]
allowed = set(l.strip() for l in allowed_table.splitlines() if l.strip())
# File metadata, not settings -- effects-map.md lists `schema_version` and
# `framework.*` under "Locked to project.yaml" with the reason "File metadata,
# not preferences". They are not preferences, so "you cannot override this
# preference locally" is the wrong complaint about them: nothing was dropped
# and nothing needs moving. This repo's own project.local.yaml carries
# `schema_version: 1`, which is how the false positive was caught -- the check
# was written against a hand-made fixture and only run against the real file
# afterwards, where it immediately flagged a correct file.
EXEMPT_EXACT = {'schema_version'}
EXEMPT_PREFIX = ('framework.',)
def parse(lines):
root = {}
stack = [(-1, root)]
for raw in lines:
line = raw.rstrip('\n').rstrip('\r')
stripped = line.lstrip(' ')
if not stripped.strip() or stripped.lstrip().startswith('#'):
continue
indent = len(line) - len(stripped)
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
return root
parent = stack[-1][1]
m = re.match(r'([^:#\s][^:]*?)\s*:\s*(.*)$', stripped)
if not m:
continue
key = m.group(1).strip()
value = m.group(2).strip()
if value.startswith('#'):
value = ''
elif value[:1] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
value = value[1:end] if end >= 0 else value[1:]
else:
hash_pos = value.find(' #')
if hash_pos >= 0:
value = value[:hash_pos].strip()
if value == '':
new_map = {}
parent[key] = new_map
stack.append((indent, new_map))
else:
parent[key] = value
return root
try:
with open(path_file, 'r', encoding='utf-8-sig', errors='replace') as f:
data = parse(f.readlines())
except OSError:
sys.exit(0)
# An EMPTY map counts as a leaf. Array-valued keys parse that way -- the
# `- item` lines carry no colon and are skipped -- so treating an empty map as
# a branch would silently exempt every list-valued setting from this check.
def walk(node, prefix):
for k, v in node.items():
dotted = prefix + k if not prefix else prefix + '.' + k
if isinstance(v, dict) and v:
walk(v, dotted)
elif dotted in allowed or dotted in EXEMPT_EXACT:
continue
elif dotted.startswith(EXEMPT_PREFIX):
continue
else:
print(dotted)
walk(data, '')
PYEOF
)
[ -n "$found" ] || return 0
while IFS= read -r key; do
[ -z "$key" ] && continue
echo "$file: \`$key\` is not locally overridable — ignored, not applied. Move it to project.yaml (or delete it); see the whitelist in yaml-helper.sh." >&2
rc=1
done <<EOF
$found
EOF
return "$rc"
}
# session_state_enabled
# Returns 0 when the session-state pipeline should run, 1 when the user has
# turned it off with `features.session_state: off`. Honours the local
# override (the key is on the whitelist above), local winning over base.
#
# DEFAULT IS `on`. A default of `off` would contradict practice: every project
# runs with the pipeline active --
# and `production/session-state/active.md` is the documented recovery
# checkpoint that `.claude/docs/context-management.md` tells users to rely on.
# Shipping `off` as the default would have silently removed crash recovery,
# the session archive and the subagent spawn tally from every existing
# project. Users who want the ~2-5k tokens per session opt out explicitly.
#
# Deliberately awk, not get_effective_yaml_key: this runs in log-agent.sh on
# EVERY subagent spawn, and get_effective_yaml_key shells out to Python twice.
# Spending 200ms of interpreter startup per spawn to check a token-saving flag
# would be self-defeating.
session_state_enabled() {
local f v
for f in project.local.yaml project.yaml; do
[ -f "$f" ] || continue
v=$(awk '
/^features:[[:space:]]*$/ { inf=1; next }
/^[^[:space:]#]/ { inf=0 }
inf && /^[[:space:]]+session_state:/ {
sub(/^[[:space:]]*session_state:[[:space:]]*/, "")
sub(/[[:space:]]*#.*$/, "")
gsub(/["'"'"']/, "")
print; exit
}
' "$f" 2>/dev/null | tr -d '\r' | tr -d ' ')
if [ -n "$v" ]; then
[ "$v" = "off" ] && return 1
return 0
fi
done
return 0 # unset anywhere => on
}
validate_yaml_enum() {
local file="$1"
if [ -z "$file" ] || [ ! -f "$file" ]; then return 0; fi
if ! _yaml_helper_resolve_python; then
echo "yaml-helper: no python interpreter found (tried python, python3, py)" >&2
return 0
fi
# ONE interpreter spawn for the whole enum table.
#
# Calling `get_yaml_key` once per enum -- 23 calls -- spawns a Python
# interpreter each time (see get_yaml_key). At ~214ms of process startup on
# Windows that is ~4.9s per config file, and with project.yaml plus
# project.local.yaml it puts session-start.sh over its 10s hook timeout,
# killing it mid-write so the recovery checkpoint never reaches context.
#
# A bash pre-filter that skips lookups for keys ABSENT from the file is
# correct but insufficient, and the distinction is the lesson: it only removes
# spawns for keys you do not have. A sparse config skips nearly all of them
# and looks like a 35x win. A fully configured project sets all of them, every
# lookup survives the filter, and the cost returns in full -- ~4683ms filtered
# versus ~4576ms unfiltered, i.e. no improvement on the population that
# matters.
# The count of spawns was never the invariant worth fixing; the cost of one
# was. Parsing the document once and answering every enum from that parse is
# constant in the number of keys, so a denser config no longer costs more.
#
# Behaviour is unchanged by construction: the parser below is the same one
# get_yaml_key uses, the enum table is walked in the same order, absent keys
# are skipped exactly as `[ -z "$actual" ] && continue` did, and the error
# text is byte-identical.
"$_yaml_helper_python" - "$file" "$_yaml_helper_enums" <<'PYEOF'
import sys, re
path_file, enum_table = sys.argv[1], sys.argv[2]
def parse(lines):
root = {}
stack = [(-1, root)] # (indent, mapping)
for raw in lines:
line = raw.rstrip('\n').rstrip('\r')
stripped = line.lstrip(' ')
if not stripped.strip() or stripped.lstrip().startswith('#'):
continue
indent = len(line) - len(stripped)
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
return root
parent = stack[-1][1]
m = re.match(r'([^:#\s][^:]*?)\s*:\s*(.*)$', stripped)
if not m:
continue
key = m.group(1).strip()
value = m.group(2).strip()
if value.startswith('#'):
value = ''
elif value[:1] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end >= 0:
value = value[1:end]
else:
value = value[1:]
else:
hash_pos = value.find(' #')
if hash_pos >= 0:
value = value[:hash_pos].strip()
if value == '':
new_map = {}
parent[key] = new_map
stack.append((indent, new_map))
else:
parent[key] = value
return root
try:
with open(path_file, 'r', encoding='utf-8-sig', errors='replace') as f:
raw_lines = f.readlines()
except OSError:
sys.exit(0)
data = parse(raw_lines)
# A TAB IN THE INDENTATION IS AN ERROR, not something to normalise away.
#
# The parser stripped leading SPACES only, so a tab-indented line never matched
# the key regex and vanished -- the setting then silently resolved to its
# default. Every enum check below passed on such a file, because a key that
# cannot be read cannot hold an invalid value. That is rc=0 on a config that
# configures nothing, byte-identical in outcome to a genuinely clean file.
# Measured before this check: the same file with tabs instead of spaces
# reported no errors while `modes.review_mode: BOGUS_VALUE` sat in it unread.
#
# This is the validator's own version of the rule the skills are held to --
# absence of evidence is not evidence of absence. It cannot report "no invalid
# values" when the real answer is "no values".
#
# YAML forbids tabs for indentation, so reporting rather than repairing is
# correct; doing neither was the defect. Comment lines are exempt: a tab in
# front of a `#` hides nothing.
tab_lines = []
for _n, _raw in enumerate(raw_lines, 1):
_line = _raw.rstrip('\n').rstrip('\r')
if not _line.strip() or _line.lstrip(' \t').startswith('#'):
continue
_lead = _line[:len(_line) - len(_line.lstrip(' \t'))]
if '\t' in _lead:
tab_lines.append(_n)
def lookup(dotted):
cur = data
for k in dotted.split('.'):
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
return ''
if isinstance(cur, dict):
return ''
return str(cur)
errors = 0
out = []
if tab_lines:
_shown = ', '.join(str(n) for n in tab_lines[:5])
if len(tab_lines) > 5:
_shown += ' (and %d more)' % (len(tab_lines) - 5)
out.append("line %s: indented with a TAB. YAML forbids tabs for indentation, "
"so these lines are invisible to the config parser and their "
"settings silently fall back to defaults. Replace leading tabs "
"with spaces." % _shown)
errors += 1
for line in enum_table.splitlines():
line = line.strip()
if not line or '::' not in line:
continue
enum_path, _, enum_values = line.partition('::')
actual = lookup(enum_path)
if actual == '':
continue
if actual not in enum_values.split('|'):
out.append("%s: '%s' is not a valid value (expected: %s)"
% (enum_path, actual, enum_values))
errors += 1
if out:
sys.stderr.buffer.write(('\n'.join(out) + '\n').encode('utf-8'))
sys.exit(1 if errors else 0)
PYEOF
}
# Validate a single pending value for /settings writes. Looks up
# the key in _yaml_helper_enums; if found, checks value membership. If
# the key has no enum constraint, returns 0 (no validation applies).
# Returns 1 with stderr error on invalid value.
validate_enum_value() {
local key="$1"
local value="$2"
[ -z "$key" ] && return 0
local line enum_path enum_values v
while IFS= read -r line; do
[ -z "$line" ] && continue
enum_path="${line%::*}"
enum_values="${line##*::}"
if [ "$key" = "$enum_path" ]; then
local oldIFS="$IFS"
IFS='|'
for v in $enum_values; do
if [ "$value" = "$v" ]; then IFS="$oldIFS"; return 0; fi
done
IFS="$oldIFS"
echo "Invalid value '$value' for '$key'. Allowed: $enum_values" >&2
return 1
fi
done <<EOF
$_yaml_helper_enums
EOF
# Key has no enum constraint — caller decides what to do.
return 0
}
# automation_always_ask category check.
# Returns 0 if the named category is in modes.automation_always_ask (or in
# the default list when unset), 1 otherwise. Used by skills in autonomous
# mode to know which decisions still warrant a prompt.
# Default list (when modes.automation_always_ask absent from both files):
# scope_changes, file_deletions, schema_changes
_yaml_helper_always_ask_default="scope_changes
file_deletions
schema_changes"
is_always_ask_category() {
local category="$1"
[ -z "$category" ] && return 1
local configured
configured=$(get_effective_yaml_array modes.automation_always_ask)
if [ -z "$configured" ]; then
configured="$_yaml_helper_always_ask_default"
fi
local item
while IFS= read -r item; do
[ -z "$item" ] && continue
if [ "$item" = "$category" ]; then return 0; fi
done <<EOF
$configured
EOF
return 1
}
# Append a decision-log entry. Format per effects-map.md
# "Decision log format (autonomous mode)". Creates the file and parent
# directory if absent. Uses ISO 8601 UTC timestamps.
log_decision() {
_yaml_helper_set_root
local skill="$1"
local point="$2"
local options="$3"
local chosen="$4"
local reason="$5"
local category="$6"
local logfile="$_YH_ROOT/production/session-logs/decision-log.md"
mkdir -p "$(dirname "$logfile")"
if [ ! -f "$logfile" ]; then
printf '# Decision Log\n\nAppend-only audit trail of decisions made in autonomous mode.\n' > "$logfile"
fi
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u)
cat >> "$logfile" <<EOF
## $timestamp — $skill
**Decision point:** $point
**Options considered:** $options
**Chosen:** $chosen
**Reason:** $reason
**Category:** $category
EOF
}
# =============================================================================
# Whole-config resolution.
#
# WHY THIS EXISTS
# ~51 SKILL.md files each carried an English description of a deterministic
# fallback chain ("1. If --review passed -> use that; 2. Else read
# modes.review_mode from project.yaml; 3. Else production/review-mode.txt;
# 4. Else lean"), re-interpreted by a model on every invocation at roughly
# +1,360 tokens per skill. resolve_config computes the same answer once, in
# ~150 tokens, deterministically -- and testably, which prose never was.
#
# CONTRACT BOUNDARY
# This layer resolves SOURCES. It does not own per-skill policy. Anything
# whose default legitimately differs between skills (testing.strict.*) is
# reported as configured-or-unset and left to the skill to default.
# =============================================================================
# Terminal defaults. ONLY knobs whose default is uniform across every consumer.
#
# testing.strict.* is deliberately ABSENT: /smoke-check defaults it to blocking
# (build-health gate) while /story-done and /dev-story default per story type.
# Emitting one value here would silently pick a winner between them.
#
# The six knobs `rigor` fronts (modes.workflow, docs.density, qa.level,
# modes.story_granularity, modes.review_mode, team.size) are ALSO absent: their
# value comes from the rigor expansion below, which sits between the legacy step
# and this table. Leaving a terminal default here as well would shadow the
# expansion and make `rigor` a no-op for anyone who had not also set the sub-knob.
# modes.review_mode is fronted by rigor so `rigor: minimal` resolves
# review_mode to `solo` (skipping the ~44k-token director/specialist gates),
# `standard` yields `lean` and `full` yields `full`. team.size joined the fronted
# set the same way: `full` yields `studio` (the whole roster) while
# `minimal`/`standard` yield `individual`. Both stay locally overridable (they
# are personal-experience knobs, not on-disk artifacts): that source sits ABOVE
# the expansion, so only the terminal fallback moved.
#
# --- WHY modes.rigor DEFAULTS TO `minimal` -----------------------------------
#
# It defaulted to `standard`. Building the same project both ways showed the
# heavier tier costing several times as much to reach working code without
# producing a better result, and giving nothing back when a fresh developer
# picked the project up. A default that costs more and does not repay is the
# wrong default, and it was what every user who never opened /settings received.
#
# Raising rigor stays one question in /start and one `/settings` call, and
# settings-guidance.md's upward triggers ("system-GDD count crosses ~9",
# "gate-check PASS into Production while rigor is minimal") are written to fire
# from exactly this starting state. The cost of under-running is one prompt.
#
# ORDER MATTERS: this default is only safe while /gate-check keeps its floors.
# `rigor: minimal` expands to `workflow: minimal` + `qa.level: minimal`, which
# together once left the Production->Polish gate with ZERO required artifacts --
# a vacuous PASS. That is fixed (the smoke-report floor and the "nothing
# required -> NOT ASSESSED, never PASS" rule). Do not move this default again
# without re-checking that the gates below it still require something.
_yaml_helper_defaults="\
modes.automation::collaborative
modes.rigor::minimal
performance.enforce::warn"
# Rigor expansion — one asked-at-/start knob that supplies four.
#
# WHY: /start has never asked about workflow, docs.density, qa.level or
# story_granularity, so in practice every project ran all four at their defaults.
# Between them the latter three drive ONE behaviour each (prose verbosity, is-
# evidence-required, story size) restated across 19 skills with 17 "we are
# orthogonal" disclaimers. `rigor` makes the common case reachable in one
# question while each knob stays individually settable.
#
# modes.workflow is fronted, NOT replaced: it drives ~5 distinct behaviours and
# owns the only per-system override mechanism (workflow_overrides.system_
# overrides), which continues to win over the rigor-derived value.
_yaml_helper_rigor_expansion="\
minimal::modes.workflow=minimal,docs.density=terse,qa.level=minimal,modes.story_granularity=coarse,modes.review_mode=solo,team.size=individual
standard::modes.workflow=standard,docs.density=balanced,qa.level=standard,modes.story_granularity=balanced,modes.review_mode=lean,team.size=individual
full::modes.workflow=full,docs.density=thorough,qa.level=full,modes.story_granularity=fine,modes.review_mode=full,team.size=studio"
# Value for <path> implied by the project's rigor level, or '' if rigor does not
# front that key. Never consulted for modes.rigor itself — that would recurse.
_yaml_helper_rigor_value() {
_yaml_helper_set_root
local path="$1" level="" line pair
[ -z "$path" ] && return 0
[ "$path" = "modes.rigor" ] && return 0
# Resolve rigor WITHOUT resolve_setting, to keep the recursion impossible.
[ -f "$_YH_ROOT/project.yaml" ] && level=$(get_yaml_key "$_YH_ROOT/project.yaml" modes.rigor)
if [ -n "$level" ] && ! validate_enum_value modes.rigor "$level" 2>/dev/null; then level=""; fi
[ -z "$level" ] && level=$(get_yaml_default modes.rigor)
[ -z "$level" ] && return 0
while IFS= read -r line; do
[ -z "$line" ] && continue
[ "${line%%::*}" = "$level" ] || continue
local oldIFS="$IFS"; IFS=','
for pair in ${line##*::}; do
if [ "${pair%%=*}" = "$path" ]; then IFS="$oldIFS"; printf '%s' "${pair#*=}"; return 0; fi
done
IFS="$oldIFS"
done <<EOF
$_yaml_helper_rigor_expansion
EOF
return 0
}
# The rigor level currently in effect (for source labels and /settings).
_yaml_helper_rigor_level() {
_yaml_helper_set_root
local level=""
[ -f "$_YH_ROOT/project.yaml" ] && level=$(get_yaml_key "$_YH_ROOT/project.yaml" modes.rigor)
if [ -n "$level" ] && ! validate_enum_value modes.rigor "$level" 2>/dev/null; then level=""; fi
[ -z "$level" ] && level=$(get_yaml_default modes.rigor)
printf '%s' "$level"
}
# Plain-text legacy mirrors — the read side of the dual-write contract.
_yaml_helper_legacy_files="\
modes.review_mode::production/review-mode.txt
project.stage::production/stage.txt"
# Which keys consult project.local.yaml DURING RESOLUTION.
#
# This list MUST equal the `/settings --local` write whitelist. If resolution
# consults project.local.yaml for a narrower set than /settings will write to
# it, `/settings --local modes.review_mode=solo` writes a value that /settings
# accepts and displays, and that every skill then ignores. A write
# list and a read list that disagree is not a design; it is a bug with a
# plausible-looking symptom.
#
# Kept as its own variable rather than inlining the whitelist: "may be written
# locally" and "is read during resolution" are distinct concepts that merely
# coincide today. A future setting could legitimately be one and not the other.
_yaml_helper_local_read_scope="$_yaml_helper_locally_overridable"
_yaml_helper_in_local_read_scope() {
local path="$1" entry
[ -z "$path" ] && return 1
while IFS= read -r entry; do
[ -z "$entry" ] && continue
[ "$path" = "$entry" ] && return 0
done <<EOF
$_yaml_helper_local_read_scope
EOF
return 1
}
_yaml_helper_legacy_file_for() {
local path="$1" line
[ -z "$path" ] && return 0
while IFS= read -r line; do
[ -z "$line" ] && continue
if [ "${line%%::*}" = "$path" ]; then printf '%s' "${line##*::}"; return 0; fi
done <<EOF
$_yaml_helper_legacy_files
EOF
return 0
}
# Documented terminal default for a key, or '' when it has none.
get_yaml_default() {
local path="$1" line
[ -z "$path" ] && return 0
while IFS= read -r line; do
[ -z "$line" ] && continue
if [ "${line%%::*}" = "$path" ]; then printf '%s' "${line##*::}"; return 0; fi
done <<EOF
$_yaml_helper_defaults
EOF
return 0
}
# Read a legacy plain-text mirror. Strips CR and surrounding whitespace but
# PRESERVES INTERNAL SPACES — "Systems Design" is a legal project.stage value,
# and a `tr -d '[:space:]'` reader collapsed it to "SystemsDesign", which
# matched none of the phase keys downstream. Skips leading blank lines.
get_legacy_key() {
local path="$1" file
[ -z "$path" ] && return 0
file=$(_yaml_helper_legacy_file_for "$path")
[ -z "$file" ] && return 0
[ -f "$file" ] || return 0
awk 'BEGIN{FS="\n"} { gsub(/\r/,""); sub(/^[ \t]+/,""); sub(/[ \t]+$/,"");
if (length($0) > 0) { print; exit } }' "$file"
}
# Resolve one setting through the full chain.
# prints: "<value>\t<source>"
# source: project.local.yaml | project.yaml | <legacy path> | default | unset
# exit: always 0
#
# Enum-invalid values FALL THROUGH rather than winning — a typo degrades to the
# next source and ultimately the documented default, instead of propagating a
# nonsense mode into every skill. resolve_config surfaces what was rejected.
resolve_setting() {
_yaml_helper_set_root
local path="$1"
if [ -z "$path" ]; then printf '\tunset'; return 0; fi
local val="" src="" legacy=""
if _yaml_helper_in_local_read_scope "$path" && [ -f "$_YH_ROOT/project.local.yaml" ]; then
val=$(get_yaml_key "$_YH_ROOT/project.local.yaml" "$path")
if [ -n "$val" ] && ! validate_enum_value "$path" "$val" 2>/dev/null; then val=""; fi
[ -n "$val" ] && src="project.local.yaml"
fi
if [ -z "$val" ] && [ -f "$_YH_ROOT/project.yaml" ]; then
val=$(get_yaml_key "$_YH_ROOT/project.yaml" "$path")
if [ -n "$val" ] && ! validate_enum_value "$path" "$val" 2>/dev/null; then val=""; fi
[ -n "$val" ] && src="project.yaml"
fi
if [ -z "$val" ]; then
legacy=$(_yaml_helper_legacy_file_for "$path")
if [ -n "$legacy" ] && [ -f "$legacy" ]; then
val=$(get_legacy_key "$path")
if [ -n "$val" ] && ! validate_enum_value "$path" "$val" 2>/dev/null; then val=""; fi
[ -n "$val" ] && src="$legacy"
fi
fi
# Rigor expansion sits BELOW every explicit source and ABOVE the terminal
# default. That ordering is the back-compat guarantee: a project.yaml that
# already sets docs.density resolves exactly as it did before rigor existed.
if [ -z "$val" ]; then
val=$(_yaml_helper_rigor_value "$path")
[ -n "$val" ] && src="rigor:$(_yaml_helper_rigor_level)"
fi
if [ -z "$val" ]; then
val=$(get_yaml_default "$path")
[ -n "$val" ] && src="default"
fi
[ -z "$src" ] && src="unset"
printf '%s\t%s' "$val" "$src"
}
# Resolve the ENGINE-SPECIFIC code root.
# prints: "<dir>\t<source>"
# source: engine.name | <legacy path> | detected | unset
# exit: always 0
#
# WHY THIS EXISTS. `.claude/docs/directory-structure.md` is explicit that the
# code root is not a style preference but a hard toolchain constraint: Unity
# compiles only `Assets/` and `Packages/`, and UnrealBuildTool discovers modules
# under `Source/`. `src/` is that table's GODOT row, not a universal path.
#
# Every hook that filtered staged or on-disk files with a literal `^src/`
# therefore matched NOTHING on a Unity or Unreal project — and matching nothing
# is indistinguishable from scanning cleanly. That is the exact row
# `.claude/rules/skill-authoring.md` records for `/security-audit`
# ("Godot-only greps returned zero hits on Unity/Unreal, and zero hits read as
# clean"), reproduced in four more places.
#
# UNSET DOES NOT DEFAULT TO `src`. Obligation 2 of skill-authoring.md: an absent
# value may not default to the permissive one. Defaulting here would re-create
# the Godot bias this function exists to remove, and would do it invisibly. An
# unresolvable root returns empty, and the CALLER must announce that its check
# did not run — see the `_no_code_root` reporting in validate-commit.sh.
#
# Step 3 (detect) is derivation, not enumeration (obligation 5): it believes the
# tree over the absence of config, but only when the tree is UNAMBIGUOUS. Two
# roots present means a genuinely undecidable project, and guessing there would
# scan half of it and report a pass.
resolve_code_root() {
_yaml_helper_set_root
local ename="" src="" root="" legacy="" found="" d n=0
# 1. The configured engine, through the normal chain.
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
ename=$(get_yaml_key "$_YH_ROOT/project.local.yaml" engine.name 2>/dev/null)
[ -n "$ename" ] && src="project.local.yaml"
fi
if [ -z "$ename" ] && [ -f "$_YH_ROOT/project.yaml" ]; then
ename=$(get_yaml_key "$_YH_ROOT/project.yaml" engine.name 2>/dev/null)
[ -n "$ename" ] && src="engine.name"
fi
# 2. The legacy mirror. `/setup-engine` wrote an `Engine:` line here before
# project.yaml existed, and workflow-catalog's `engine-setup` step still
# accepts it as an `any_of` alternative — so a v1.0 project that never
# migrated resolves rather than falling through to "unset".
legacy="$_YH_ROOT/.claude/docs/technical-preferences.md"
if [ -z "$ename" ] && [ -f "$legacy" ]; then
ename=$(grep -iE '^[[:space:]]*[-*]?[[:space:]]*\*{0,2}Engine\*{0,2}[[:space:]]*:' "$legacy" 2>/dev/null \
| head -1 | sed 's/.*://' | tr -d '\r' \
| sed 's/^[[:space:]]*//; s/[[:space:]].*$//; s/\*//g')
case "$ename" in
*"[TO BE CONFIGURED]"*|"["*) ename="" ;;
esac
[ -n "$ename" ] && src=".claude/docs/technical-preferences.md"
fi
case "$(printf '%s' "$ename" | tr '[:upper:]' '[:lower:]')" in
godot) root="src" ;;
unity) root="Assets" ;;
unreal*) root="Source" ;;
*) root="" ;;
esac
# 3. No usable engine value — believe the tree, but only if it is unambiguous.
if [ -z "$root" ]; then
for d in src Assets Source; do
if [ -d "$_YH_ROOT/$d" ]; then found="$d"; n=$((n + 1)); fi
done
if [ "$n" = 1 ]; then root="$found"; src="detected"; else root=""; src=""; fi
fi
[ -z "$src" ] && src="unset"
printf '%s\t%s' "$root" "$src"
}
# List the child key names of a mapping node (one per line). get_yaml_key
# deliberately returns nothing for a mapping, so this is how the whole
# workflow_overrides.system_overrides map gets enumerated in one shot.
get_yaml_child_keys() {
local file="$1" path="$2"
if [ -z "$file" ] || [ -z "$path" ] || [ ! -f "$file" ]; then return 0; fi
_yaml_helper_resolve_python || return 0
"$_yaml_helper_python" - "$file" "$path" <<'PYEOF'
import sys, re
path_file, dotted = sys.argv[1], sys.argv[2]
keys = dotted.split('.')
def parse(lines):
root = {}
stack = [(-1, root)]
for raw in lines:
line = raw.rstrip('\n').rstrip('\r')
stripped = line.lstrip(' ')
if not stripped.strip() or stripped.lstrip().startswith('#'):
continue
indent = len(line) - len(stripped)
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
return root
parent = stack[-1][1]
m = re.match(r'([^:#\s][^:]*?)\s*:\s*(.*)$', stripped)
if not m:
continue
key, value = m.group(1).strip(), m.group(2).strip()
if value.startswith('#'):
value = ''
elif value[:1] in ('"', "'"):
q = value[0]; end = value.find(q, 1)
value = value[1:end] if end >= 0 else value[1:]
else:
hp = value.find(' #')
if hp >= 0:
value = value[:hp].strip()
if value == '':
nm = {}; parent[key] = nm; stack.append((indent, nm))
else:
parent[key] = value
return root
try:
with open(path_file, 'r', encoding='utf-8-sig', errors='replace') as f:
data = parse(f.readlines())
except OSError:
sys.exit(0)
cur = data
for k in keys:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
sys.exit(0)
if not isinstance(cur, dict):
sys.exit(0)
sys.stdout.buffer.write("\n".join(cur.keys()).encode('utf-8'))
PYEOF
}
# Emit the resolved-config block a skill reads instead of re-deriving anything.
#
# resolve_config [<system>]
#
# ALWAYS exits 0 and ALWAYS emits a complete block — a missing project.yaml,
# malformed YAML, an orphan project.local.yaml or a missing Python interpreter
# each degrade to defaults and surface on the notes: line rather than producing
# a truncated block a skill might half-read.
# resolve_config [--keys k1,k2,...] [<system>]
#
# --keys restricts output to the named knobs. USE IT. Emitting all 12 lines costs
# ~188 tokens, while the resolution prose it replaces averages only ~101 tokens
# per skill -- so a full block is a NET LOSS for any skill that reads 2-3 knobs.
# A 3-knob block costs ~45 tokens, which is the actual win. Measured, not
# estimated.
# Valid key names are the output labels: rigor, review_mode, automation, workflow,
# docs.density, story_granularity, qa.level, team.size, project.stage,
# automation_always_ask, engine, testing.strict, system_overrides.
resolve_config() {
_yaml_helper_set_root
local want=""
if [ "${1:-}" = "--keys" ]; then want=",${2},"; shift 2; fi
local system="${1:-}"
local notes="" v s pair
# emit <label> — true when the label was requested (or nothing was restricted)
_rc_want() { [ -z "$want" ] || case "$want" in *",$1,"*) return 0;; *) return 1;; esac; }
if ! _yaml_helper_resolve_python; then
notes="NO PYTHON INTERPRETER — YAML unreadable; legacy files and defaults only"
fi
if [ ! -f "$_YH_ROOT/project.yaml" ]; then
notes="${notes:+$notes; }project.yaml absent — defaults in use"
elif [ ! -s "$_YH_ROOT/project.yaml" ]; then
# An EMPTY project.yaml resolves to defaults; unannounced it is
# indistinguishable from a healthy fully-defaulted project. Absence is
# announced one line above; emptiness is the more suspicious state of the two
# (a truncated write, an interrupted /setup-engine) and was the silent one.
notes="${notes:+$notes; }project.yaml is EMPTY — defaults in use; if you configured this project, the file did not survive"
fi
local orphan
orphan=$(validate_local_yaml_base 2>&1) || notes="${notes:+$notes; }$orphan"
# Collect enum complaints from both files so a typo is visible, not silent.
local enum_err
enum_err=$(validate_yaml_enum project.yaml 2>&1 >/dev/null)
[ -n "$enum_err" ] && notes="${notes:+$notes; }$(echo "$enum_err" | tr '\n' ';' | sed 's/;$//') — ignored, chain continued"
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
enum_err=$(validate_yaml_enum project.local.yaml 2>&1 >/dev/null)
[ -n "$enum_err" ] && notes="${notes:+$notes; }local: $(echo "$enum_err" | tr '\n' ';' | sed 's/;$//')"
# Locked keys in the local file. Separate from the enum check
# above because their VALUES are legal — it is the location that is not, so
# every value-shaped validation passes them and resolution then drops them
# in silence. Reported, never applied: see validate_local_scope.
# Collapsed to one note listing the keys: validate_local_scope prints a
# full remedy sentence per key for direct callers, and three copies of it
# would cost more of this block than every resolved value put together.
local scope_err scope_keys
scope_err=$(validate_local_scope project.local.yaml 2>&1 >/dev/null)
if [ -n "$scope_err" ]; then
scope_keys=$(printf '%s\n' "$scope_err" | sed -n 's/.*`\([^`]*\)`.*/\1/p' | paste -sd, - | sed 's/,/, /g')
notes="${notes:+$notes; }local: not locally overridable, ignored — $scope_keys (move to project.yaml or delete)"
fi
fi
# Framing costs ~81 chars. For a skill reading 1-2 knobs that is more than the
# inline chain it replaced, so bare lines are emitted instead -- "automation:
# guided (project.local.yaml)" is self-describing without a banner. Measured:
# single-knob skills were +226 chars WITH framing, negative without it.
local nkeys=0
if [ -n "$want" ]; then
nkeys=$(printf '%s' "$want" | tr ',' '\n' | grep -c '[a-z]')
fi
local bare=0
[ -n "$want" ] && [ "$nkeys" -le 2 ] && bare=1
if [ "$bare" = "1" ]; then
:
elif [ -n "$want" ]; then
echo "=== CCGS Config (resolved: local->yaml->legacy->default; use as-is) ==="
else
echo "=== CCGS Resolved Config ==="
fi
for pair in modes.rigor:rigor \
modes.review_mode:review_mode \
modes.automation:automation \
modes.workflow:workflow \
docs.density:docs.density \
modes.story_granularity:story_granularity \
qa.level:qa.level \
team.size:team.size \
project.stage:project.stage; do
_rc_want "${pair##*:}" || continue
v=$(resolve_setting "${pair%%:*}")
s="${v#*$(printf '\t')}"; v="${v%%$(printf '\t')*}"
echo "${pair##*:}: ${v:-not set} ($s)"
done
# automation_always_ask: array-valued, so it does not go through resolve_setting.
local aaa
if _rc_want automation_always_ask; then
aaa=$(get_effective_yaml_array modes.automation_always_ask 2>/dev/null)
if [ -n "$aaa" ]; then
echo "automation_always_ask: $(echo "$aaa" | tr '\n' ',' | sed 's/,$//; s/,/, /g') (configured)"
else
echo "automation_always_ask: $(echo "$_yaml_helper_always_ask_default" | tr '\n' ',' | sed 's/,$//; s/,/, /g') (default)"
fi
fi
# engine: project.yaml only. No markdown reader is built here — skills keep
# their existing technical-preferences.md fallback prose, and forbidden
# patterns / allowed libraries never migrate at all.
local ename eversion
if _rc_want engine; then
ename=$(get_yaml_key "$_YH_ROOT/project.yaml" engine.name 2>/dev/null)
eversion=$(get_yaml_key "$_YH_ROOT/project.yaml" engine.version 2>/dev/null)
if [ -n "$ename" ]; then
echo "engine: $ename${eversion:+ $eversion} (project.yaml)"
else
echo "engine: unset (fall back to .claude/docs/technical-preferences.md)"
fi
fi
# testing.strict.*: reported as CONFIGURED STATE ONLY, never defaulted here.
# Its unset default differs per skill by design.
local ts_out="" k tv
if _rc_want testing.strict; then
for k in logic integration visual ui config; do
tv=$(get_effective_yaml_key "testing.strict.$k" 2>/dev/null)
[ -z "$tv" ] && tv=$(get_yaml_key "$_YH_ROOT/project.yaml" testing.strict 2>/dev/null)
ts_out="$ts_out $k=${tv:-unset}"
done
echo "testing.strict:${ts_out} (unset = each skill applies its own default)"
fi
# performance.enforce: warn | block | off, default `warn`.
# Unlike testing.strict.* this DOES get defaulted here -- effects-map gives it
# a single terminal default (`warn`) rather than a per-skill one, so resolving
# it in one place keeps /perf-profile and /gate-check from disagreeing. The
# default lives in _yaml_helper_defaults, so resolve_setting supplies it and
# labels it, exactly like the nine keys in the loop above.
#
# WAS `get_effective_yaml_key`, which reads the two YAML files and NOTHING
# ELSE -- no enum validation and no source. That broke two invariants at the
# one knob whose entire job is to decide whether a breach blocks a release:
#
# 1. An invalid value WON. `enforce: nonsense` resolved to `nonsense` and
# was handed to /perf-profile and /gate-check under a header that says
# "use as-is", while the notes line simultaneously claimed it had been
# "ignored, chain continued". resolve_setting falls invalid values
# through to the default, which is what every other knob already did --
# `review_mode: nonsense` resolves to `lean (rigor:standard)`.
# 2. No provenance. Every other line names its source; this one printed a
# bare value with a `${pe:+}` suffix that expands to nothing -- a
# provenance tag someone started and never finished. The key is on the
# `/settings --local` whitelist and the docs stress that a teammate's
# stricter local setting "is meant to bite on their machine only", so
# "did this FAIL come from the project or from my own project.local.yaml"
# is exactly the question the line has to answer.
local pe
if _rc_want performance.enforce; then
pe=$(resolve_setting performance.enforce)
s="${pe#*$(printf '\t')}"; pe="${pe%%$(printf '\t')*}"
echo "performance.enforce: ${pe:-warn} ($s)"
fi
# platform.cert_tier. /launch-checklist and /release-checklist both bootstrap
# with `--keys rigor,stage,cert_tier` and are then told "Resolved above -- use
# as-is", so without a handler here they receive nothing, always, for the
# setting that decides which certification tracks they emit. A setting is not
# "wired" because skills REFERENCE it; it is wired when the plumbing that
# delivers it exists. Check the plumbing, not the references.
#
# NO DEFAULT SUBSTITUTED, deliberately. Unset must stay empty so the skills
# can take their "ask which platforms are in scope" branch -- obligation 2 of
# skill-authoring.md. Substituting `none` would make the question unaskable;
# substituting anything else would emit every track, which is the ~150-item
# checklist a single-platform jam project once received.
local ct
if _rc_want cert_tier || _rc_want platform.cert_tier; then
ct=$(get_effective_yaml_key platform.cert_tier 2>/dev/null)
if [ -n "$ct" ]; then
echo "platform.cert_tier: $ct"
else
echo "platform.cert_tier: (unset -- ask which platforms are in scope)"
fi
fi
# system_overrides: dump the whole map so the 10 per-system skills need no
# second call. A named <system> is echoed explicitly for convenience.
local so_keys so_out="" sk sv
if _rc_want system_overrides; then
so_keys=$(get_yaml_child_keys project.yaml workflow_overrides.system_overrides 2>/dev/null)
if [ -n "$so_keys" ]; then
while IFS= read -r sk; do
[ -z "$sk" ] && continue
sv=$(get_yaml_key "$_YH_ROOT/project.yaml" "workflow_overrides.system_overrides.$sk" 2>/dev/null)
[ -n "$sv" ] && so_out="$so_out $sk=$sv"
done <<EOF
$so_keys
EOF
fi
echo "system_overrides:${so_out:- none}"
fi
if [ -n "$system" ]; then
sv=$(get_yaml_key "$_YH_ROOT/project.yaml" "workflow_overrides.system_overrides.$system" 2>/dev/null)
if [ -n "$sv" ]; then
echo "workflow[$system]: $sv (system_overrides)"
else
v=$(resolve_setting modes.workflow); v="${v%%$(printf '\t')*}"
echo "workflow[$system]: $v (no override — project value)"
fi
fi
if [ "$bare" = "1" ]; then
# Bare form: values only. notes still surface, because a rejected enum value
# or a missing interpreter must never be silent.
[ -n "$notes" ] && echo "notes: $notes"
elif [ -n "$want" ]; then
# Terse form: the header already states the chain, and notes only appear
# when something actually went wrong. Every line here is paid on every
# invocation of every migrated skill.
[ -n "$notes" ] && echo "notes: $notes"
echo "=== end ==="
else
echo "notes: ${notes:-none}"
echo "Values above are fully resolved (local -> yaml -> legacy -> default). Use as-is."
echo "An inline --review flag, if passed, overrides review_mode."
echo "=== end CCGS config ==="
fi
return 0
}