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
+197 -18
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
# Hook: detect-gaps.sh
# Event: SessionStart
# Purpose: Detect missing documentation when code/prototypes exist
@@ -12,24 +40,78 @@ echo "=== Checking for Documentation Gaps ==="
# --- Check 0: Fresh project detection (suggests /start) ---
FRESH_PROJECT=true
# Check if engine is configured
if [ -f ".claude/docs/technical-preferences.md" ]; then
ENGINE_LINE=$(grep -E "^\- \*\*Engine\*\*:" .claude/docs/technical-preferences.md 2>/dev/null)
# Check if engine is configured (project.yaml first, fall back to technical-preferences.md)
if [ -f "project.yaml" ] && [ -f ".claude/hooks/yaml-helper.sh" ]; then
source .claude/hooks/yaml-helper.sh
ENGINE_NAME=$(get_yaml_key project.yaml engine.name 2>/dev/null)
if [ -n "$ENGINE_NAME" ]; then
FRESH_PROJECT=false
fi
fi
if [ "$FRESH_PROJECT" = true ] && [ -f ".claude/docs/technical-preferences.md" ]; then
ENGINE_LINE=$(grep -E "^[[:space:]]*-[[:space:]]+\*\*Engine\*\*:" .claude/docs/technical-preferences.md 2>/dev/null)
if [ -n "$ENGINE_LINE" ] && ! echo "$ENGINE_LINE" | grep -q "TO BE CONFIGURED" 2>/dev/null; then
FRESH_PROJECT=false
fi
fi
# Check if game concept exists
if [ -f "design/gdd/game-concept.md" ]; then
# Check if a design record exists.
#
# TWO FILES, NOT ONE. `game-concept.md` is the `standard`/`full` concept doc;
# at `minimal` the design record is the one-page `design/game-brief.md`, which
# `/brainstorm` writes in its place. workflow-modes.md states the rule this
# check has to satisfy: any check, gate or glob for the minimal design artifact
# must target `design/game-brief.md`.
#
# Testing only for `game-concept.md` means a minimal-tier project that has
# already run `/start` AND `/brainstorm` is greeted with "NEW PROJECT ... Run:
# /start" at every session start -- the banner wrong about the one artifact
# that tier produces.
if [ -f "design/gdd/game-concept.md" ] || [ -f "design/game-brief.md" ]; then
FRESH_PROJECT=false
fi
# Check if source code exists
if [ -d "src" ]; then
SRC_CHECK=$(find src -type f \( -name "*.gd" -o -name "*.cs" -o -name "*.cpp" -o -name "*.c" -o -name "*.h" -o -name "*.hpp" -o -name "*.rs" -o -name "*.py" -o -name "*.js" -o -name "*.ts" \) 2>/dev/null | head -1)
# Check if source code exists.
#
# All three code roots, not just `src/`. Per .claude/docs/directory-structure.md
# `src/` is the Godot row; Unity builds from `Assets/` and Unreal from
# `Source/`. Testing only `src/` meant a Unity project with a full codebase
# could still be greeted with "NEW PROJECT ... Run: /start".
#
# Tested directly rather than through resolve_code_root: this check runs before
# the config helper is guaranteed to be sourced, and "is there any code at all"
# does not need the engine to be known.
for _root in src Assets Source; do
[ -d "$_root" ] || continue
SRC_CHECK=$(find "$_root" -type f \( -name "*.gd" -o -name "*.cs" -o -name "*.cpp" -o -name "*.c" -o -name "*.h" -o -name "*.hpp" -o -name "*.rs" -o -name "*.py" -o -name "*.js" -o -name "*.ts" \) 2>/dev/null | head -1)
if [ -n "$SRC_CHECK" ]; then
FRESH_PROJECT=false
break
fi
done
# --- Check 0b: v1.0 project — legacy config present, project.yaml absent ---
# The migration story depends on users discovering /adopt; UPGRADING.md only
# reaches people who read it. An unconfigured technical-preferences.md template
# does not count as legacy data (same rule as migrate-v1-config.sh), and legacy
# config also means the project is not fresh — the NEW PROJECT banner below
# would be wrong for it.
if [ ! -f "project.yaml" ]; then
LEGACY_FILES=""
[ -f "production/stage.txt" ] && LEGACY_FILES="production/stage.txt"
[ -f "production/review-mode.txt" ] && LEGACY_FILES="${LEGACY_FILES:+$LEGACY_FILES, }production/review-mode.txt"
if [ -f ".claude/docs/technical-preferences.md" ]; then
TP_ENGINE=$(grep -E "^[[:space:]]*-[[:space:]]+\*\*Engine\*\*:" .claude/docs/technical-preferences.md 2>/dev/null)
if [ -n "$TP_ENGINE" ] && ! echo "$TP_ENGINE" | grep -q "TO BE CONFIGURED" 2>/dev/null; then
LEGACY_FILES="${LEGACY_FILES:+$LEGACY_FILES, }.claude/docs/technical-preferences.md"
fi
fi
if [ -n "$LEGACY_FILES" ]; then
FRESH_PROJECT=false
echo ""
echo "⬆️ v1.0 PROJECT: legacy config ($LEGACY_FILES) but no project.yaml."
echo " Run: /adopt — it detects this and drives the migration (non-destructive)."
echo " Preview first: bash .claude/scripts/migrate-v1-config.sh --dry-run"
fi
fi
@@ -43,10 +125,75 @@ if [ "$FRESH_PROJECT" = true ]; then
exit 0
fi
# --- Resolve the workflow tier once, for the checks below ---
#
# Checks 1, 3 and 4 all ask "where is the design/architecture document for this
# code?". At `minimal` the answer is that there deliberately is none:
# workflow-modes.md requires only engine choice and a filled
# `design/game-brief.md` before code starts, and says everything else can be
# skipped. Reporting their absence as a GAP nags the user about work their own
# configuration told them to skip -- and a warning that fires when nothing is
# wrong trains the user to ignore the ones that matter.
#
# resolve_setting, NOT get_yaml_key: `modes.workflow` is rigor-fronted, so a
# project that set `rigor: minimal` and nothing else has no `modes.workflow` key
# to read. Only the full chain sees the expansion. Same pattern and same reason
# as validate-commit.sh.
#
# Resolved AFTER the fresh-project early-exit above, so a brand-new project
# never pays for the lookup.
WORKFLOW="standard"
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh 2>/dev/null
W=$(resolve_setting modes.workflow 2>/dev/null | cut -f1)
[ -n "$W" ] && WORKFLOW="$W"
fi
# Check 2 is NOT gated: an undocumented prototype is a gap about the prototype
# itself, not about the design pipeline the tier scales.
if [ "$WORKFLOW" = "minimal" ]; then
DOC_CHECKS=false
else
DOC_CHECKS=true
fi
# --- Resolve the engine-specific code root for checks 1, 3 and 4 ------------
#
# Checks 1, 3 and 4 were all hardcoded to `src/`, which .claude/docs/directory-
# structure.md defines as the GODOT row of the code-root table -- Unity uses
# `Assets/`, Unreal `Source/`. On those two engines every directory test below
# failed, the source-file count came back 0, and NONE of the three checks ran.
# A project with 200 undocumented Unity systems produced the same empty output
# as a fully documented one.
#
# Only the ROOT is engine-specific. The `core/`, `engine/` and `gameplay/`
# sub-layout is CCGS's own convention, unchanged by the engine, so it stays
# literal.
#
# Resolved AFTER the fresh-project early-exit, so a brand-new project never
# pays for the lookup.
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
# A doc check that cannot locate the code root has not established that there
# are no gaps -- it has established nothing (obligation 1 and 3 of
# .claude/rules/skill-authoring.md). Announce it, once, and only when the checks
# would otherwise have had somewhere to look.
if [ "$DOC_CHECKS" = true ] && [ -z "$CODE_ROOT" ]; then
if [ -d src ] || [ -d Assets ] || [ -d Source ]; then
echo "ℹ️ NOT CHECKED: code exists but the code root is ambiguous (engine.name unset, and the tree does not name one unambiguously)."
echo " Checks 1, 3 and 4 (design and architecture docs for existing code) did NOT run."
echo " Suggested action: /setup-engine, or set engine.name in project.yaml"
fi
fi
# --- Check 1: Substantial codebase but sparse design docs ---
if [ -d "src" ]; then
if [ -n "$CODE_ROOT" ] && [ -d "$CODE_ROOT" ]; then
# Count source files (cross-platform, handles Windows paths)
SRC_FILES=$(find src -type f \( -name "*.gd" -o -name "*.cs" -o -name "*.cpp" -o -name "*.c" -o -name "*.h" -o -name "*.hpp" -o -name "*.rs" -o -name "*.py" -o -name "*.js" -o -name "*.ts" \) 2>/dev/null | wc -l)
SRC_FILES=$(find "$CODE_ROOT" -type f \( -name "*.gd" -o -name "*.cs" -o -name "*.cpp" -o -name "*.c" -o -name "*.h" -o -name "*.hpp" -o -name "*.rs" -o -name "*.py" -o -name "*.js" -o -name "*.ts" \) 2>/dev/null | wc -l)
else
SRC_FILES=0
fi
@@ -61,9 +208,9 @@ fi
SRC_FILES=$(echo "$SRC_FILES" | tr -d ' ')
DESIGN_FILES=$(echo "$DESIGN_FILES" | tr -d ' ')
if [ "$SRC_FILES" -gt 50 ] && [ "$DESIGN_FILES" -lt 5 ]; then
if [ "$DOC_CHECKS" = true ] && [ "$SRC_FILES" -gt 50 ] && [ "$DESIGN_FILES" -lt 5 ]; then
echo "⚠️ GAP: Substantial codebase ($SRC_FILES source files) but sparse design docs ($DESIGN_FILES files)"
echo " Suggested action: /reverse-document design src/[system]"
echo " Suggested action: /reverse-document design ${CODE_ROOT:-<code root>}/[system]"
echo " Or run: /project-stage-detect to get full analysis"
fi
@@ -95,7 +242,7 @@ if [ -d "prototypes" ]; then
fi
# --- Check 3: Core systems without architecture docs ---
if [ -d "src/core" ] || [ -d "src/engine" ]; then
if [ "$DOC_CHECKS" = true ] && [ -n "$CODE_ROOT" ] && { [ -d "$CODE_ROOT/core" ] || [ -d "$CODE_ROOT/engine" ]; }; then
if [ ! -d "docs/architecture" ]; then
echo "⚠️ GAP: Core engine/systems exist but no docs/architecture/ directory"
echo " Suggested action: Create docs/architecture/ and run /architecture-decision"
@@ -105,15 +252,15 @@ if [ -d "src/core" ] || [ -d "src/engine" ]; then
if [ "$ADR_COUNT" -lt 3 ]; then
echo "⚠️ GAP: Core systems exist but only $ADR_COUNT ADR(s) documented"
echo " Suggested action: /reverse-document architecture src/core/[system]"
echo " Suggested action: /reverse-document architecture $CODE_ROOT/core/[system]"
fi
fi
fi
# --- Check 4: Gameplay systems without design docs ---
if [ -d "src/gameplay" ]; then
if [ "$DOC_CHECKS" = true ] && [ -n "$CODE_ROOT" ] && [ -d "$CODE_ROOT/gameplay" ]; then
# Find major gameplay subdirectories (those with 5+ files)
GAMEPLAY_SYSTEMS=$(find src/gameplay -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
GAMEPLAY_SYSTEMS=$(find "$CODE_ROOT/gameplay" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
if [ -n "$GAMEPLAY_SYSTEMS" ]; then
while IFS= read -r system_dir; do
@@ -129,9 +276,9 @@ if [ -d "src/gameplay" ]; then
design_doc_2="design/gdd/${system_name}.md"
if [ ! -f "$design_doc_1" ] && [ ! -f "$design_doc_2" ]; then
echo "⚠️ GAP: Gameplay system 'src/gameplay/$system_name/' ($file_count files) has no design doc"
echo "⚠️ GAP: Gameplay system '$CODE_ROOT/gameplay/$system_name/' ($file_count files) has no design doc"
echo " Expected: design/gdd/${system_name}-system.md or design/gdd/${system_name}.md"
echo " Suggested action: /reverse-document design src/gameplay/$system_name"
echo " Suggested action: /reverse-document design $CODE_ROOT/gameplay/$system_name"
fi
fi
done <<< "$GAMEPLAY_SYSTEMS"
@@ -147,6 +294,38 @@ if [ "$SRC_FILES" -gt 100 ]; then
fi
fi
# --- Check 6: project.stage is behind what is actually on disk ---
#
# On the `minimal` path nothing ever advances the stage. Only
# /gate-check writes it, and the four-step jam route never invokes one -- so a
# project with stories and running game code keeps reporting `Concept` to the
# status line, to /help, and to every gate checklist that branches on stage.
#
# This is an OBSERVATION, never a write. CLAUDE.md is explicit that the stage
# advances on a /gate-check PASS with the user confirming, so a hook that
# advanced it silently would be the worse bug. Say what is inconsistent and name
# the skill that resolves it.
STAGE=""
if [ -f .claude/hooks/yaml-helper.sh ]; then
STAGE=$(get_yaml_key project.stage 2>/dev/null)
fi
[ -z "$STAGE" ] && [ -f production/stage.txt ] && STAGE=$(head -1 production/stage.txt 2>/dev/null | tr -d '
' | tr -d ' ')
if [ -n "$STAGE" ]; then
STORY_COUNT=$(find production/epics -name "story-*.md" 2>/dev/null | wc -l | tr -d ' ')
case "$STAGE" in
Concept|concept|Pre-Production|"Pre-Production")
if [ "$STORY_COUNT" -gt 0 ] && [ "$SRC_FILES" -gt 0 ]; then
echo "⚠️ GAP: project.stage says '$STAGE', but $STORY_COUNT stories and $SRC_FILES source files exist"
echo " The stage only advances on a /gate-check PASS, and nothing on the"
echo " minimal path runs one -- so the status line can sit at '$STAGE' indefinitely."
echo " Suggested action: /gate-check (it asks before advancing; this hook never writes the stage)"
fi
;;
esac
fi
# --- Summary ---
echo ""
echo "💡 To get a comprehensive project analysis, run: /project-stage-detect"
+51 -3
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 SubagentStop hook: Log agent completion for audit trail
# Tracks when agents finish and their outcome
#
@@ -10,21 +38,41 @@
# returns null on every invocation, so the fallback "unknown" is always used
# and the audit trail captures nothing useful.
# features.session_state: off => no audit trail. Default `on`; see
# session_state_enabled() in yaml-helper.sh.
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh
session_state_enabled || exit 0
fi
INPUT=$(cat)
# Parse agent name -- use jq if available, fall back to grep
if command -v jq >/dev/null 2>&1; then
AGENT_NAME=$(echo "$INPUT" | jq -r '.agent_type // "unknown"' 2>/dev/null)
else
AGENT_NAME=$(echo "$INPUT" | grep -oE '"agent_type"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"agent_type"[[:space:]]*:[[:space:]]*"//;s/"$//')
[ -z "$AGENT_NAME" ] && AGENT_NAME="unknown"
AGENT_NAME=$(echo "$INPUT" | grep -oE '"agent_type"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"agent_type"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# OUTSIDE the branch, like SESSION_ID below. jq's `// "unknown"` substitutes for
# null and false but NOT for an empty string, so the jq path could emit a record
# with no agent name at all while the grep path guarded against it. One guard,
# both paths.
[ -z "$AGENT_NAME" ] && AGENT_NAME="unknown"
# Parse session id -- keeps the completion record in the same three-field shape
# as log-agent.sh so both sides of a spawn belong to an identifiable session.
if command -v jq >/dev/null 2>&1; then
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null)
else
SESSION_ID=$(echo "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"session_id"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
[ -z "$SESSION_ID" ] && SESSION_ID="unknown"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
SESSION_LOG_DIR="production/session-logs"
mkdir -p "$SESSION_LOG_DIR" 2>/dev/null
echo "$TIMESTAMP | Agent completed: $AGENT_NAME" >> "$SESSION_LOG_DIR/agent-audit.log" 2>/dev/null
echo "$TIMESTAMP | $SESSION_ID | Agent completed: $AGENT_NAME" >> "$SESSION_LOG_DIR/agent-audit.log" 2>/dev/null
exit 0
+55 -3
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 SubagentStart hook: Log agent invocations for audit trail
# Tracks which agents are being used and when
#
@@ -9,21 +37,45 @@
# returns null on every invocation, so the fallback "unknown" is always used
# and the audit trail captures nothing useful.
# features.session_state: off => no audit trail, no spawn tally. Default is
# `on`, so this changes nothing unless the user opted out. Checked before
# reading stdin: there is no point paying for the parse we are about to discard.
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh
session_state_enabled || exit 0
fi
INPUT=$(cat)
# Parse agent name -- use jq if available, fall back to grep
if command -v jq >/dev/null 2>&1; then
AGENT_NAME=$(echo "$INPUT" | jq -r '.agent_type // "unknown"' 2>/dev/null)
else
AGENT_NAME=$(echo "$INPUT" | grep -oE '"agent_type"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"agent_type"[[:space:]]*:[[:space:]]*"//;s/"$//')
[ -z "$AGENT_NAME" ] && AGENT_NAME="unknown"
AGENT_NAME=$(echo "$INPUT" | grep -oE '"agent_type"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"agent_type"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# OUTSIDE the branch, like SESSION_ID below. jq's `// "unknown"` substitutes for
# null and false but NOT for an empty string, so the jq path could emit a record
# with no agent name at all while the grep path guarded against it. One guard,
# both paths.
[ -z "$AGENT_NAME" ] && AGENT_NAME="unknown"
# Parse session id -- session-stop.sh tallies spawns per session, so the record
# is useless without it. Same jq/grep fallback pattern as the agent name.
if command -v jq >/dev/null 2>&1; then
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null)
else
SESSION_ID=$(echo "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"session_id"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
[ -z "$SESSION_ID" ] && SESSION_ID="unknown"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
SESSION_LOG_DIR="production/session-logs"
mkdir -p "$SESSION_LOG_DIR" 2>/dev/null
echo "$TIMESTAMP | Agent invoked: $AGENT_NAME" >> "$SESSION_LOG_DIR/agent-audit.log" 2>/dev/null
# Field order is timestamp | session | event -- session-stop.sh greps the
# fixed string " | $SESSION_ID | Agent invoked: " to tally the current session.
# Changing this format silently breaks the spawn count (nothing else reads it).
echo "$TIMESTAMP | $SESSION_ID | Agent invoked: $AGENT_NAME" >> "$SESSION_LOG_DIR/agent-audit.log" 2>/dev/null
exit 0
+137
View File
@@ -0,0 +1,137 @@
#!/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 write the WRONG TREE -- 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 InstructionsLoaded hook: record WHICH instruction file loaded and
# WHY.
#
# WHAT THIS ANSWERS
# `.claude/rules/*.md` files carry `paths:` frontmatter, and a path-scoped rule
# loads only when Claude READS a file matching the pattern -- not on every tool
# use, and not when it CREATES one. This framework's agents predominantly create
# files: a new GDD, a new source file, a new test. So "does the rule for
# src/gameplay/** ever actually reach the model?" has a real answer, and before
# this hook the only way to get it was to infer it from behaviour afterwards.
#
# That inference was made once here, expensively: a data-file rule specified one
# key casing, agents wrote the other, and nothing surfaced the disagreement.
# Measurement replaces the inference.
#
# MEASURED, same repo, one session:
# session_start CLAUDE.md
# include its four @-imports, each carrying parent_file_path
# path_glob_match .claude/rules/skill-authoring.md, after READING a file
# under .claude/agents/** -- one of its declared paths
# (nothing) after CREATING a file under prototypes/** -- a declared
# path of .claude/rules/prototype-code.md
# Read fires, create does not. Keep this hook wired: the behaviour is upstream
# and can change in either direction, and this is the only thing here that would
# notice.
#
# SCHEMA CONFIRMED, NOT ASSUMED
# The common fields are documented; the event-specific ones are not. `pick()`
# tries the confirmed name first and keeps alternates behind it, so a schema
# change degrades to a fallback rather than to a blank column -- and the RAW
# line carries the answer regardless. That is how `load_reason` was found: the
# first guess (`reason`) missed, and the raw payload named the real field.
#
# NOT GATED ON features.session_state, deliberately. This is a diagnostic wired
# up on purpose; silencing it from an unrelated flag would make it do nothing
# while still looking installed -- the failure shape
# `.claude/rules/skill-authoring.md` exists to prevent.
#
# The exit code is ignored for this event and it cannot block a load. Fail
# silent, stay cheap: it fires once per instruction file per load.
LOG_DIR="production/session-logs"
LOG="$LOG_DIR/instructions-loaded.log"
# stdin may be absent; an unbounded read would then hang until the hook timeout.
[ -t 0 ] && exit 0
if command -v timeout >/dev/null 2>&1; then
INPUT=$(timeout 2 cat 2>/dev/null)
else
INPUT=$(cat 2>/dev/null)
fi
[ -n "$INPUT" ] || exit 0
mkdir -p "$LOG_DIR" 2>/dev/null || exit 0
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# `head -1` on every grep: an unanchored match can hit the same key nested
# deeper in the payload, and a multi-line value would break the one-record-per-
# line format this log depends on.
pick() { # <json> <key>...
_j="$1"; shift
for _k in "$@"; do
_v=$(printf '%s' "$_j" \
| grep -oE "\"$_k\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \
| head -1 \
| sed "s/\"$_k\"[[:space:]]*:[[:space:]]*\"//;s/\"$//")
if [ -n "$_v" ]; then printf '%s' "$_v"; return 0; fi
done
printf ''
}
FILE_VAL=$(pick "$INPUT" file_path path file source_path instruction_path filePath)
REASON_VAL=$(pick "$INPUT" load_reason reason loadReason matcher trigger source)
PARENT_VAL=$(pick "$INPUT" parent_file_path parentFilePath parent)
# Paths arrive as absolute Windows paths with JSON-escaped separators. Left raw,
# the readable line is the least readable thing in the log. Unescape, normalise
# to forward slashes, strip the repo prefix -- so the column reads
# `.claude/rules/gameplay-code.md`, which is what anyone scanning this is
# looking for. The RAW line below keeps the original bytes untouched.
# TWO forms of the repo root, because the payload and the shell disagree about
# what a path looks like on Windows. Git Bash reports $PWD as `/c/Users/...`
# while the payload carries `C:\Users\...`; stripping only the shell's form
# silently strips nothing and the column stays absolute -- which is why this
# needs checking on real payloads rather than a fixture. `pwd -W` gives the Windows
# form where it exists and is empty elsewhere, so both are tried and neither is
# required.
_PWD_FWD=$(printf '%s' "$PWD" | sed 's|\\|/|g')
_PWD_WIN=$(pwd -W 2>/dev/null | sed 's|\\|/|g')
shorten() {
[ -n "$1" ] || return 0
printf '%s' "$1" | sed 's|\\\\|/|g; s|\\|/|g' \
| sed "s|^${_PWD_FWD}/||" \
| { if [ -n "$_PWD_WIN" ]; then sed "s|^${_PWD_WIN}/||"; else cat; fi; }
}
FILE_SHORT=$(shorten "$FILE_VAL")
PARENT_SHORT=$(shorten "$PARENT_VAL")
{
printf '%s | %-16s | %s%s\n' \
"$TIMESTAMP" "${REASON_VAL:-?}" "${FILE_SHORT:-?}" \
"${PARENT_SHORT:+ (via $PARENT_SHORT)}"
# Raw payload on the following line, prefixed so the log stays greppable and
# so an unrecognised schema is still recoverable from the file itself.
printf '%s RAW %s\n' "$TIMESTAMP" "$INPUT"
} >> "$LOG" 2>/dev/null
exit 0
+41 -4
View File
@@ -1,4 +1,32 @@
#!/usr/bin/env 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
# Notification hook — fires when Claude Code sends a notification
# Shows a Windows toast via PowerShell
@@ -9,15 +37,24 @@ INPUT=$(cat)
if command -v jq &>/dev/null; then
MESSAGE=$(echo "$INPUT" | jq -r '.message // empty' 2>/dev/null)
fi
if [ -z "$MESSAGE" ]; then
MESSAGE=$(echo "$INPUT" | grep -oE '"message":"[^"]*"' | sed 's/"message":"//;s/"//')
if [ -z "${MESSAGE:-}" ]; then
# `[[:space:]]*` around the colon, like every other hook's fallback. This one
# required `"message":"` with no space, so a pretty-printed or space-separated
# payload fell through to the generic text below and the real notification was
# lost. Nothing reported that -- the hook still fired, just saying nothing.
MESSAGE=$(echo "$INPUT" | grep -oE '"message"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/"message"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
if [ -z "$MESSAGE" ]; then
MESSAGE="Claude Code needs your attention"
fi
# Sanitize message for PowerShell string embedding (escape single quotes)
MESSAGE_SAFE=$(echo "$MESSAGE" | sed "s/'/''/g" | head -c 200)
# TRUNCATE FIRST, then escape. Escaping doubles each `'` into `''`, so cutting
# at a fixed byte count afterwards can land between the two halves of a pair and
# hand PowerShell an unbalanced quote. The command then fails to parse, and
# because it is backgrounded with stderr discarded the only symptom is a
# notification that never appears.
MESSAGE_SAFE=$(printf '%s' "$MESSAGE" | head -c 200 | sed "s/'/''/g")
# Show Windows balloon tip notification (works on all Windows 10/11 without extra modules)
powershell.exe -NonInteractive -WindowStyle Hidden -Command "
+35
View File
@@ -1,7 +1,42 @@
#!/usr/bin/env 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
# post-compact.sh — fires after conversation compaction
# Reminds Claude to restore session state from the file-backed checkpoint.
# features.session_state: off => this hook is a no-op. Default `on`; see
# session_state_enabled() in yaml-helper.sh.
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh
session_state_enabled || exit 0
fi
ACTIVE="production/session-state/active.md"
echo "=== Context Restored After Compaction ==="
+119 -27
View File
@@ -1,49 +1,126 @@
#!/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 PreCompact hook: Dump session state before context compression
# This output appears in the conversation right before compaction, ensuring
# critical state survives the summarization process.
# features.session_state: off => this hook is a no-op. Default `on`; see
# session_state_enabled() in yaml-helper.sh.
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh
session_state_enabled || exit 0
fi
echo "=== SESSION STATE BEFORE COMPACTION ==="
echo "Timestamp: $(date)"
# --- Active session state file ---
# --- Active session state: the CHECKPOINT region, BY REFERENCE ---
#
# Injecting `head -n 100` of active.md here would be wrong twice over.
#
# First, the cap bounds the wrong dimension. active.md's lines are long, so 100
# of them was 12.7 KB (~3,200 tokens) -- injected at the one moment context is
# scarcest, and growing with the file.
#
# Second, and worse: content emitted here goes INTO the context that is about to
# be compacted, so it is summarised like everything else rather than preserved.
# We were paying full price for a lossy copy of a file that is already on disk
# -- while post-compact.sh, which fires after, already tells the agent to read
# that file. Pointer + a bounded checkpoint beats a dump on both cost and
# fidelity.
#
# So: emit only the region between the CHECKPOINT markers (bounded by the schema
# in .claude/docs/templates/session-state.md) plus a pointer to the whole file.
STATE_FILE="production/session-state/active.md"
if [ -f "$STATE_FILE" ]; then
echo ""
echo "## Active Session State (from $STATE_FILE)"
STATE_LINES=$(wc -l < "$STATE_FILE" 2>/dev/null | tr -d ' ')
if [ "$STATE_LINES" -gt 100 ] 2>/dev/null; then
head -n 100 "$STATE_FILE"
echo "... (truncated — $STATE_LINES total lines, showing first 100)"
echo "## Active Session State — checkpoint from $STATE_FILE"
CHECKPOINT=$(sed -n '/<!-- CHECKPOINT -->/,/<!-- \/CHECKPOINT -->/p' "$STATE_FILE" 2>/dev/null \
| grep -v '<!-- /\?CHECKPOINT -->')
STATUS_BLOCK=$(sed -n '/<!-- STATUS -->/,/<!-- \/STATUS -->/p' "$STATE_FILE" 2>/dev/null \
| grep -v '<!-- /\?STATUS -->' | grep -E '^(Epic|Feature|Task):[[:space:]]*[^[:space:]]')
[ -n "$STATUS_BLOCK" ] && printf '%s\n' "$STATUS_BLOCK"
if [ -n "$CHECKPOINT" ]; then
printf '%s\n' "$CHECKPOINT"
else
cat "$STATE_FILE"
# No markers: an unmigrated or hand-written file. Fall back to a small
# head slice rather than nothing -- but say so, because a missing
# checkpoint is a real problem the user should fix, not absorb silently.
echo "(no CHECKPOINT block — showing the first 20 lines instead;"
echo " re-create this file from .claude/docs/templates/session-state.md)"
head -n 20 "$STATE_FILE"
fi
echo ""
echo "Full detail (NOT reproduced here — read the file if you need it): $STATE_FILE"
else
echo ""
echo "## No active session state file found"
echo "Consider maintaining production/session-state/active.md for better recovery."
echo "Template: .claude/docs/templates/session-state.md"
fi
# --- Files modified this session (unstaged + staged + untracked) ---
echo ""
echo "## Files Modified (git working tree)"
# BOUNDED. These three lists must never be emitted in full, one line per
# file, with no cap -- and this output goes INTO the context that is about to be
# compacted. The checkpoint above was redesigned to be by-reference for exactly
# that reason (see the note at the top of this file); the lists below were left
# unbounded, so the hook still dumped whatever the working tree happened to
# contain at the moment context was scarcest. Measured on a tree with ~5k
# untracked files: 181,125 bytes, roughly 45,000 tokens, of which ~4,984 lines
# were this section. A fresh asset import or un-gitignored build output is
# enough to trigger it.
#
# A count plus a sample is what this section is actually for -- orienting the
# agent after compaction -- and the full list is one `git status` away.
_PC_CAP=20
_pc_list() { # $1=label $2=newline-separated paths
[ -n "$2" ] || return 0
_n=$(printf '%s\n' "$2" | grep -c .)
echo "$1 ($_n):"
printf '%s\n' "$2" | head -n "$_PC_CAP" | while read -r f; do [ -n "$f" ] && echo " - $f"; done
if [ "$_n" -gt "$_PC_CAP" ]; then
echo " ... and $((_n - _PC_CAP)) more (run: git status)"
fi
}
CHANGED=$(git diff --name-only 2>/dev/null)
STAGED=$(git diff --staged --name-only 2>/dev/null)
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null)
if [ -n "$CHANGED" ]; then
echo "Unstaged changes:"
echo "$CHANGED" | while read -r f; do echo " - $f"; done
fi
if [ -n "$STAGED" ]; then
echo "Staged changes:"
echo "$STAGED" | while read -r f; do echo " - $f"; done
fi
if [ -n "$UNTRACKED" ]; then
echo "New untracked files:"
echo "$UNTRACKED" | while read -r f; do echo " - $f"; done
fi
_pc_list "Unstaged changes" "$CHANGED"
_pc_list "Staged changes" "$STAGED"
_pc_list "New untracked files" "$UNTRACKED"
if [ -z "$CHANGED" ] && [ -z "$STAGED" ] && [ -z "$UNTRACKED" ]; then
echo " (no uncommitted changes)"
fi
@@ -53,15 +130,30 @@ echo ""
echo "## Design Docs — Work In Progress"
WIP_FOUND=false
for f in design/gdd/*.md; do
[ -f "$f" ] || continue
INCOMPLETE=$(grep -n -E "TODO|WIP|PLACEHOLDER|\[TO BE|\[TBD\]" "$f" 2>/dev/null)
if [ -n "$INCOMPLETE" ]; then
WIP_FOUND=true
echo " $f:"
echo "$INCOMPLETE" | while read -r line; do echo " $line"; done
# BOUNDED and BATCHED. One `grep -n` per design doc, printing every matching
# line from every file, fails twice over -- the same two ways as above:
# unbounded OUTPUT into a compacting context, and unbounded
# COST -- ~85ms per file, crossing this hook's 10s budget at roughly 90 GDDs.
# Measured: 220 docs 18262ms, 420 docs killed at the timeout.
#
# `xargs -0` bounds the spawn count by ARG_MAX rather than by file count, and
# the NUL delimiter keeps it portable: `-d` is GNU-only, BSD xargs rejects it,
# and the pipeline swallows that error silently. The
# report is capped. What the agent needs after compaction is "these docs are
# unfinished", not every TODO line in the project.
_WIP_HITS=$(printf '%s\n' design/gdd/*.md | tr '\n' '\0' \
| xargs -0 grep -lE "TODO|WIP|PLACEHOLDER|\[TO BE|\[TBD\]" 2>/dev/null || true)
if [ -n "$_WIP_HITS" ]; then
WIP_FOUND=true
_wn=$(printf '%s\n' "$_WIP_HITS" | grep -c .)
echo "$_wn design doc(s) contain TODO/WIP/PLACEHOLDER markers:"
printf '%s\n' "$_WIP_HITS" | head -n "$_PC_CAP" | while read -r f; do
[ -n "$f" ] && echo " - $f"
done
if [ "$_wn" -gt "$_PC_CAP" ]; then
echo " ... and $((_wn - _PC_CAP)) more"
fi
done
fi
if [ "$WIP_FOUND" = false ]; then
echo " (no WIP markers found in design docs)"
+224 -11
View File
@@ -1,8 +1,53 @@
#!/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): No stdin input
# 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 ==="
@@ -19,6 +64,84 @@ if [ -n "$BRANCH" ]; then
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
@@ -44,32 +167,122 @@ if [ "$BUG_COUNT" -gt 0 ]; then
echo "Open bugs: $BUG_COUNT"
fi
# Code health quick check
if [ -d "src" ]; then
TODO_COUNT=$(grep -r "TODO" src/ 2>/dev/null | wc -l)
FIXME_COUNT=$(grep -r "FIXME" src/ 2>/dev/null | wc -l)
# 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 src/"
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 "$STATE_FILE" ]; then
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 ""
echo "Quick summary (last 20 lines):"
tail -20 "$STATE_FILE" 2>/dev/null
TOTAL_LINES=$(wc -l < "$STATE_FILE" 2>/dev/null)
if [ "$TOTAL_LINES" -gt 20 ]; then
# 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
+146 -18
View File
@@ -1,7 +1,42 @@
#!/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 Stop hook: Log session summary when Claude finishes
# Records what was worked on for audit trail and sprint tracking
# features.session_state: off => this hook is a no-op. Default `on`; see
# session_state_enabled() in yaml-helper.sh.
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh
session_state_enabled || exit 0
fi
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
SESSION_LOG_DIR="production/session-logs"
@@ -14,30 +49,123 @@ MODIFIED_FILES=$(git diff --name-only 2>/dev/null)
# --- Archive active session state on shutdown (do NOT delete) ---
# active.md persists across clean exits so multi-session recovery works.
# It is only valid to delete active.md manually or when explicitly superseded.
# APPEND ONLY WHEN THE CONTENT CHANGED.
#
# The Stop hook fires once per RESPONSE, not once per session. Both blocks below
# appended unconditionally, so a long session wrote the whole of active.md on
# every turn. Measured on this repository before this guard:
#
# 3,187 Stop firings, 665 archived copies, session-log.md at 17.5 MB
#
# and session-logs/ is gitignored, so nothing ever surfaced it. The growth
# compounds: the larger active.md gets, the more each firing appends -- and
# rotate-session-state.sh caps active.md precisely because it grows, while the
# log accumulating full copies of it had no cap at all.
#
# Content hash, not mtime: an editor that rewrites a file unchanged would
# otherwise re-archive it. Same reasoning and same tool as review-receipts.sh --
# git hash-object is content-addressed and git is already called above.
#
# A hash that cannot be computed archives ANYWAY. For an audit trail the safe
# failure direction is a duplicate entry, never a lost one.
STATE_FILE="production/session-state/active.md"
STATE_HASH_FILE="$SESSION_LOG_DIR/.active-state.hash"
if [ -f "$STATE_FILE" ]; then
{
echo "## Archived Session State: $TIMESTAMP"
cat "$STATE_FILE"
echo "---"
echo ""
} >> "$SESSION_LOG_DIR/session-log.md" 2>/dev/null
STATE_HASH=$(git hash-object -- "$STATE_FILE" 2>/dev/null)
if [ -z "$STATE_HASH" ] || [ "$STATE_HASH" != "$(cat "$STATE_HASH_FILE" 2>/dev/null)" ]; then
{
echo "## Archived Session State: $TIMESTAMP"
cat "$STATE_FILE"
echo "---"
echo ""
} >> "$SESSION_LOG_DIR/session-log.md" 2>/dev/null
[ -n "$STATE_HASH" ] && printf '%s\n' "$STATE_HASH" > "$STATE_HASH_FILE" 2>/dev/null
fi
fi
SUMMARY_HASH_FILE="$SESSION_LOG_DIR/.session-end.hash"
if [ -n "$RECENT_COMMITS" ] || [ -n "$MODIFIED_FILES" ]; then
{
echo "## Session End: $TIMESTAMP"
if [ -n "$RECENT_COMMITS" ]; then
echo "### Commits"
echo "$RECENT_COMMITS"
# Hash the CONTENT, not the rendered block: the block carries $TIMESTAMP,
# which differs every firing and would defeat the comparison entirely.
SUMMARY_HASH=$(printf '%s\n---\n%s' "$RECENT_COMMITS" "$MODIFIED_FILES" \
| git hash-object --stdin 2>/dev/null)
if [ -z "$SUMMARY_HASH" ] || [ "$SUMMARY_HASH" != "$(cat "$SUMMARY_HASH_FILE" 2>/dev/null)" ]; then
{
echo "## Session End: $TIMESTAMP"
if [ -n "$RECENT_COMMITS" ]; then
echo "### Commits"
echo "$RECENT_COMMITS"
fi
if [ -n "$MODIFIED_FILES" ]; then
echo "### Uncommitted Changes"
echo "$MODIFIED_FILES"
fi
echo "---"
echo ""
} >> "$SESSION_LOG_DIR/session-log.md" 2>/dev/null
[ -n "$SUMMARY_HASH" ] && printf '%s\n' "$SUMMARY_HASH" > "$SUMMARY_HASH_FILE" 2>/dev/null
fi
fi
# --- Subagent spawn tally (cost visibility) ---
# log-agent.sh has always written per-spawn records that nothing read, so the
# framework's dominant cost was invisible to the user. This turns those records
# into a running count.
#
# Ordering matters: everything above runs BEFORE stdin is touched. Stop hooks
# are piped JSON, but if stdin were ever absent the read would block until the
# 10s timeout killed the hook -- and the state archive above must not be lost
# to that. Read last, fail silent.
AUDIT_LOG="$SESSION_LOG_DIR/agent-audit.log"
if [ ! -t 0 ] && [ -f "$AUDIT_LOG" ]; then
if command -v timeout >/dev/null 2>&1; then
INPUT=$(timeout 2 cat 2>/dev/null)
else
INPUT=$(cat 2>/dev/null)
fi
if command -v jq >/dev/null 2>&1; then
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
else
SESSION_ID=$(echo "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"session_id"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# No session id means no honest per-session number. Report nothing rather
# than a total that silently spans every session in the log.
if [ -n "$SESSION_ID" ] && [ "$SESSION_ID" != "unknown" ] && [ "$SESSION_ID" != "null" ]; then
SPAWN_LINES=$(grep -F " | $SESSION_ID | Agent invoked: " "$AUDIT_LOG" 2>/dev/null)
SPAWN_COUNT=$(printf '%s' "$SPAWN_LINES" | grep -c . 2>/dev/null)
[ -z "$SPAWN_COUNT" ] && SPAWN_COUNT=0
if [ "$SPAWN_COUNT" -gt 0 ]; then
BREAKDOWN=$(printf '%s\n' "$SPAWN_LINES" \
| sed 's/.*Agent invoked: //' \
| sort | uniq -c | sort -rn)
{
echo "# Session Subagent Cost"
echo ""
echo "**Session:** \`$SESSION_ID\` "
echo "**Updated:** $TIMESTAMP"
echo ""
echo "Subagent spawns this session: **$SPAWN_COUNT**"
echo ""
echo "| Agent | Spawns |"
echo "|-------|--------|"
printf '%s\n' "$BREAKDOWN" | while read -r count name; do
[ -z "$name" ] && continue
echo "| $name | $count |"
done
echo ""
echo "Each spawn is a fresh context window that re-reads its own inputs."
echo "To reduce this: lower \`modes.review_mode\` (\`/settings modes.review_mode=solo\`)"
echo "or \`modes.rigor\` (\`/settings modes.rigor=minimal\`)."
} > "$SESSION_LOG_DIR/session-cost.md" 2>/dev/null
echo "Subagent spawns this session: $SPAWN_COUNT (see production/session-logs/session-cost.md)"
fi
if [ -n "$MODIFIED_FILES" ]; then
echo "### Uncommitted Changes"
echo "$MODIFIED_FILES"
fi
echo "---"
echo ""
} >> "$SESSION_LOG_DIR/session-log.md" 2>/dev/null
fi
fi
exit 0
+59 -8
View File
@@ -1,10 +1,48 @@
#!/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 PostToolUse hook: Validates asset files after Write/Edit
# Checks naming conventions for files in assets/ directory
#
# Exit behavior:
# exit 0 = success or advisory warnings only (non-blocking)
# exit 1 = blocking error (build-breaking issues: invalid JSON, missing required fields)
# exit 0 = success or advisory warnings only
# exit 2 = build-breaking issue (invalid JSON, missing required fields)
#
# A PostToolUse hook CANNOT block -- the write has already happened by the time
# this runs (see .claude/docs/hooks-reference/hook-input-schemas.md). The only
# question is who hears about it, and that is what the exit code selects:
# exit 1 -> stderr goes to the USER only. Claude never sees it, so a JSON file
# it just corrupted looks like it wrote cleanly and it moves on.
# exit 2 -> stderr is fed back to CLAUDE as actionable feedback, so it can fix
# the file it just wrote.
# Exiting 1 while printing "ERRORS (Blocking) ... fix before proceeding" would be
# a promise the hook cannot keep, delivered to the one party who cannot act on it.
#
# Input schema (PostToolUse for Write/Edit):
# { "tool_name": "Write", "tool_input": { "file_path": "assets/data/foo.json", "content": "..." } }
@@ -15,11 +53,16 @@ INPUT=$(cat)
if command -v jq >/dev/null 2>&1; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
else
FILE_PATH=$(echo "$INPUT" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
FILE_PATH=$(echo "$INPUT" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# Normalize path separators (Windows backslash to forward slash)
FILE_PATH=$(echo "$FILE_PATH" | sed 's|\\|/|g')
# Normalize path separators (Windows backslash to forward slash).
# Two rules, order significant -- see the long note in validate-skill-change.sh.
# Short version: hook input is JSON, so a Windows path arrives as `\\`. Without
# jq the fallback cannot unescape it, and a single-rule sed turns each escaped
# pair into two slashes, so `assets/` never matches and this hook silently
# validates nothing on Windows.
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's|\\\\|/|g; s|\\|/|g')
# Only check files in assets/
if ! echo "$FILE_PATH" | grep -qE '(^|/)assets/'; then
@@ -54,6 +97,13 @@ if echo "$FILE_PATH" | grep -qE '(^|/)assets/data/.*\.json$'; then
if ! "$PYTHON_CMD" -m json.tool "$FILE_PATH" > /dev/null 2>&1; then
ERRORS="$ERRORS\n FORMAT: $FILE_PATH is not valid JSON — fix syntax errors before continuing"
fi
else
# Say so rather than skipping in silence. This is the only BLOCKING
# check in this hook, so without an interpreter the hook exits 0 on
# a file it never opened -- indistinguishable from valid JSON. The
# sibling validate-commit.sh already warns in exactly this case;
# this half was the one that did not.
echo "WARNING: Cannot validate JSON (python not found) — $FILE_PATH unchecked" >&2
fi
fi
fi
@@ -63,10 +113,11 @@ if [ -n "$WARNINGS" ]; then
echo -e "=== Asset Validation: Warnings ===$WARNINGS\n==================================\n(Warnings are advisory. Fix before final commit.)" >&2
fi
# Report errors and block if any build-breaking issues found
# Report build-breaking issues. exit 2 routes this to Claude, not just the user,
# so the file that was already written can actually be corrected.
if [ -n "$ERRORS" ]; then
echo -e "=== Asset Validation: ERRORS (Blocking) ===$ERRORS\n===========================================\nFix these errors before proceeding." >&2
exit 1
echo -e "=== Asset Validation: ERRORS ===$ERRORS\n================================\nThe file was written but is broken. Fix it now, before continuing." >&2
exit 2
fi
exit 0
+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
+29 -1
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 push commands
# Warns on pushes to protected branches
# Exit 0 = allow, Exit 2 = block
@@ -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 push commands
+43 -3
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 PostToolUse hook: Advises running skill-test after skill file changes
# Fires when any file inside .claude/skills/ is written or edited.
#
@@ -14,11 +42,23 @@ INPUT=$(cat)
if command -v jq >/dev/null 2>&1; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
else
FILE_PATH=$(echo "$INPUT" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
FILE_PATH=$(echo "$INPUT" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
fi
# Normalize path separators (Windows backslash to forward slash)
FILE_PATH=$(echo "$FILE_PATH" | sed 's|\\|/|g')
# Normalize path separators (Windows backslash to forward slash).
#
# TWO rules, and the order matters. The hook input is JSON, so a Windows path
# arrives escaped: "C:\\Users\\x". jq unescapes it to a single backslash, but
# the grep fallback CANNOT -- it hands back the raw two-character `\\`. A
# single `s|\\|/|g` then turns each of those into its own slash, producing
# `.claude//skills//help//SKILL.md`, which no path test below matches. The hook
# went silent on every Windows edit whenever jq was absent -- and jq is absent
# on a stock Windows Git Bash, which is this project's primary platform.
#
# Rule 1 collapses the escaped pair to one slash; rule 2 handles an already
# unescaped separator (the jq path, or a POSIX caller). Running both is safe
# for either input because rule 1 finds nothing to do on unescaped text.
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's|\\\\|/|g; s|\\|/|g')
# Only act on files inside .claude/skills/
if ! echo "$FILE_PATH" | grep -qE '(^|/)\.claude/skills/'; then
File diff suppressed because it is too large Load Diff