Files
Claude-Code-Game-Studios/.claude/scripts/migrate-v1-config.sh
T
7ed2c3e9c4 Release 1.1.1 — skills and agents work again outside auto mode (#129)
The skill config line is now one plain command, pre-approved in each
skill's own allowed-tools. The 1.1.0 line failed Claude Code's injected-command
permission check as "Contains expansion", so outside auto mode 66 skills
aborted and game-designer / creative-director could not launch.

Fixes #128

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:40:13 +10:00

741 lines
35 KiB
Bash

#!/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.1"
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