Files
Claude-Code-Game-Studios/.claude/scripts/review-receipts.sh
T
DonchitosandClaude Opus 5 d05699707f Release 1.1.0 — one config file, and a process weight you choose
`project.yaml` at the repo root is now the single source of truth for a
project's configuration, replacing `production/stage.txt`,
`production/review-mode.txt` and `.claude/docs/technical-preferences.md` (all
three still read as a fallback; nothing is force-deleted).

**`modes.rigor` — one question instead of six.** `minimal`, `standard` or
`full` fronts six knobs: `modes.workflow`, `docs.density`, `qa.level`,
`modes.story_granularity`, `modes.review_mode` and `team.size`. It defaults to
`minimal`, which is a change from 1.0's behaviour: the heavier tier was
measured costing several times more to reach working code without producing a
better result, and returning nothing when a fresh developer inherited the
project. Pin `modes.rigor: standard` to keep 1.0's process level.

Also in this release:

- `/settings` to view or change any setting, and `project.local.yaml` for
  per-developer preferences that should not be committed.
- `system_overrides` to hold one system to a higher standard than the rest of
  the project, and `testing.strict` to set per test type whether missing
  evidence blocks a story or warns.
- A story that changes anything the player sees is not closed until the game
  has been launched, observed, and a screenshot retained in
  `production/qa/evidence/`. A parse check is not a run. This holds at every
  rigor level, including `minimal`, where automated tests are waived but the
  look is not.
- The code root is engine-specific and enforced as such: Godot `src/`, Unity
  `Assets/`, Unreal `Source/<Module>/`. Two of the three will not build a
  project laid out any other way.
- Migration tooling for existing projects: `.claude/scripts/migrate-v1-config.sh`
  converts a 1.0 layout, non-destructively by default, with a report to review
  before `--finalize` deletes anything.
- Ask-before-write holds regardless of personal automation settings.

Upgrading from 1.0 or earlier: see UPGRADING.md. Full list of changes:
CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xvzG3i7pWQg4LjyDwWMXd
2026-09-23 12:25:11 +10:00

325 lines
13 KiB
Bash

