mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
984023ddac
commit
d05699707f
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# adr-dep-graph.sh — build the ADR dependency graph and detect cycles.
|
||||
#
|
||||
# Replaces a full read of every ADR (to extract one table row each) plus a manual
|
||||
# cycle trace. A model tracing A→B→C→A across a dozen ADRs will eventually miss an
|
||||
# edge; Kahn's algorithm cannot. This is the deterministic half of
|
||||
# /gate-check's pre-production "ADR Circular Dependency Check".
|
||||
#
|
||||
# EMITS OBSERVATIONS, NOT A VERDICT (per .claude/docs/context-management.md rule 2).
|
||||
# The caller (gate-pre-production) decides FAIL vs CONCERNS per tier. In
|
||||
# particular NO_DEPS_SECTION is load-bearing: it makes "no cycles because the
|
||||
# graph is clean" distinguishable from "no cycles because half the ADRs have no
|
||||
# dependency section".
|
||||
#
|
||||
# Usage: bash .claude/scripts/adr-dep-graph.sh [adr-dir]
|
||||
# adr-dir defaults to docs/architecture (relative to repo root). An explicit
|
||||
# dir (used by the test suite) is taken as-is.
|
||||
#
|
||||
# Output:
|
||||
# ADRS: <n>
|
||||
# EDGES: one "adr-A -> adr-B" per Depends-On reference
|
||||
# (or " (none)")
|
||||
# NO_DEPS_SECTION: adr-X, ... ADRs with no `## ADR Dependencies` / no Depends On
|
||||
# CYCLE: adr-A -> adr-B -> adr-A one line per node in a dependency cycle
|
||||
# (absent when the graph is acyclic)
|
||||
#
|
||||
# CORRECTNESS NOTES (mirrors review-scope.sh):
|
||||
# - `while IFS= read -r` everywhere: an ADR path with a space must not split.
|
||||
# - IDs are normalised to lowercase `adr-NNNN` so a `Depends On: ADR-0003`
|
||||
# reference matches the file `adr-0003-*.md`.
|
||||
|
||||
set -u
|
||||
|
||||
# Default to the repo's ADR dir; an explicit arg (absolute or CWD-relative, used
|
||||
# by the suite) is taken as-is without cd-ing to the repo root.
|
||||
if [ -n "${1:-}" ]; then
|
||||
ADR_DIR="$1"
|
||||
else
|
||||
cd "$(dirname "$0")/../.." || { echo "adr-dep-graph: cannot reach repo root" >&2; exit 1; }
|
||||
ADR_DIR="docs/architecture"
|
||||
fi
|
||||
|
||||
# Normalise any adr reference (filename or "ADR-0003") to `adr-0003`.
|
||||
adr_id() {
|
||||
printf '%s' "$1" | grep -oiE 'adr-[0-9]+' | head -1 | tr 'A-Z' 'a-z'
|
||||
}
|
||||
|
||||
adr_files=$(find "$ADR_DIR" -maxdepth 1 -name 'adr-*.md' -type f 2>/dev/null \
|
||||
| sed 's|\\|/|g' | sort)
|
||||
|
||||
if [ -z "$adr_files" ]; then
|
||||
echo "ADRS: 0"
|
||||
echo "EDGES:"
|
||||
echo " (none — no ADRs in $ADR_DIR)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
n=0; edges=""; no_deps=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
n=$((n + 1))
|
||||
from=$(adr_id "$f")
|
||||
|
||||
# Slice the `## ADR Dependencies` section, take the `**Depends On**` row, and
|
||||
# pull every ADR-NNNN out of its value cell. `None` yields no matches.
|
||||
deps_row=$(awk '
|
||||
/^## /{ insec = ($0 ~ /## ADR Dependencies/) ? 1 : 0 }
|
||||
insec && /\*\*Depends On\*\*/ { print }
|
||||
' "$f")
|
||||
|
||||
if [ -z "$deps_row" ]; then
|
||||
no_deps="$no_deps $from"
|
||||
continue
|
||||
fi
|
||||
|
||||
to_ids=$(printf '%s' "$deps_row" | grep -oiE 'adr-[0-9]+' | tr 'A-Z' 'a-z' | sort -u)
|
||||
if [ -z "$to_ids" ]; then
|
||||
# Section present but Depends On is "None" (or empty) — a real leaf, not a
|
||||
# missing section. Not reported under NO_DEPS_SECTION.
|
||||
continue
|
||||
fi
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] || continue
|
||||
[ "$t" = "$from" ] && continue # ignore a self-reference
|
||||
edges="$edges$from $t"$'\n'
|
||||
done <<EOF
|
||||
$to_ids
|
||||
EOF
|
||||
done <<EOF
|
||||
$adr_files
|
||||
EOF
|
||||
|
||||
echo "ADRS: $n"
|
||||
|
||||
echo "EDGES:"
|
||||
if [ -z "$edges" ]; then
|
||||
echo " (none)"
|
||||
else
|
||||
printf '%s' "$edges" | while IFS=' ' read -r a b; do
|
||||
[ -n "$a" ] || continue
|
||||
echo " $a -> $b"
|
||||
done
|
||||
fi
|
||||
|
||||
# no_deps carries a leading space per append; strip it, then comma-join.
|
||||
[ -n "$no_deps" ] && echo "NO_DEPS_SECTION: $(printf '%s' "${no_deps# }" | sed 's/ /, /g')"
|
||||
|
||||
# Cycle detection — Kahn's algorithm on out-degree (a node "depends on" its
|
||||
# out-neighbours; a leaf depends on nothing remaining). Whatever cannot be peeled
|
||||
# away is part of a cycle. Reported as an observation; the caller sets the verdict.
|
||||
if [ -n "$edges" ]; then
|
||||
cyc=$(printf '%s' "$edges" | awk -F'\t' '
|
||||
NF==2 {
|
||||
e_from[++ne]=$1; e_to[ne]=$2
|
||||
node[$1]=1; node[$2]=1
|
||||
od[$1]++
|
||||
}
|
||||
END {
|
||||
total=0; for (x in node) total++
|
||||
removed=0; progress=1
|
||||
while (progress) {
|
||||
progress=0
|
||||
for (x in node) {
|
||||
if (gone[x] || od[x] > 0) continue
|
||||
gone[x]=1; removed++; progress=1
|
||||
for (i=1;i<=ne;i++)
|
||||
if (e_to[i]==x && !edone[i]) { edone[i]=1; od[e_from[i]]-- }
|
||||
}
|
||||
}
|
||||
if (removed < total)
|
||||
for (x in node) if (!gone[x]) print x
|
||||
}
|
||||
' | sort)
|
||||
if [ -n "$cyc" ]; then
|
||||
# printf '%s\n' (not '%s') so the final node isn't dropped by read.
|
||||
printf '%s\n' "$cyc" | while IFS= read -r x; do
|
||||
[ -n "$x" ] || continue
|
||||
# Show each cycle node with the in-cycle edges it participates in.
|
||||
outs=$(printf '%s' "$edges" | awk -F'\t' -v x="$x" '$1==x{printf " -> %s", $2}')
|
||||
echo "CYCLE: $x$outs"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# gdd-structure-check.sh — deterministic section-presence check for system GDDs.
|
||||
#
|
||||
# Replaces a full-document model read per GDD with ~2 lines of output that cannot
|
||||
# hallucinate a missing section. This is the cheapest real win available: the
|
||||
# model spends its budget judging content, not counting headings.
|
||||
#
|
||||
# REPORTS PRESENCE ONLY — IT DOES NOT JUDGE COMPLETENESS.
|
||||
# Which sections are REQUIRED depends on the workflow tier and on what the system
|
||||
# defines (/design-review Phase 2b: Player Fantasy and Tuning Knobs are advisory
|
||||
# at `standard`; Formulas is required at `standard` whenever the system defines
|
||||
# numeric rules -- rates, curves, thresholds, costs. The system's category is a
|
||||
# hint, not the test). A `standard`-tier GDD missing Player Fantasy is
|
||||
# COMPLETE, so an "N/8" score would actively misreport it. The caller applies the
|
||||
# tier; this script only says what is on disk.
|
||||
#
|
||||
# Usage:
|
||||
# bash .claude/scripts/gdd-structure-check.sh # all system GDDs
|
||||
# bash .claude/scripts/gdd-structure-check.sh design/gdd/foo.md # one GDD
|
||||
#
|
||||
# Output (per GDD; the ABSENT line is omitted when nothing is absent):
|
||||
# combat.md PRESENT: Overview,Player Fantasy,Detailed Rules,...
|
||||
# loot.md PRESENT: Overview,Detailed Rules,Edge Cases
|
||||
# loot.md ABSENT: Player Fantasy,Formulas,Tuning Knobs
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/../.." || { echo "gdd-structure-check: cannot reach repo root" >&2; exit 1; }
|
||||
|
||||
# Each MATCH entry is an ERE alternation of every accepted heading for one
|
||||
# required section; LABEL is the canonical name reported to the caller.
|
||||
#
|
||||
# "Detailed Rules" is authored as "## Detailed Design" by the GDD template and by
|
||||
# /design-system. They denote the SAME required section. Accepting only one name
|
||||
# reports 7/8 MISSING on every conforming GDD — the exact failure this guard
|
||||
# exists to prevent, and it is regression-tested.
|
||||
MATCH=("Overview" \
|
||||
"Player Fantasy" \
|
||||
"Detailed (Rules|Design)" \
|
||||
"Formulas" \
|
||||
"Edge Cases" \
|
||||
"Dependencies" \
|
||||
"Tuning Knobs" \
|
||||
"Acceptance Criteria")
|
||||
LABEL=("Overview" "Player Fantasy" "Detailed Rules" "Formulas" "Edge Cases" \
|
||||
"Dependencies" "Tuning Knobs" "Acceptance Criteria")
|
||||
|
||||
# Governance and registry docs that live in design/gdd/ but are not system GDDs.
|
||||
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 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_file() {
|
||||
local f="$1" present="" absent="" i s
|
||||
for i in "${!MATCH[@]}"; do
|
||||
s="${MATCH[$i]}"
|
||||
# [[:space:]] not [ \t] — inside double quotes the shell leaves \t literal
|
||||
# and ERE reads the class as {space, backslash, t}, so real tabs fail to
|
||||
# match while "##tOverview" false-positives.
|
||||
if grep -qiE "^##+[[:space:]]+([0-9]+[.)]?[[:space:]]*)?${s}" "$f"; then
|
||||
present="$present,${LABEL[$i]}"
|
||||
else
|
||||
absent="$absent,${LABEL[$i]}"
|
||||
fi
|
||||
done
|
||||
local b; b="$(basename "$f")"
|
||||
echo "$b PRESENT: ${present#,}"
|
||||
[ -n "$absent" ] && echo "$b ABSENT: ${absent#,}"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ $# -gt 0 ] && [ -n "${1:-}" ]; then
|
||||
[ -f "$1" ] || { echo "Not found: $1" >&2; exit 1; }
|
||||
check_file "$1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
any=0
|
||||
# -maxdepth 1 keeps design/gdd/reviews/ out of scope — /design-review writes its
|
||||
# own output there, and sweeping it in makes the tool review its own reports.
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
not_a_system_gdd "$f" && continue
|
||||
any=1
|
||||
check_file "$f"
|
||||
done <<EOF
|
||||
$(find design/gdd -maxdepth 1 -name '*.md' -type f 2>/dev/null | sort)
|
||||
EOF
|
||||
|
||||
[ "$any" -eq 1 ] || echo "No system GDDs found in design/gdd/."
|
||||
@@ -0,0 +1,740 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate-v1-config.sh — convert a v1.0 CCGS project to v1.1 `project.yaml`.
|
||||
#
|
||||
# WHY THIS IS A SCRIPT AND NOT SKILL PROSE
|
||||
#
|
||||
# "Migration on a real v1.0 project loses data" is the highest-severity risk in
|
||||
# this whole area. Skill files are prose executed by a model and cannot be
|
||||
# unit-tested, so a prose migration could not be proven correct before being
|
||||
# pointed at someone's project. Every
|
||||
# failure mode the spec names — malformed file, enum mismatch, split-brain,
|
||||
# custom sections — is a testable branch here instead.
|
||||
#
|
||||
# Spec: `.claude/docs/effects-map.md` -> "Migration from v1.0 -> v1.1".
|
||||
# That section is the authority. If this script and that section disagree, the
|
||||
# section wins and this script is the bug.
|
||||
#
|
||||
# MODES
|
||||
# (default) migrate: write project.yaml + production/migration-report.md
|
||||
# --dry-run report what would happen; write nothing
|
||||
# --finalize delete legacy files, but ONLY after proving their content is
|
||||
# preserved in project.yaml
|
||||
#
|
||||
# NON-DESTRUCTIVE BY DEFAULT. Migration leaves every legacy file in place, so
|
||||
# the whole operation is reversible with `git checkout` until --finalize is run
|
||||
# deliberately. That is why --finalize is a separate invocation and not a flag
|
||||
# on the same run.
|
||||
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$REPO" || exit 1
|
||||
|
||||
MODE="migrate"
|
||||
case "${1:-}" in
|
||||
--dry-run) MODE="dry-run" ;;
|
||||
--finalize) MODE="finalize" ;;
|
||||
"") MODE="migrate" ;;
|
||||
*) echo "usage: $0 [--dry-run|--finalize]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
PY="project.yaml"
|
||||
STAGE_TXT="production/stage.txt"
|
||||
REVIEW_TXT="production/review-mode.txt"
|
||||
TP=".claude/docs/technical-preferences.md"
|
||||
REPORT="production/migration-report.md"
|
||||
|
||||
WARN=""
|
||||
MANUAL=""
|
||||
warn() { WARN="${WARN}- $1"$'\n'; }
|
||||
manual() { MANUAL="${MANUAL}- $1"$'\n'; }
|
||||
|
||||
# --- legacy detection -------------------------------------------------------
|
||||
|
||||
legacy_present() {
|
||||
[ -f "$STAGE_TXT" ] || [ -f "$REVIEW_TXT" ] || { [ -f "$TP" ] && tp_configured; }
|
||||
}
|
||||
|
||||
# A technical-preferences.md full of [TO BE CONFIGURED] is the template default,
|
||||
# not a v1.0 project's data. Treating it as legacy content would make every
|
||||
# fresh clone look like it needs migrating.
|
||||
# Only the keys this script actually MIGRATES count as legacy data.
|
||||
#
|
||||
# `\*\*[^*]+\*\*` matched ANY bullet, and the shipped template carries one line
|
||||
# that is a prose default rather than a placeholder:
|
||||
# - **Required Tests**: Balance formulas, gameplay systems, networking
|
||||
# so a FRESH v1.1 clone satisfied this and legacy_present() returned true for it.
|
||||
# With project.yaml also present that is the split-brain branch, so `--dry-run`
|
||||
# on an ordinary v1.1 project answered "REFUSED: both project.yaml and legacy
|
||||
# files exist. Resolve which is authoritative before migrating." -- naming a
|
||||
# conflict that does not exist and blocking the preview. That is precisely the
|
||||
# case the note above says this function exists to prevent.
|
||||
#
|
||||
# Keys, not shape. A value under a key nothing reads is not data this migration
|
||||
# would carry over, so it cannot be evidence that a migration is needed.
|
||||
# `Required Tests` is guidance for a human and is never passed to tp_get.
|
||||
#
|
||||
# This list is every key handed to tp_get below -- keep the two in step. A key
|
||||
# missing from here fails safe (the file reads as unconfigured) rather than
|
||||
# resurrecting the false positive.
|
||||
_TP_KEYS='Engine|Language|Rendering|Physics|Target Platforms|Primary Input|Gamepad Support|Touch Support|Classes|Variables|Constants|Signals(/Events)?|Files|Scenes(/Prefabs)?|Target Framerate|Frame Budget|Draw Calls|Memory Ceiling|Framework|Minimum Coverage|Additional Specialists|Language/Code Specialist|Shader Specialist|UI Specialist'
|
||||
tp_configured() {
|
||||
[ -f "$TP" ] || return 1
|
||||
grep -qE "^[[:space:]]*-[[:space:]]+\*\*($_TP_KEYS)\*\*:[[:space:]]*[^[:space:]\[]" "$TP" 2>/dev/null
|
||||
}
|
||||
|
||||
# --- technical-preferences.md parsing ---------------------------------------
|
||||
|
||||
# tp_get <section> <key> -> value, or empty when absent/unconfigured.
|
||||
#
|
||||
# JOINS WRAPPED CONTINUATION LINES. Reading only the first physical line
|
||||
# truncated values mid-sentence -- a framerate budget migrated as
|
||||
# "...Pro-tier target.** *(Changed", cut exactly where the author had wrapped.
|
||||
# A continuation is indented and is not itself a new `- **Key**:` bullet.
|
||||
tp_get() {
|
||||
[ -f "$TP" ] || return 0
|
||||
awk -v sec="$1" -v key="$2" '
|
||||
index($0, "## " sec) == 1 { ins = 1; next }
|
||||
ins && index($0, "## ") == 1 { ins = 0 }
|
||||
ins && grab {
|
||||
if ($0 ~ /^[[:space:]]+[^[:space:]]/ && $0 !~ /^[[:space:]]*-[[:space:]]+\*\*/) {
|
||||
cont = $0
|
||||
sub(/^[[:space:]]+/, "", cont)
|
||||
gsub(/[[:space:]]+$/, "", cont)
|
||||
val = val " " cont
|
||||
next
|
||||
}
|
||||
print val; grab = 0; exit
|
||||
}
|
||||
ins {
|
||||
pat = "^- \\*\\*" key "\\*\\*:[[:space:]]*"
|
||||
if ($0 ~ pat) {
|
||||
val = $0
|
||||
sub(pat, "", val)
|
||||
gsub(/[[:space:]]+$/, "", val)
|
||||
grab = 1
|
||||
next
|
||||
}
|
||||
}
|
||||
END { if (grab) print val }
|
||||
' "$TP" | sed 's/^\[TO BE CONFIGURED.*$//'
|
||||
}
|
||||
|
||||
# Strip a trailing unit so "16.67ms" and "1024MB" become numbers. Non-numeric
|
||||
# input is returned untouched, which is what surfaces a forked file's prose.
|
||||
num() { printf '%s' "$1" | sed -E 's/[[:space:]]*(ms|MB|mb|fps|FPS|%)[[:space:]]*$//'; }
|
||||
|
||||
is_num() { printf '%s' "$1" | grep -qE '^[0-9]+(\.[0-9]+)?$'; }
|
||||
|
||||
# Escapes before quoting. These values are hand-written markdown prose, so a
|
||||
# double quote inside one is ordinary -- and wrapping it unescaped emitted
|
||||
# `key: "He said "hi""`, which is not valid YAML and takes the whole file down
|
||||
# with it. Backslashes first, or the quote's escape gets re-escaped.
|
||||
# Escapes before quoting. These values are hand-written markdown prose, so a
|
||||
# double quote inside one is ordinary -- and wrapping it unescaped emitted
|
||||
# `key: "He said "hi""`, which is not valid YAML and takes the whole file down
|
||||
# with it. Backslashes first, or the quote's escape gets re-escaped.
|
||||
yaml_str() {
|
||||
[ -z "$1" ] && { printf 'null'; return; }
|
||||
_ys=$1
|
||||
_ys=${_ys//\\/\\\\}
|
||||
_ys=${_ys//\"/\\\"}
|
||||
printf '"%s"' "$_ys"
|
||||
}
|
||||
yaml_num() { { [ -z "$1" ] || ! is_num "$1"; } && printf 'null' || printf '%s' "$1"; }
|
||||
|
||||
# --- shared legacy-file reader ----------------------------------------------
|
||||
|
||||
# Trim CR and leading/trailing whitespace from the first line, but NEVER interior
|
||||
# spaces: two valid v1.1 stages contain one (`Systems Design`, `Technical Setup`).
|
||||
# A `tr -d ' \r\n'` here silently produced `SystemsDesign`, which fails the enum
|
||||
# and made the migrate-step warning misdiagnose a valid stage as a forked one.
|
||||
#
|
||||
# BOTH the migrate step and the finalize verifier below must use this. They read
|
||||
# the same legacy files and compare the results, so any divergence in trimming
|
||||
# desynchronises them: while both mangled `Systems Design` identically the
|
||||
# comparison still matched, but fixing only the writer would make finalize search
|
||||
# project.yaml for a value migrate never wrote and refuse to delete anything.
|
||||
# Quote regex metacharacters before a value is spliced into a grep -E pattern.
|
||||
# The finalize verifier builds its pattern from legacy file content, so a forked
|
||||
# value like `Alpha (draft)` would be read as a regex group and never match, so
|
||||
# the script would refuse to delete a file whose content HAD been preserved. That
|
||||
# fails closed, which is the right direction, but it fails on correct input.
|
||||
re_quote() { printf '%s' "$1" | sed 's/[][\\.*^$(){}?+|/]/\\&/g'; }
|
||||
|
||||
trim_line() {
|
||||
sed -e 's/\r//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | head -1
|
||||
}
|
||||
|
||||
# --- preflight --------------------------------------------------------------
|
||||
|
||||
# A legacy file whose value project.yaml ALREADY carries is a MIRROR, not a
|
||||
# rival source of truth.
|
||||
#
|
||||
# `/start` writes production/stage.txt on every NEW v1.1 project, deliberately,
|
||||
# as a mirror for hooks that have not migrated -- its own header says so.
|
||||
# legacy_present() cannot tell that from a v1.0 leftover, so every project
|
||||
# onboarded by /start answered:
|
||||
#
|
||||
# REFUSED: both project.yaml and legacy files exist.
|
||||
# Resolve which is authoritative before migrating.
|
||||
#
|
||||
# with nothing whatever to resolve, because the two agreed. And the same message
|
||||
# points the user at --finalize, which verifies the value is preserved and then
|
||||
# DELETES the mirror /start had just created on purpose.
|
||||
#
|
||||
# The test is the one --finalize already applies, inverted: a legacy value
|
||||
# project.yaml does not carry is a genuine second source; one it does carry is a
|
||||
# mirror and needs no resolution. Only reachable when project.yaml exists, so a
|
||||
# real v1.0 project -- which has none -- takes exactly the path it did before.
|
||||
#
|
||||
# These helpers must be defined ABOVE this point. A preflight placed before them
|
||||
# can only ever call legacy_present().
|
||||
legacy_conflict() {
|
||||
_lc=""
|
||||
if [ -f "$STAGE_TXT" ]; then
|
||||
_lc=$(trim_line "$STAGE_TXT")
|
||||
[ -z "$_lc" ] || grep -qE "^[[:space:]]*stage:[[:space:]]*\"?$(re_quote "$_lc")\"?" "$PY" || return 0
|
||||
fi
|
||||
if [ -f "$REVIEW_TXT" ]; then
|
||||
_lc=$(trim_line "$REVIEW_TXT")
|
||||
# The same v1.0 `none` -> v1.1 `solo` rename the migrate step applies, or a
|
||||
# v1.0 project that chose "no reviews" would read as a disagreement.
|
||||
[ "$_lc" = "none" ] && _lc="solo"
|
||||
[ -z "$_lc" ] || grep -qE "^[[:space:]]*review_mode:[[:space:]]*\"?$(re_quote "$_lc")\"?" "$PY" || return 0
|
||||
fi
|
||||
if [ -f "$TP" ] && tp_configured; then
|
||||
_lc=$(tp_get "Engine & Language" "Engine" | awk '{print $1}')
|
||||
[ -z "$_lc" ] || grep -qE "^[[:space:]]*name:[[:space:]]*\"?$(re_quote "$_lc")\"?" "$PY" || return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$MODE" != "finalize" ]; then
|
||||
if [ -f "$PY" ] && legacy_present; then
|
||||
# THREE states share this shape and only two are problems. Collapsing them
|
||||
# is what made a brand-new project look like a split brain.
|
||||
if legacy_conflict; then
|
||||
# Values differ: a real second source of truth, and no way to know which
|
||||
# the user edited last.
|
||||
echo "REFUSED: both $PY and legacy files exist, and their values DISAGREE."
|
||||
echo " Resolve which is authoritative before migrating."
|
||||
echo " If you already migrated, the next step is: $0 --finalize"
|
||||
exit 3
|
||||
elif [ -f "$REPORT" ]; then
|
||||
# Values agree AND a migration ran here -- the report is written by this
|
||||
# script and by nothing else. The legacy files are post-migration
|
||||
# leftovers, so --finalize is the next step and a second `migrate` is not.
|
||||
# Refusing is what keeps the user off that path.
|
||||
echo "REFUSED: both $PY and legacy files exist."
|
||||
echo " Resolve which is authoritative before migrating."
|
||||
echo " If you already migrated, the next step is: $0 --finalize"
|
||||
exit 3
|
||||
else
|
||||
# Values agree and no migration ever ran here. These are v1.1 mirrors --
|
||||
# /start writes production/stage.txt on every new project by design.
|
||||
# Nothing to resolve, and --finalize would DELETE a file /start meant to
|
||||
# create.
|
||||
echo "Nothing to do: $PY exists and the legacy files mirror it (values agree)."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ -f "$PY" ]; then
|
||||
echo "Nothing to do: $PY exists and no legacy config was found."
|
||||
exit 0
|
||||
fi
|
||||
if ! legacy_present; then
|
||||
echo "Nothing to do: no legacy config files found (not a v1.0 project)."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- finalize ---------------------------------------------------------------
|
||||
|
||||
if [ "$MODE" = "finalize" ]; then
|
||||
[ -f "$PY" ] || { echo "REFUSED: $PY does not exist. Run migration first."; exit 3; }
|
||||
FAILED=""
|
||||
# Verify preservation before deleting anything. A legacy file is only removed
|
||||
# once its value is demonstrably present in project.yaml.
|
||||
if [ -f "$STAGE_TXT" ]; then
|
||||
want=$(trim_line "$STAGE_TXT")
|
||||
grep -qE "^[[:space:]]*stage:[[:space:]]*\"?$(re_quote "$want")\"?" "$PY" || FAILED="$FAILED $STAGE_TXT"
|
||||
fi
|
||||
if [ -f "$REVIEW_TXT" ]; then
|
||||
want=$(trim_line "$REVIEW_TXT")
|
||||
# Mirror the migrate step's v1.0 `none` -> v1.1 `solo` rename, or this check
|
||||
# would look for `review_mode: none` (never written) and refuse to finalize.
|
||||
[ "$want" = "none" ] && want="solo"
|
||||
grep -qE "^[[:space:]]*review_mode:[[:space:]]*\"?$(re_quote "$want")\"?" "$PY" || FAILED="$FAILED $REVIEW_TXT"
|
||||
fi
|
||||
if [ -f "$TP" ] && tp_configured; then
|
||||
want=$(tp_get "Engine & Language" "Engine" | awk '{print $1}')
|
||||
[ -z "$want" ] || grep -qE "^[[:space:]]*name:[[:space:]]*\"?$(re_quote "$want")\"?" "$PY" || FAILED="$FAILED $TP"
|
||||
fi
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "REFUSED: content not preserved in $PY for:$FAILED"
|
||||
echo " Nothing was deleted. Re-run migration or fix $PY by hand."
|
||||
exit 4
|
||||
fi
|
||||
for f in "$STAGE_TXT" "$REVIEW_TXT"; do
|
||||
[ -f "$f" ] && rm -f "$f" && echo "deleted $f"
|
||||
done
|
||||
echo "NOTE: $TP is NOT deleted — it still holds Forbidden Patterns and"
|
||||
echo " Allowed Libraries, which have no project.yaml equivalent."
|
||||
echo "finalize complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- parse ------------------------------------------------------------------
|
||||
|
||||
STAGE=""; REVIEW=""
|
||||
if [ -f "$STAGE_TXT" ]; then
|
||||
STAGE=$(trim_line "$STAGE_TXT")
|
||||
[ -z "$STAGE" ] && warn "$STAGE_TXT is empty — project.stage left unset"
|
||||
fi
|
||||
if [ -f "$REVIEW_TXT" ]; then
|
||||
REVIEW=$(trim_line "$REVIEW_TXT")
|
||||
[ -z "$REVIEW" ] && warn "$REVIEW_TXT is empty — modes.review_mode left unset, so modes.rigor supplies it"
|
||||
# The consequential direction, which went unreported until it was measured.
|
||||
# An explicit modes.review_mode OUTRANKS the modes.rigor expansion -- that is
|
||||
# deliberate, so a genuine v1.0 choice survives migration. But it means rigor
|
||||
# can never move review depth on this project again, and the user is told
|
||||
# elsewhere that rigor "drives six settings". On a migrated project it drives
|
||||
# five. Reporting only the empty case explained the harmless half.
|
||||
[ -n "$REVIEW" ] && warn "$REVIEW_TXT is '$REVIEW' — modes.review_mode is now set EXPLICITLY, which outranks the modes.rigor expansion. Setting modes.rigor later will NOT change review depth on this project. To let rigor drive it, delete the modes.review_mode line from project.yaml after migrating."
|
||||
fi
|
||||
|
||||
# Enum checks. A fork may hold values v1.1 does not know; the spec says preserve
|
||||
# them raw and flag, never silently coerce.
|
||||
case "$STAGE" in
|
||||
""|Concept|"Systems Design"|"Technical Setup"|Pre-Production|Production|Polish|Release) ;;
|
||||
*) warn "project.stage \`$STAGE\` is not a v1.1 stage — preserved raw, resolve by hand"; manual "Confirm \`project.stage: $STAGE\` is intended" ;;
|
||||
esac
|
||||
# v1.0 used `none` for "no director reviews"; v1.1 renamed that tier to `solo`.
|
||||
# Map it transparently (the rename is reported below via warn()) rather than
|
||||
# writing `none`, which v1.1's enum rejects — that would silently resolve to
|
||||
# `lean`, the OPPOSITE of the user's intent. This is a documented rename, not a
|
||||
# forked value, so it is coerced-and-flagged, not preserved-raw. See
|
||||
# effects-map.md "Migration from v1.0 -> v1.1".
|
||||
if [ "$REVIEW" = "none" ]; then
|
||||
REVIEW="solo"
|
||||
warn "modes.review_mode \`none\` (v1.0 \"no reviews\") migrated to v1.1 \`solo\` — the renamed \"no reviews\" tier"
|
||||
fi
|
||||
case "$REVIEW" in
|
||||
""|full|lean|solo) ;;
|
||||
*) warn "modes.review_mode \`$REVIEW\` is not a v1.1 value — preserved raw, resolve by hand"; manual "Confirm \`modes.review_mode: $REVIEW\` is intended" ;;
|
||||
esac
|
||||
# NO DEFAULT HERE. `production/review-mode.txt` was OPTIONAL in v1.0, so it is
|
||||
# absent for a large share of upgraders. Defaulting to `lean` wrote an explicit
|
||||
# value the user never chose, and an explicit `modes.review_mode` sits ABOVE the
|
||||
# rigor expansion in the resolution chain — so it pinned the knob permanently.
|
||||
# A project that later set `rigor: full` got workflow/qa/team.size at full and
|
||||
# `review_mode: lean`, with no error: the exact defect this release exists to
|
||||
# fix, reintroduced through the upgrade path. Leaving it unset lets
|
||||
# `modes.rigor` supply it, which is the v1.1 design. If the user genuinely
|
||||
# chose a review mode in v1.0, the file exists and its value is preserved above.
|
||||
|
||||
ENGINE_RAW=$(tp_get "Engine & Language" "Engine")
|
||||
ENGINE_NAME=$(printf '%s' "$ENGINE_RAW" | awk '{print $1}')
|
||||
ENGINE_VER=$(printf '%s' "$ENGINE_RAW" | awk '{if (NF>1 && $NF ~ /^[0-9]+(\.[0-9]+)*[A-Za-z0-9]*$/) print $NF}')
|
||||
LANG=$(tp_get "Engine & Language" "Language")
|
||||
RENDER=$(tp_get "Engine & Language" "Rendering")
|
||||
PHYSICS=$(tp_get "Engine & Language" "Physics")
|
||||
|
||||
TARGETS=$(tp_get "Input & Platform" "Target Platforms")
|
||||
PRIMARY_INPUT=$(tp_get "Input & Platform" "Primary Input")
|
||||
GAMEPAD=$(tp_get "Input & Platform" "Gamepad Support")
|
||||
TOUCH=$(tp_get "Input & Platform" "Touch Support")
|
||||
|
||||
N_CLASSES=$(tp_get "Naming Conventions" "Classes")
|
||||
N_VARS=$(tp_get "Naming Conventions" "Variables")
|
||||
N_CONST=$(tp_get "Naming Conventions" "Constants")
|
||||
# The v1.0 template spells these two keys `Signals/Events` and `Scenes/Prefabs`;
|
||||
# a hand-trimmed file may carry the short form. The key is a regex, so accept both.
|
||||
N_SIG=$(tp_get "Naming Conventions" "Signals(/Events)?")
|
||||
N_FILES=$(tp_get "Naming Conventions" "Files")
|
||||
N_SCENES=$(tp_get "Naming Conventions" "Scenes(/Prefabs)?")
|
||||
|
||||
P_FPS=$(num "$(tp_get "Performance Budgets" "Target Framerate")")
|
||||
P_BUDGET=$(num "$(tp_get "Performance Budgets" "Frame Budget")")
|
||||
P_DRAW=$(num "$(tp_get "Performance Budgets" "Draw Calls")")
|
||||
P_MEM=$(num "$(tp_get "Performance Budgets" "Memory Ceiling")")
|
||||
|
||||
# A BUDGET THAT IS PRESENT BUT NOT A BARE NUMBER MUST BE REPORTED, NOT DROPPED.
|
||||
#
|
||||
# These four are written with yaml_num, which emits `null` for anything that is
|
||||
# not numeric -- while the report row tested only for non-empty. So a budget
|
||||
# recorded as prose ("30 FPS on the console floor; 60 FPS as a PC target") wrote
|
||||
# `null` to the file and printed as PRESERVED in the report, with Warnings: None.
|
||||
# The report is the artifact the user is told to review before --finalize, so it
|
||||
# is the one place a silent drop is least affordable.
|
||||
#
|
||||
# Real budgets routinely carry per-platform splits, ranges and rationale, none of
|
||||
# which is a bare number. The converter cannot pick one, and MUST NOT guess which
|
||||
# platform the user meant -- so it names what it found and hands the choice back.
|
||||
for _pb in "target_framerate:$P_FPS" "frame_budget_ms:$P_BUDGET" "draw_call_limit:$P_DRAW" "memory_ceiling_mb:$P_MEM"; do
|
||||
_pk="${_pb%%:*}"; _pv="${_pb#*:}"
|
||||
if [ -n "$_pv" ] && ! is_num "$_pv"; then
|
||||
warn "performance.$_pk not migrated: \"$_pv\" is not a single number"
|
||||
manual "Set \`performance.$_pk\` by hand — the legacy value was: $_pv"
|
||||
fi
|
||||
done
|
||||
|
||||
TEST_FW=$(tp_get "Testing" "Framework")
|
||||
# "Minimum Coverage" has a v1.1 home (`qa.coverage_minimum`, integer 0-100,
|
||||
# enforced only at qa.level: full). Same rule as the budgets: a bare number
|
||||
# migrates, prose is reported and handed back, nothing is dropped in silence.
|
||||
TEST_COV=$(num "$(tp_get "Testing" "Minimum Coverage")")
|
||||
if [ -n "$TEST_COV" ] && ! is_num "$TEST_COV"; then
|
||||
warn "qa.coverage_minimum not migrated: \"$TEST_COV\" is not a single number"
|
||||
manual "Set \`qa.coverage_minimum\` by hand — the legacy value was: $TEST_COV"
|
||||
fi
|
||||
|
||||
# v1.0 /setup-engine wrote every specialist line as `agent-name (what it covers)`,
|
||||
# and the Unreal code line as `a-specialist (Blueprint) or b-specialist (C++)`.
|
||||
# Skills pass `specialists.*` straight to the Agent tool as a subagent_type, so
|
||||
# anything but the bare agent name is an agent that does not exist. Keep the
|
||||
# first agent name per entry; the prose goes to the report, not the yaml.
|
||||
S_CODE_RAW=$(tp_get "Engine Specialists" "Language/Code Specialist")
|
||||
S_SHADER_RAW=$(tp_get "Engine Specialists" "Shader Specialist")
|
||||
S_UI_RAW=$(tp_get "Engine Specialists" "UI Specialist")
|
||||
S_ADD_RAW=$(tp_get "Engine Specialists" "Additional Specialists")
|
||||
|
||||
# agent_name <prose> -> first token that looks like an agent name, else the
|
||||
# trimmed input unchanged (a value this cannot parse is surfaced, not dropped).
|
||||
agent_name() {
|
||||
_an=$(printf '%s' "$1" | grep -oE '[a-z][a-z0-9]*(-[a-z0-9]+)+' | head -1)
|
||||
[ -n "$_an" ] && printf '%s' "$_an" || printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'
|
||||
}
|
||||
# agent_names <prose list> -> comma-separated agent names, one per top-level
|
||||
# comma-separated element (commas inside parentheses belong to the prose).
|
||||
agent_names() {
|
||||
[ -z "$1" ] && return 0
|
||||
printf '%s' "$1" | awk '
|
||||
{ d = 0; cur = ""
|
||||
for (i = 1; i <= length($0); i++) { c = substr($0, i, 1)
|
||||
if (c == "(") d++; else if (c == ")") { if (d > 0) d-- }
|
||||
if (c == "," && d == 0) { print cur; cur = "" } else cur = cur c }
|
||||
print cur }' | while IFS= read -r _el; do
|
||||
_n=$(agent_name "$_el"); [ -n "$_n" ] && printf '%s, ' "$_n"
|
||||
done | sed -E 's/, $//'
|
||||
}
|
||||
specialist_warn() { # <dotted.key> <raw> <kept>
|
||||
[ -n "$2" ] && [ "$2" != "$3" ] || return 0
|
||||
warn "$1 trimmed to \`$3\` — the legacy line was: $2"
|
||||
case "$2" in
|
||||
*" or "*) manual "\`$1\` had two candidates in $TP (\"$2\"); kept the first — change it in project.yaml if the other is right" ;;
|
||||
esac
|
||||
}
|
||||
S_CODE=$(agent_name "$S_CODE_RAW")
|
||||
S_SHADER=$(agent_name "$S_SHADER_RAW")
|
||||
S_UI=$(agent_name "$S_UI_RAW")
|
||||
S_ADD=$(agent_names "$S_ADD_RAW")
|
||||
specialist_warn "specialists.code" "$S_CODE_RAW" "$S_CODE"
|
||||
specialist_warn "specialists.shader" "$S_SHADER_RAW" "$S_SHADER"
|
||||
specialist_warn "specialists.ui" "$S_UI_RAW" "$S_UI"
|
||||
specialist_warn "specialists.additional" "$S_ADD_RAW" "$S_ADD"
|
||||
|
||||
# Sections a fork may have added. Not migrated — surfaced instead of dropped.
|
||||
if [ -f "$TP" ]; then
|
||||
while IFS= read -r h; do
|
||||
case "$h" in
|
||||
"Engine & Language"|"Input & Platform"|"Naming Conventions"|"Performance Budgets"|"Testing"|"Forbidden Patterns"|"Allowed Libraries / Addons"|"Architecture Decisions Log"|"Engine Specialists") ;;
|
||||
"") ;;
|
||||
*) manual "Custom section \`## $h\` in $TP — not migrated, integrate by hand" ;;
|
||||
esac
|
||||
done < <(grep -E '^## ' "$TP" | sed 's/^## //; s/\r$//')
|
||||
fi
|
||||
[ -f "$TP" ] && manual "Forbidden Patterns and Allowed Libraries stay in $TP — they have no project.yaml equivalent"
|
||||
|
||||
# v1.0 had no `modes.rigor`; its fixed process level matches v1.1's `standard`.
|
||||
# v1.1 defaults `rigor` to `minimal`, and this script writes nothing it did not
|
||||
# read, so a migrated project lands on `minimal` — fewer required GDD sections,
|
||||
# terser docs, lighter test evidence, no director panels. That is a behaviour
|
||||
# change the upgrader never chose; say so in the one artifact they read first.
|
||||
warn "modes.rigor is not written — v1.1 defaults it to \`minimal\`, which is lighter than v1.0 behaved (v1.0 matched \`standard\`). To keep v1.0's process level, add \`modes: { rigor: standard }\` to project.yaml or run \`/settings modes.rigor=standard\`"
|
||||
manual "Decide \`modes.rigor\`: leave unset for the lighter v1.1 default, or pin \`standard\` to keep v1.0's process level"
|
||||
|
||||
# Report the STRING keys that land in the file as `null`, which the numeric
|
||||
# family above already does and this half never did.
|
||||
#
|
||||
# `yaml_str` writes `null` for an empty value exactly as `yaml_num` does. A block
|
||||
# is emitted when ANY of its members is set -- every guard in the writer below is
|
||||
# `[ -n "$A$B$C" ]` -- so PARTIAL configuration is what produces these, and
|
||||
# partial is the ordinary state of a v1.0 file: you configure what you use. A
|
||||
# project that named its code specialist and left the rest at [TO BE CONFIGURED]
|
||||
# got `shader: null` and `ui: null` written, "Warnings: None", and no report row
|
||||
# for either.
|
||||
#
|
||||
# It matters because `get_yaml_key` returns the four-character STRING "null" for
|
||||
# such a key. That is not empty, so a caller testing `[ -z ... ]` reads it as
|
||||
# CONFIGURED, and nothing in the framework tests for the string -- /code-review
|
||||
# reads `specialists.shader` to choose which agent to spawn.
|
||||
#
|
||||
# The value written is deliberately NOT changed here. `null` is the record that
|
||||
# the key was seen and held nothing, and the config suite pins that. What was
|
||||
# missing is telling the user, in the artifact they are told to read before
|
||||
# --finalize deletes anything.
|
||||
#
|
||||
# performance.* is left to its own loop above on purpose: it distinguishes "held
|
||||
# a value that would not convert" from "held nothing", and only reports the
|
||||
# first. Blurring that here would overwrite a deliberate contract.
|
||||
null_warn() { # <block-guard> <dotted.key> <value>
|
||||
[ -n "$1" ] && [ -z "$3" ] || return 0
|
||||
warn "$2 written as \`null\` — $TP had no value for it"
|
||||
manual "Set \`$2\` in project.yaml, or delete the key so it resolves to its default"
|
||||
}
|
||||
_G_ENGINE="$ENGINE_NAME"
|
||||
null_warn "$_G_ENGINE" "engine.version" "$ENGINE_VER"
|
||||
null_warn "$_G_ENGINE" "engine.language" "$LANG"
|
||||
null_warn "$_G_ENGINE" "engine.rendering" "$RENDER"
|
||||
null_warn "$_G_ENGINE" "engine.physics" "$PHYSICS"
|
||||
_G_SPEC="$S_CODE$S_SHADER$S_UI$S_ADD"
|
||||
null_warn "$_G_SPEC" "specialists.code" "$S_CODE"
|
||||
null_warn "$_G_SPEC" "specialists.shader" "$S_SHADER"
|
||||
null_warn "$_G_SPEC" "specialists.ui" "$S_UI"
|
||||
_G_NAME="$N_CLASSES$N_VARS$N_CONST$N_SIG$N_FILES$N_SCENES"
|
||||
null_warn "$_G_NAME" "naming.classes" "$N_CLASSES"
|
||||
null_warn "$_G_NAME" "naming.variables" "$N_VARS"
|
||||
null_warn "$_G_NAME" "naming.constants" "$N_CONST"
|
||||
null_warn "$_G_NAME" "naming.signals" "$N_SIG"
|
||||
null_warn "$_G_NAME" "naming.files" "$N_FILES"
|
||||
null_warn "$_G_NAME" "naming.scenes" "$N_SCENES"
|
||||
_G_PLAT="$TARGETS$PRIMARY_INPUT$GAMEPAD$TOUCH"
|
||||
null_warn "$_G_PLAT" "platform.primary_input" "$PRIMARY_INPUT"
|
||||
null_warn "$_G_PLAT" "platform.gamepad_support" "$GAMEPAD"
|
||||
null_warn "$_G_PLAT" "platform.touch_support" "$TOUCH"
|
||||
|
||||
# --- helpers for list-ish values --------------------------------------------
|
||||
|
||||
yaml_list() {
|
||||
# "PC, Mac" -> ["PC", "Mac"]; empty -> []
|
||||
#
|
||||
# Splits on top-level commas ONLY -- a comma inside parentheses stays part of
|
||||
# its element. These values come from a hand-written document, so an entry is
|
||||
# routinely prose: `ue-gas-specialist (abilities, effects)` is ONE specialist,
|
||||
# not two, and splitting it produced entries like `attributes` that read as
|
||||
# real agent names. Every element is quoted for the same reason: prose carries
|
||||
# colons, dashes and brackets, all of which make a bare YAML scalar ambiguous
|
||||
# or invalid.
|
||||
[ -z "$1" ] && { printf '[]'; return; }
|
||||
printf '%s' "$1" | awk '
|
||||
function emit(s) {
|
||||
sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s)
|
||||
if (s == "") return
|
||||
gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
|
||||
out = out (n++ ? ", " : "") "\"" s "\""
|
||||
}
|
||||
{ depth = 0; buf = ""; out = ""; n = 0
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (c == "(") depth++
|
||||
else if (c == ")") { if (depth > 0) depth-- }
|
||||
if (c == "," && depth == 0) { emit(buf); buf = "" } else buf = buf c
|
||||
}
|
||||
emit(buf)
|
||||
printf "[%s]", out
|
||||
}'
|
||||
}
|
||||
|
||||
if [ "$MODE" = "dry-run" ]; then
|
||||
echo "DRY RUN — nothing written."
|
||||
echo "would create: $PY"
|
||||
echo "would create: $REPORT"
|
||||
echo
|
||||
echo "detected sources:"
|
||||
[ -f "$STAGE_TXT" ] && echo " $STAGE_TXT -> project.stage: ${STAGE:-<empty>}"
|
||||
[ -f "$REVIEW_TXT" ] && echo " $REVIEW_TXT -> modes.review_mode: ${REVIEW:-<empty>}"
|
||||
# State the omission explicitly. Silence here is what let the invented `lean`
|
||||
# default ship unnoticed: the preview simply had no line for it, so the one
|
||||
# artifact a cautious user reads before migrating never mentioned the value.
|
||||
[ -z "$REVIEW" ] && echo " (no $REVIEW_TXT — modes.review_mode left unset; modes.rigor will supply it)"
|
||||
# And the other direction, for the same reason: the preview is the one artifact
|
||||
# a cautious user reads before migrating, so the consequence belongs here too.
|
||||
[ -n "$REVIEW" ] && echo " (modes.review_mode will be set EXPLICITLY to '$REVIEW' — this outranks modes.rigor, so rigor will not control review depth afterwards)"
|
||||
[ -f "$TP" ] && tp_configured && echo " $TP -> engine/platform/naming/performance/testing/specialists"
|
||||
[ -n "$WARN" ] && { echo; echo "warnings:"; printf '%s' "$WARN"; }
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- write project.yaml -----------------------------------------------------
|
||||
|
||||
{
|
||||
echo "# CCGS project configuration — migrated from v1.0 legacy files."
|
||||
echo "# Generated by .claude/scripts/migrate-v1-config.sh"
|
||||
echo "# Review production/migration-report.md before running --finalize."
|
||||
echo ""
|
||||
echo "schema_version: 1"
|
||||
echo ""
|
||||
echo "framework:"
|
||||
echo " version: 1.1.0"
|
||||
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
|
||||
# PROJECT'S OWN SCHEMA VALIDATION: `null` is not in the stage enum, so every
|
||||
# migrated project without a legacy stage.txt greeted its user with a schema
|
||||
# error caused by the migration itself. An absent key resolves to unset, which
|
||||
# is what "no stage recorded yet" means.
|
||||
if [ -n "$STAGE" ]; then
|
||||
echo "project:"
|
||||
echo " stage: \"$STAGE\""
|
||||
echo ""
|
||||
fi
|
||||
# Emit the `modes:` block only when there is something to put in it. An
|
||||
# unguarded `review_mode: $REVIEW` wrote a bare `review_mode:` (empty) once
|
||||
# the invented default was removed, which resolves no better than the default
|
||||
# did. No legacy review mode means no `modes:` block — rigor supplies it.
|
||||
if [ -n "$REVIEW" ]; then
|
||||
echo "modes:"
|
||||
echo " review_mode: $REVIEW"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$ENGINE_NAME" ]; then
|
||||
echo "engine:"
|
||||
echo " name: $(yaml_str "$ENGINE_NAME")"
|
||||
echo " version: $(yaml_str "$ENGINE_VER")"
|
||||
echo " language: $(yaml_str "$LANG")"
|
||||
echo " rendering: $(yaml_str "$RENDER")"
|
||||
echo " physics: $(yaml_str "$PHYSICS")"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$S_CODE$S_SHADER$S_UI$S_ADD" ]; then
|
||||
echo "specialists:"
|
||||
echo " code: $(yaml_str "$S_CODE")"
|
||||
echo " shader: $(yaml_str "$S_SHADER")"
|
||||
echo " ui: $(yaml_str "$S_UI")"
|
||||
echo " additional: $(yaml_list "$S_ADD")"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$N_CLASSES$N_VARS$N_CONST$N_SIG$N_FILES$N_SCENES" ]; then
|
||||
echo "naming:"
|
||||
echo " classes: $(yaml_str "$N_CLASSES")"
|
||||
echo " variables: $(yaml_str "$N_VARS")"
|
||||
echo " constants: $(yaml_str "$N_CONST")"
|
||||
echo " signals: $(yaml_str "$N_SIG")"
|
||||
echo " files: $(yaml_str "$N_FILES")"
|
||||
echo " scenes: $(yaml_str "$N_SCENES")"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$P_FPS$P_BUDGET$P_DRAW$P_MEM" ]; then
|
||||
echo "performance:"
|
||||
echo " target_framerate: $(yaml_num "$P_FPS")"
|
||||
echo " frame_budget_ms: $(yaml_num "$P_BUDGET")"
|
||||
echo " draw_call_limit: $(yaml_num "$P_DRAW")"
|
||||
echo " memory_ceiling_mb: $(yaml_num "$P_MEM")"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$TARGETS$PRIMARY_INPUT$GAMEPAD$TOUCH" ]; then
|
||||
echo "platform:"
|
||||
echo " targets: $(yaml_list "$TARGETS")"
|
||||
echo " primary_input: $(yaml_str "$PRIMARY_INPUT")"
|
||||
echo " gamepad_support: $(yaml_str "$GAMEPAD")"
|
||||
echo " touch_support: $(yaml_str "$TOUCH")"
|
||||
echo ""
|
||||
fi
|
||||
if [ -n "$TEST_FW" ]; then
|
||||
echo "testing:"
|
||||
echo " framework: $(yaml_str "$TEST_FW")"
|
||||
echo ""
|
||||
fi
|
||||
if is_num "$TEST_COV"; then
|
||||
echo "qa:"
|
||||
echo " coverage_minimum: $TEST_COV"
|
||||
echo ""
|
||||
fi
|
||||
} > "$PY"
|
||||
|
||||
# --- write the report -------------------------------------------------------
|
||||
|
||||
mkdir -p production 2>/dev/null
|
||||
{
|
||||
echo "# Migration report — v1.0 to v1.1"
|
||||
echo ""
|
||||
echo "Generated by \`.claude/scripts/migrate-v1-config.sh\`."
|
||||
echo "Legacy files were **not** deleted. Read this report, then run"
|
||||
echo "\`bash .claude/scripts/migrate-v1-config.sh --finalize\` when satisfied."
|
||||
echo ""
|
||||
echo "## Source files detected"
|
||||
echo ""
|
||||
[ -f "$STAGE_TXT" ] && echo "- \`$STAGE_TXT\`"
|
||||
[ -f "$REVIEW_TXT" ] && echo "- \`$REVIEW_TXT\`"
|
||||
{ [ -f "$TP" ] && tp_configured; } && echo "- \`$TP\`"
|
||||
echo ""
|
||||
echo "## Values preserved"
|
||||
echo ""
|
||||
echo "| Setting | Value | From |"
|
||||
echo "|---|---|---|"
|
||||
[ -n "$STAGE" ] && echo "| \`project.stage\` | $STAGE | $STAGE_TXT |"
|
||||
[ -n "$REVIEW" ] && echo "| \`modes.review_mode\` | $REVIEW | ${REVIEW_TXT} |"
|
||||
[ -n "$ENGINE_NAME" ] && echo "| \`engine.name\` | $ENGINE_NAME | $TP |"
|
||||
[ -n "$ENGINE_VER" ] && echo "| \`engine.version\` | $ENGINE_VER | $TP |"
|
||||
[ -n "$LANG" ] && echo "| \`engine.language\` | $LANG | $TP |"
|
||||
[ -n "$TEST_FW" ] && echo "| \`testing.framework\` | $TEST_FW | $TP |"
|
||||
is_num "$TEST_COV" && echo "| \`qa.coverage_minimum\` | $TEST_COV | $TP |"
|
||||
[ -n "$S_CODE" ] && echo "| \`specialists.code\` | $S_CODE | $TP |"
|
||||
# Every migrated value gets a row. This table is titled "Values preserved" and
|
||||
# is what the user reviews before --finalize deletes anything -- listing 6 of
|
||||
# the 29 keys actually written meant someone checking "did my naming
|
||||
# conventions survive?" found nothing and could reasonably conclude they had
|
||||
# not. Understating what was preserved is the same defect as overstating it.
|
||||
[ -n "$RENDER" ] && echo "| \`engine.rendering\` | $RENDER | $TP |"
|
||||
[ -n "$PHYSICS" ] && echo "| \`engine.physics\` | $PHYSICS | $TP |"
|
||||
[ -n "$S_SHADER" ] && echo "| \`specialists.shader\` | $S_SHADER | $TP |"
|
||||
[ -n "$S_UI" ] && echo "| \`specialists.ui\` | $S_UI | $TP |"
|
||||
[ -n "$S_ADD" ] && echo "| \`specialists.additional\` | $S_ADD | $TP |"
|
||||
[ -n "$N_CLASSES" ] && echo "| \`naming.classes\` | $N_CLASSES | $TP |"
|
||||
[ -n "$N_VARS" ] && echo "| \`naming.variables\` | $N_VARS | $TP |"
|
||||
[ -n "$N_CONST" ] && echo "| \`naming.constants\` | $N_CONST | $TP |"
|
||||
[ -n "$N_SIG" ] && echo "| \`naming.signals\` | $N_SIG | $TP |"
|
||||
[ -n "$N_FILES" ] && echo "| \`naming.files\` | $N_FILES | $TP |"
|
||||
[ -n "$N_SCENES" ] && echo "| \`naming.scenes\` | $N_SCENES | $TP |"
|
||||
[ -n "$TARGETS" ] && echo "| \`platform.targets\` | $TARGETS | $TP |"
|
||||
[ -n "$PRIMARY_INPUT" ] && echo "| \`platform.primary_input\` | $PRIMARY_INPUT | $TP |"
|
||||
[ -n "$GAMEPAD" ] && echo "| \`platform.gamepad_support\` | $GAMEPAD | $TP |"
|
||||
[ -n "$TOUCH" ] && echo "| \`platform.touch_support\` | $TOUCH | $TP |"
|
||||
# Report what was WRITTEN, not what was extracted. These rows sit under
|
||||
# "Values preserved", so testing merely for non-empty claimed preservation for
|
||||
# a value the file recorded as null. is_num is the same test yaml_num applies.
|
||||
is_num "$P_FPS" && echo "| \`performance.target_framerate\` | $P_FPS | $TP |"
|
||||
is_num "$P_BUDGET" && echo "| \`performance.frame_budget_ms\` | $P_BUDGET | $TP |"
|
||||
is_num "$P_DRAW" && echo "| \`performance.draw_call_limit\` | $P_DRAW | $TP |"
|
||||
is_num "$P_MEM" && echo "| \`performance.memory_ceiling_mb\` | $P_MEM | $TP |"
|
||||
echo ""
|
||||
echo "## Defaults applied"
|
||||
echo ""
|
||||
echo "Settings new in v1.1 are absent from legacy files and are NOT written."
|
||||
echo "They resolve to their documented defaults at read time"
|
||||
echo "(\`.claude/docs/config-resolution.md\`): \`modes.rigor\`, \`modes.workflow\`,"
|
||||
echo "\`modes.automation\`, \`docs.density\`, \`qa.level\`, \`team.size\`,"
|
||||
echo "\`modes.story_granularity\`, \`testing.strict\`."
|
||||
echo ""
|
||||
echo "Run \`/settings\` to see the effective values, or \`/start\` to be asked."
|
||||
echo ""
|
||||
# `modes.review_mode` is NOT new in v1.1, so the paragraph above does not
|
||||
# cover it. Call the omission out by name: this table is the artifact the
|
||||
# user reads before `--finalize`, and listing an invented `lean` sourced to a
|
||||
# file that does not exist is worse than saying nothing.
|
||||
if [ -z "$REVIEW" ]; then
|
||||
echo "\`modes.review_mode\` is **deliberately not written**: no"
|
||||
echo "\`$REVIEW_TXT\` was found, so you never chose one. It is now supplied by"
|
||||
echo "\`modes.rigor\` — writing it here would pin it and shadow that expansion."
|
||||
echo ""
|
||||
fi
|
||||
echo "## Warnings"
|
||||
echo ""
|
||||
if [ -n "$WARN" ]; then printf '%s' "$WARN"; else echo "None."; fi
|
||||
echo ""
|
||||
echo "## Manual review"
|
||||
echo ""
|
||||
if [ -n "$MANUAL" ]; then printf '%s' "$MANUAL"; else echo "None."; fi
|
||||
} > "$REPORT"
|
||||
|
||||
echo "wrote $PY"
|
||||
echo "wrote $REPORT"
|
||||
[ -n "$WARN" ] && { echo; echo "warnings:"; printf '%s' "$WARN"; }
|
||||
echo
|
||||
echo "Legacy files left in place. Review $REPORT, then:"
|
||||
echo " bash .claude/scripts/migrate-v1-config.sh --finalize"
|
||||
exit 0
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# project-coherence.sh — compare what `project.yaml` DECLARES against what the
|
||||
# project actually IS.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# Three defects share one shape: `project.yaml` is treated as the source of truth
|
||||
# and is never reconciled against the artifact it describes.
|
||||
#
|
||||
# 1 engine.version says 4.7.2; project.godot says 4.6; the binary is 4.6.1
|
||||
# 2 engine.rendering says Forward+; project.godot says gl_compatibility
|
||||
# 3 commands.build names an export preset with no export_presets.cfg
|
||||
#
|
||||
# The second is why this is a script and not another paragraph. `/setup-engine`
|
||||
# ALREADY carries an emphatic, source-cited warning naming both of the exact
|
||||
# mistakes that produce it ("Jolt is Godot's default 3D engine", "Forward+ is
|
||||
# desktop-focused"). Prose is in place and does not hold on its own. What was
|
||||
# missing was a step that reads back what was written.
|
||||
#
|
||||
# F1's root cause was subtler and is fixed elsewhere: `/setup-engine` is told to
|
||||
# write an `Installed at pin time` row into VERSION.md, and no VERSION.md had
|
||||
# such a row. There was no slot, so the step vanished leaving no trace. The row
|
||||
# now ships in all three reference files, and check 1 below reads it.
|
||||
#
|
||||
# OBSERVATIONS, NEVER VERDICTS
|
||||
#
|
||||
# Per CLAUDE.md: a script that scores or judges will eventually contradict a mode
|
||||
# or override it cannot see. This one prints what it compared and what it found,
|
||||
# and always exits 0. The reader decides. A check that cannot run says so by
|
||||
# name — silence is never treated as agreement.
|
||||
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
||||
cd "$ROOT" || exit 0
|
||||
|
||||
DIFFERS=0
|
||||
CHECKED=0
|
||||
SKIPPED=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
match() { CHECKED=$((CHECKED+1)); say "[MATCH] $*"; }
|
||||
differs() { CHECKED=$((CHECKED+1)); DIFFERS=$((DIFFERS+1)); say "[DIFFERS] $*"; }
|
||||
skipped() { SKIPPED=$((SKIPPED+1)); say "[NOT CHECKED] $*"; }
|
||||
|
||||
# Read one `key: value` from a named top-level block of project.yaml.
|
||||
# Deliberately simple: this file is machine-written and two levels deep.
|
||||
yaml_block_value() {
|
||||
block="$1"; key="$2"
|
||||
awk -v b="$block" -v k="$key" '
|
||||
$0 ~ "^"b":" { inb=1; next }
|
||||
inb && /^[a-zA-Z_]/ { inb=0 }
|
||||
inb && $0 ~ "^[[:space:]]+"k":" {
|
||||
sub("^[[:space:]]*"k":[[:space:]]*", "")
|
||||
gsub(/^"|"$/, "")
|
||||
print; exit
|
||||
}
|
||||
' project.yaml 2>/dev/null
|
||||
}
|
||||
|
||||
summarise() {
|
||||
say ""
|
||||
say "checked $CHECKED · differs $DIFFERS · not checked $SKIPPED"
|
||||
say ""
|
||||
say "Observations only — no verdict. 'not checked' is not agreement:"
|
||||
say "each line above names why that comparison could not be made."
|
||||
exit 0
|
||||
}
|
||||
|
||||
say "=== project coherence ==="
|
||||
|
||||
if [ ! -f project.yaml ]; then
|
||||
skipped "everything — no project.yaml at the repo root. Run /start."
|
||||
summarise
|
||||
fi
|
||||
|
||||
ENGINE="$(yaml_block_value engine name)"
|
||||
VERSION="$(yaml_block_value engine version)"
|
||||
RENDERING="$(yaml_block_value engine rendering)"
|
||||
PHYSICS="$(yaml_block_value engine physics)"
|
||||
|
||||
if [ -z "$ENGINE" ]; then
|
||||
skipped "every engine check — project.yaml has no engine.name. Run /setup-engine."
|
||||
summarise
|
||||
fi
|
||||
|
||||
ENGINE_LC="$(printf '%s' "$ENGINE" | tr 'A-Z' 'a-z')"
|
||||
VERFILE="docs/engine-reference/$ENGINE_LC/VERSION.md"
|
||||
|
||||
# --- 1. VERSION.md carries a recorded installed-version probe -----------------
|
||||
if [ ! -f "$VERFILE" ]; then
|
||||
skipped "pinned-reference checks — $VERFILE does not exist"
|
||||
else
|
||||
PINNED="$(grep -E '^\| \*\*Engine Version\*\* \|' "$VERFILE" \
|
||||
| sed -E 's/^\| \*\*Engine Version\*\* \| *//; s/ *\|$//')"
|
||||
INSTALLED_ROW="$(grep -E '^\| \*\*Installed at pin time\*\* \|' "$VERFILE" \
|
||||
| sed -E 's/^\| \*\*Installed at pin time\*\* \| *//; s/ *\|$//')"
|
||||
|
||||
if [ -z "$INSTALLED_ROW" ]; then
|
||||
differs "$VERFILE has no 'Installed at pin time' row. /setup-engine is required to write one; its absence means the probe result was never recorded, not that the versions agree."
|
||||
elif printf '%s' "$INSTALLED_ROW" | grep -qE 'NOT DETERMINED'; then
|
||||
skipped "pinned-vs-installed — $VERFILE records '$INSTALLED_ROW'. A probe that did not run has not established a match."
|
||||
else
|
||||
match "VERSION.md records the installed version at pin time: $INSTALLED_ROW"
|
||||
fi
|
||||
|
||||
# --- 2. project.yaml version vs the pinned reference ------------------------
|
||||
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
|
||||
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."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3. Declared version vs the binary actually installed ---------------------
|
||||
PROBE=""
|
||||
case "$ENGINE_LC" in
|
||||
godot)
|
||||
if command -v godot >/dev/null 2>&1; then
|
||||
PROBE="$(godot --version 2>/dev/null | head -1)"
|
||||
fi
|
||||
;;
|
||||
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."
|
||||
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)"
|
||||
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."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 4/5. Godot: declared rendering + physics vs the real project file --------
|
||||
if [ "$ENGINE_LC" != "godot" ]; then
|
||||
skipped "project-file checks — implemented for Godot only; $ENGINE has no single equivalent file this script can read"
|
||||
elif [ ! -f project.godot ]; then
|
||||
skipped "project-file checks — no project.godot in this repo"
|
||||
else
|
||||
METHOD="$(grep -E '^renderer/rendering_method' project.godot \
|
||||
| sed -E 's/.*= *"?([a-z_]+)"?.*/\1/' | head -1)"
|
||||
if [ -z "$METHOD" ]; then
|
||||
skipped "rendering — project.godot sets no renderer/rendering_method (Godot then uses its own default, which this script will not guess)"
|
||||
elif [ -z "$RENDERING" ]; then
|
||||
skipped "rendering — project.yaml has no engine.rendering"
|
||||
else
|
||||
case "$RENDERING" in
|
||||
Compatibility|compatibility|"GL Compatibility") WANT_M="gl_compatibility" ;;
|
||||
"Forward+"|forward_plus|"Forward Plus") WANT_M="forward_plus" ;;
|
||||
Mobile|mobile) WANT_M="mobile" ;;
|
||||
*) WANT_M="" ;;
|
||||
esac
|
||||
if [ -z "$WANT_M" ]; then
|
||||
skipped "rendering — engine.rendering '$RENDERING' is not one this script maps to a Godot rendering_method"
|
||||
elif [ "$WANT_M" = "$METHOD" ]; then
|
||||
match "engine.rendering ($RENDERING) agrees with project.godot ($METHOD)"
|
||||
else
|
||||
differs "engine.rendering is '$RENDERING' (expects $WANT_M) but project.godot runs '$METHOD'. Whichever is wrong, an agent reading project.yaml will advise for the wrong renderer."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Jolt is Godot's default 3D physics engine; 2D is unchanged (Godot Physics
|
||||
# 2D). See docs/engine-reference/godot/modules/physics.md. Declaring Jolt on a
|
||||
# 2D project sends the implementer to tune a backend the game does not use.
|
||||
if [ -z "$PHYSICS" ]; then
|
||||
skipped "physics — project.yaml has no engine.physics"
|
||||
elif printf '%s' "$PHYSICS" | grep -qiE 'jolt'; then
|
||||
if grep -qE '(Node2D|CharacterBody2D|RigidBody2D|Area2D|StaticBody2D)' \
|
||||
$(find src -name '*.gd' -o -name '*.tscn' 2>/dev/null) /dev/null 2>/dev/null; then
|
||||
differs "engine.physics is 'Jolt', but 2D nodes were found under src/. Jolt is Godot's default 3D engine; 2D still uses Godot Physics 2D (docs/engine-reference/godot/modules/physics.md)."
|
||||
else
|
||||
match "engine.physics ($PHYSICS) — no 2D nodes found under src/ to contradict it"
|
||||
fi
|
||||
else
|
||||
match "engine.physics ($PHYSICS)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. Declared commands reference files that exist -------------------------
|
||||
BUILD_CMD="$(yaml_block_value commands build)"
|
||||
TEST_CMD="$(yaml_block_value commands test)"
|
||||
|
||||
if [ -z "$BUILD_CMD" ] && [ -z "$TEST_CMD" ]; then
|
||||
skipped "command checks — project.yaml declares no commands.build or commands.test"
|
||||
else
|
||||
if printf '%s' "$BUILD_CMD" | grep -qE 'export-(debug|release|pack)'; then
|
||||
if [ -f export_presets.cfg ]; then
|
||||
match "commands.build names an export preset and export_presets.cfg exists"
|
||||
else
|
||||
differs "commands.build is '$BUILD_CMD' but there is no export_presets.cfg. Godot cannot resolve the preset, so this command fails the first time CI or /smoke-check runs it."
|
||||
fi
|
||||
fi
|
||||
|
||||
RUNNER="$(printf '%s' "$TEST_CMD" | grep -oE '[A-Za-z0-9_/.-]+\.(gd|cs|py|sh)' | head -1)"
|
||||
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."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
summarise
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# review-scope.sh — compute the GDD scope for an incremental cross-GDD review.
|
||||
#
|
||||
# Replaces the git archaeology /review-all-gdds would otherwise reason through:
|
||||
# finds the most recent cross-review report, lists GDDs changed since it, and
|
||||
# lists each changed GDD's declared dependencies (which must also be in scope).
|
||||
#
|
||||
# Usage: bash .claude/scripts/review-scope.sh
|
||||
#
|
||||
# Output:
|
||||
# PRIOR_REVIEW: <path> (or "NONE (full review required)")
|
||||
# CHANGED: GDDs modified since that review
|
||||
# DEPS ...: declared .md dependencies of the changed GDDs
|
||||
#
|
||||
# CORRECTNESS NOTES — three silent-data-loss bugs were fixed when porting this.
|
||||
# All three dropped files without warning, which in a review tool means a changed
|
||||
# design doc quietly escapes review:
|
||||
#
|
||||
# 1. git pathspecs are fnmatch WITHOUT FNM_PATHNAME, so `*` crosses `/`:
|
||||
# 'design/gdd/*.md' also matched design/gdd/reviews/*.md, pulling the
|
||||
# review logs this script writes into the review scope. Fixed with
|
||||
# :(glob) magic, which restricts * to one path segment.
|
||||
# 2. `git status --porcelain | awk '{print $NF}'` mangles any path containing
|
||||
# a space (returns the trailing fragment) and returns the wrong field for
|
||||
# renames (`R old -> new`). Fixed with -z, which disables quoting entirely.
|
||||
# 3. `for f in $changed` word-splits on IFS and glob-expands, so a GDD whose
|
||||
# filename contains a space was split into two nonexistent paths and
|
||||
# silently discarded. Fixed with `while IFS= read -r`.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/../.." || { echo "review-scope: cannot reach repo root" >&2; exit 1; }
|
||||
|
||||
GDD_GLOB=':(glob)design/gdd/*.md'
|
||||
|
||||
# Single definition — this list is needed at three call sites and is exactly the
|
||||
# kind of thing that drifts when copied.
|
||||
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 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
all_gdds() {
|
||||
find design/gdd -maxdepth 1 -name '*.md' -type f 2>/dev/null | sed 's|\\|/|g' | sort \
|
||||
| while IFS= read -r f; do
|
||||
not_a_system_gdd "$f" || echo "$f"
|
||||
done
|
||||
}
|
||||
|
||||
# `sort | tail -1` is chronological ONLY because the report filename carries an
|
||||
# ISO 8601 date. /review-all-gdds now states that requirement at the point it
|
||||
# asks permission to write the file; if that ever loosens, this line silently
|
||||
# picks the wrong baseline and every GDD changed since the real last review
|
||||
# escapes the next one -- failure number four in the list above, reached from
|
||||
# the filename instead of the path handling.
|
||||
#
|
||||
# Not mtime: on a fresh clone every file carries the checkout time, so `ls -t`
|
||||
# here would order the reports arbitrarily. The date in the name is the only
|
||||
# ordering that survives being cloned.
|
||||
last_review=$(find design/gdd -maxdepth 1 -name 'gdd-cross-review-*.md' -type f 2>/dev/null \
|
||||
| sed 's|\\|/|g' | sort | tail -1)
|
||||
|
||||
if [ -z "$last_review" ]; then
|
||||
echo "PRIOR_REVIEW: NONE (full review required)"
|
||||
echo "CHANGED:"
|
||||
gdds=$(all_gdds)
|
||||
if [ -z "$gdds" ]; then
|
||||
echo " (no system GDDs found)"
|
||||
else
|
||||
printf '%s\n' "$gdds" | sed 's/^/ /'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "PRIOR_REVIEW: $last_review"
|
||||
|
||||
# Prefer git history if the review file is committed; fall back to file mtime.
|
||||
base=$(git log -1 --format=%H -- "$last_review" 2>/dev/null)
|
||||
if [ -n "$base" ]; then
|
||||
changed=$( {
|
||||
git diff --name-only "$base" -- "$GDD_GLOB" 2>/dev/null
|
||||
# -z emits NUL-delimited, unquoted paths; cut -c4- strips the fixed-width
|
||||
# "XY " status prefix. For a rename, -z emits new NUL old, so taking the
|
||||
# first of each pair gives the new name, which is what we want.
|
||||
git status --porcelain -z -- "$GDD_GLOB" 2>/dev/null | tr '\0' '\n' | cut -c4-
|
||||
} | sed 's|\\|/|g' | sort -u )
|
||||
else
|
||||
changed=$(find design/gdd -maxdepth 1 -name '*.md' -type f -newer "$last_review" 2>/dev/null \
|
||||
| sed 's|\\|/|g' | sort)
|
||||
fi
|
||||
|
||||
scope=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
not_a_system_gdd "$f" && continue
|
||||
[ -f "$f" ] && scope="$scope$f"$'\n'
|
||||
done <<EOF
|
||||
$changed
|
||||
EOF
|
||||
|
||||
echo "CHANGED:"
|
||||
if [ -z "$scope" ]; then
|
||||
echo " (none — no GDDs modified since last review)"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$scope" | sed 's/^/ /'
|
||||
|
||||
echo "DEPS (declared in changed GDDs — include these in review scope too):"
|
||||
printf '%s' "$scope" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
deps=$(awk '
|
||||
/^## /{
|
||||
h = tolower($0); sub(/^##[ \t]+/, "", h); sub(/^[0-9]+[.)]?[ \t]*/, "", h)
|
||||
dep = (h ~ /^dependencies/) ? 1 : 0
|
||||
}
|
||||
dep { print }
|
||||
' "$f" | grep -oE '[A-Za-z0-9_-]+\.md' | sort -u)
|
||||
if [ -n "$deps" ]; then
|
||||
printf '%s\n' "$deps" | sed "s|^| $(basename "$f") -> |"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# rotate-session-state.sh — move active.md's narrative into the session log,
|
||||
# leaving the checkpoint behind.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# active.md is two things at once: a small machine-read CHECKPOINT that hooks
|
||||
# inject, and a free-form narrative for humans. Only the first is bounded by the
|
||||
# schema. Left alone the second grows without limit -- it reached 712 lines /
|
||||
# 44 KB before this script existed -- and every session paid to preview a file
|
||||
# that was mostly history the commit log already records.
|
||||
#
|
||||
# Rotation keeps the checkpoint, appends the narrative to a dated file under
|
||||
# production/session-logs/, and starts the Notes section fresh. Nothing is
|
||||
# deleted.
|
||||
#
|
||||
# EXPLICIT INVOCATION ONLY. No hook calls this. session-start.sh observes that
|
||||
# the file is large and names this script; the user decides whether to run it.
|
||||
# Helpers here emit observations, never verdicts (CLAUDE.md) -- and silently
|
||||
# moving a user's working notes would be a verdict.
|
||||
#
|
||||
# Usage:
|
||||
# bash .claude/scripts/rotate-session-state.sh # rotate
|
||||
# bash .claude/scripts/rotate-session-state.sh --dry-run # show, change nothing
|
||||
# bash .claude/scripts/rotate-session-state.sh --help
|
||||
|
||||
set -u
|
||||
cd "$(dirname "$0")/../.." || exit 1
|
||||
|
||||
STATE="production/session-state/active.md"
|
||||
LOGDIR="production/session-logs"
|
||||
DRY=0
|
||||
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
sed -n '2,30p' "$0" | sed 's/^# \?//'
|
||||
exit 0 ;;
|
||||
--dry-run) DRY=1 ;;
|
||||
"") ;;
|
||||
*) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$STATE" ]; then
|
||||
echo "no session state at $STATE — nothing to rotate"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! grep -q '<!-- /CHECKPOINT -->' "$STATE"; then
|
||||
echo "REFUSED: $STATE has no <!-- /CHECKPOINT --> marker."
|
||||
echo " Rotation splits the file at that marker, so without it this script"
|
||||
echo " cannot tell the checkpoint from the narrative and would move both."
|
||||
echo " Re-create the file from .claude/docs/templates/session-state.md first."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Everything through the marker is the checkpoint; everything after is narrative.
|
||||
SPLIT=$(grep -n '<!-- /CHECKPOINT -->' "$STATE" | head -1 | cut -d: -f1)
|
||||
TOTAL=$(wc -l < "$STATE" | tr -d ' ')
|
||||
NARRATIVE=$((TOTAL - SPLIT))
|
||||
|
||||
if [ "$NARRATIVE" -le 5 ]; then
|
||||
echo "narrative is $NARRATIVE lines — nothing worth rotating"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Date comes from the filesystem, not `date`, so a dry run and the real run
|
||||
# agree and repeated runs on the same day append to one file.
|
||||
STAMP=$(date +%Y-%m-%d 2>/dev/null || echo "undated")
|
||||
ARCHIVE="$LOGDIR/session-state-$STAMP.md"
|
||||
|
||||
if [ "$DRY" = "1" ]; then
|
||||
echo "DRY RUN — no changes written"
|
||||
echo " checkpoint kept : lines 1-$SPLIT"
|
||||
echo " narrative moved : lines $((SPLIT + 1))-$TOTAL ($NARRATIVE lines)"
|
||||
echo " destination : $ARCHIVE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$LOGDIR" 2>/dev/null
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Rotated from active.md on $STAMP ($NARRATIVE lines)"
|
||||
echo ""
|
||||
tail -n "+$((SPLIT + 1))" "$STATE"
|
||||
} >> "$ARCHIVE"
|
||||
|
||||
# Write the truncated state beside the file it replaces, not in a system temp
|
||||
# dir. `${TMPDIR:-/tmp}` assumes /tmp exists and is writable; where it is not,
|
||||
# the redirect failed, `&&` skipped the mv, and the script then printed
|
||||
# "rotated N lines" and a line count it had not produced -- reporting a
|
||||
# rotation that did not happen, while the narrative it had ALREADY appended to
|
||||
# the archive stayed in active.md and got archived again on the next run.
|
||||
# mktemp in the state directory needs no external writable location and keeps
|
||||
# the mv on one filesystem, so it stays atomic.
|
||||
TMP=$(mktemp "$(dirname "$STATE")/.ccgs-rotate-XXXXXX" 2>/dev/null) || TMP=""
|
||||
if [ -z "$TMP" ]; then
|
||||
echo "rotate-session-state: FAILED — cannot create a temporary file in $(dirname "$STATE")" >&2
|
||||
echo " $STATE is UNCHANGED. The narrative was appended to $ARCHIVE and is still" >&2
|
||||
echo " in $STATE; remove one copy before rotating again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! {
|
||||
head -n "$SPLIT" "$STATE"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Notes"
|
||||
echo ""
|
||||
echo "_Narrative through $STAMP rotated to \`$ARCHIVE\`._"
|
||||
echo ""
|
||||
} > "$TMP"; then
|
||||
rm -f "$TMP"
|
||||
echo "rotate-session-state: FAILED — could not write $TMP" >&2
|
||||
echo " $STATE is UNCHANGED (the narrative is now in BOTH $ARCHIVE and $STATE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! mv "$TMP" "$STATE"; then
|
||||
rm -f "$TMP"
|
||||
echo "rotate-session-state: FAILED — could not replace $STATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "rotated $NARRATIVE lines -> $ARCHIVE"
|
||||
echo "$STATE is now $(wc -l < "$STATE" | tr -d ' ') lines"
|
||||
Reference in New Issue
Block a user