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
297 lines
11 KiB
Bash
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
|