#!/usr/bin/env bash
# review-receipts.sh — content-hash change detection for review skills.
#
# WHY HASHES, NOT GIT OR MTIME
# A review baseline must not depend on the user's commit habits. `git diff`
# needs a committed baseline; `git status` catches uncommitted edits but only
# relative to git's own view; file mtimes lie under backup restores and cloud
# sync (an old-mtime restore with new content silently escapes review — a
# false NEGATIVE, the dangerous direction). Hashing the bytes has neither
# problem: content changed <=> hash changed. The residual failure directions
# are false POSITIVES (re-review something unchanged — wasted tokens, never a
# missed review): a hash tool changing between stamp and check, or a receipt
# file that was never written.
#
# ONE EXCEPTION, and it is deliberate. `check` stays silent on an absent
# LITERAL path, because a caller may name an optional input on every run
# (/design-review does exactly this with the entity registry) and reporting it
# would block a legitimate skip. That silence is a false negative only if a
# caller passes a literal path it believes must exist — so callers name
# optional inputs and nothing else.
#
# An unexpanded GLOB is the case the same code path can swallow, and is NOT
# covered by the above: it means a whole document class was
# requested and none examined, which a caller could not distinguish from
# "nothing to do". It is now reported as UNRESOLVED.
#
# Observation-only per the load-bearing convention in CLAUDE.md: this script
# emits facts (UNCHANGED / CHANGED / NEW / RECEIPT: NONE), never verdicts.
# The calling skill decides what to do — including automation-mode handling,
# which this script cannot see.
#
# Usage:
# bash .claude/scripts/review-receipts.sh hash <file> [<file>...]
# Prints one stamp line per file:
# Reviewed-Content-Hash: <path> <hash>
# The calling skill pastes these lines into the review report / log
# entry it is writing. Paths are normalised to forward slashes.
#
# bash .claude/scripts/review-receipts.sh check <receipt-file> <file> [<file>...]
# First line: RECEIPT: <receipt-file> (or RECEIPT: NONE if missing/empty)
# Then, per file:
# UNCHANGED: <path> hash matches the LATEST stamp for that path
# CHANGED: <path> hash differs from the latest stamp
# NEW: <path> no stamp for that path in the receipt file
# UNRESOLVED: <pat> the argument is a glob the shell never expanded,
# so a whole class of documents was asked for and
# none was examined. NOT the same as an absent
# literal path, which stays silent on purpose --
# see is_unexpanded_glob() for why.
# With RECEIPT: NONE, every file is reported NEW (nothing to compare).
#
# A caller that skips work on "everything UNCHANGED" MUST treat an
# UNRESOLVED line as disqualifying: the set it compared was not the set
# it asked for.
#
# bash .claude/scripts/review-receipts.sh sections-hash <file>
# Per ^## section of the file (plus __preamble__ for content before the
# first ^##), prints:
# Reviewed-Section-Hash: <path>#<heading> <hash>
# Enables DELTA re-reviews: the next pass learns WHICH sections changed
# without anything storing the old content. Duplicate headings get a
# " #N" suffix (same algorithm on stamp and check side, so they match).
#
# bash .claude/scripts/review-receipts.sh sections-check <receipt-file> <file>
# Compares the file's current sections against the LATEST section stamps
# for that path in the receipt file. Prints, per section:
# SECTION-UNCHANGED: <heading>
# SECTION-CHANGED: <heading>
# SECTION-NEW: <heading> current section with no stored stamp
# then, for stored stamps whose section no longer exists:
# SECTION-REMOVED: <heading>
# With no stamps for the path at all: prints SECTIONS: NONE first and
# every current section as SECTION-NEW.
#
# The LATEST stamp wins: receipt files are append-only logs, so the last
# occurrence of a path is the most recent review of it.
set -u
export LC_ALL=C
hash_file() {
# git hash-object is preferred (stable, content-addressed, no mtime input).
# sha1sum fallback produces a DIFFERENT hash for the same bytes (git
# prepends a blob header) — so a stamp made with one tool and checked with
# the other reads CHANGED. That failure direction is safe (re-review).
if command -v git >/dev/null 2>&1; then
git hash-object -- "$1" 2>/dev/null && return 0
fi
sha1sum "$1" 2>/dev/null | cut -d' ' -f1
}
norm_path() {
printf '%s\n' "$1" | sed 's|\\|/|g'
}
# An argument that does not name a file is one of two very different things,
# and `check` must NOT treat them alike:
#
# a literal path that is absent -> an optional input the caller named on
# purpose. /design-review passes
# design/registry/entities.yaml on every
# run and documents that an absent
# registry "doesn't appear in the output
# and doesn't block the skip". Reporting
# it would break that skip and force a
# full re-review (~46k tokens, measured)
# on every project without a registry.
# Staying silent here is DELIBERATE.
#
# a pattern the shell never -> the caller asked for a whole class of
# expanded documents and got none. bash passes an
# unmatched glob through verbatim, so the
# wildcard surviving into argv IS the
# evidence that nothing matched.
#
# Only the second is a coverage hole, and only the second is reported. The
# discriminator is exact: a real filename cannot reach this script still
# carrying an unexpanded wildcard, so this cannot fire on the registry case.
is_unexpanded_glob() {
case "$1" in
*'*'*|*'?'*|*'['*) return 0 ;;
*) return 1 ;;
esac
}
# Emit "start<TAB>end<TAB>heading" per ^## section of a file. Content before
# the first ^## (if any) is a __preamble__ span; a file with no ^## at all is
# one __preamble__ span. Duplicate headings get " #N" so keys stay unique —
# the SAME function runs on stamp and check side, so suffixes always agree.
section_spans() {
awk '
BEGIN { pre = 1 }
/^## / {
if (pre && NR > 1) print 1 "\t" NR-1 "\t__preamble__"
pre = 0
if (s) print s "\t" NR-1 "\t" h
s = NR
h = substr($0, 4)
if (seen[h]++) h = h " #" seen[h]
}
END {
if (pre && NR > 0) print 1 "\t" NR "\t__preamble__"
if (s) print s "\t" NR "\t" h
}
' "$1"
}
section_hash() { # <file> <start> <end>
# Mirrors hash_file above, and for the same reason it was written that way.
# This called bare `sha1sum`, which is GNU coreutils and absent on macOS. The
# failure was not a visible error: sha1sum missing makes this return EMPTY,
# an empty `cur` compares EQUAL to an empty stored hash, and the check below
# then prints SECTION-UNCHANGED for every section however it was edited.
# That is the false-NEGATIVE direction this script's header names as the
# dangerous one -- a changed section silently escaping review -- and it was
# reachable on any machine without GNU coreutils.
_sec=""
if command -v git >/dev/null 2>&1; then
_sec=$(sed -n "$2,$3p" "$1" | git hash-object --stdin 2>/dev/null)
fi
[ -n "$_sec" ] || _sec=$(sed -n "$2,$3p" "$1" | sha1sum 2>/dev/null | cut -d' ' -f1)
# Never empty. An unhashable section must be visible in the receipt rather
# than silently equal to the next unhashable one.
[ -n "$_sec" ] || _sec="NO-HASH-TOOL"
printf '%s' "$_sec"
}
mode="${1:-}"
case "$mode" in
hash)
shift
[ $# -ge 1 ] || { echo "review-receipts: hash mode needs at least one file" >&2; exit 2; }
rc=0
for f in "$@"; do
if [ ! -f "$f" ]; then
echo "review-receipts: no such file: $f" >&2
rc=2
continue
fi
h=$(hash_file "$f")
[ -n "$h" ] || { echo "review-receipts: could not hash: $f" >&2; rc=2; continue; }
printf 'Reviewed-Content-Hash: %s %s\n' "$(norm_path "$f")" "$h"
done
exit "$rc"
;;
check)
shift
receipt="${1:-}"
shift 2>/dev/null || true
[ $# -ge 1 ] || { echo "review-receipts: check mode needs a receipt file and at least one file" >&2; exit 2; }
if [ ! -s "${receipt:-}" ]; then
echo "RECEIPT: NONE"
for f in "$@"; do
if [ ! -f "$f" ]; then
is_unexpanded_glob "$f" && printf 'UNRESOLVED: %s\n' "$(norm_path "$f")"
continue
fi
printf 'NEW: %s\n' "$(norm_path "$f")"
done
exit 0
fi
echo "RECEIPT: $(norm_path "$receipt")"
for f in "$@"; do
if [ ! -f "$f" ]; then
is_unexpanded_glob "$f" && printf 'UNRESOLVED: %s\n' "$(norm_path "$f")"
continue
fi
p=$(norm_path "$f")
# Last occurrence wins (append-only log). grep -F on the path avoids
# regex surprises from dots/dashes in filenames.
stored=$(grep -F "Reviewed-Content-Hash: $p " "$receipt" 2>/dev/null | tail -1 | awk '{print $NF}')
if [ -z "$stored" ]; then
printf 'NEW: %s\n' "$p"
continue
fi
current=$(hash_file "$f")
if [ "$current" = "$stored" ]; then
printf 'UNCHANGED: %s\n' "$p"
else
printf 'CHANGED: %s\n' "$p"
fi
done
exit 0
;;
sections-hash)
shift
f="${1:-}"
[ -f "${f:-}" ] || { echo "review-receipts: sections-hash needs an existing file" >&2; exit 2; }
p=$(norm_path "$f")
while IFS=$(printf '\t') read -r s e h; do
[ -n "$h" ] || continue
printf 'Reviewed-Section-Hash: %s#%s %s\n' "$p" "$h" "$(section_hash "$f" "$s" "$e")"
done <<EOF_SH
$(section_spans "$f")
EOF_SH
exit 0
;;
sections-check)
shift
receipt="${1:-}"
f="${2:-}"
[ -f "${f:-}" ] || { echo "review-receipts: sections-check needs a receipt file and an existing file" >&2; exit 2; }
p=$(norm_path "$f")
# Latest stamp per section key for this path (append-only log: last wins).
#
# TAB-DELIMITED ACCUMULATORS, NOT `declare -A`. Associative arrays are bash
# 4, and macOS ships bash 3.2 as /bin/bash. There `declare -A` fails but
# execution CONTINUES -- and a string subscript is then evaluated as
# ARITHMETIC, so every heading collapses to index 0, each stamp overwrites
# the last, and the UNCHANGED / CHANGED / REMOVED verdicts come out garbage
# with nothing reported. A review tool reporting UNCHANGED for an edited
# section is the false-negative direction this script's header calls the
# dangerous one.
#
# Safe as a delimited string because a section key is heading text, which
# cannot contain a TAB or a newline. Matching is field-exact via awk, never
# a substring grep, so one heading cannot match another that contains it.
_RR_TAB=$(printf '\t')
STORED=""
STORED_N=0
if [ -s "${receipt:-}" ]; then
while IFS= read -r line; do
case "$line" in
"Reviewed-Section-Hash: $p#"*) ;;
*) continue ;;
esac
rest="${line#Reviewed-Section-Hash: $p#}"
key="${rest% *}"
[ -n "$key" ] || continue
STORED="$STORED$key$_RR_TAB${rest##* }
"
STORED_N=$((STORED_N + 1))
done <<EOF_SC
$(grep -F "Reviewed-Section-Hash: $p#" "$receipt" 2>/dev/null)
EOF_SC
fi
if [ "$STORED_N" -eq 0 ]; then
echo "SECTIONS: NONE"
fi
# Last occurrence wins, matching the append-only rule stated in the header.
stored_get() {
printf '%s' "$STORED" | awk -F"$_RR_TAB" -v k="$1" '$1==k{v=$2} END{printf "%s", v}'
}
CURRENT=""
while IFS="$_RR_TAB" read -r s e h; do
[ -n "$h" ] || continue
CURRENT="$CURRENT$h
"
cur=$(section_hash "$f" "$s" "$e")
sv=$(stored_get "$h")
# `-z` not a key-existence test, exactly as the array version did: a
# stamp recorded with an empty hash reads NEW, not UNCHANGED.
if [ -z "$sv" ]; then
printf 'SECTION-NEW: %s\n' "$h"
elif [ "$cur" = "$sv" ]; then
printf 'SECTION-UNCHANGED: %s\n' "$h"
else
printf 'SECTION-CHANGED: %s\n' "$h"
fi
done <<EOF_SC2
$(section_spans "$f")
EOF_SC2
printf '%s' "$STORED" | awk -F"$_RR_TAB" 'NF{print $1}' | sort -u | while IFS= read -r k; do
[ -n "$k" ] || continue
printf '%s' "$CURRENT" | awk -v k="$k" '$0==k{f=1} END{exit f?0:1}' \
|| printf 'SECTION-REMOVED: %s\n' "$k"
done
exit 0
;;
*)
echo "Usage: review-receipts.sh hash <file>... | check <receipt-file> <file>... | sections-hash <file> | sections-check <receipt-file> <file>" >&2
exit 2
;;
esac