Files
Claude-Code-Game-Studios/.claude/hooks/validate-commit.sh
T
DonchitosandClaude Opus 5.5 be8993bbc5 Release 1.1.3 — long skills keep their later steps
Fix release for 1.1.2: the 14 longest skills keep their later phases
after a compaction, a question nobody answered is never taken as an
answer, settings read the same from a subfolder, the commit check works
for a project in a subfolder of a larger repository, every skill opens
with a title, and the shipped allow rules no longer approve deleting a
branch or writing files without asking. Details in CHANGELOG.md; upgrade
steps in UPGRADING.md (v1.1.2 → v1.1.3).

Refs #83
Refs #133

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185en4t76fPn7315q4c3nko
2026-10-08 17:52:48 +11:00

777 lines
43 KiB
Bash

#!/bin/bash
# --- work from the project root ----------------------------------------------
# Every path below is repo-relative, so a hook invoked with a working directory
# that is not the repo root would silently read and write the WRONG TREE --
# returning a near-empty result instead of the session-recovery block, and
# creating stray trees such as docs/production/session-logs/ on write.
#
# PRECEDENCE IS LOAD-BEARING. A cwd that IS a project root carries real
# information and must win: a caller sitting inside another project means that
# project, not this one. Resolving to the script's own location first would
# override them. So, in order:
# 1. cwd holds project.yaml -> cwd (a project root)
# 2. cwd holds .claude/ -> cwd (a project root not yet configured)
# 3. CLAUDE_PROJECT_DIR -> that (populated in the hook environment)
# 4. this script's location -> <root>/.claude/hooks/../.. by construction
# Rule 4 always works and needs no environment at all; rules 1-2 stop it from
# overriding a caller that legitimately means somewhere else.
#
# NOT an upward search: that resolves a nested project to its parent's config.
if [ -f "project.yaml" ] || [ -d ".claude" ]; then
CCGS_ROOT="$PWD"
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then
CCGS_ROOT="$CLAUDE_PROJECT_DIR"
else
CCGS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
fi
[ -n "$CCGS_ROOT" ] && cd "$CCGS_ROOT" 2>/dev/null || true
# Claude Code PreToolUse hook: Validates git commit commands
# Receives JSON on stdin with tool_input.command
# Exit 0 = allow, Exit 2 = block (stderr shown to Claude)
#
# Input schema (PreToolUse for Bash):
# { "tool_name": "Bash", "tool_input": { "command": "git commit -m ..." } }
INPUT=$(cat)
# Most Bash and PowerShell calls do not mention git at all. Leave before any
# parsing: this hook runs on every one of them, and the parse is most of its
# cost (the early exit is the shell start-up alone).
# Tested only after the "tool_input" key: matching the whole payload also
# tests cwd and transcript_path, so a project path containing "GitHub" or
# "digital" never took this exit and paid the full parse on every call.
case "$INPUT" in *'"tool_input"'*[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
# Parse command -- use jq if available, fall back to grep. The fallback's string
# body is (\\.|[^"\\])* so an escaped quote (-m \"msg\") does not end it early.
if command -v jq >/dev/null 2>&1; then
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
else
# LC_ALL=C: in a UTF-8 locale an emoji in the command makes this grep match
# nothing, and an empty COMMAND skips every check below.
COMMAND=$(echo "$INPUT" | LC_ALL=C grep -oE '"command"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"' | head -1 | LC_ALL=C sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
# The grep leaves the JSON escapes in place; decode them once, here, and
# never jq's output. \\ goes first, through a placeholder: decoded after \r,
# the path C:\\Users\\me\\review lost the `r` of `review` (a segment
# starting t or n became a tab or a line break), the -C check below found
# no repository, and the commit went unchecked. A \" left escaped ended a
# quoted branch name in a stray backslash; a \t or \r left escaped never
# closed a tab-indented `<<-EOF` or a CRLF here-doc, dropping every command
# after it.
COMMAND=$(printf '%s\n' "$COMMAND" \
| LC_ALL=C awk '{ gsub(/\\\\/, "\002"); gsub(/\\r/, ""); gsub(/\\t/, "\t"); gsub(/\\n/, "\n"); gsub(/\\"/, "\""); print }' \
| tr '\002' '\\')
fi
# Print the first segment of $2 that runs `git $1`, anywhere in the command.
# Anchoring on ^git let every chained form through unchecked:
# `git add -A && git commit`, `git -C . commit`, `cd x && git commit`,
# `GIT_DIR=.git git commit`, a commit on a second line. Segments split on
# && || ; & | ( ) backticks and quotes, and on newlines. Before a segment's
# `git`, VAR=value words, `env`/`sudo`/`doas`/`xargs`/`nice`/`timeout` with
# their flags (a flag's value and timeout's duration included), shell keywords
# (`if`, `do`, `{`, `time`, ...) and a path, either slash (`/usr/bin/git`,
# `C:\Git\cmd\git.exe`) are allowed, and a QUOTED path to git ("C:\Program
# Files\Git\cmd\git.exe") is read as plain `git`; after it, git's own options
# (-C <dir>, -c <k=v>, --git-dir <dir> and the other long options that take a
# separate value, -p, --flag[=v]). A quoted value of git's own options --
# `-C "my repo"`, `--git-dir ".git"`, `-c user.name="A B"`, `--work-tree="."`,
# `-C "$(pwd)"` -- is unquoted first and its spaces and shell operators carried
# as \001, or the split cut `git` off from its subcommand and the commit went
# unchecked; an unquoted `$(pwd)` with no space in it is one word, too. Only
# when it follows `git`: a shell's own `bash -c "git commit"` was unquoted the
# same way, which glued the inner command into one word and skipped the check,
# and the quote after `=` in `grep "key=" f && git commit -m "x"` closes a
# string rather than opening a value. All splitting is awk: BSD sed writes a
# literal `n` for `\n`, so on macOS a sed split missed every chained form.
# Over-splitting only costs a check that finds nothing staged; under-splitting
# is the bypass. Kept identical in validate-push.sh.
#
# `git` itself is matched case-insensitively (`Git`, `GIT`) and a bare quoted
# `"git"` or `'git'` (no path) reads as plain `git` too, the same as a quoted
# path ending in it. `stdbuf`, `winpty` and `command -p` join `env`/`sudo`/...
# as prefixes that take flags. A line ending in a lone `\` is joined with the
# next before any of this runs, so `git \` then `commit` on the next line is
# still one command; `1<<y` inside `$((...))` is left-shift, not a here-doc
# opener, even though it matches `<<` the same as a real one. A quoted WORD
# with no shell-meaningful character inside it (a quoted branch name, `"main"`)
# is unquoted in place rather than read as a delimiter; a quoted string that
# DOES contain one is left for the here-doc/command handling above.
#
# A here-document body is text, not commands: a commit message, a file being
# written. Read as commands, a message quoting `git push origin main` warned
# about a push, and `cat > notes.md <<EOF` mentioning git commit blocked on
# staged data. So the body is dropped -- UNLESS its line runs an interpreter
# (bash <<EOF, python - <<EOF), whose here-doc IS commands: dropping that
# would be the bypass. `<<<` is a here-string, not a here-doc. An interpreter
# may carry a path (`/bin/bash <<EOF`). The delimiter is the whole word, or the
# whole quoted string (`<<'END-NOTES'`), and a `<<` inside a quoted string
# (`echo "a<<b"`) opens nothing -- `$(cat <<EOF` inside one still does. Either
# mistake left a here-doc that never closed and dropped every later line, a
# real commit included. A PowerShell single-quote here-string (`@'` ... `'@`)
# is the same kind of text and is dropped the same way; the double-quote form
# (`@"` ... `"@`) is left alone, because PowerShell itself expands `$(...)`
# inside it, and that content is still live.
git_segment() {
printf '%s\n' "$2" \
| awk '{ while ($0 ~ /\\$/) { line = $0; sub(/\\$/, "", line); if ((getline nxt) > 0) { $0 = line nxt } else { $0 = line; break } } print }' \
| awk '
pd != "" { t = $0; sub(/\r$/, "", t); if (t ~ /^'"'"'@/) pd = ""; next }
d != "" { t = $0; sub(/\r$/, "", t); if (tabs) sub(/^\t+/, "", t); if (t == d) d = ""; next }
{ print }
match($0, /<<-?[ \t]*("[^"]*"|'"'"'[^'"'"']*'"'"'|\\?[A-Za-z_][^ \t\r;&|()<>"'"'"'\\]*)/) && substr($0, RSTART - 1, 1) != "<" \
&& substr($0, 1, RSTART - 1) !~ /\$\(\([^)]*$/ \
&& $0 !~ /(^|[^A-Za-z0-9_.-])(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|python[0-9.]*|py|node|ruby|perl|php|eval|source)([ \t]|$)/ {
t = substr($0, RSTART, RLENGTH); q = ""
for (i = 1; i < RSTART; i++) {
c = substr($0, i, 1)
if (q == "") { if (c == "\"" || c == "'"'"'") q = c }
else if (c == q || (q == "\"" && c == "$" && substr($0, i + 1, 1) == "(")) q = ""
}
if (q == "") { d = t; tabs = (d ~ /^<<-/); sub(/^<<-?[ \t]*/, "", d); gsub(/[\\"'"'"']/, "", d) }
}
# A PowerShell single-quote here-string body (opened by @ then a
# single quote, closed by a single quote then @) is literal text,
# the same as a bash here-doc -- UNLESS it is the double-quote form,
# which PowerShell itself expands $(...) inside, so that form is
# left unmarked and flows through unchanged.
$0 ~ /@'"'"'[ \t]*\r?$/ { pd = "x" }' \
| awk '{
s = $0; out = ""
gsub(/"([^"]*[\/\\])?[Gg][Ii][Tt](\.exe)?"/, "git", s); gsub(/'"'"'([^'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?'"'"'/, "git", s)
gsub(/\$\([^ \t;&|()`"'"'"']*\)|`[^ \t;&|()`"'"'"']*`/, "$_", s)
while (match(s, /(-[Cc]|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source))[ \t]+("[^"]*"|'"'"'[^'"'"']*'"'"')|=("[^"]*"|'"'"'[^'"'"']*'"'"')/)) {
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
if (a ~ /^=/) {
f = "="; ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)+$/
} else {
f = a; sub(/[ \t]+["'"'"'].*$/, " ", f)
ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)*[ \t]+$/
}
if (!ok) { out = p substr(a, 1, 1); s = substr(a, 2) s; continue }
v = a; sub(/^[^"'"'"']*["'"'"']/, "", v); v = substr(v, 1, length(v) - 1); gsub(/[ \t;&|()`]/, "\001", v)
out = p f v
}
s = out s
# A quoted WORD with nothing inside it a shell would split on (a
# quoted branch name, a quoted refspec) is unquoted the same way
# rather than read as a delimiter -- a bare `"` or `'"'"'` used to
# split the line here, dropping the quoted text (and everything
# after it on that line) from what the classifier below ever saw.
# A quoted string that DOES contain such characters is left alone:
# it may be a whole command (`bash -c "git commit"`) meant to fall
# on its own line for re-inspection, or a commit message, and either
# reading is unchanged from before.
out = ""
while (match(s, /"[^" \t;&|()`]*"|'"'"'[^'"'"' \t;&|()`]*'"'"'/)) {
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
out = p substr(a, 2, length(a) - 2)
}
s = out s
gsub(/&&|\|\|/, "\n", s); gsub(/[;&|()`"'"'"']/, "\n", s); print s
}' \
| grep -E "^[[:space:]]*((if|then|else|elif|do|while|until|time|exec|command|nohup|!|[{])[[:space:]]+|(env|sudo|doas|xargs|nice|timeout|stdbuf|winpty|command)([[:space:]]+-[A-Za-z0-9-]+(=[^[:space:]]*)?([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+([0-9][0-9.]*[smhd]?[[:space:]]+)?|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:]]*[/\\])?[Gg][Ii][Tt](\.exe)?([[:space:]]+(-[Cc][[:space:]]+[^[:space:]]+|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source)[[:space:]]+[^[:space:]]+|-[A-Za-z]|--[A-Za-z-]+(=[^[:space:]]+)?))*[[:space:]]+$1([[:space:]]|\$)"
}
# Only process git commit commands
COMMIT_SEG=$(git_segment commit "$COMMAND") || exit 0
# `git -C <dir> commit` commits whatever repository <dir> is in. Only this
# project's staged files are ours to check; another repository is not. Leave
# only when <dir> PROVABLY is another repository: a <dir> rev-parse cannot
# resolve here -- `"$CLAUDE_PROJECT_DIR"`, `~/x`, `"$(pwd)"`, all unexpanded in
# the command text -- is this project as often as not, and skipping it let the
# commit through unchecked.
#
# COMMIT_SEG can hold more than one line -- a chained `git -C other commit &&
# git commit` -- and this early exit must not fire on the strength of the
# FIRST one alone: that let a real commit of THIS repo, chained after a
# provably-elsewhere one, through unchecked. So it only applies when exactly
# one commit invocation was found; a chain of several falls through to the
# normal staged-file checks below, which are already scoped to this repo.
if [ "$(printf '%s\n' "$COMMIT_SEG" | grep -c .)" = 1 ]; then
GIT_OPTS=${COMMIT_SEG%%[[:space:]]commit*}
case "$GIT_OPTS" in
*-C*)
C_DIR=$(printf '%s' "$GIT_OPTS" | sed -nE 's/.*[[:space:]]-C[[:space:]]+([^[:space:]]+).*/\1/p' | tr '\001' ' ')
if [ -n "$C_DIR" ] && _C_TOP=$(git -C "$C_DIR" rev-parse --show-toplevel 2>/dev/null) && [ -n "$_C_TOP" ]; then
_OUR_TOP=$(git rev-parse --show-toplevel 2>/dev/null)
[ "$_C_TOP" != "$_OUR_TOP" ] && [ ! "$_C_TOP" -ef "$_OUR_TOP" ] && exit 0
fi ;;
esac
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.
# Run it, do not just find it: the Windows Store `python` placeholder passes
# `command -v` and then prints an install hint instead of running anything.
# And ask for Python 3: a Python 2 first on PATH runs `import sys` fine, then
# fails every check below (they read sys.stdin.buffer), and valid data blocked.
_VC_PY=""
for _c in python python3 py; do
if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'import sys; sys.exit(sys.version_info[0] < 3)' >/dev/null 2>&1; then
_VC_PY="$_c"; break
fi
done
# Get the files this commit will record, and where each committed copy lives.
# A plain commit records the INDEX. `-a` / `--all` (or a cluster such as -am)
# also commits every modified tracked file, and `git commit <path>` commits the
# named paths' WORKING copies, staged or not -- both read from the working tree
# (WT_FILES). core.quotePath=false: without it a non-ASCII path comes back
# quoted ("assets/data/caf\303\251.json") and no pattern below matched it.
_GIT="git -c core.quotePath=false"
COMMIT_ARGS=" ${COMMIT_SEG#*[[:space:]]commit}"
INDEX_FILES=$($_GIT diff --cached --name-only 2>/dev/null)
WT_FILES=""
if printf '%s' "$COMMIT_ARGS" | grep -qE '[[:space:]](-[ABDEGHIJKLMNOPQRTUVWXYZbdefghijklnopqrsvwxyz]*a[A-Za-z]*|--all)([[:space:]]|$)'; then
WT_FILES=$($_GIT diff --name-only 2>/dev/null)
fi
# Pathspecs after the `commit` word. ANY pathspec takes working copies -- `.`,
# `-- .`, a folder, a glob, `:/` -- not only a literal assets/ path, and a
# message or an -F file is not one. So Python tokenises the command as a shell
# would (shlex; COMMIT_SEG was split at quotes and cannot say where `-m "a b"`
# ends) and git expands the pathspecs. Without Python only literal data paths
# are found, and the data check below blocks on them anyway.
#
# The tokeniser also reads an -a the grep above cannot see: COMMIT_ARGS stops
# at the first quote, so `git commit -m "x" -a` and `--all` committed every
# modified tracked file unchecked. `-a` counts in a short-option cluster only
# before a letter that takes a value (`-am x` is -a; `-ma` is the message "a").
#
# A path-limited commit (`git commit -m x src/player.gd`, no -i/--include)
# records ONLY those paths -- `git commit --dry-run` lists nothing else -- so a
# broken file staged for later must not block it. The tokeniser says so on its
# first line (CCGS-ONLY), and only when it read every commit in the command:
# one inside `bash -c "..."` or a here-doc is a single word or plain text to it.
#
# A relative pathspec resolves where the commit runs, and this hook runs at the
# project root: after a `cd`, under -C, or in a session sitting in a subfolder,
# `cd assets && git commit -m x data/item.json` named nothing here and the file
# went unchecked. There, and whenever a pathspec names no tracked file, every
# modified tracked file is checked (`:/`), and the index with it.
#
# A `git add` (or `git stage`) earlier in the same command stages its files
# after this hook has read the index, so `git add -A && git commit` committed a
# new broken data file unchecked. Each add before a commit is read too, and
# what it will stage -- modified tracked files, and untracked ones unless it is
# -u (ignored ones only with -f) -- is checked as working copies. A dry run
# (-n) stages nothing, an add after the commit is for a later one, a
# path-limited commit still records only its own paths, and after a cd or -C
# an add's pathspecs are read as the whole tree, as above.
if [ -n "$_VC_PY" ]; then
if command -v jq >/dev/null 2>&1; then
_VC_CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
else
_VC_CWD=$(echo "$INPUT" | LC_ALL=C grep -oE '"cwd"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"' | head -1 \
| LC_ALL=C sed 's/^"cwd"[[:space:]]*:[[:space:]]*"//; s/"$//; s/\\\\/\\/g')
fi
_VC_AWAY=0
[ -n "$_VC_CWD" ] && [ ! "$_VC_CWD" -ef . ] && _VC_AWAY=1
_VC_TOK=$(printf '%s\n' "$COMMAND" | "$_VC_PY" -c '
import re, shlex, subprocess, sys
OPS = set(";&|()<>")
GIT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--attr-source"}
REDIR = re.compile(r"^[<>]+&?$|^&>+$") # >, >>, >&, <, <<, &>: a redirection operator
COMMIT_ARG = {"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash",
"--author", "--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file"}
def words(line):
try:
lx = shlex.shlex(line, posix=True, punctuation_chars=True)
lx.whitespace_split = True
lx.commenters = ""
return list(lx)
except ValueError: # an unclosed quote: the line continues a string
return line.split()
def git(*args):
return subprocess.run(["git", "-c", "core.quotePath=false"] + list(args),
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
def add(w, k): # git add/stage from w[k] -> pathspecs, takes untracked, -f, -n, next k
specs, rest, alln, upd, force, dry = [], False, False, False, False, False
while k < len(w):
a = w[k]
if a.isdigit() and k + 1 < len(w) and w[k + 1][:1] in "<>":
k += 1
continue
if REDIR.match(a):
k += 2
continue
if a and set(a) <= OPS:
break
if rest or not a.startswith("-") or a == "-":
specs.append(a)
elif a == "--":
rest = True
elif a.startswith("--"):
alln = alln or a in ("--all", "--no-ignore-removal")
upd, force, dry = upd or a == "--update", force or a == "--force", dry or a == "--dry-run"
if a.startswith("--pathspec-from-file"):
specs.append(":/") # paths only git reads
else: # no short option of git add takes a value: -Av is -A -v
alln, upd, force, dry = alln or "A" in a, upd or "u" in a, force or "f" in a, dry or "n" in a
k += 1
if not specs and (alln or upd):
specs = [":/"] # -A and -u with no pathspec take the whole tree
return specs, alln or not upd, force, dry, k
text = sys.stdin.buffer.read().decode("utf-8", "replace")
away = sys.argv[1] == "1" or re.search(r"(^|[^\w.-])(cd|pushd|popd)(\s|[;&|)]|$)", text, re.M) is not None
specs, commits, only = [], 0, True
adds, pending = [], [] # what each git add stages: before a commit, not yet
for line in text.splitlines():
w, i = words(line), 0
while i < len(w):
if w[i].replace("\\", "/").rsplit("/", 1)[-1].lower() not in ("git", "git.exe"):
i += 1
continue
j = i + 1
while j < len(w) and w[j].startswith("-"):
away = away or w[j] in ("-C", "--git-dir", "--work-tree") or w[j].startswith(("--git-dir=", "--work-tree="))
j += 2 if w[j] in GIT_ARG else 1
if j < len(w) and w[j] in ("add", "stage"):
s, new, force, dry, i = add(w, j + 1)
if not dry:
pending.append((s, new, force))
continue
if j >= len(w) or w[j] != "commit":
i = j
continue
commits += 1
adds, pending = adds + pending, []
k, rest, mine, index = j + 1, False, [], False
while k < len(w):
a = w[k]
if a.isdigit() and k + 1 < len(w) and w[k + 1][:1] in "<>":
k += 1 # 2>&1, 2> err.txt: a file descriptor, not a pathspec
continue
if REDIR.match(a):
k += 2 # the redirection and its target; the commit goes on after it
continue
if a and set(a) <= OPS:
break # the next command
if rest or not a.startswith("-") or a == "-":
mine.append(a)
elif a == "--":
rest = True
elif a.startswith("--"):
if a == "--all" or a.startswith("--pathspec-from-file"):
mine.append(":/") # every tracked change, or paths only git reads
index = index or a in ("--all", "--include", "--patch", "--interactive") \
or a.startswith("--pathspec-from-file")
if a in COMMIT_ARG:
k += 1 # --message <msg>: the value is not a pathspec
else:
for n, ch in enumerate(a[1:], 1): # -am: -a, then -m <msg>
if ch in "mFCct":
if n == len(a) - 1:
k += 1
break
if ch in "uS":
break # -uno, -S<key>: the rest is its value
if ch == "a":
mine.append(":/")
index = index or ch in "aip"
k += 1
specs += mine
only = only and bool(mine) and not index
i = k
if specs and not away:
away = git("ls-files", "--error-unmatch", "--", *specs).returncode != 0
if specs and away:
specs.append(":/")
only = False
only = only and commits > 0 and len(re.findall(r"(?<![\w.-])commit(?![\w-])", text)) == commits
out = git("diff", "--name-only", "HEAD", "--", *specs) if only else None
if out is None or out.returncode != 0: # not path-limited, or no HEAD yet
only = False
out = git("diff", "--name-only", "--", *specs) if specs else None
more = b""
for s, new, force in ([] if only else adds):
s = [":/"] if away else s
more += git("diff", "--name-only", "--", *s).stdout
if new:
more += git("ls-files", "--others", "--full-name", *([] if force else ["--exclude-standard"]), "--", *s).stdout
sys.stdout.buffer.write((b"CCGS-ONLY\n" if only else b"CCGS-INDEX\n") + (out.stdout if out else b"") + more)
' "$_VC_AWAY" 2>/dev/null)
case "$_VC_TOK" in CCGS-ONLY*) INDEX_FILES="" ;; esac
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$(printf '%s\n' "$_VC_TOK" | tail -n +2)")
else
_AFTER=$(printf '%s' "$COMMAND" | LC_ALL=C awk '
!f && match($0, /(^|[^A-Za-z0-9_.-])commit([ \t]|$)/) { f = 1; print substr($0, RSTART + RLENGTH); next }
f { print }')
for _p in $(printf '%s' "$_AFTER" | LC_ALL=C grep -oE '(assets|Assets|Content)/[^[:space:]"'"'"';|&)]*'); do
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$($_GIT diff --name-only -- "$_p" 2>/dev/null)")
done
# No tokeniser to read a `git add`'s paths: take every change and new file.
if git_segment add "$COMMAND" >/dev/null || git_segment stage "$COMMAND" >/dev/null; then
WT_FILES=$(printf '%s\n%s\n%s' "$WT_FILES" "$($_GIT diff --name-only 2>/dev/null)" \
"$($_GIT ls-files --others --exclude-standard --full-name 2>/dev/null)")
fi
fi
WT_FILES=$(printf '%s\n' "$WT_FILES" | grep -v '^$' | sort -u)
# A project in a subfolder of a larger repository (game/ in a monorepo): git
# names every path above from the repository's top level, and every check below
# matches from the PROJECT root (^assets/, ^design/gdd/, the code root) and
# reads files relative to it. Unconverted, nothing matched and the commit went
# through unchecked. Keep this project's paths with the prefix dropped; files
# outside the project are not ours to check. "" at the top level.
_VC_PREFIX=$(git rev-parse --show-prefix 2>/dev/null)
if [ -n "$_VC_PREFIX" ]; then
INDEX_FILES=$(printf '%s\n' "$INDEX_FILES" | LC_ALL=C awk -v p="$_VC_PREFIX" 'index($0, p) == 1 { print substr($0, length(p) + 1) }')
WT_FILES=$(printf '%s\n' "$WT_FILES" | LC_ALL=C awk -v p="$_VC_PREFIX" 'index($0, p) == 1 { print substr($0, length(p) + 1) }')
fi
STAGED=$(printf '%s\n%s\n' "$INDEX_FILES" "$WT_FILES" | grep -v '^$' | sort -u)
if [ -z "$STAGED" ]; then
exit 0
fi
WARNINGS=""
# 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
# ---------------------------------------------------------------------------
# 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 after the design-section scan, and validating one file per
# `python -m json.tool` invocation -- one interpreter spawn each -- would get the
# hook killed by its 15s timeout on a large commit before it reaches a corrupt
# file, so whether a bad file is caught would depend on where its NAME SORTS: a
# corrupt file sorting first is blocked, one sorting last lets the commit through.
#
# 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.
#
# WHERE DATA LIVES is engine-specific, like the code root: Godot keeps it in
# assets/data/, Unity under Assets/ (Assets/Data/, Assets/Game/Data/), Unreal
# under Content/. Anchored to ^assets/data/ alone, this -- the hook's only
# blocking check -- never fired on Unity or Unreal. It stays inside the asset
# roots so an editor's JSON-with-comments (.vscode/settings.json) never blocks.
# ---------------------------------------------------------------------------
DATA_FILES=$(echo "$STAGED" | grep -E '^(assets|Assets|Content)/(.*/)?[Dd]ata/.*\.[Jj][Ss][Oo][Nn]$')
if [ -n "$DATA_FILES" ]; then
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.
# The first output line is a sentinel. Without it the validator did not
# run -- a crash, or an interpreter that is not Python -- and an empty
# list of bad files would read as "all valid".
# Each line is "W<tab>path" (commit takes the working copy) or
# "I<tab>path" (commit takes the staged copy). Index copies are read
# through ONE `git cat-file --batch`, not a `git show` per file.
JSON_OUT=$(LC_ALL=C awk -v OFS='\t' 'NR == FNR { if ($0 != "") wt[$0] = 1; next }
$0 != "" { print (($0 in wt) ? "W" : "I"), $0 }' \
<(printf '%s\n' "$WT_FILES") <(printf '%s\n' "$DATA_FILES") \
| CCGS_VC_PREFIX="$_VC_PREFIX" "$PYTHON_CMD" -c '
import json, sys, os, subprocess
bad, lfs_unread = [], []
# Bytes in, decoded as UTF-8: a Windows console code page would mangle a
# non-ASCII path before it reached os.path or git.
items = [l.split("\t", 1) for l in sys.stdin.buffer.read().decode("utf-8").splitlines() if "\t" in l]
staged = [p for mode, p in items if mode == "I"]
# Paths are project-relative; the index names them from the repository top.
prefix = os.environ.get("CCGS_VC_PREFIX", "")
blobs = {}
if staged:
out = subprocess.run(["git", "cat-file", "--batch"], stdout=subprocess.PIPE,
input="".join(":" + prefix + p + "\n" for p in staged).encode("utf-8")).stdout
pos = 0
for p in staged:
end = out.index(b"\n", pos)
head = out[pos:end].split()
pos = end + 1
if len(head) == 3 and head[1] == b"blob":
size = int(head[2])
blobs[p] = out[pos:pos + size]
pos += size + 1
for mode, p in items:
try:
if mode == "I":
if p not in blobs:
continue # removed from the index: nothing of it is committed
data = blobs[p]
if data.startswith(b"version https://git-lfs.github.com/spec/"):
# A Git LFS pointer: git add stored the file in LFS and staged
# this stub, which is never JSON. The working copy is the file
# it stored. With no working copy there is nothing to read --
# say so, do not block a valid file or pass it in silence.
if not os.path.isfile(p):
lfs_unread.append(p)
continue
with open(p, "rb") as fh:
data = fh.read()
else:
if not os.path.isfile(p):
continue
with open(p, "rb") as fh:
data = fh.read()
json.loads(data.decode("utf-8-sig"))
except Exception:
bad.append(p)
# 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.
# One line per file: "B<tab>path" not valid JSON, "S<tab>path" an LFS pointer
# with no working copy to read.
sys.stdout.buffer.write(("CCGS-JSON-CHECKED\n" + "".join("B\t" + p + "\n" for p in bad)
+ "".join("S\t" + p + "\n" for p in lfs_unread)).encode("utf-8"))
' 2>/dev/null)
if [ "$(printf '%s\n' "$JSON_OUT" | head -1)" != "CCGS-JSON-CHECKED" ]; then
echo "BLOCKED: $(printf '%s\n' "$DATA_FILES" | grep -c .) staged data file(s) could not be validated -- the JSON check did not run under '$PYTHON_CMD'. Check that it is a working Python 3 and commit again." >&2
exit 2
fi
BAD_JSON=$(printf '%s\n' "$JSON_OUT" | tail -n +2 | LC_ALL=C awk -F'\t' '$1 == "B" { print $2 }')
LFS_UNREAD=$(printf '%s\n' "$JSON_OUT" | tail -n +2 | LC_ALL=C awk -F'\t' '$1 == "S" { print $2 }')
if [ -n "$LFS_UNREAD" ]; then
WARNINGS="$WARNINGS\nSKIPPED: $(printf '%s\n' "$LFS_UNREAD" | grep -c .) staged data file(s) are Git LFS pointers with no working copy, so the JSON check did NOT read them: $(printf '%s' "$LFS_UNREAD" | tr '\n' ' ')"
fi
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
else
# Block, do not warn. Stderr from a hook that exits 0 goes to the debug
# log only -- Claude never sees it -- so the old warning let unvalidated
# data through in silence. A check that cannot run must not pass.
echo "BLOCKED: $(printf '%s\n' "$DATA_FILES" | grep -c .) staged data file(s) could not be validated -- no Python 3 interpreter found (tried python, python3, py). CCGS needs Python for its config reads too; install it and commit again." >&2
exit 2
fi
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.
_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/gdd/ also holds governance and registry files that are not system
# GDDs and have none of these sections, so they warned on every commit (#133).
# Skip the names not_a_system_gdd() skips in gdd-structure-check.sh and
# review-scope.sh; keep the three lists the same.
DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/' \
| grep -vE '/(game-concept|systems-index|game-pillars|gdd-cross-review-[^/]*|gameplay-tags|fixture-swap-ledger|entity-registry|sound-bible)\.md$')
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" ] && [ -z "$_VC_PY" ]; then
# A skipped step announces itself (obligation 3, as below). Handing the
# scan to a `python` that is not there printed nothing, which read as a
# GDD with every section its tier requires.
WARNINGS="$WARNINGS\nSKIPPED: no Python 3 interpreter found (tried python, python3, py), so the GDD section check did NOT run on the staged design doc(s)."
elif [ -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, so an ordinary project of about 50 GDDs would use nearly all
# of this hook's 15s budget on the ADVISORY half alone.
#
# 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" -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
fi
fi
# Check for hardcoded gameplay values in gameplay code
# Uses grep -E (POSIX extended) instead of grep -P (Perl) for cross-platform compatibility
#
# 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
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
# 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
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
# Warnings allow the commit. They go out as hook JSON: stderr on exit 0 reaches
# the debug log only, so neither the user nor Claude ever saw them. stderr stays
# as the fallback for a tree whose helper did not load.
if [ -n "$WARNINGS" ]; then
_VC_MSG=$(printf '%b' "=== Commit Validation Warnings ===$WARNINGS\n================================")
if command -v hook_warn >/dev/null 2>&1; then
hook_warn PreToolUse "$_VC_MSG"
else
printf '%s\n' "$_VC_MSG" >&2
fi
fi
exit 0