Files
Claude-Code-Game-Studios/.claude/hooks/session-start.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

289 lines
14 KiB
Bash

#!/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 SessionStart hook: Load project context at session start
# Outputs context information that Claude sees when a session begins
#
# Input schema (SessionStart): stdin IS supplied -- VERIFIED, not asserted.
# Observed on real harness events as a single line of JSON with session_id,
# transcript_path, cwd, hook_event_name and `source` ("clear", "compact").
# Do NOT read stdin here unboundedly -- the harness may leave it open, and a
# blocking read costs this hook its whole 10s budget on every session start.
#
# THIS HOOK MUST FINISH INSIDE ITS BUDGET. If it is killed partway, the
# session-state block never reaches context and the session starts blind --
# and nothing visible from inside the session says so. The early-exit gating
# and bounded reads below are what keep it inside 2s; treat them as load-
# bearing, not as optimisation.
#
# If that ever recurs, the diagnostic that works is a FIRE/DONE pair appended
# to a gitignored log at the top and bottom of this script -- "did it run at
# all" and "did it get here" are opposite failures with opposite fixes, and
# nothing else distinguishes them. /compact in particular CANNOT be used as
# evidence: PostCompact runs post-compact.sh, which prints the checkpoint
# independently, so seeing the checkpoint proves nothing about this script.
echo "=== Claude Code Game Studios — Session Context ==="
# Current branch
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ -n "$BRANCH" ]; then
echo "Branch: $BRANCH"
# Recent commits
echo ""
echo "Recent commits:"
git log --oneline -5 2>/dev/null | while read -r line; do
echo " $line"
done
fi
# Resolve review_mode the same way skills do: resolve_setting applies the FULL
# chain (project.local.yaml -> project.yaml -> legacy review-mode.txt -> modes.rigor
# expansion -> default). modes.review_mode is rigor-fronted and locally overridable,
# so only resolve_setting reflects both a rigor-derived value (a project that set
# only `rigor: minimal` shows `solo`) and a local override. get_effective_yaml_key
# returns empty for a rigor-only project, which would leave the banner showing a
# stale `lean`.
REVIEW_MODE=""
if [ -f "project.yaml" ] && [ -f ".claude/hooks/yaml-helper.sh" ]; then
source .claude/hooks/yaml-helper.sh
REVIEW_MODE=$(resolve_setting modes.review_mode 2>/dev/null | cut -f1)
fi
if [ -z "$REVIEW_MODE" ] && [ -f "production/review-mode.txt" ]; then
REVIEW_MODE=$(head -1 production/review-mode.txt 2>/dev/null | tr -d '[:space:]')
fi
if [ -z "$REVIEW_MODE" ]; then
REVIEW_MODE="lean"
fi
echo ""
echo "Review mode: $REVIEW_MODE"
# Schema validation — surface invalid enum values in
# project.yaml and project.local.yaml so typos like `automation: chaotic`
# surface at session start rather than failing inside a skill.
if [ -f ".claude/hooks/yaml-helper.sh" ]; then
if ! type validate_yaml_enum >/dev/null 2>&1; then
source .claude/hooks/yaml-helper.sh
fi
# Hard-error guard: project.local.yaml requires a project.yaml base
if ! BASE_ERR=$(validate_local_yaml_base 2>&1); then
echo ""
echo "[!] $BASE_ERR"
fi
if [ -f "project.yaml" ]; then
SCHEMA_ERRORS=$(validate_yaml_enum project.yaml 2>&1)
if [ -n "$SCHEMA_ERRORS" ]; then
echo ""
echo "[!] project.yaml schema errors:"
echo "$SCHEMA_ERRORS" | sed 's/^/ /'
fi
fi
if [ -f "project.local.yaml" ]; then
SCHEMA_ERRORS=$(validate_yaml_enum project.local.yaml 2>&1)
if [ -n "$SCHEMA_ERRORS" ]; then
echo ""
echo "[!] project.local.yaml schema errors:"
echo "$SCHEMA_ERRORS" | sed 's/^/ /'
fi
fi
fi
# --- Stage source agreement ---
# `project.stage` in project.yaml is authoritative; production/stage.txt is a
# legacy mirror kept for unmigrated hooks. Twelve consumers read the mirror --
# including detect-gaps.sh and yaml-helper.sh -- so when the two disagree, part
# of the system acts on one stage and part on the other, silently.
#
# Warn, never reconcile. Only /gate-check on a PASS may change a stage, so a
# hook that "helpfully" rewrote the mirror would be advancing a phase gate
# nobody passed. Helpers emit observations, never verdicts (CLAUDE.md).
STAGE_YAML=""
STAGE_TXT=""
if [ -f "project.yaml" ] && command -v get_yaml_key >/dev/null 2>&1; then
STAGE_YAML=$(get_yaml_key project.yaml project.stage 2>/dev/null)
fi
if [ -f "production/stage.txt" ]; then
STAGE_TXT=$(head -1 production/stage.txt 2>/dev/null | tr -d '\r' | sed 's/[[:space:]]*$//')
fi
if [ -n "$STAGE_YAML" ] && [ -n "$STAGE_TXT" ] && [ "$STAGE_YAML" != "$STAGE_TXT" ]; then
echo ""
echo "[!] Stage sources disagree:"
echo " project.yaml project.stage : $STAGE_YAML (authoritative)"
echo " production/stage.txt : $STAGE_TXT (legacy mirror)"
echo " Twelve consumers read the mirror, so part of the system is acting on"
echo " each value. Only /gate-check on a PASS should change a stage — do not"
echo " hand-edit either file to silence this."
fi
# Current sprint (find most recent sprint file)
LATEST_SPRINT=$(ls -t production/sprints/sprint-*.md 2>/dev/null | head -1)
if [ -n "$LATEST_SPRINT" ]; then
echo ""
echo "Active sprint: $(basename "$LATEST_SPRINT" .md)"
fi
# Current milestone
LATEST_MILESTONE=$(ls -t production/milestones/*.md 2>/dev/null | head -1)
if [ -n "$LATEST_MILESTONE" ]; then
echo "Active milestone: $(basename "$LATEST_MILESTONE" .md)"
fi
# Open bug count
BUG_COUNT=0
for dir in tests/playtest production; do
if [ -d "$dir" ]; then
count=$(find "$dir" -name "BUG-*.md" 2>/dev/null | wc -l)
BUG_COUNT=$((BUG_COUNT + count))
fi
done
if [ "$BUG_COUNT" -gt 0 ]; then
echo "Open bugs: $BUG_COUNT"
fi
# Code health quick check.
#
# Reads the ENGINE-SPECIFIC code root, not a literal `src/`. Hardcoding `src/`
# made this line Godot-only: on a Unity or Unreal project the directory test
# failed, the banner printed no code-health line at all, and a clean project and
# an unreadable one looked identical. See resolve_code_root in yaml-helper.sh.
#
# Sourcing here is defensive: the two source sites above are both conditional,
# so by this point the function may or may not exist.
if [ -f .claude/hooks/yaml-helper.sh ] && ! command -v resolve_code_root >/dev/null 2>&1; then
. .claude/hooks/yaml-helper.sh 2>/dev/null
fi
if command -v resolve_code_root >/dev/null 2>&1; then
CODE_ROOT=$(resolve_code_root 2>/dev/null | cut -f1)
else
CODE_ROOT=""
fi
if [ -n "$CODE_ROOT" ] && [ -d "$CODE_ROOT" ]; then
TODO_COUNT=$(grep -r "TODO" "$CODE_ROOT/" 2>/dev/null | wc -l)
FIXME_COUNT=$(grep -r "FIXME" "$CODE_ROOT/" 2>/dev/null | wc -l)
if [ "$TODO_COUNT" -gt 0 ] || [ "$FIXME_COUNT" -gt 0 ]; then
echo ""
echo "Code health: ${TODO_COUNT} TODOs, ${FIXME_COUNT} FIXMEs in ${CODE_ROOT}/"
fi
fi
# --- Active session state recovery ---
# Only THIS block is gated by features.session_state, not the whole hook: the
# sprint/milestone/git context above is not part of the session-state pipeline
# and a user who turns that pipeline off still wants it. Gating the whole hook
# (as the original spec's "exit early at the top" wording implies) would take
# the branch and stage context away with it.
#
# Fail OPEN: this hook only sources yaml-helper.sh conditionally, so
# session_state_enabled may be undefined. An undefined function is falsey, which
# would silently suppress the recovery checkpoint -- the one piece of output
# whose absence the user cannot notice. Show the state unless we positively
# determined the flag is off.
STATE_FILE="production/session-state/active.md"
if [ -f .claude/hooks/yaml-helper.sh ] && ! command -v session_state_enabled >/dev/null 2>&1; then
. .claude/hooks/yaml-helper.sh
fi
if [ -f "$STATE_FILE" ] && { ! command -v session_state_enabled >/dev/null 2>&1 || session_state_enabled; }; then
echo ""
echo "=== ACTIVE SESSION STATE DETECTED ==="
echo "A previous session left state at: $STATE_FILE"
echo "Read this file to recover context and continue where you left off."
echo ""
# The CHECKPOINT region -- the same region pre-compact.sh injects.
#
# Previewing `tail -20` here while pre-compact takes `head -100` would put two
# consumers on opposite ends of one file, so which slice you got would
# depend on which hook happened to fire. Neither was wrong, because
# nothing defined where the recoverable state lived. The schema in
# .claude/docs/templates/session-state.md defines it; both read it now.
CHECKPOINT=$(sed -n '/<!-- CHECKPOINT -->/,/<!-- \/CHECKPOINT -->/p' "$STATE_FILE" 2>/dev/null \
| grep -v '<!-- /\?CHECKPOINT -->')
TOTAL_LINES=$(wc -l < "$STATE_FILE" 2>/dev/null | tr -d ' ')
# A sed range whose END address never matches runs to EOF. So a file with an
# opening marker and NO closing one produced a non-empty capture of the whole
# remaining file and took the healthy branch below -- silently previewing
# narrative under a "Checkpoint:" heading, with no warning and no template
# named. Emptiness cannot distinguish "no block" from
# "unterminated block"; only the markers can, so test them directly.
# rotate-session-state.sh already checks the CLOSING marker and refuses.
# Two consumers of one region must agree on what malformed means.
# `<!-- CHECKPOINT -->` cannot match `<!-- /CHECKPOINT -->` -- the slash sits
# where the space would be -- so these two counts are independent.
CP_OPEN=$(grep -c '<!-- CHECKPOINT -->' "$STATE_FILE" 2>/dev/null | tr -d ' ')
CP_CLOSE=$(grep -c '<!-- /CHECKPOINT -->' "$STATE_FILE" 2>/dev/null | tr -d ' ')
if [ "${CP_OPEN:-0}" -gt 0 ] && [ "${CP_CLOSE:-0}" -eq 0 ]; then
echo " [!] CHECKPOINT block is not terminated — no <!-- /CHECKPOINT --> marker."
echo " Not previewing it: without the closing marker the checkpoint"
echo " cannot be told from the narrative, and everything to the end"
echo " of the file would be shown as if it were recoverable state."
echo " Re-create from .claude/docs/templates/session-state.md."
echo " ... ($TOTAL_LINES total lines — read the full file to continue)"
elif [ -n "$CHECKPOINT" ]; then
echo "Checkpoint:"
printf '%s\n' "$CHECKPOINT"
echo " ... ($TOTAL_LINES total lines — read the full file for detail)"
else
echo "Quick summary (first 20 lines — no CHECKPOINT block in this file):"
head -20 "$STATE_FILE" 2>/dev/null
echo " ... ($TOTAL_LINES total lines — read the full file to continue)"
echo " NOTE: re-create from .claude/docs/templates/session-state.md so"
echo " recovery reads a bounded checkpoint instead of a slice."
fi
# Rotation is an OBSERVATION, never an action: helpers in .claude/scripts/
# emit observations, never verdicts (CLAUDE.md). The user decides.
if [ "${TOTAL_LINES:-0}" -gt 200 ] 2>/dev/null; then
echo " Note: $TOTAL_LINES lines. Narrative can be rotated into"
echo " production/session-logs/ — bash .claude/scripts/rotate-session-state.sh"
fi
echo "=== END SESSION STATE PREVIEW ==="
fi
# --- engine reference vs configured engine -----------------------------------
# /setup-engine is the only thing that rewrites CLAUDE.md's ENGINE-REFERENCE-IMPORT
# line. Edit engine.name in project.yaml by hand and the import keeps loading the
# previous engine's reference as project instructions -- every session, silently.
# An OBSERVATION, never an action (CLAUDE.md): it reports, the user decides.
if [ -f project.yaml ] && [ -f CLAUDE.md ]; then
ERI=$(grep -m1 -E '^@docs/engine-reference/[a-z]+/VERSION\.md' CLAUDE.md 2>/dev/null \
| sed -n 's|^@docs/engine-reference/\([a-z]*\)/VERSION\.md.*|\1|p')
CFG=$(sed -n 's/^[[:space:]]*name:[[:space:]]*"\{0,1\}\([A-Za-z]*\)"\{0,1\}[[:space:]]*$/\1/p' \
project.yaml 2>/dev/null | head -1 | tr '[:upper:]' '[:lower:]')
if [ -n "$ERI" ] && [ -n "$CFG" ] && [ "$ERI" != "$CFG" ]; then
echo ""
echo "!! ENGINE REFERENCE MISMATCH"
echo " project.yaml engine.name : $CFG"
echo " CLAUDE.md imports : docs/engine-reference/$ERI/VERSION.md"
echo " Every session is loading the $ERI reference on a $CFG project."
echo " Fix: re-run /setup-engine, or edit the ENGINE-REFERENCE-IMPORT line."
fi
fi
echo "==================================="
exit 0