mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 09:58:36 +00:00
`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
325 lines
13 KiB
Bash
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
|