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
+282 -49
View File
@@ -1,4 +1,32 @@
#!/bin/bash
# --- work from the project root ----------------------------------------------
# Every path below is repo-relative, so a hook invoked with a working directory
# that is not the repo root would silently read and write the WRONG TREE --
# returning a near-empty result instead of the session-recovery block, and
# creating stray trees such as docs/production/session-logs/ on write.
#
# PRECEDENCE IS LOAD-BEARING. A cwd that IS a project root carries real
# information and must win: a caller sitting inside another project means that
# project, not this one. Resolving to the script's own location first would
# override them. So, in order:
# 1. cwd holds project.yaml -> cwd (a project root)
# 2. cwd holds .claude/ -> cwd (a project root not yet configured)
# 3. CLAUDE_PROJECT_DIR -> that (populated in the hook environment)
# 4. this script's location -> <root>/.claude/hooks/../.. by construction
# Rule 4 always works and needs no environment at all; rules 1-2 stop it from
# overriding a caller that legitimately means somewhere else.
#
# NOT an upward search: that resolves a nested project to its parent's config.
if [ -f "project.yaml" ] || [ -d ".claude" ]; then
CCGS_ROOT="$PWD"
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then
CCGS_ROOT="$CLAUDE_PROJECT_DIR"
else
CCGS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
fi
[ -n "$CCGS_ROOT" ] && cd "$CCGS_ROOT" 2>/dev/null || true
# Claude Code PreToolUse hook: Validates git commit commands
# Receives JSON on stdin with tool_input.command
# Exit 0 = allow, Exit 2 = block (stderr shown to Claude)
@@ -12,7 +40,7 @@ INPUT=$(cat)
if command -v jq >/dev/null 2>&1; then
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
else
COMMAND=$(echo "$INPUT" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
COMMAND=$(echo "$INPUT" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# Only process git commit commands
@@ -20,6 +48,14 @@ if ! echo "$COMMAND" | grep -qE '^git[[:space:]]+commit'; then
exit 0
fi
# Resolve a Python interpreter ONCE for every batched check below. Both the
# blocking JSON scan and the advisory design scan must not spawn one process per
# item; they spawn one each, and share this lookup.
_VC_PY=""
for _c in python python3 py; do
if command -v "$_c" >/dev/null 2>&1; then _VC_PY="$_c"; break; fi
done
# Get staged files
STAGED=$(git diff --cached --name-only 2>/dev/null)
if [ -z "$STAGED" ]; then
@@ -28,69 +64,266 @@ fi
WARNINGS=""
# Check design documents for required sections
DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/')
if [ -n "$DESIGN_FILES" ]; then
while IFS= read -r file; do
if [[ "$file" == *.md ]] && [ -f "$file" ]; then
for section in "Overview" "Player Fantasy" "Detailed" "Formulas" "Edge Cases" "Dependencies" "Tuning Knobs" "Acceptance Criteria"; do
if ! grep -qi "$section" "$file"; then
WARNINGS="$WARNINGS\nDESIGN: $file missing required section: $section"
fi
done
fi
done <<< "$DESIGN_FILES"
fi
# Section findings are collected through a file, not a variable: the loops below
# run inside pipelines, and a subshell cannot assign to WARNINGS in the parent.
# The scratch file must actually be WRITABLE, and that has to be tested rather
# than assumed. `mktemp` and the `${TMPDIR:-/tmp}` fallback beside it both aim
# at /tmp, which does not exist or is not writable in several environments this
# hook runs in -- notably sandboxed Windows shells. When the redirect failed,
# `[ -s "$TMP_DESIGN" ]` was false, the design-section scan emitted nothing, and
# the hook exited 0: a check that could not run, reporting clean.
#
# So: try mktemp, then TMPDIR, then the repo own .git directory (writable by
# definition -- `git diff --cached` has already succeeded against it), and PROVE
# each candidate by writing to it. If none works, TMP_DESIGN stays empty and the
# scan announces that it did not run instead of skipping in silence.
TMP_DESIGN=""
for _cand in "$(mktemp 2>/dev/null)" "${TMPDIR:+$TMPDIR/ccgs-commit-$$}" \
"$(git rev-parse --git-dir 2>/dev/null)/ccgs-commit-$$"; do
[ -z "$_cand" ] && continue
if : > "$_cand" 2>/dev/null; then TMP_DESIGN="$_cand"; break; fi
done
trap '[ -n "$TMP_DESIGN" ] && rm -f "$TMP_DESIGN"' EXIT
# Validate JSON data files -- block invalid JSON
# ---------------------------------------------------------------------------
# BLOCKING CHECK FIRST. Everything below this block is advisory:
# it appends to $WARNINGS and the hook still exits 0. This block is the only one
# that can exit 2, and exit 2 is the entire reason the hook is registered.
#
# Running it THIRD, after the design-section scan, and validating one file
# per `python -m json.tool` invocation -- one interpreter spawn each, ~110ms on
# Windows -- gets the hook killed by its 15s timeout on a large commit before
# it reaches the corrupt file, so whether a bad file is caught depends on where
# its NAME SORTED. Measured with 201 staged files and one corrupt:
#
# corrupt sorts FIRST -> 730ms, rc=2, BLOCKED emitted
# corrupt sorts LAST -> 15217ms, rc=124, nothing emitted, commit not blocked
#
# Two independent fixes, because either alone is a mitigation rather than a
# guarantee:
# (a) ORDER. The blocking check runs before any advisory work, so a starved
# hook has already done the part that matters.
# (b) COST. One interpreter validates every staged JSON file, so the check is
# constant in file count instead of linear. The one-subprocess-per-item
# shape has caused this same starvation more than once; batching is the
# only fix that holds.
#
# Making it merely faster is not enough -- the cost stays linear and the hook
# starves again at a larger file count. Batch it, or it returns.
# ---------------------------------------------------------------------------
DATA_FILES=$(echo "$STAGED" | grep -E '^assets/data/.*\.json$')
if [ -n "$DATA_FILES" ]; then
# Find a working Python command
PYTHON_CMD=""
for cmd in python python3 py; do
if command -v "$cmd" >/dev/null 2>&1; then
PYTHON_CMD="$cmd"
break
PYTHON_CMD="$_VC_PY"
if [ -n "$PYTHON_CMD" ]; then
# One spawn, all files. The file list arrives on stdin so an unbounded
# number of paths cannot overflow the argument limit.
BAD_JSON=$(printf '%s\n' "$DATA_FILES" | "$PYTHON_CMD" -c '
import json, sys, os
bad = []
for line in sys.stdin.read().splitlines():
path = line.strip()
if not path or not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8-sig") as fh:
json.load(fh)
except Exception:
bad.append(path)
# Bytes, not text. Python text-mode stdout on Windows rewrites \n as \r\n,
# putting a CR inside every path but the last. Invisible in a terminal and in
# a line-by-line diff -- only a byte comparison against the pre-batch
# implementation surfaced it. get_yaml_key writes bytes for the same reason.
sys.stdout.buffer.write("\n".join(bad).encode("utf-8"))
' 2>/dev/null)
if [ -n "$BAD_JSON" ]; then
# Every offender at once. The per-file loop reported only the first
# and exited, so a commit with several bad files took several
# round-trips to clean up.
echo "$BAD_JSON" | while IFS= read -r bad; do
[ -n "$bad" ] && echo "BLOCKED: $bad is not valid JSON" >&2
done
exit 2
fi
done
else
echo "WARNING: Cannot validate JSON (python not found)" >&2
fi
fi
while IFS= read -r file; do
if [ -f "$file" ]; then
if [ -n "$PYTHON_CMD" ]; then
if ! "$PYTHON_CMD" -m json.tool "$file" > /dev/null 2>&1; then
echo "BLOCKED: $file is not valid JSON" >&2
exit 2
fi
else
echo "WARNING: Cannot validate JSON (python not found): $file" >&2
fi
# Check design documents for the sections REQUIRED AT THIS PROJECT'S TIER.
#
# Demanding all 8 sections unconditionally would contradict
# .claude/docs/coding-standards.md and workflow-modes.md: the required count is
# a function of `modes.workflow` -- 8 at `full`, 5 (+ conditional Formulas) at
# `standard`, and no GDD requirement at all at `minimal`. A jam project on the
# recommended `minimal` tier was being warned about six sections its own
# configuration says it does not need, which trains the user to ignore the hook.
# Source the config helper ONCE, ahead of every consumer below. It was
# previously sourced inside the design-doc branch, so the code scans further
# down -- which now need resolve_code_root -- could not reach it on a commit
# that staged no GDD.
# Detect success by asking whether the function EXISTS, not by the source's exit
# status. A sourced file returns the status of its last statement, which here is
# incidental -- gating on it left _VC_HELPER at 0 on a perfectly good source, the
# workflow tier silently stayed at its "standard" fallback, and the GDD section
# check stopped scaling with the tier (caught by test LL.9).
_VC_HELPER=0
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh 2>/dev/null || true
command -v resolve_setting >/dev/null 2>&1 && _VC_HELPER=1
fi
DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/')
if [ -n "$DESIGN_FILES" ]; then
WORKFLOW="standard"
if [ "$_VC_HELPER" = 1 ]; then
W=$(resolve_setting modes.workflow 2>/dev/null | cut -f1)
[ -n "$W" ] && WORKFLOW="$W"
fi
# "Detailed" (not "Detailed Rules") because the canonical heading has a
# documented alias, "Detailed Design" -- workflow-modes.md says not to treat
# one as missing when the other is present.
case "$WORKFLOW" in
minimal) REQUIRED="" ;;
full) REQUIRED="Overview|Player Fantasy|Detailed|Formulas|Edge Cases|Dependencies|Tuning Knobs|Acceptance Criteria" ;;
*) REQUIRED="Overview|Detailed|Edge Cases|Dependencies|Acceptance Criteria" ;;
esac
if [ -n "$REQUIRED" ]; then
# One pass for every file x section pair.
#
# A nested shell loop -- for each staged design doc, `echo |
# tr | while read` then one `grep -qi` per required section -- does not
# scale. At
# workflow=full that is 8 greps per file, and each is a process. 50
# design docs measured 14644ms -- 97% of this hook's 15s budget, from
# the ADVISORY half alone. 50 GDDs is an ordinary project, not a
# stress case.
#
# Matching is unchanged on purpose: case-insensitive SUBSTRING anywhere
# in the file, exactly what `grep -qi "$section"` did. It is looser than
# a heading check, and tightening it here would silently change which
# documents warn -- a behaviour change smuggled inside a performance
# fix. If that wants tightening it should be its own change with its
# own test.
printf '%s\n' "$DESIGN_FILES" | "${_VC_PY:-python}" -c '
import sys, os
argv = sys.argv
required = [x for x in argv[1].split("|") if x]
workflow = argv[2]
out = []
for line in sys.stdin.read().splitlines():
path = line.strip()
if not path or not path.endswith(".md") or not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
body = fh.read().lower()
except OSError:
continue
for section in required:
if section.lower() not in body:
out.append("DESIGN: %s missing section required at workflow=%s: %s"
% (path, workflow, section))
if out:
# Bytes, not text -- see the CRLF note in the JSON scan above.
sys.stdout.buffer.write(("\n".join(out) + "\n").encode("utf-8"))
' "$REQUIRED" "$WORKFLOW" > "$TMP_DESIGN" 2>/dev/null
if [ -z "$TMP_DESIGN" ]; then
# Obligation 3 of .claude/rules/skill-authoring.md: a skipped step
# announces itself. Silence here is indistinguishable from a GDD
# that already has every section its tier requires.
WARNINGS="$WARNINGS\nSKIPPED: no writable scratch location, so the GDD section check did NOT run on the staged design doc(s)."
elif [ -s "$TMP_DESIGN" ]; then
WARNINGS="$WARNINGS\n$(cat "$TMP_DESIGN")"
fi
done <<< "$DATA_FILES"
fi
fi
# Check for hardcoded gameplay values in gameplay code
# Uses grep -E (POSIX extended) instead of grep -P (Perl) for cross-platform compatibility
CODE_FILES=$(echo "$STAGED" | grep -E '^src/gameplay/')
#
# Batched through xargs. Run one `grep` per staged file -- a process each,
# ~60ms on Windows -- and 400 staged gameplay files blow a 15s budget on their
# own. `xargs` packs as many
# paths into each grep as ARG_MAX allows, so the spawn count is bounded by total
# path length rather than by file count.
#
# `-l` not `-q`: with many files per invocation the exit status can no longer
# say WHICH matched, and the warning names the file. It stays a test, not a
# report -- `-l` prints only names, never matched lines, which is what the -q
# comment here was guarding against.
#
# NUL-delimited, NOT `-d '\n'`: both `-d` and `-r` are GNU-only extensions.
# BSD xargs (macOS) rejects `-d`, and because this pipeline ends in
# `2>/dev/null || true` the rejection is SILENT -- the check returns empty and
# reports nothing found, which is indistinguishable from a clean scan. README
# claims these hooks run on macOS, so a silent no-op there is a false pass.
# `-0` exists in both GNU and BSD xargs. `-r` is dropped, not replaced: both
# callers below reach xargs only inside `if [ -n ... ]`, so the empty-input case
# it guarded cannot occur. `|| true` stays -- grep exits 1 on no match, which is
# the normal case and not an error.
# --- resolve the code root before either scan below ------------------------
#
# Both scans used to be hardcoded to `^src/gameplay/` and `^src/`. Per
# .claude/docs/directory-structure.md, `src/` is the GODOT row of the code-root
# table -- Unity compiles only `Assets/`, Unreal builds only from `Source/`. So
# on two of the three supported engines both greps matched nothing on every
# commit, both checks silently did not run, and the hook exited 0. A scan that
# cannot run and a scan that found nothing produced the identical artifact.
#
# The `/gameplay/` narrowing is dropped rather than translated: it describes a
# Godot source layout and has no Unity or Unreal equivalent, so keeping it would
# leave the check Godot-only by a second route. The hardcoded-value scan instead
# widens to the whole code root and is bounded by source-code EXTENSION, which
# is what actually keeps it precise -- without it, a Unity commit would drag
# `.meta`, `.asset` and `.prefab` YAML (all full of `speed: 5`) into a scan
# meant for code, and a hook that cries wolf gets muted rather than fixed.
CODE_ROOT=""; CODE_ROOT_SRC="unset"
if [ "$_VC_HELPER" = 1 ]; then
_CR=$(resolve_code_root 2>/dev/null)
CODE_ROOT=$(printf '%s' "$_CR" | cut -f1)
CODE_ROOT_SRC=$(printf '%s' "$_CR" | cut -f2)
fi
# Obligation 3 of .claude/rules/skill-authoring.md -- a skipped step announces
# itself. Only fires when source code is actually being committed, so a
# docs-only or config-only commit stays quiet and the notice keeps its meaning.
if [ -z "$CODE_ROOT" ]; then
if echo "$STAGED" | grep -qiE '\.(gd|cs|cpp|cc|hpp|h|c|py|js|ts|rs|java|kt|lua|gdshader|shader|hlsl)$'; then
WARNINGS="$WARNINGS\nSKIPPED: source files are staged but no code root could be resolved (engine.name unset, and the tree does not name one unambiguously). The hardcoded-value and TODO-owner scans did NOT run. Run /setup-engine, or set engine.name in project.yaml."
fi
fi
CODE_FILES=""
[ -n "$CODE_ROOT" ] && CODE_FILES=$(echo "$STAGED" \
| grep -E "^${CODE_ROOT}/" \
| grep -iE '\.(gd|cs|cpp|cc|hpp|h|c|py|js|ts|rs|java|kt|lua)$')
if [ -n "$CODE_FILES" ]; then
while IFS= read -r file; do
if [ -f "$file" ]; then
if grep -nE '(damage|health|speed|rate|chance|cost|duration)[[:space:]]*[:=][[:space:]]*[0-9]+' "$file" 2>/dev/null; then
WARNINGS="$WARNINGS\nCODE: $file may contain hardcoded gameplay values. Use data files."
fi
fi
done <<< "$CODE_FILES"
HARDCODED=$(printf '%s\n' "$CODE_FILES" | tr '\n' '\0' \
| xargs -0 grep -lE '(damage|health|speed|rate|chance|cost|duration)[[:space:]]*[:=][[:space:]]*[0-9]+' 2>/dev/null || true)
if [ -n "$HARDCODED" ]; then
while IFS= read -r file; do
[ -n "$file" ] && WARNINGS="$WARNINGS\nCODE: $file may contain hardcoded gameplay values. Use data files."
done <<< "$HARDCODED"
fi
fi
# Check for TODO/FIXME without assignee -- uses grep -E instead of grep -P
SRC_FILES=$(echo "$STAGED" | grep -E '^src/')
# Same code root as the scan above. Not extension-bounded: an unowned TODO is
# worth flagging in a shader or a build script too, and this scan greps for a
# literal marker rather than a numeric pattern, so it does not misfire on
# serialized asset YAML the way the hardcoded-value scan would.
SRC_FILES=""
[ -n "$CODE_ROOT" ] && SRC_FILES=$(echo "$STAGED" | grep -E "^${CODE_ROOT}/")
if [ -n "$SRC_FILES" ]; then
while IFS= read -r file; do
if [ -f "$file" ]; then
if grep -nE '(TODO|FIXME|HACK)[^(]' "$file" 2>/dev/null; then
WARNINGS="$WARNINGS\nSTYLE: $file has TODO/FIXME without owner tag. Use TODO(name) format."
fi
fi
done <<< "$SRC_FILES"
UNOWNED=$(printf '%s\n' "$SRC_FILES" | tr '\n' '\0' \
| xargs -0 grep -lE '(TODO|FIXME|HACK)[^(]' 2>/dev/null || true)
if [ -n "$UNOWNED" ]; then
while IFS= read -r file; do
[ -n "$file" ] && WARNINGS="$WARNINGS\nSTYLE: $file has TODO/FIXME without owner tag. Use TODO(name) format."
done <<< "$UNOWNED"
fi
fi
# Print warnings (non-blocking) and allow commit