mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
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:
co-authored by
Claude Opus 5
parent
984023ddac
commit
d05699707f
+197
-18
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user