Files
Claude-Code-Game-Studios/.claude/scripts/artifact-check.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

297 lines
11 KiB
Bash

#!/usr/bin/env bash
# artifact-check.sh — evaluate every workflow-catalog step's artifact spec
# against what is actually on disk.
#
# Replaces the "Use Glob and Read to verify files exist and have meaningful
# content" loop in /gate-check (and the equivalent hand-scans in /help and
# /project-stage-detect). workflow-catalog.yaml already encodes `glob`,
# `pattern`, `min_count` and `any_of` per step; nothing consumed it
# deterministically, so the model re-derived the same answers by opening files.
#
# EMITS OBSERVATIONS, NOT A VERDICT (per .claude/docs/context-management.md
# rule 2). It reports what is on disk; the CALLER applies the workflow tier,
# the required/optional distinction, and any per-system override. In
# 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]
# --phase <id> restrict output to one phase (concept, systems-design, ...)
# 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>
# STEP: <id> required=<bool> repeatable=<bool> check=<kind> status=<status> ...
#
# status values (observations):
# PRESENT glob matched, count >= min_count, pattern found if specified
# ABSENT no file matched the glob
# SHORT files matched but fewer than min_count
# PATTERN_MISS files matched but none contained the required pattern
# NO_CHECK the step declares no artifact — completion is not detectable
# from disk. NOT the same as ABSENT. A `note=` field carries the
# catalog's human-readable fallback where one exists.
#
# DENOMINATOR DISCIPLINE (mirrors create-control-manifest / adr-dep-graph.sh):
# STEPS is printed before any per-step line so a caller can tell "0 steps
# reported because the catalog failed to parse" from "0 steps are incomplete".
# A NO_CHECK count is printed too — a phase that is all NO_CHECK has been
# *scanned*, not *satisfied*, and reporting it as clean would be a false pass.
#
# Patterns are POSIX ERE and are matched with `grep -E`, never Python `re`
# (the catalog uses classes like [[:space:]]) and never `grep -P` (unavailable
# on Windows Git Bash — see .claude/docs/coding-standards notes).
set -u
PHASE_FILTER=""
ROOT_ARG=""
while [ $# -gt 0 ]; do
case "$1" in
--phase) PHASE_FILTER="${2:-}"; shift 2 ;;
--phase=*) PHASE_FILTER="${1#--phase=}"; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) ROOT_ARG="$1"; shift ;;
esac
done
if [ -n "$ROOT_ARG" ]; then
ROOT="$ROOT_ARG"
else
cd "$(dirname "$0")/../.." || { echo "artifact-check: cannot reach repo root" >&2; exit 1; }
ROOT="$(pwd)"
fi
CATALOG="$ROOT/.claude/docs/workflow-catalog.yaml"
if [ ! -f "$CATALOG" ]; then
echo "artifact-check: catalog not found at $CATALOG" >&2
exit 1
fi
# Python fallback chain: python -> python3 -> py (matches yaml-helper.sh:65).
PYBIN=""
for candidate in python python3 py; do
if command -v "$candidate" >/dev/null 2>&1; then
if "$candidate" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then
PYBIN="$candidate"; break
fi
fi
done
if [ -z "$PYBIN" ]; then
echo "artifact-check: no python 3 interpreter found (tried python, python3, py)" >&2
exit 1
fi
"$PYBIN" - "$CATALOG" "$ROOT" "$PHASE_FILTER" <<'PYEOF'
import glob as globmod
import os
import subprocess
import sys
catalog_path, root, phase_filter = sys.argv[1], sys.argv[2], sys.argv[3]
# 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
# UnicodeEncodeError mid-report — dying after some rows have printed, which
# reads as a short but successful run. Force UTF-8 and never crash on a glyph.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
def indent_of(line):
return len(line) - len(line.lstrip(" "))
def strip_val(v):
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1]
return v
# --- parse ---------------------------------------------------------------
# Hand-rolled on purpose: PyYAML is not a guaranteed dependency (yaml-helper.sh
# promises "no external deps beyond a Python 3 interpreter"). The catalog's
# shape is fixed and regular, so an indentation walk is sufficient and cannot
# drag in an import that fails on a user's machine.
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, ...])]
cur_phase = None
cur_step = None
ctx = None # None | "artifact" | "any_of"
in_phases = False
for raw in lines:
if not raw.strip() or raw.lstrip().startswith("#"):
continue
ind = indent_of(raw)
s = raw.strip()
if ind == 0:
in_phases = (s == "phases:")
continue
if not in_phases:
continue
if ind == 2 and s.endswith(":"):
cur_phase = (s[:-1].strip(), [])
phases.append(cur_phase)
cur_step = None
ctx = None
continue
if cur_phase is None:
continue
if ind == 6 and s.startswith("- id:"):
cur_step = {"id": strip_val(s[len("- id:"):]), "required": False,
"repeatable": False, "artifact": None, "note": None}
cur_phase[1].append(cur_step)
ctx = None
continue
if cur_step is None:
continue
if ind == 8:
ctx = None
if s == "artifact:":
cur_step["artifact"] = {"glob": None, "pattern": None,
"min_count": 1, "any_of": [], "note": None}
ctx = "artifact"
elif s.startswith("required:"):
cur_step["required"] = strip_val(s[len("required:"):]).lower() == "true"
elif s.startswith("repeatable:"):
cur_step["repeatable"] = strip_val(s[len("repeatable:"):]).lower() == "true"
continue
art = cur_step["artifact"]
if art is None:
continue
if ind == 10:
if s == "any_of:":
ctx = "any_of"
elif s.startswith("glob:"):
art["glob"] = strip_val(s[len("glob:"):]); ctx = "artifact"
elif s.startswith("pattern:"):
art["pattern"] = strip_val(s[len("pattern:"):]); ctx = "artifact"
elif s.startswith("min_count:"):
try:
art["min_count"] = int(strip_val(s[len("min_count:"):]))
except ValueError:
pass
ctx = "artifact"
elif s.startswith("note:"):
art["note"] = strip_val(s[len("note:"):]); ctx = "artifact"
continue
if ind >= 12 and ctx == "any_of":
if s.startswith("- glob:"):
art["any_of"].append({"glob": strip_val(s[len("- glob:"):]), "pattern": None})
elif s.startswith("pattern:") and art["any_of"]:
art["any_of"][-1]["pattern"] = strip_val(s[len("pattern:"):])
# --- evaluate ------------------------------------------------------------
def match_files(pat):
if not pat:
return []
full = os.path.join(root, pat.replace("/", os.sep))
return sorted(p for p in globmod.glob(full, recursive=True) if os.path.isfile(p))
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."""
if not pattern:
return files
hits = []
for f in files:
try:
rc = subprocess.call(["grep", "-qE", "--", pattern, f],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError:
return None # no grep — caller reports UNKNOWN rather than a false miss
if rc == 0:
hits.append(f)
return hits
def evaluate(art):
"""-> (status, kind, detail dict). Observation only; no verdict."""
if art is None:
return "NO_CHECK", "none", {}
if art["any_of"]:
for i, alt in enumerate(art["any_of"]):
files = match_files(alt["glob"])
if not files:
continue
hits = pattern_hits(files, alt["pattern"])
if hits is None:
return "UNKNOWN", "any_of", {"why": "grep-unavailable"}
if hits:
return "PRESENT", "any_of", {"match": alt["glob"], "alt": str(i + 1)}
return "ABSENT", "any_of", {"alts": str(len(art["any_of"]))}
if not art["glob"]:
return "NO_CHECK", "none", ({"note": art["note"]} if art["note"] else {})
files = match_files(art["glob"])
if not files:
return "ABSENT", "glob", {"glob": art["glob"]}
hits = pattern_hits(files, art["pattern"])
if hits is None:
return "UNKNOWN", "glob", {"why": "grep-unavailable"}
if art["pattern"] and not hits:
return "PATTERN_MISS", "glob", {"glob": art["glob"], "found": str(len(files))}
counted = hits if art["pattern"] else files
need = art["min_count"]
if len(counted) < need:
return "SHORT", "glob", {"glob": art["glob"], "count": str(len(counted)), "min": str(need)}
d = {"count": str(len(counted))}
if need > 1:
d["min"] = str(need)
return "PRESENT", "glob", d
sel = [(pid, steps) for pid, steps in phases if not phase_filter or pid == phase_filter]
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))
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("STEPS: %d" % total_steps)
no_check = 0
rows = []
for pid, steps in sel:
rows.append("PHASE: %s" % pid)
for st in steps:
status, kind, det = evaluate(st["artifact"])
if status == "NO_CHECK":
no_check += 1
extra = "".join(" %s=%s" % (k, v) for k, v in sorted(det.items()) if v is not None)
rows.append(" STEP: %s required=%s repeatable=%s check=%s status=%s%s"
% (st["id"], str(st["required"]).lower(),
str(st["repeatable"]).lower(), kind, status, extra))
# Printed BEFORE the rows so a caller reading top-down knows how much of what
# follows is undetectable-from-disk before it reads any of it.
print("NO_CHECK: %d" % no_check)
for r in rows:
print(r)
PYEOF