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:
Donchitos
2026-09-29 21:32:19 +10:00
co-authored by Claude Opus 5.5
parent 7ed2c3e9c4
commit b21fa0f7f2
383 changed files with 22888 additions and 10618 deletions
+71 -25
View File
@@ -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":
+1
View File
@@ -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
}
+33
View File
@@ -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)
+1 -1
View File
@@ -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
+60 -10
View File
@@ -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
+48 -8
View File
@@ -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
+1
View File
@@ -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
}
+78
View File
@@ -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