mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
Release 1.1.2 — the default path works end to end
Fix release for 1.1.1: the minimal path end to end, engine commands that run on Godot, Unity and Unreal, hook warnings you can see, and verdicts that say when they could not check. Details in CHANGELOG.md; upgrade steps in UPGRADING.md (v1.1.1 → v1.1.2). Fixes #67 Fixes #131 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WuJn2FdynEybfL5KC697nJ
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7ed2c3e9c4
commit
b21fa0f7f2
@@ -14,16 +14,20 @@
|
||||
# particular this script never says PASS or FAIL, and never decides that an
|
||||
# ABSENT artifact is a blocker — at `minimal` most of them are not.
|
||||
#
|
||||
# Usage: bash .claude/scripts/artifact-check.sh [--phase <id>] [project-root]
|
||||
# Usage: bash .claude/scripts/artifact-check.sh [--phase <id> | --path <id>] [project-root]
|
||||
# --phase <id> restrict output to one phase (concept, systems-design, ...)
|
||||
# --path <id> report one tier path instead of the phase ladder (`minimal`).
|
||||
# Paths live under the catalog's top-level `paths:` key with the
|
||||
# same step schema. Without --path they are never reported, so
|
||||
# the ladder's consumers see exactly what they saw before paths.
|
||||
# project-root defaults to the repo root; an explicit path is taken as-is
|
||||
# (used by the test suite against fixtures).
|
||||
#
|
||||
# Output:
|
||||
# CATALOG: <path> the catalog actually read
|
||||
# ROOT: <path> the tree evaluated against
|
||||
# PHASES: <n> / STEPS: <n> denominators — see below
|
||||
# PHASE: <id>
|
||||
# PHASES: <n> / STEPS: <n> denominators — see below (PATHS: with --path)
|
||||
# PHASE: <id> (PATH: <id> with --path)
|
||||
# STEP: <id> required=<bool> repeatable=<bool> check=<kind> status=<status> ...
|
||||
#
|
||||
# status values (observations):
|
||||
@@ -48,21 +52,34 @@
|
||||
set -u
|
||||
|
||||
PHASE_FILTER=""
|
||||
PATH_FILTER=""
|
||||
ROOT_ARG=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--phase) PHASE_FILTER="${2:-}"; shift 2 ;;
|
||||
# A trailing flag with no value used to loop forever: `shift 2` with one
|
||||
# argument left fails without shifting anything.
|
||||
--phase|--path)
|
||||
[ $# -ge 2 ] || { echo "artifact-check: $1 needs a value" >&2; exit 2; }
|
||||
if [ "$1" = "--phase" ]; then PHASE_FILTER="$2"; else PATH_FILTER="$2"; fi
|
||||
shift 2 ;;
|
||||
--phase=*) PHASE_FILTER="${1#--phase=}"; shift ;;
|
||||
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
|
||||
--path=*) PATH_FILTER="${1#--path=}"; shift ;;
|
||||
-h|--help) sed -n '2,/^$/p' "$0"; exit 0 ;;
|
||||
*) ROOT_ARG="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$PHASE_FILTER" ] && [ -n "$PATH_FILTER" ]; then
|
||||
echo "artifact-check: --phase and --path are exclusive (a path is a tier's whole route, not a phase)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ -n "$ROOT_ARG" ]; then
|
||||
ROOT="$ROOT_ARG"
|
||||
else
|
||||
cd "$(dirname "$0")/../.." || { echo "artifact-check: cannot reach repo root" >&2; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
# `pwd -W` (Git Bash) gives C:/... . A /c/... path reaches the Windows Python
|
||||
# below only through MSYS path conversion, which MSYS_NO_PATHCONV=1 turns off.
|
||||
ROOT="$(pwd -W 2>/dev/null || pwd)"
|
||||
fi
|
||||
|
||||
CATALOG="$ROOT/.claude/docs/workflow-catalog.yaml"
|
||||
@@ -85,13 +102,14 @@ if [ -z "$PYBIN" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$PYBIN" - "$CATALOG" "$ROOT" "$PHASE_FILTER" <<'PYEOF'
|
||||
"$PYBIN" - "$CATALOG" "$ROOT" "$PHASE_FILTER" "$PATH_FILTER" <<'PYEOF'
|
||||
import glob as globmod
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
catalog_path, root, phase_filter = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
catalog_path, root, phase_filter, path_filter = sys.argv[1:5]
|
||||
|
||||
# A gate's own output path is part of the gate: the catalog's `note:` fields
|
||||
# contain em-dashes, and on a cp1252 console an unreconfigured stdout raises
|
||||
@@ -123,10 +141,11 @@ with open(catalog_path, encoding="utf-8", errors="replace") as fh:
|
||||
lines = [ln.rstrip("\n").rstrip("\r") for ln in fh]
|
||||
|
||||
phases = [] # [(phase_id, [step, ...])]
|
||||
paths = [] # [(path_id, [step, ...])] — same shape, top-level `paths:`
|
||||
cur_phase = None
|
||||
cur_step = None
|
||||
ctx = None # None | "artifact" | "any_of"
|
||||
in_phases = False
|
||||
section = None # the list the current top-level key feeds, or None
|
||||
|
||||
for raw in lines:
|
||||
if not raw.strip() or raw.lstrip().startswith("#"):
|
||||
@@ -135,14 +154,15 @@ for raw in lines:
|
||||
s = raw.strip()
|
||||
|
||||
if ind == 0:
|
||||
in_phases = (s == "phases:")
|
||||
section = {"phases:": phases, "paths:": paths}.get(s)
|
||||
cur_phase = cur_step = ctx = None
|
||||
continue
|
||||
if not in_phases:
|
||||
if section is None:
|
||||
continue
|
||||
|
||||
if ind == 2 and s.endswith(":"):
|
||||
cur_phase = (s[:-1].strip(), [])
|
||||
phases.append(cur_phase)
|
||||
section.append(cur_phase)
|
||||
cur_step = None
|
||||
ctx = None
|
||||
continue
|
||||
@@ -207,18 +227,40 @@ def match_files(pat):
|
||||
|
||||
def pattern_hits(files, pattern):
|
||||
"""POSIX ERE via grep -E. Python's re cannot parse [[:space:]], and grep -P
|
||||
is unavailable on Windows Git Bash."""
|
||||
is unavailable on Windows Git Bash.
|
||||
|
||||
The pattern goes to grep via a pattern FILE (-f <path>), never as an
|
||||
argument and never on stdin: on Windows an argv element is re-quoted into
|
||||
a command line and re-split by MSYS grep, and a `"` in the pattern did not
|
||||
survive the round trip — the step read ABSENT against a file that matched
|
||||
(the quoted `name: "Godot"`). Stdin (-f -) was tried next, but BSD grep's
|
||||
reading of `-f -` is unconfirmed, and a build that does not read stdin
|
||||
there exits 2, which this function would have to treat as UNKNOWN anyway
|
||||
— so it writes the pattern to a real file instead, which every grep reads
|
||||
the same way."""
|
||||
if not pattern:
|
||||
return files
|
||||
hits = []
|
||||
for f in files:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".grep", delete=False) as pf:
|
||||
pf.write(pattern + "\n")
|
||||
pattern_path = pf.name
|
||||
except OSError:
|
||||
return None # no writable temp location — caller reports UNKNOWN
|
||||
try:
|
||||
for f in files:
|
||||
try:
|
||||
rc = subprocess.run(["grep", "-qE", "-f", pattern_path, f],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode
|
||||
except OSError:
|
||||
return None # no grep — caller reports UNKNOWN rather than a false miss
|
||||
if rc == 0:
|
||||
hits.append(f)
|
||||
finally:
|
||||
try:
|
||||
rc = subprocess.call(["grep", "-qE", "--", pattern, f],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
os.unlink(pattern_path)
|
||||
except OSError:
|
||||
return None # no grep — caller reports UNKNOWN rather than a false miss
|
||||
if rc == 0:
|
||||
hits.append(f)
|
||||
pass
|
||||
return hits
|
||||
|
||||
|
||||
@@ -262,23 +304,27 @@ def evaluate(art):
|
||||
return "PRESENT", "glob", d
|
||||
|
||||
|
||||
sel = [(pid, steps) for pid, steps in phases if not phase_filter or pid == phase_filter]
|
||||
if path_filter:
|
||||
label, pool, flt = "PATH", paths, path_filter
|
||||
else:
|
||||
label, pool, flt = "PHASE", phases, phase_filter
|
||||
sel = [(pid, steps) for pid, steps in pool if not flt or pid == flt]
|
||||
|
||||
if phase_filter and not sel:
|
||||
known = ", ".join(pid for pid, _ in phases) or "(none parsed)"
|
||||
sys.stderr.write("artifact-check: unknown phase '%s' (known: %s)\n" % (phase_filter, known))
|
||||
if flt and not sel:
|
||||
known = ", ".join(pid for pid, _ in pool) or "(none parsed)"
|
||||
sys.stderr.write("artifact-check: unknown %s '%s' (known: %s)\n" % (label.lower(), flt, known))
|
||||
sys.exit(2)
|
||||
|
||||
total_steps = sum(len(s) for _, s in sel)
|
||||
print("CATALOG: %s" % os.path.relpath(catalog_path, root).replace(os.sep, "/"))
|
||||
print("ROOT: %s" % root.replace(os.sep, "/"))
|
||||
print("PHASES: %d" % len(sel))
|
||||
print("%sS: %d" % (label, len(sel)))
|
||||
print("STEPS: %d" % total_steps)
|
||||
|
||||
no_check = 0
|
||||
rows = []
|
||||
for pid, steps in sel:
|
||||
rows.append("PHASE: %s" % pid)
|
||||
rows.append("%s: %s" % (label, pid))
|
||||
for st in steps:
|
||||
status, kind, det = evaluate(st["artifact"])
|
||||
if status == "NO_CHECK":
|
||||
|
||||
@@ -50,6 +50,7 @@ not_a_system_gdd() {
|
||||
case "$(basename "$1")" in
|
||||
game-concept.md|systems-index.md|game-pillars.md|gdd-cross-review-*.md) return 0 ;;
|
||||
gameplay-tags.md|fixture-swap-ledger.md|entity-registry.md) return 0 ;;
|
||||
sound-bible.md) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
extends SceneTree
|
||||
## Parse-checks Godot scripts the way the game loads them.
|
||||
##
|
||||
## Usage, from the project root, after `godot --headless --path . --import`
|
||||
## (which builds the class cache that `class_name` lookups need):
|
||||
##
|
||||
## godot --headless --path . -s res://.claude/scripts/godot-parse-check.gd -- res://src/a.gd res://src/b.gd
|
||||
##
|
||||
## Prints `ok: <path>` or `PARSE FAIL: <path>` per script, with Godot's own
|
||||
## error lines above each failure. Exit 0 when every script loads, 1 when any
|
||||
## does not, 2 when no script was given.
|
||||
##
|
||||
## Why not `--check-only`: it compiles the file with no autoloads
|
||||
## registered, so valid code that names an autoload exits 1 with
|
||||
## "Identifier not found". Loading in `_initialize()` runs after startup has
|
||||
## registered them; `_init()` runs before, and fails the same way.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var paths := OS.get_cmdline_user_args()
|
||||
if paths.is_empty():
|
||||
printerr("godot-parse-check: no scripts given. Pass res:// paths after `--`.")
|
||||
quit(2)
|
||||
return
|
||||
var failed := 0
|
||||
for path in paths:
|
||||
var script := load(path) as Script
|
||||
if script == null or not script.can_instantiate():
|
||||
printerr("PARSE FAIL: ", path)
|
||||
failed += 1
|
||||
else:
|
||||
print("ok: ", path)
|
||||
quit(1 if failed > 0 else 0)
|
||||
@@ -569,7 +569,7 @@ fi
|
||||
echo "schema_version: 1"
|
||||
echo ""
|
||||
echo "framework:"
|
||||
echo " version: 1.1.1"
|
||||
echo " version: 1.1.2"
|
||||
echo ""
|
||||
# Emit `project:` only when there is a stage to put in it -- same rule as the
|
||||
# `modes:` block below. Writing `stage: null` produced a file that FAILED THIS
|
||||
|
||||
@@ -69,6 +69,9 @@ summarise() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# major.minor of the first version in $1, with Unity's 6000.N read as 6.N.
|
||||
vmm() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+' | head -1 | sed -E 's/^6000\./6./'; }
|
||||
|
||||
say "=== project coherence ==="
|
||||
|
||||
if [ ! -f project.yaml ]; then
|
||||
@@ -107,11 +110,15 @@ else
|
||||
fi
|
||||
|
||||
# --- 2. project.yaml version vs the pinned reference ------------------------
|
||||
# Compared on major.minor, as check 3 does: engine.version is usually the full
|
||||
# release (4.6.1, 6000.3.23f1) and the pin the series (Godot 4.6, Unity 6.3
|
||||
# LTS). Unity numbers the same editor two ways -- 6000.3 is marketed as 6.3.
|
||||
WANT2="$(vmm "$VERSION")"; GOT2="$(vmm "$PINNED")"
|
||||
if [ -z "$PINNED" ]; then
|
||||
skipped "project.yaml version vs pinned reference — no Engine Version row in $VERFILE"
|
||||
elif [ -z "$VERSION" ]; then
|
||||
skipped "project.yaml version vs pinned reference — project.yaml has no engine.version"
|
||||
elif printf '%s' "$PINNED" | grep -qF "$VERSION"; then
|
||||
elif printf '%s' "$PINNED" | grep -qF "$VERSION" || { [ -n "$WANT2" ] && [ "$WANT2" = "$GOT2" ]; }; then
|
||||
match "engine.version ($VERSION) agrees with $VERFILE ($PINNED)"
|
||||
else
|
||||
differs "engine.version is '$VERSION' but $VERFILE pins '$PINNED'. Agents consult the reference file, so they will answer for a version the project does not declare."
|
||||
@@ -122,26 +129,61 @@ fi
|
||||
PROBE=""
|
||||
case "$ENGINE_LC" in
|
||||
godot)
|
||||
if command -v godot >/dev/null 2>&1; then
|
||||
PROBE="$(godot --version 2>/dev/null | head -1)"
|
||||
# The executable named in commands.test (quoted, or its first word), else
|
||||
# engine.path, else `godot` on PATH -- the same fallback order dev-story
|
||||
# and smoke-check use. commands.* now carries the full editor path once
|
||||
# /setup-engine has run, so a bare `command -v godot` alone reported "no
|
||||
# Godot binary found on PATH" even on a correctly configured project.
|
||||
GEXE=""
|
||||
for GCAND in \
|
||||
"$(yaml_block_value commands test | grep -oE '"[^"]*"' | head -1 | tr -d '"')" \
|
||||
"$(yaml_block_value commands test | awk '{print $1}')" \
|
||||
"$(yaml_block_value engine path)" \
|
||||
"godot"; do
|
||||
[ -n "$GCAND" ] || continue
|
||||
if command -v "$GCAND" >/dev/null 2>&1 || [ -x "$GCAND" ]; then
|
||||
GEXE="$GCAND"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$GEXE" ] && PROBE="$("$GEXE" --version 2>/dev/null | head -1)"
|
||||
;;
|
||||
unity)
|
||||
# The editor named in commands.test (/setup-engine writes its full, quoted
|
||||
# path), else the Hub folder for engine.version, picked by OS -- Unity's
|
||||
# Hub installs to a different path on each. Never bare `Unity`: on PATH
|
||||
# that is often Unity's separate CLI, which rejects -version with exit 2.
|
||||
UEXE="$(yaml_block_value commands test | grep -oE '"[^"]*/Unity(\.exe)?"' | head -1 | tr -d '"')"
|
||||
if [ -z "$UEXE" ] && [ -n "$VERSION" ]; then
|
||||
case "$(uname -s)" in
|
||||
Darwin) UEXE="/Applications/Unity/Hub/Editor/$VERSION/Unity.app/Contents/MacOS/Unity" ;;
|
||||
Linux) UEXE="$HOME/Unity/Hub/Editor/$VERSION/Editor/Unity" ;;
|
||||
*) UEXE="C:/Program Files/Unity/Hub/Editor/$VERSION/Editor/Unity.exe" ;;
|
||||
esac
|
||||
fi
|
||||
[ -n "$UEXE" ] && [ -x "$UEXE" ] && PROBE="$("$UEXE" -version 2>/dev/null | head -1)"
|
||||
;;
|
||||
unity) command -v Unity >/dev/null 2>&1 && PROBE="$(Unity -version 2>/dev/null | head -1)" ;;
|
||||
unreal) command -v UnrealEditor-Cmd >/dev/null 2>&1 && PROBE="$(UnrealEditor-Cmd -version 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$PROBE" ]; then
|
||||
skipped "declared version vs installed binary — no $ENGINE binary found on PATH. A probe that could not run has not established absence."
|
||||
if [ "$ENGINE_LC" = "unity" ]; then
|
||||
skipped "declared version vs installed binary — no Unity editor at the path in commands.test or in the Hub folder for engine.version. A probe that could not run has not established absence."
|
||||
elif [ "$ENGINE_LC" = "godot" ]; then
|
||||
skipped "declared version vs installed binary — no Godot executable found (commands.test, engine.path, PATH). A probe that could not run has not established absence."
|
||||
else
|
||||
skipped "declared version vs installed binary — no $ENGINE binary found on PATH. A probe that could not run has not established absence."
|
||||
fi
|
||||
elif [ -z "$VERSION" ]; then
|
||||
skipped "declared version vs installed binary — project.yaml has no engine.version"
|
||||
else
|
||||
# Compare on major.minor: a patch difference is normal and not worth a flag.
|
||||
WANT="$(printf '%s' "$VERSION" | grep -oE '^[0-9]+\.[0-9]+')"
|
||||
GOT="$(printf '%s' "$PROBE" | grep -oE '[0-9]+\.[0-9]+' | head -1)"
|
||||
WANT="$(vmm "$VERSION")"
|
||||
GOT="$(vmm "$PROBE")"
|
||||
if [ -n "$WANT" ] && [ "$WANT" = "$GOT" ]; then
|
||||
match "engine.version $VERSION matches the installed binary ($PROBE)"
|
||||
else
|
||||
differs "engine.version is '$VERSION' but the binary on PATH is '$PROBE'. Build, test and smoke commands all target the declared version."
|
||||
differs "engine.version is '$VERSION' but the installed binary reports '$PROBE'. Build, test and smoke commands all target the declared version."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -205,12 +247,20 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
RUNNER="$(printf '%s' "$TEST_CMD" | grep -oE '[A-Za-z0-9_/.-]+\.(gd|cs|py|sh)' | head -1)"
|
||||
# Godot names its runner as res://addons/gdUnit4/bin/GdUnitCmdTool.gd. Match the
|
||||
# res:// prefix and strip it: a class that stops at the colon checked
|
||||
# //addons/... and reported a present runner as missing.
|
||||
RUNNER="$(printf '%s' "$TEST_CMD" | grep -oE '(res://)?[A-Za-z0-9_/.-]+\.(gd|cs|py|sh)' | head -1)"
|
||||
RUNNER="${RUNNER#res://}"
|
||||
if [ -n "$RUNNER" ]; then
|
||||
if [ -f "$RUNNER" ]; then
|
||||
match "commands.test runner exists ($RUNNER)"
|
||||
else
|
||||
differs "commands.test is '$TEST_CMD' but '$RUNNER' does not exist. At qa.level: minimal nothing ever creates it, so this command is unrunnable as written."
|
||||
case "$RUNNER" in
|
||||
addons/gdUnit4/*) HINT=" gdUnit4 is not installed at addons/gdUnit4/ (capital U) — see /test-setup." ;;
|
||||
*) HINT="" ;;
|
||||
esac
|
||||
differs "commands.test is '$TEST_CMD' but '$RUNNER' does not exist, so this command cannot run as written.$HINT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
# 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.
|
||||
# missed review): a hash tool changing between stamp and check, a receipt
|
||||
# file that was never written, or no hash tool at all (git, sha1sum, shasum),
|
||||
# which makes everything read CHANGED and says so on stderr.
|
||||
#
|
||||
# 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
|
||||
@@ -78,15 +79,44 @@
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
# SHA-1 of stdin, as "<hex> -". The fallback when git cannot hash: sha1sum is
|
||||
# GNU coreutils, which older macOS releases do not ship; every macOS has
|
||||
# shasum, which prints the same hex. Each is RUN before it is used -- a tool that is found but fails must
|
||||
# not win. Returns 1 when neither runs.
|
||||
sha1_stdin() {
|
||||
if [ -z "${_RR_SHA1+x}" ]; then
|
||||
_RR_SHA1=""
|
||||
if printf '' | sha1sum >/dev/null 2>&1; then _RR_SHA1=sha1sum
|
||||
elif printf '' | shasum -a 1 >/dev/null 2>&1; then _RR_SHA1=shasum
|
||||
fi
|
||||
fi
|
||||
case "$_RR_SHA1" in
|
||||
sha1sum) sha1sum ;;
|
||||
shasum) shasum -a 1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Printed at most once per run, on stderr: with no hash tool nothing can be
|
||||
# verified unchanged, and the caller should know why everything reads CHANGED.
|
||||
_rr_warned=""
|
||||
warn_no_hash() {
|
||||
[ -n "$_rr_warned" ] && return 0
|
||||
echo "review-receipts: no hash tool (git, sha1sum or shasum) -- nothing can read UNCHANGED" >&2
|
||||
_rr_warned=1
|
||||
}
|
||||
|
||||
hash_file() {
|
||||
# git hash-object is preferred (stable, content-addressed, no mtime input).
|
||||
# sha1sum fallback produces a DIFFERENT hash for the same bytes (git
|
||||
# The SHA-1 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).
|
||||
# The file goes in on stdin: sha1sum starts its line with "\" when the file
|
||||
# name holds a backslash, as a Windows path can.
|
||||
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
|
||||
sha1_stdin < "$1" 2>/dev/null | cut -d' ' -f1
|
||||
}
|
||||
|
||||
norm_path() {
|
||||
@@ -147,7 +177,7 @@ section_spans() {
|
||||
|
||||
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
|
||||
# This called bare `sha1sum`, which is GNU coreutils and absent on older 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.
|
||||
@@ -158,9 +188,10 @@ section_hash() { # <file> <start> <end>
|
||||
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)
|
||||
[ -n "$_sec" ] || _sec=$(sed -n "$2,$3p" "$1" | sha1_stdin 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.
|
||||
# than silently equal to the next unhashable one -- and sections-check never
|
||||
# reads this placeholder as UNCHANGED, since it matches every section.
|
||||
[ -n "$_sec" ] || _sec="NO-HASH-TOOL"
|
||||
printf '%s' "$_sec"
|
||||
}
|
||||
@@ -217,6 +248,7 @@ case "$mode" in
|
||||
continue
|
||||
fi
|
||||
current=$(hash_file "$f")
|
||||
[ -n "$current" ] || warn_no_hash
|
||||
if [ "$current" = "$stored" ]; then
|
||||
printf 'UNCHANGED: %s\n' "$p"
|
||||
else
|
||||
@@ -233,7 +265,9 @@ case "$mode" in
|
||||
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")"
|
||||
sec_hash=$(section_hash "$f" "$s" "$e")
|
||||
[ "$sec_hash" = "NO-HASH-TOOL" ] && warn_no_hash
|
||||
printf 'Reviewed-Section-Hash: %s#%s %s\n' "$p" "$h" "$sec_hash"
|
||||
done <<EOF_SH
|
||||
$(section_spans "$f")
|
||||
EOF_SH
|
||||
@@ -300,6 +334,12 @@ EOF_SC
|
||||
# stamp recorded with an empty hash reads NEW, not UNCHANGED.
|
||||
if [ -z "$sv" ]; then
|
||||
printf 'SECTION-NEW: %s\n' "$h"
|
||||
elif [ "$cur" = "NO-HASH-TOOL" ]; then
|
||||
# Nothing could hash this section, so nothing can call it unchanged --
|
||||
# least of all a stored placeholder, which equals every other one. A
|
||||
# wasted re-review is safe; a changed section skipped is not.
|
||||
warn_no_hash
|
||||
printf 'SECTION-CHANGED: %s\n' "$h"
|
||||
elif [ "$cur" = "$sv" ]; then
|
||||
printf 'SECTION-UNCHANGED: %s\n' "$h"
|
||||
else
|
||||
|
||||
@@ -39,6 +39,7 @@ not_a_system_gdd() {
|
||||
case "$(basename "$1")" in
|
||||
game-concept.md|systems-index.md|game-pillars.md|gdd-cross-review-*.md) return 0 ;;
|
||||
gameplay-tags.md|fixture-swap-ledger.md|entity-registry.md) return 0 ;;
|
||||
sound-bible.md) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# story-status.sh -- the story files' Status lines, in the order the minimal
|
||||
# route takes them. Injected into /help and /sprint-status before the model reads
|
||||
# either skill:
|
||||
#
|
||||
# !`bash "${CLAUDE_SKILL_DIR}/../../scripts/story-status.sh"`
|
||||
#
|
||||
# WHY A SCRIPT. The route's rule is "an In Review story is next (its work is
|
||||
# written; close it), else an In Progress one, else the first Ready or Not
|
||||
# Started one in build order". Given every status line at once, a small model
|
||||
# ranked them by file order and got the Ready/Not Started case wrong; listing the
|
||||
# stories already in the route's order leaves nothing to rank. (In Review comes
|
||||
# first: its work is already written, /story-done is quick, and closing it
|
||||
# keeps finished work from piling up behind started work.)
|
||||
#
|
||||
# OBSERVATIONS ONLY (CLAUDE.md): each line is a story and the status as written.
|
||||
# The skill decides what the first line means; nothing here says "next".
|
||||
#
|
||||
# Output, one line per unfinished story, file-name (build) order within a group:
|
||||
# IN_REVIEW <path>
|
||||
# IN_PROGRESS <path>
|
||||
# TODO <path> (Ready | Not Started)
|
||||
# BLOCKED <path> (<status as written>)
|
||||
# OTHER <path> (<status as written>) -- e.g. "Not Done", "Draft"
|
||||
# NO_STATUS <path>
|
||||
# COMPLETE <done> of <total>
|
||||
# or `STORIES none` when there are no story files.
|
||||
#
|
||||
# Reads the same files and Status formats as statusline.sh's story count
|
||||
# (production/epics/*/story-*.md; `Status: X`, `**Status:** X`,
|
||||
# `> **Status**: X`, `- **Status**: X`) and counts "done" by the same rule --
|
||||
# the value's leading word, in any case, is Complete or Done: `Done.` counts,
|
||||
# `Not Done` and `Completed` do not -- so the two cannot disagree on "done".
|
||||
# Builtins and one awk -- no Python, no per-file process.
|
||||
|
||||
# The project root is two levels above this file: the injected command runs from
|
||||
# wherever the skill's shell starts, which need not be the root.
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null || { echo "STORIES none"; exit 0; }
|
||||
|
||||
files=()
|
||||
for f in production/epics/*/story-*.md; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "STORIES none"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "${files[@]}" | LC_ALL=C sort | awk '
|
||||
{
|
||||
f = $0; st = ""; found = 0
|
||||
while ((getline line < f) > 0) {
|
||||
sub(/\r$/, "", line); sub(/^[ \t]+/, "", line)
|
||||
sub(/^> /, "", line); sub(/^- /, "", line); gsub(/\*/, "", line)
|
||||
if (line ~ /^Status:/) {
|
||||
st = substr(line, 8); sub(/^[ \t]+/, "", st); sub(/[ \t]+$/, "", st)
|
||||
found = 1; break
|
||||
}
|
||||
}
|
||||
close(f)
|
||||
n++; s = tolower(st)
|
||||
if (!found) { other[++no] = "NO_STATUS " f }
|
||||
else if (s ~ /^(complete|done)([^a-z]|$)/) { done++ }
|
||||
else if (s ~ /^in progress([^a-z]|$)/) { prog[++np] = "IN_PROGRESS " f }
|
||||
else if (s ~ /^in review([^a-z]|$)/) { rev[++nr] = "IN_REVIEW " f }
|
||||
else if (s ~ /^(ready|not started)([^a-z]|$)/) { todo[++nt] = "TODO " f " (" st ")" }
|
||||
else if (s ~ /^blocked([^a-z]|$)/) { blk[++nb] = "BLOCKED " f " (" st ")" }
|
||||
else { other[++no] = "OTHER " f " (" st ")" }
|
||||
}
|
||||
END {
|
||||
for (i = 1; i <= nr; i++) print rev[i]
|
||||
for (i = 1; i <= np; i++) print prog[i]
|
||||
for (i = 1; i <= nt; i++) print todo[i]
|
||||
for (i = 1; i <= nb; i++) print blk[i]
|
||||
for (i = 1; i <= no; i++) print other[i]
|
||||
printf "COMPLETE %d of %d\n", done, n
|
||||
}'
|
||||
exit 0
|
||||
Reference in New Issue
Block a user