mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
Release 1.1.3 — long skills keep their later steps
Fix release for 1.1.2: the 14 longest skills keep their later phases after a compaction, a question nobody answered is never taken as an answer, settings read the same from a subfolder, the commit check works for a project in a subfolder of a larger repository, every skill opens with a title, and the shipped allow rules no longer approve deleting a branch or writing files without asking. Details in CHANGELOG.md; upgrade steps in UPGRADING.md (v1.1.2 → v1.1.3). Refs #83 Refs #133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185en4t76fPn7315q4c3nko
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b21fa0f7f2
commit
be8993bbc5
@@ -98,15 +98,11 @@ echo ""
|
||||
echo "## Files Modified (git working tree)"
|
||||
|
||||
# BOUNDED. These three lists must never be emitted in full, one line per
|
||||
# file, with no cap. (They were bounded believing this output goes into the
|
||||
# compacting context; it reaches the debug log, where an unbounded dump still
|
||||
# costs time and log size.) The checkpoint above was redesigned to be by-reference for exactly
|
||||
# that reason (see the note at the top of this file); the lists below were left
|
||||
# unbounded, so the hook still dumped whatever the working tree happened to
|
||||
# contain at the moment context was scarcest. Measured on a tree with ~5k
|
||||
# untracked files: 181,125 bytes, roughly 45,000 tokens, of which ~4,984 lines
|
||||
# were this section. A fresh asset import or un-gitignored build output is
|
||||
# enough to trigger it.
|
||||
# file, with no cap. This output reaches the debug log, where an unbounded dump
|
||||
# still costs time and log size: a tree with thousands of untracked files (a
|
||||
# fresh asset import, or build output that is not gitignored) would otherwise
|
||||
# print one line per file on every compaction. The checkpoint above is
|
||||
# by-reference for the same reason (see the note at the top of this file).
|
||||
#
|
||||
# A count plus a sample is what this section is actually for -- orienting the
|
||||
# agent after compaction -- and the full list is one `git status` away.
|
||||
@@ -141,8 +137,8 @@ WIP_FOUND=false
|
||||
# BOUNDED and BATCHED. One `grep -n` per design doc, printing every matching
|
||||
# line from every file, fails twice over -- the same two ways as above:
|
||||
# unbounded OUTPUT into a compacting context, and unbounded
|
||||
# COST -- ~85ms per file, crossing this hook's 10s budget at roughly 90 GDDs.
|
||||
# Measured: 220 docs 18262ms, 420 docs killed at the timeout.
|
||||
# COST -- one process spawn per file, which grows past this hook's 10s budget
|
||||
# on a project with a large set of GDDs.
|
||||
#
|
||||
# `xargs -0` bounds the spawn count by ARG_MAX rather than by file count, and
|
||||
# the NUL delimiter keeps it portable: `-d` is GNU-only, BSD xargs rejects it,
|
||||
|
||||
@@ -51,16 +51,12 @@ MODIFIED_FILES=$(git diff --name-only 2>/dev/null)
|
||||
# It is only valid to delete active.md manually or when explicitly superseded.
|
||||
# APPEND ONLY WHEN THE CONTENT CHANGED.
|
||||
#
|
||||
# The Stop hook fires once per RESPONSE, not once per session. Both blocks below
|
||||
# appended unconditionally, so a long session wrote the whole of active.md on
|
||||
# every turn. Measured on this repository before this guard:
|
||||
#
|
||||
# 3,187 Stop firings, 665 archived copies, session-log.md at 17.5 MB
|
||||
#
|
||||
# and session-logs/ is gitignored, so nothing ever surfaced it. The growth
|
||||
# compounds: the larger active.md gets, the more each firing appends -- and
|
||||
# rotate-session-state.sh caps active.md precisely because it grows, while the
|
||||
# log accumulating full copies of it had no cap at all.
|
||||
# The Stop hook fires once per RESPONSE, not once per session. Appending
|
||||
# unconditionally in the two blocks below would write the whole of active.md on
|
||||
# every turn of a long session, and session-logs/ is gitignored, so nothing
|
||||
# would surface the growth. It compounds: the larger active.md gets, the more
|
||||
# each firing appends -- and rotate-session-state.sh caps active.md precisely
|
||||
# because it grows, so a log accumulating full copies of it needs a guard too.
|
||||
#
|
||||
# Content hash, not mtime: an editor that rewrites a file unchanged would
|
||||
# otherwise re-archive it. Same reasoning and same tool as review-receipts.sh --
|
||||
|
||||
@@ -38,7 +38,7 @@ INPUT=$(cat)
|
||||
|
||||
# Most Bash and PowerShell calls do not mention git at all. Leave before any
|
||||
# parsing: this hook runs on every one of them, and the parse is most of its
|
||||
# cost (~350 ms a call measured; the early exit is the shell start-up alone).
|
||||
# cost (the early exit is the shell start-up alone).
|
||||
# Tested only after the "tool_input" key: matching the whole payload also
|
||||
# tests cwd and transcript_path, so a project path containing "GitHub" or
|
||||
# "digital" never took this exit and paid the full parse on every call.
|
||||
@@ -253,6 +253,15 @@ fi
|
||||
# `cd assets && git commit -m x data/item.json` named nothing here and the file
|
||||
# went unchecked. There, and whenever a pathspec names no tracked file, every
|
||||
# modified tracked file is checked (`:/`), and the index with it.
|
||||
#
|
||||
# A `git add` (or `git stage`) earlier in the same command stages its files
|
||||
# after this hook has read the index, so `git add -A && git commit` committed a
|
||||
# new broken data file unchecked. Each add before a commit is read too, and
|
||||
# what it will stage -- modified tracked files, and untracked ones unless it is
|
||||
# -u (ignored ones only with -f) -- is checked as working copies. A dry run
|
||||
# (-n) stages nothing, an add after the commit is for a later one, a
|
||||
# path-limited commit still records only its own paths, and after a cd or -C
|
||||
# an add's pathspecs are read as the whole tree, as above.
|
||||
if [ -n "$_VC_PY" ]; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
_VC_CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
|
||||
@@ -280,9 +289,37 @@ def words(line):
|
||||
def git(*args):
|
||||
return subprocess.run(["git", "-c", "core.quotePath=false"] + list(args),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
def add(w, k): # git add/stage from w[k] -> pathspecs, takes untracked, -f, -n, next k
|
||||
specs, rest, alln, upd, force, dry = [], False, False, False, False, False
|
||||
while k < len(w):
|
||||
a = w[k]
|
||||
if a.isdigit() and k + 1 < len(w) and w[k + 1][:1] in "<>":
|
||||
k += 1
|
||||
continue
|
||||
if REDIR.match(a):
|
||||
k += 2
|
||||
continue
|
||||
if a and set(a) <= OPS:
|
||||
break
|
||||
if rest or not a.startswith("-") or a == "-":
|
||||
specs.append(a)
|
||||
elif a == "--":
|
||||
rest = True
|
||||
elif a.startswith("--"):
|
||||
alln = alln or a in ("--all", "--no-ignore-removal")
|
||||
upd, force, dry = upd or a == "--update", force or a == "--force", dry or a == "--dry-run"
|
||||
if a.startswith("--pathspec-from-file"):
|
||||
specs.append(":/") # paths only git reads
|
||||
else: # no short option of git add takes a value: -Av is -A -v
|
||||
alln, upd, force, dry = alln or "A" in a, upd or "u" in a, force or "f" in a, dry or "n" in a
|
||||
k += 1
|
||||
if not specs and (alln or upd):
|
||||
specs = [":/"] # -A and -u with no pathspec take the whole tree
|
||||
return specs, alln or not upd, force, dry, k
|
||||
text = sys.stdin.buffer.read().decode("utf-8", "replace")
|
||||
away = sys.argv[1] == "1" or re.search(r"(^|[^\w.-])(cd|pushd|popd)(\s|[;&|)]|$)", text, re.M) is not None
|
||||
specs, commits, only = [], 0, True
|
||||
adds, pending = [], [] # what each git add stages: before a commit, not yet
|
||||
for line in text.splitlines():
|
||||
w, i = words(line), 0
|
||||
while i < len(w):
|
||||
@@ -293,10 +330,16 @@ for line in text.splitlines():
|
||||
while j < len(w) and w[j].startswith("-"):
|
||||
away = away or w[j] in ("-C", "--git-dir", "--work-tree") or w[j].startswith(("--git-dir=", "--work-tree="))
|
||||
j += 2 if w[j] in GIT_ARG else 1
|
||||
if j < len(w) and w[j] in ("add", "stage"):
|
||||
s, new, force, dry, i = add(w, j + 1)
|
||||
if not dry:
|
||||
pending.append((s, new, force))
|
||||
continue
|
||||
if j >= len(w) or w[j] != "commit":
|
||||
i = j
|
||||
continue
|
||||
commits += 1
|
||||
adds, pending = adds + pending, []
|
||||
k, rest, mine, index = j + 1, False, [], False
|
||||
while k < len(w):
|
||||
a = w[k]
|
||||
@@ -344,7 +387,13 @@ out = git("diff", "--name-only", "HEAD", "--", *specs) if only else None
|
||||
if out is None or out.returncode != 0: # not path-limited, or no HEAD yet
|
||||
only = False
|
||||
out = git("diff", "--name-only", "--", *specs) if specs else None
|
||||
sys.stdout.buffer.write((b"CCGS-ONLY\n" if only else b"CCGS-INDEX\n") + (out.stdout if out else b""))
|
||||
more = b""
|
||||
for s, new, force in ([] if only else adds):
|
||||
s = [":/"] if away else s
|
||||
more += git("diff", "--name-only", "--", *s).stdout
|
||||
if new:
|
||||
more += git("ls-files", "--others", "--full-name", *([] if force else ["--exclude-standard"]), "--", *s).stdout
|
||||
sys.stdout.buffer.write((b"CCGS-ONLY\n" if only else b"CCGS-INDEX\n") + (out.stdout if out else b"") + more)
|
||||
' "$_VC_AWAY" 2>/dev/null)
|
||||
case "$_VC_TOK" in CCGS-ONLY*) INDEX_FILES="" ;; esac
|
||||
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$(printf '%s\n' "$_VC_TOK" | tail -n +2)")
|
||||
@@ -355,8 +404,24 @@ else
|
||||
for _p in $(printf '%s' "$_AFTER" | LC_ALL=C grep -oE '(assets|Assets|Content)/[^[:space:]"'"'"';|&)]*'); do
|
||||
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$($_GIT diff --name-only -- "$_p" 2>/dev/null)")
|
||||
done
|
||||
# No tokeniser to read a `git add`'s paths: take every change and new file.
|
||||
if git_segment add "$COMMAND" >/dev/null || git_segment stage "$COMMAND" >/dev/null; then
|
||||
WT_FILES=$(printf '%s\n%s\n%s' "$WT_FILES" "$($_GIT diff --name-only 2>/dev/null)" \
|
||||
"$($_GIT ls-files --others --exclude-standard --full-name 2>/dev/null)")
|
||||
fi
|
||||
fi
|
||||
WT_FILES=$(printf '%s\n' "$WT_FILES" | grep -v '^$' | sort -u)
|
||||
# A project in a subfolder of a larger repository (game/ in a monorepo): git
|
||||
# names every path above from the repository's top level, and every check below
|
||||
# matches from the PROJECT root (^assets/, ^design/gdd/, the code root) and
|
||||
# reads files relative to it. Unconverted, nothing matched and the commit went
|
||||
# through unchecked. Keep this project's paths with the prefix dropped; files
|
||||
# outside the project are not ours to check. "" at the top level.
|
||||
_VC_PREFIX=$(git rev-parse --show-prefix 2>/dev/null)
|
||||
if [ -n "$_VC_PREFIX" ]; then
|
||||
INDEX_FILES=$(printf '%s\n' "$INDEX_FILES" | LC_ALL=C awk -v p="$_VC_PREFIX" 'index($0, p) == 1 { print substr($0, length(p) + 1) }')
|
||||
WT_FILES=$(printf '%s\n' "$WT_FILES" | LC_ALL=C awk -v p="$_VC_PREFIX" 'index($0, p) == 1 { print substr($0, length(p) + 1) }')
|
||||
fi
|
||||
STAGED=$(printf '%s\n%s\n' "$INDEX_FILES" "$WT_FILES" | grep -v '^$' | sort -u)
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
@@ -390,14 +455,11 @@ trap '[ -n "$TMP_DESIGN" ] && rm -f "$TMP_DESIGN"' EXIT
|
||||
# it appends to $WARNINGS and the hook still exits 0. This block is the only one
|
||||
# that can exit 2, and exit 2 is the entire reason the hook is registered.
|
||||
#
|
||||
# Running it THIRD, after the design-section scan, and validating one file
|
||||
# per `python -m json.tool` invocation -- one interpreter spawn each, ~110ms on
|
||||
# Windows -- gets the hook killed by its 15s timeout on a large commit before
|
||||
# it reaches the corrupt file, so whether a bad file is caught depends on where
|
||||
# its NAME SORTED. Measured with 201 staged files and one corrupt:
|
||||
#
|
||||
# corrupt sorts FIRST -> 730ms, rc=2, BLOCKED emitted
|
||||
# corrupt sorts LAST -> 15217ms, rc=124, nothing emitted, commit not blocked
|
||||
# Running it after the design-section scan, and validating one file per
|
||||
# `python -m json.tool` invocation -- one interpreter spawn each -- would get the
|
||||
# hook killed by its 15s timeout on a large commit before it reaches a corrupt
|
||||
# file, so whether a bad file is caught would depend on where its NAME SORTS: a
|
||||
# corrupt file sorting first is blocked, one sorting last lets the commit through.
|
||||
#
|
||||
# Two independent fixes, because either alone is a mitigation rather than a
|
||||
# guarantee:
|
||||
@@ -432,17 +494,19 @@ if [ -n "$DATA_FILES" ]; then
|
||||
JSON_OUT=$(LC_ALL=C awk -v OFS='\t' 'NR == FNR { if ($0 != "") wt[$0] = 1; next }
|
||||
$0 != "" { print (($0 in wt) ? "W" : "I"), $0 }' \
|
||||
<(printf '%s\n' "$WT_FILES") <(printf '%s\n' "$DATA_FILES") \
|
||||
| "$PYTHON_CMD" -c '
|
||||
| CCGS_VC_PREFIX="$_VC_PREFIX" "$PYTHON_CMD" -c '
|
||||
import json, sys, os, subprocess
|
||||
bad = []
|
||||
bad, lfs_unread = [], []
|
||||
# Bytes in, decoded as UTF-8: a Windows console code page would mangle a
|
||||
# non-ASCII path before it reached os.path or git.
|
||||
items = [l.split("\t", 1) for l in sys.stdin.buffer.read().decode("utf-8").splitlines() if "\t" in l]
|
||||
staged = [p for mode, p in items if mode == "I"]
|
||||
# Paths are project-relative; the index names them from the repository top.
|
||||
prefix = os.environ.get("CCGS_VC_PREFIX", "")
|
||||
blobs = {}
|
||||
if staged:
|
||||
out = subprocess.run(["git", "cat-file", "--batch"], stdout=subprocess.PIPE,
|
||||
input="".join(":" + p + "\n" for p in staged).encode("utf-8")).stdout
|
||||
input="".join(":" + prefix + p + "\n" for p in staged).encode("utf-8")).stdout
|
||||
pos = 0
|
||||
for p in staged:
|
||||
end = out.index(b"\n", pos)
|
||||
@@ -458,6 +522,16 @@ for mode, p in items:
|
||||
if p not in blobs:
|
||||
continue # removed from the index: nothing of it is committed
|
||||
data = blobs[p]
|
||||
if data.startswith(b"version https://git-lfs.github.com/spec/"):
|
||||
# A Git LFS pointer: git add stored the file in LFS and staged
|
||||
# this stub, which is never JSON. The working copy is the file
|
||||
# it stored. With no working copy there is nothing to read --
|
||||
# say so, do not block a valid file or pass it in silence.
|
||||
if not os.path.isfile(p):
|
||||
lfs_unread.append(p)
|
||||
continue
|
||||
with open(p, "rb") as fh:
|
||||
data = fh.read()
|
||||
else:
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
@@ -470,13 +544,20 @@ for mode, p in items:
|
||||
# putting a CR inside every path but the last. Invisible in a terminal and in
|
||||
# a line-by-line diff -- only a byte comparison against the pre-batch
|
||||
# implementation surfaced it. get_yaml_key writes bytes for the same reason.
|
||||
sys.stdout.buffer.write(("CCGS-JSON-CHECKED\n" + "\n".join(bad)).encode("utf-8"))
|
||||
# One line per file: "B<tab>path" not valid JSON, "S<tab>path" an LFS pointer
|
||||
# with no working copy to read.
|
||||
sys.stdout.buffer.write(("CCGS-JSON-CHECKED\n" + "".join("B\t" + p + "\n" for p in bad)
|
||||
+ "".join("S\t" + p + "\n" for p in lfs_unread)).encode("utf-8"))
|
||||
' 2>/dev/null)
|
||||
if [ "$(printf '%s\n' "$JSON_OUT" | head -1)" != "CCGS-JSON-CHECKED" ]; then
|
||||
echo "BLOCKED: $(printf '%s\n' "$DATA_FILES" | grep -c .) staged data file(s) could not be validated -- the JSON check did not run under '$PYTHON_CMD'. Check that it is a working Python 3 and commit again." >&2
|
||||
exit 2
|
||||
fi
|
||||
BAD_JSON=$(printf '%s\n' "$JSON_OUT" | tail -n +2)
|
||||
BAD_JSON=$(printf '%s\n' "$JSON_OUT" | tail -n +2 | LC_ALL=C awk -F'\t' '$1 == "B" { print $2 }')
|
||||
LFS_UNREAD=$(printf '%s\n' "$JSON_OUT" | tail -n +2 | LC_ALL=C awk -F'\t' '$1 == "S" { print $2 }')
|
||||
if [ -n "$LFS_UNREAD" ]; then
|
||||
WARNINGS="$WARNINGS\nSKIPPED: $(printf '%s\n' "$LFS_UNREAD" | grep -c .) staged data file(s) are Git LFS pointers with no working copy, so the JSON check did NOT read them: $(printf '%s' "$LFS_UNREAD" | tr '\n' ' ')"
|
||||
fi
|
||||
if [ -n "$BAD_JSON" ]; then
|
||||
# Every offender at once. The per-file loop reported only the first
|
||||
# and exited, so a commit with several bad files took several
|
||||
@@ -518,7 +599,12 @@ if [ -f .claude/hooks/yaml-helper.sh ]; then
|
||||
command -v resolve_setting >/dev/null 2>&1 && _VC_HELPER=1
|
||||
fi
|
||||
|
||||
DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/')
|
||||
# design/gdd/ also holds governance and registry files that are not system
|
||||
# GDDs and have none of these sections, so they warned on every commit (#133).
|
||||
# Skip the names not_a_system_gdd() skips in gdd-structure-check.sh and
|
||||
# review-scope.sh; keep the three lists the same.
|
||||
DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/' \
|
||||
| grep -vE '/(game-concept|systems-index|game-pillars|gdd-cross-review-[^/]*|gameplay-tags|fixture-swap-ledger|entity-registry|sound-bible)\.md$')
|
||||
if [ -n "$DESIGN_FILES" ]; then
|
||||
WORKFLOW="standard"
|
||||
if [ "$_VC_HELPER" = 1 ]; then
|
||||
@@ -545,11 +631,9 @@ if [ -n "$DESIGN_FILES" ]; then
|
||||
#
|
||||
# A nested shell loop -- for each staged design doc, `echo |
|
||||
# tr | while read` then one `grep -qi` per required section -- does not
|
||||
# scale. At
|
||||
# workflow=full that is 8 greps per file, and each is a process. 50
|
||||
# design docs measured 14644ms -- 97% of this hook's 15s budget, from
|
||||
# the ADVISORY half alone. 50 GDDs is an ordinary project, not a
|
||||
# stress case.
|
||||
# scale. At workflow=full that is 8 greps per file, and each is a
|
||||
# process, so an ordinary project of about 50 GDDs would use nearly all
|
||||
# of this hook's 15s budget on the ADVISORY half alone.
|
||||
#
|
||||
# Matching is unchanged on purpose: case-insensitive SUBSTRING anywhere
|
||||
# in the file, exactly what `grep -qi "$section"` did. It is looser than
|
||||
|
||||
@@ -39,7 +39,7 @@ INPUT=$(cat)
|
||||
|
||||
# Most Bash and PowerShell calls do not mention git at all. Leave before any
|
||||
# parsing: this hook runs on every one of them, and the parse is most of its
|
||||
# cost (~350 ms a call measured; the early exit is the shell start-up alone).
|
||||
# cost (the early exit is the shell start-up alone).
|
||||
# Tested only after the "tool_input" key: matching the whole payload also
|
||||
# tests cwd and transcript_path, so a project path containing "GitHub" or
|
||||
# "digital" never took this exit and paid the full parse on every call.
|
||||
|
||||
@@ -569,7 +569,11 @@ EOF
|
||||
# and must agree on YAML edge cases exactly, and a shared copy that drifted
|
||||
# would make two functions disagree about what a file says.
|
||||
validate_local_scope() {
|
||||
local file="${1:-project.local.yaml}"
|
||||
local file="${1:-}"
|
||||
# Default to the PROJECT ROOT's local file, not the working directory's: run
|
||||
# from src/, a bare `project.local.yaml` names a file that is not there, and
|
||||
# the check passes because it found nothing to check.
|
||||
if [ -z "$file" ]; then _yaml_helper_set_root; file="$_YH_ROOT/project.local.yaml"; fi
|
||||
[ -f "$file" ] || return 0
|
||||
if ! _yaml_helper_resolve_python; then return 0; fi
|
||||
local found key rc=0
|
||||
@@ -646,7 +650,10 @@ def walk(node, prefix):
|
||||
elif dotted.startswith(EXEMPT_PREFIX):
|
||||
continue
|
||||
else:
|
||||
print(dotted)
|
||||
# Bytes, not print(): on Windows print() writes CRLF, bash keeps
|
||||
# the CR, and every key but the last reached the notes line with
|
||||
# a carriage return on it. The other parsers here write this way.
|
||||
sys.stdout.buffer.write((dotted + '\n').encode('utf-8'))
|
||||
|
||||
walk(data, '')
|
||||
PYEOF
|
||||
@@ -682,7 +689,9 @@ EOF
|
||||
# would be self-defeating.
|
||||
session_state_enabled() {
|
||||
local f v
|
||||
for f in project.local.yaml project.yaml; do
|
||||
# The root, not the working directory (builtins only -- no added spawn).
|
||||
_yaml_helper_set_root
|
||||
for f in "$_YH_ROOT/project.local.yaml" "$_YH_ROOT/project.yaml"; do
|
||||
[ -f "$f" ] || continue
|
||||
v=$(awk '
|
||||
/^features:[[:space:]]*$/ { inf=1; next }
|
||||
@@ -792,8 +801,8 @@ data = parse(raw_lines)
|
||||
# default. Every enum check below passed on such a file, because a key that
|
||||
# cannot be read cannot hold an invalid value. That is rc=0 on a config that
|
||||
# configures nothing, byte-identical in outcome to a genuinely clean file.
|
||||
# Measured before this check: the same file with tabs instead of spaces
|
||||
# reported no errors while `modes.review_mode: BOGUS_VALUE` sat in it unread.
|
||||
# Without this check, the same file with tabs instead of spaces reports no
|
||||
# errors while `modes.review_mode: BOGUS_VALUE` sits in it unread.
|
||||
#
|
||||
# This is the validator's own version of the rule the skills are held to --
|
||||
# absence of evidence is not evidence of absence. It cannot report "no invalid
|
||||
@@ -944,12 +953,12 @@ EOF
|
||||
# Whole-config resolution.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ~51 SKILL.md files each carried an English description of a deterministic
|
||||
# fallback chain ("1. If --review passed -> use that; 2. Else read
|
||||
# modes.review_mode from project.yaml; 3. Else production/review-mode.txt;
|
||||
# 4. Else lean"), re-interpreted by a model on every invocation at roughly
|
||||
# +1,360 tokens per skill. resolve_config computes the same answer once, in
|
||||
# ~150 tokens, deterministically -- and testably, which prose never was.
|
||||
# Every skill that reads a setting needs the same deterministic fallback chain
|
||||
# ("1. If --review passed -> use that; 2. Else read modes.review_mode from
|
||||
# project.yaml; 3. Else production/review-mode.txt; 4. Else lean"). Written out
|
||||
# in each SKILL.md, a model would re-interpret that prose on every invocation.
|
||||
# resolve_config computes the same answer once, deterministically -- and
|
||||
# testably, which prose never was.
|
||||
#
|
||||
# CONTRACT BOUNDARY
|
||||
# This layer resolves SOURCES. It does not own per-skill policy. Anything
|
||||
@@ -969,32 +978,31 @@ EOF
|
||||
# and this table. Leaving a terminal default here as well would shadow the
|
||||
# expansion and make `rigor` a no-op for anyone who had not also set the sub-knob.
|
||||
# modes.review_mode is fronted by rigor so `rigor: minimal` resolves
|
||||
# review_mode to `solo` (skipping the ~44k-token director/specialist gates),
|
||||
# `standard` yields `lean` and `full` yields `full`. team.size joined the fronted
|
||||
# set the same way: `full` yields `studio` (the whole roster) while
|
||||
# review_mode to `solo` (skipping the director and specialist gates),
|
||||
# `standard` yields `lean` and `full` yields `full`. team.size is fronted the
|
||||
# same way: `full` yields `studio` (the whole roster) while
|
||||
# `minimal`/`standard` yield `individual`. Both stay locally overridable (they
|
||||
# are personal-experience knobs, not on-disk artifacts): that source sits ABOVE
|
||||
# the expansion, so only the terminal fallback moved.
|
||||
# the expansion, so a local value wins over the rigor level.
|
||||
#
|
||||
# --- WHY modes.rigor DEFAULTS TO `minimal` -----------------------------------
|
||||
#
|
||||
# It defaulted to `standard`. Building the same project both ways showed the
|
||||
# heavier tier costing several times as much to reach working code without
|
||||
# producing a better result, and giving nothing back when a fresh developer
|
||||
# picked the project up. A default that costs more and does not repay is the
|
||||
# wrong default, and it was what every user who never opened /settings received.
|
||||
# The heavier tier costs more to reach working code without producing a better
|
||||
# result, and gives nothing back when a new contributor picks the project up. A
|
||||
# default that costs more and does not repay is the wrong default, and it is
|
||||
# what every user who never opens /settings would receive.
|
||||
#
|
||||
# Raising rigor stays one question in /start and one `/settings` call, and
|
||||
# Raising rigor is one question in /start and one `/settings` call, and
|
||||
# settings-guidance.md's upward triggers ("system-GDD count crosses ~9",
|
||||
# "gate-check PASS into Production while rigor is minimal") are written to fire
|
||||
# from exactly this starting state. The cost of under-running is one prompt.
|
||||
#
|
||||
# ORDER MATTERS: this default is only safe while /gate-check keeps its floors.
|
||||
# `rigor: minimal` expands to `workflow: minimal` + `qa.level: minimal`, which
|
||||
# together once left the Production->Polish gate with ZERO required artifacts --
|
||||
# a vacuous PASS. That is fixed (the smoke-report floor and the "nothing
|
||||
# required -> NOT ASSESSED, never PASS" rule). Do not move this default again
|
||||
# without re-checking that the gates below it still require something.
|
||||
# together would leave the Production->Polish gate with ZERO required artifacts
|
||||
# -- a vacuous PASS -- without the smoke-report floor and the "nothing required
|
||||
# -> NOT ASSESSED, never PASS" rule. Do not change this default without
|
||||
# re-checking that each /gate-check gate still requires something.
|
||||
_yaml_helper_defaults="\
|
||||
modes.automation::collaborative
|
||||
modes.rigor::minimal
|
||||
@@ -1002,12 +1010,11 @@ performance.enforce::warn"
|
||||
|
||||
# Rigor expansion — one asked-at-/start knob that supplies four.
|
||||
#
|
||||
# WHY: /start has never asked about workflow, docs.density, qa.level or
|
||||
# story_granularity, so in practice every project ran all four at their defaults.
|
||||
# Between them the latter three drive ONE behaviour each (prose verbosity, is-
|
||||
# evidence-required, story size) restated across 19 skills with 17 "we are
|
||||
# orthogonal" disclaimers. `rigor` makes the common case reachable in one
|
||||
# question while each knob stays individually settable.
|
||||
# WHY: /start does not ask about workflow, docs.density, qa.level or
|
||||
# story_granularity, so in practice a project runs all four at their defaults.
|
||||
# The latter three each drive one behaviour (prose verbosity, whether evidence
|
||||
# is required, story size) that many skills read. `rigor` makes the common case
|
||||
# reachable in one question while each knob stays individually settable.
|
||||
#
|
||||
# modes.workflow is fronted, NOT replaced: it drives ~5 distinct behaviours and
|
||||
# owns the only per-system override mechanism (workflow_overrides.system_
|
||||
@@ -1120,6 +1127,8 @@ get_legacy_key() {
|
||||
[ -z "$path" ] && return 0
|
||||
file=$(_yaml_helper_legacy_file_for "$path")
|
||||
[ -z "$file" ] && return 0
|
||||
_yaml_helper_set_root
|
||||
file="$_YH_ROOT/$file"
|
||||
[ -f "$file" ] || return 0
|
||||
awk 'BEGIN{FS="\n"} { gsub(/\r/,""); sub(/^[ \t]+/,""); sub(/[ \t]+$/,"");
|
||||
if (length($0) > 0) { print; exit } }' "$file"
|
||||
@@ -1153,7 +1162,8 @@ resolve_setting() {
|
||||
|
||||
if [ -z "$val" ]; then
|
||||
legacy=$(_yaml_helper_legacy_file_for "$path")
|
||||
if [ -n "$legacy" ] && [ -f "$legacy" ]; then
|
||||
# Checked under the root; labelled with the short path the user knows.
|
||||
if [ -n "$legacy" ] && [ -f "$_YH_ROOT/$legacy" ]; then
|
||||
val=$(get_legacy_key "$path")
|
||||
if [ -n "$val" ] && ! validate_enum_value "$path" "$val" 2>/dev/null; then val=""; fi
|
||||
[ -n "$val" ] && src="$legacy"
|
||||
@@ -1330,11 +1340,9 @@ PYEOF
|
||||
# a truncated block a skill might half-read.
|
||||
# resolve_config [--keys k1,k2,...] [<system>]
|
||||
#
|
||||
# --keys restricts output to the named knobs. USE IT. Emitting all 12 lines costs
|
||||
# ~188 tokens, while the resolution prose it replaces averages only ~101 tokens
|
||||
# per skill -- so a full block is a NET LOSS for any skill that reads 2-3 knobs.
|
||||
# A 3-knob block costs ~45 tokens, which is the actual win. Measured, not
|
||||
# estimated.
|
||||
# --keys restricts output to the named knobs. USE IT. A block of every knob
|
||||
# costs more than the resolution prose it replaces in a skill that reads 2-3
|
||||
# knobs; a block of just those knobs is the saving.
|
||||
# Valid key names are the output labels: rigor, review_mode, automation, workflow,
|
||||
# docs.density, story_granularity, qa.level, team.size, project.stage,
|
||||
# automation_always_ask, engine, testing.strict, system_overrides.
|
||||
@@ -1363,11 +1371,14 @@ resolve_config() {
|
||||
orphan=$(validate_local_yaml_base 2>&1) || notes="${notes:+$notes; }$orphan"
|
||||
|
||||
# Collect enum complaints from both files so a typo is visible, not silent.
|
||||
# Every file below is named under $_YH_ROOT. A bare `project.yaml` is the
|
||||
# WORKING directory's, so run from src/ these checks found no file and
|
||||
# reported nothing, while the values above them resolved from the root.
|
||||
local enum_err
|
||||
enum_err=$(validate_yaml_enum project.yaml 2>&1 >/dev/null)
|
||||
enum_err=$(validate_yaml_enum "$_YH_ROOT/project.yaml" 2>&1 >/dev/null)
|
||||
[ -n "$enum_err" ] && notes="${notes:+$notes; }$(echo "$enum_err" | tr '\n' ';' | sed 's/;$//') — ignored, chain continued"
|
||||
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
|
||||
enum_err=$(validate_yaml_enum project.local.yaml 2>&1 >/dev/null)
|
||||
enum_err=$(validate_yaml_enum "$_YH_ROOT/project.local.yaml" 2>&1 >/dev/null)
|
||||
[ -n "$enum_err" ] && notes="${notes:+$notes; }local: $(echo "$enum_err" | tr '\n' ';' | sed 's/;$//')"
|
||||
# Locked keys in the local file. Separate from the enum check
|
||||
# above because their VALUES are legal — it is the location that is not, so
|
||||
@@ -1377,17 +1388,16 @@ resolve_config() {
|
||||
# full remedy sentence per key for direct callers, and three copies of it
|
||||
# would cost more of this block than every resolved value put together.
|
||||
local scope_err scope_keys
|
||||
scope_err=$(validate_local_scope project.local.yaml 2>&1 >/dev/null)
|
||||
scope_err=$(validate_local_scope "$_YH_ROOT/project.local.yaml" 2>&1 >/dev/null)
|
||||
if [ -n "$scope_err" ]; then
|
||||
scope_keys=$(printf '%s\n' "$scope_err" | sed -n 's/.*`\([^`]*\)`.*/\1/p' | paste -sd, - | sed 's/,/, /g')
|
||||
notes="${notes:+$notes; }local: not locally overridable, ignored — $scope_keys (move to project.yaml or delete)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Framing costs ~81 chars. For a skill reading 1-2 knobs that is more than the
|
||||
# For a skill reading 1-2 knobs the block's framing would cost more than the
|
||||
# inline chain it replaced, so bare lines are emitted instead -- "automation:
|
||||
# guided (project.local.yaml)" is self-describing without a banner. Measured:
|
||||
# single-knob skills were +226 chars WITH framing, negative without it.
|
||||
# guided (project.local.yaml)" is self-describing without a banner.
|
||||
local nkeys=0
|
||||
if [ -n "$want" ]; then
|
||||
nkeys=$(printf '%s' "$want" | tr ',' '\n' | grep -c '[a-z]')
|
||||
@@ -1418,13 +1428,21 @@ resolve_config() {
|
||||
done
|
||||
|
||||
# automation_always_ask: array-valued, so it does not go through resolve_setting.
|
||||
# Joined item by item. It used to join on commas and then turn every comma
|
||||
# into ", ", so one quoted item holding a comma printed as two items. An item
|
||||
# that holds a comma is now quoted, so one item stays one item.
|
||||
_rc_join() {
|
||||
printf '%s\n' "$1" | awk '/^[[:space:]]*$/ { next }
|
||||
{ s = $0; if (index(s, ",")) s = "\"" s "\""; out = out (n++ ? ", " : "") s }
|
||||
END { print out }'
|
||||
}
|
||||
local aaa
|
||||
if _rc_want automation_always_ask; then
|
||||
aaa=$(get_effective_yaml_array modes.automation_always_ask 2>/dev/null)
|
||||
if [ -n "$aaa" ]; then
|
||||
echo "automation_always_ask: $(echo "$aaa" | tr '\n' ',' | sed 's/,$//; s/,/, /g') (configured)"
|
||||
echo "automation_always_ask: $(_rc_join "$aaa") (configured)"
|
||||
else
|
||||
echo "automation_always_ask: $(echo "$_yaml_helper_always_ask_default" | tr '\n' ',' | sed 's/,$//; s/,/, /g') (default)"
|
||||
echo "automation_always_ask: $(_rc_join "$_yaml_helper_always_ask_default") (default)"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1444,11 +1462,29 @@ resolve_config() {
|
||||
|
||||
# testing.strict.*: reported as CONFIGURED STATE ONLY, never defaulted here.
|
||||
# Its unset default differs per skill by design.
|
||||
local ts_out="" k tv
|
||||
#
|
||||
# Each value is validated before it counts: get_effective_yaml_key printed an
|
||||
# invalid value (`logic: maybe`) as the value while the notes line said it was
|
||||
# ignored. Local first (the keys are locally overridable), then project.yaml,
|
||||
# then the scalar shorthand (`testing.strict: true`), each checked against
|
||||
# true|false. Not resolve_setting: that adds a rigor lookup per type, and
|
||||
# testing.strict is neither rigor-fronted nor defaulted -- five more
|
||||
# interpreter spawns for nothing. validate_enum_value is bash only.
|
||||
local ts_out="" k tv ts_all
|
||||
if _rc_want testing.strict; then
|
||||
ts_all=$(get_yaml_key "$_YH_ROOT/project.yaml" testing.strict 2>/dev/null)
|
||||
if [ -n "$ts_all" ] && ! validate_enum_value testing.strict.logic "$ts_all" 2>/dev/null; then ts_all=""; fi
|
||||
for k in logic integration visual ui config; do
|
||||
tv=$(get_effective_yaml_key "testing.strict.$k" 2>/dev/null)
|
||||
[ -z "$tv" ] && tv=$(get_yaml_key "$_YH_ROOT/project.yaml" testing.strict 2>/dev/null)
|
||||
tv=""
|
||||
if [ -f "$_YH_ROOT/project.local.yaml" ]; then
|
||||
tv=$(get_yaml_key "$_YH_ROOT/project.local.yaml" "testing.strict.$k" 2>/dev/null)
|
||||
if [ -n "$tv" ] && ! validate_enum_value "testing.strict.$k" "$tv" 2>/dev/null; then tv=""; fi
|
||||
fi
|
||||
if [ -z "$tv" ]; then
|
||||
tv=$(get_yaml_key "$_YH_ROOT/project.yaml" "testing.strict.$k" 2>/dev/null)
|
||||
if [ -n "$tv" ] && ! validate_enum_value "testing.strict.$k" "$tv" 2>/dev/null; then tv=""; fi
|
||||
fi
|
||||
[ -z "$tv" ] && tv="$ts_all"
|
||||
ts_out="$ts_out $k=${tv:-unset}"
|
||||
done
|
||||
echo "testing.strict:${ts_out} (unset = each skill applies its own default)"
|
||||
@@ -1498,11 +1534,18 @@ resolve_config() {
|
||||
# skill-authoring.md. Substituting `none` would make the question unaskable;
|
||||
# substituting anything else would emit every track, which is the ~150-item
|
||||
# checklist a single-platform jam project once received.
|
||||
local ct
|
||||
#
|
||||
# Through resolve_setting, as performance.enforce above. get_effective_yaml_key
|
||||
# let a hand-edited project.local.yaml win although platform.* is locked, and
|
||||
# printed an invalid value (`banana`) as the value while the notes line said
|
||||
# it was ignored. Only project.yaml can set it, so any other source -- there is
|
||||
# no default to fall to -- leaves it unset and the skill asks.
|
||||
local ct cs
|
||||
if _rc_want cert_tier || _rc_want platform.cert_tier; then
|
||||
ct=$(get_effective_yaml_key platform.cert_tier 2>/dev/null)
|
||||
if [ -n "$ct" ]; then
|
||||
echo "platform.cert_tier: $ct"
|
||||
ct=$(resolve_setting platform.cert_tier)
|
||||
cs="${ct#*$(printf '\t')}"; ct="${ct%%$(printf '\t')*}"
|
||||
if [ -n "$ct" ] && [ "$cs" = "project.yaml" ]; then
|
||||
echo "platform.cert_tier: $ct ($cs)"
|
||||
else
|
||||
echo "platform.cert_tier: (unset -- ask which platforms are in scope)"
|
||||
fi
|
||||
@@ -1512,7 +1555,7 @@ resolve_config() {
|
||||
# second call. A named <system> is echoed explicitly for convenience.
|
||||
local so_keys so_out="" sk sv
|
||||
if _rc_want system_overrides; then
|
||||
so_keys=$(get_yaml_child_keys project.yaml workflow_overrides.system_overrides 2>/dev/null)
|
||||
so_keys=$(get_yaml_child_keys "$_YH_ROOT/project.yaml" workflow_overrides.system_overrides 2>/dev/null)
|
||||
if [ -n "$so_keys" ]; then
|
||||
while IFS= read -r sk; do
|
||||
[ -z "$sk" ] && continue
|
||||
@@ -1582,7 +1625,7 @@ hook_warn() {
|
||||
# Skills cannot `source` this file in their `` !`cmd` `` bootstrap line. Claude
|
||||
# Code permission-checks every injected command before the skill renders, and
|
||||
# outside auto mode anything short of "allow" ABORTS the whole invocation --
|
||||
# measured on 2.1.281, see .claude/docs/config-resolution.md. A `${VAR:-x}` or
|
||||
# see .claude/docs/config-resolution.md. A `${VAR:-x}` or
|
||||
# `$( )` in the command fails that check as "Contains expansion" and no grant can
|
||||
# approve it; a `source … && resolve_config` compound needs every part approved.
|
||||
# `${CLAUDE_SKILL_DIR}` is substituted as text before the check, so one plain
|
||||
@@ -1594,7 +1637,7 @@ hook_warn() {
|
||||
# every name added here widens what a skill grant can reach.
|
||||
#
|
||||
# ROOT. Run from a subdirectory, the skill shell's cwd has no project.yaml and
|
||||
# $CLAUDE_PROJECT_DIR is the launch directory, not the repo root (measured), so
|
||||
# $CLAUDE_PROJECT_DIR is the launch directory, not the repo root, so
|
||||
# rules 1-3 of _yaml_helper_set_root all miss. This file's own location is the
|
||||
# one anchor that cannot drift: hooks/ sits two levels below the root. It only
|
||||
# fills CLAUDE_PROJECT_DIR when that has no project.yaml, so rules 1-2 (cwd
|
||||
|
||||
Reference in New Issue
Block a user