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:
Donchitos
2026-09-23 12:25:11 +10:00
co-authored by Claude Opus 5
parent 984023ddac
commit d05699707f
291 changed files with 24374 additions and 7740 deletions
+143
View File
@@ -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
+296
View File
@@ -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
+94
View File
@@ -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/."
+740
View File
@@ -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
+218
View File
@@ -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
+324
View File
@@ -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
+124
View File
@@ -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
+128
View File
@@ -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"