From aad0b350a17e37bc9e958269d405a83d755b9679 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:42:32 +0000 Subject: [PATCH 1/3] perf(gate-check, closeout): cut the processes a Stage close starts (#583) --closeout replays the preceding stage query's evaluation from a keyed cache; stage queries skip later-stage artifact/obligation rows; register parsed once; toolkit fetch at most once per 15 min; pure-bash trims. Field run: ab-581/base copy, Stage 3 - closeout 820 -> 127 process starts (3.6 s -> 1.5 s), gate-check 3 560 -> 275, no-stage 626 -> 338; closeout and no-stage output byte-identical, stage output differs only in skipped rows. Reported by OlafFede (#583). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013jQvaonym9V1wKKfAEPmRr --- CHANGELOG.md | 1 + bin/gate-check.sh | 49 ++++++++- bin/lib/artifact-check.sh | 106 +++++++++++++++++++- bin/lib/closeout.sh | 27 ++++- bin/lib/gate-cache.sh | 195 ++++++++++++++++++++++++++++++++++++ bin/lib/obligation-check.sh | 27 ++++- 6 files changed, 392 insertions(+), 13 deletions(-) create mode 100644 bin/lib/gate-cache.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index c7eacbd4..a56213f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- perf(gate-check, closeout): **a Stage close starts about 70% fewer processes (Stage 3 on a small project: `--closeout` 820 -> 127 starts, `gate-check 3` 560 -> 275, no-stage run 626 -> 338; verdict lines unchanged)**: (1) `--closeout` replays the evaluation of the stage query that just ran (variables snapshotted in `/.mxtk/gate-cache`, valid only while nothing under the project or the toolkit is newer than that run and the key matches; any doubt re-evaluates; `MXTK_GATE_CACHE_TTL_MIN=0` turns it off); (2) a stage query no longer evaluates obligation/artifact rows owed from later stages and says how many it skipped (the summary line is labelled the same way); (3) the register is parsed once per run instead of one awk per lookup; (4) the toolkit `git fetch` runs at most once per 15 min (`MXTK_FETCH_TTL_MIN`, `MXTK_NO_FETCH=1` unchanged); plus pure-bash trimming in the close-out block and the obligation reverse check. Reported by OlafFede (#583). - process(design-artifacts, conversion-runbook): **Stage 3 runs in parallel by default, from one brief and a mechanical task list**: the lead fixes modules, canonical names and design tokens, writes `docs/progress/stage3-brief.md`, and takes the tasks from the stage-3 lines of `bin/lib/artifact-check.sh` (so no artifact is forgotten); one subagent per 1–2 screens or artifact group, a shared opening line so the start is cached, mechanical tasks on the cheap tier, 5-line replies, the lead the only writer of `PROJECT.md`. The runbook's Stage 3 fan-out row points there, and its Stage 2 row names `facts-lock.sh resolve`. Measured on one project (single runs, Sonnet, 250k auto-compact): serial 13 min / $3.60, parallel 10 min / $4.44, parallel with a brief 6 min / $4.32; 0 name mismatches in the parallel runs (serial dropped 3 entities); both parallel runs forgot the process map and swimlanes, which the task list now covers. Baseline words 64,004 before and after — Stage 3 experiment 2026-10-28 - fix(facts-lock, gate-check): **parallel BRDs keep one spelling per identifier**: `bin/facts-lock.sh resolve` freezes each casing conflict (`WIPStatus`/`WipStatus`) whose majority spelling is used by strictly more BRD sources than any other and records the count in the lock (`resolvedBy`); a tie stays contested, and `build` still never picks on its own. Stage 2 of `gate-check.sh` now reads the lock once there are 2+ BRDs: a BRD that contradicts a frozen identifier FAILs and is quoted; no lock yet is a note, never a FAIL. Measured cause: 20 parallel BRDs on PROJECT-C carried 10 casing conflicts into Stage 3 as open questions, and facts-lock.sh, documented only as advice, reached 1 of 6 projects. Fixtures: `test-facts-lock.sh` resolve cases (4), `test-bug03-gates.sh` T19 (2 of 4 fail on the previous gate-check) — speed/parallel experiments 2026-10-28 - fix(row-groups): **a build plan whose number and Kind cells are formatted as code (`` `BUILD` ``, `` `12` ``) is read like a plain one**: before, it read 0 BUILD rows, so with `Row groups: on` grouping silently did nothing (the fast-lane agent in the 2026-10-28 speed experiment had to strip the backticks by hand). Fixture: `test-row-groups.sh` G5 (both cases fail on the previous row-groups.sh) — speed experiment 2026-10-28 diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 265bc8de..40311748 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -1947,6 +1947,41 @@ protocol_is_relevant() { return 1 } +# Evaluation cache (#583): `--closeout` right after a stage query replays that query's variables +# instead of re-evaluating. Everything from here to the "Regenerate index.html" block is the +# evaluation; see bin/lib/gate-cache.sh for exactly when a hit is allowed. +. "$TOOLKIT_DIR/bin/lib/gate-cache.sh" +GC_CACHE_HIT=0 +[ "$CLOSEOUT" = "1" ] && gc_cache_try +if [ "$GC_CACHE_HIT" = "1" ]; then + eval "$GC_CACHE_SNAPSHOT" +else +gc_cache_begin + +# The fetch below runs at most once per MXTK_FETCH_TTL_MIN minutes (default 15; 0 = every run) per +# toolkit clone (#583: a git fetch is a network round trip plus four processes on every query, and a +# Stage close runs three queries in a row). The stamp lives inside the clone's git dir, written only +# after a fetch SUCCEEDED, so a failed or skipped fetch is never remembered as a good one. A fresh +# stamp reuses the remote-tracking ref the last fetch left, which is exactly what a successful +# fetch would have compared against, minutes later. A worktree or submodule checkout has a .git +# FILE ("gitdir: "), read here without a process; a .git that is neither means there is +# nowhere safe to put the stamp, and every run fetches as before. +GC_FETCH_STAMP="" +if [ -d "$TOOLKIT_DIR/.git" ]; then + GC_FETCH_STAMP="$TOOLKIT_DIR/.git/mxtk-last-fetch" +elif [ -f "$TOOLKIT_DIR/.git" ]; then + _gc_gd=""; read -r _ _gc_gd < "$TOOLKIT_DIR/.git" 2>/dev/null || true + case "$_gc_gd" in /*|[A-Za-z]:*) ;; ?*) _gc_gd="$TOOLKIT_DIR/$_gc_gd" ;; esac + [ -n "$_gc_gd" ] && [ -d "$_gc_gd" ] && GC_FETCH_STAMP="$_gc_gd/mxtk-last-fetch" +fi +gc_fetch_is_fresh() { + local ttl="${MXTK_FETCH_TTL_MIN:-15}" + case "$ttl" in ''|*[!0-9]*) return 1 ;; esac + [ -n "$GC_FETCH_STAMP" ] && [ "$ttl" -gt 0 ] && [ -f "$GC_FETCH_STAMP" ] || return 1 + [ -n "$(find "$GC_FETCH_STAMP" -mmin "-$ttl" 2>/dev/null)" ] +} +gc_fetch_stamp() { [ -n "$GC_FETCH_STAMP" ] && : > "$GC_FETCH_STAMP" 2>/dev/null; return 0; } + TOOLKIT_HEAD="$(git -C "$TOOLKIT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")" # Resolve the reference tip. Fetch failure must not silently degrade to the old self-comparison: @@ -1964,8 +1999,10 @@ if [ "${MXTK_NO_FETCH:-0}" = "1" ]; then TOOLKIT_REF="$TOOLKIT_HEAD" SYNC_REF_LABEL="local HEAD — UNVERIFIED, fetch skipped (MXTK_NO_FETCH=1)" elif git -C "$TOOLKIT_DIR" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1 \ - && GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5}" \ - git -C "$TOOLKIT_DIR" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=8 fetch --quiet 2>/dev/null; then + && { gc_fetch_is_fresh \ + || { GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5}" \ + git -C "$TOOLKIT_DIR" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=8 fetch --quiet 2>/dev/null \ + && gc_fetch_stamp; }; }; then TOOLKIT_REF="$(git -C "$TOOLKIT_DIR" rev-parse --short '@{u}' 2>/dev/null || echo "unknown")" else TOOLKIT_REF="$TOOLKIT_HEAD" @@ -2643,6 +2680,8 @@ printf "Open questions (raised?): %s — %s\n" "$OQ_STATUS" "$OQ_NOTE" # INFORMATIONAL here, not blocking: the verify loop is optional on small projects, a module can # legitimately have no pages (an integration module), and a project that joined at Stage 5 owes # nothing below its adoption point. What must not happen is silence. Stage 6 still blocks. +# Rows for stages after the requested one are not evaluated (#583); see the two libs. +case "$REQUESTED_STAGE" in P|p|[0-7]) export MXTK_ROWS_UPTO="$REQUESTED_STAGE" ;; *) unset MXTK_ROWS_UPTO ;; esac if [ -r "$TOOLKIT_DIR/bin/lib/obligation-check.sh" ]; then # shellcheck source=lib/obligation-check.sh . "$TOOLKIT_DIR/bin/lib/obligation-check.sh" @@ -3020,6 +3059,9 @@ case "${LEDGER_OWED:-}" in *) _roll="$_roll Source files: ${LEDGER_OWED} owed (pending/fault/missing/not inventoried)" _owed=$(( _owed + ${LEDGER_OWED:-0} )) ;; esac +# A stage query skipped the rows owed from later stages (#583); the counts above are for the +# requested stage and earlier, and must not read as "nothing is owed anywhere". +[ -n "${MXTK_ROWS_UPTO:-}" ] && _roll="$_roll (obligation/artifact rows for stages after ${MXTK_ROWS_UPTO} not evaluated)" printf " %s\n" "$_roll" if [ "$_owed" -gt 0 ]; then echo " These do not affect the exit code and never have. They are passes and files" @@ -3055,6 +3097,9 @@ if [ "$N_WAIVED" = "0" ] && { [ "$N_PENDING" -ge 4 ] || [ "$N_FAIL" -ge 3 ]; }; echo " $0 --waive look/ --reason \"...\" $PROJECT_DIR (one PASS on one module → WAIVED)" fi +gc_cache_end +fi # GC_CACHE_HIT + # Regenerate index.html from these exact results — subject to three conditions, all of which # exist because this write has destroyed a real file: # 1. Only on a full informational run, unless --html says otherwise. A stage query is a diff --git a/bin/lib/artifact-check.sh b/bin/lib/artifact-check.sh index 7da2fa3e..c12c638c 100755 --- a/bin/lib/artifact-check.sh +++ b/bin/lib/artifact-check.sh @@ -88,6 +88,12 @@ _art_register_lines() { _art_field() { local reg="$1" want="$2" out [ -f "$reg" ] || return 1 + # Loaded register (see _art_kv_load): a lookup is parameter expansion, not an awk process. + if [ "$_ART_KV_REG" = "$reg" ]; then + _art_kv_get "$want" || return 1 + printf '%s\n' "$_ART_KV_VAL" + return 0 + fi out="$(awk -v want="$want" ' //) c = 0; next } { @@ -102,11 +108,84 @@ _art_field() { printf '%s\n' "$out" } +# The register parsed ONCE per run (#583). _art_field above is called two or three times per +# manifest row, and each call was an awk process that re-read the whole register: 58 awk +# starts in one Stage 3 query, each ~100 ms under Git Bash. _art_kv_load runs the very same +# awk body once, emitting "keyvalue" lines (key lower-cased and trimmed, empty values +# dropped, comments skipped, first line per key wins at lookup) into one string; _art_kv_get +# answers from it with parameter expansion. Loaded only between mxtk_artifacts_report / +# mxtk_closeout_report entry and exit, so a caller that edits the register between two bare +# _art_field calls still gets the awk path and a fresh read. +_ART_KV=""; _ART_KV_REG=""; _ART_KV_VAL="" +_art_kv_load() { + local reg="$1" + _ART_KV_REG=""; _ART_KV="" + [ -f "$reg" ] || return 0 + _ART_KV="$(awk ' + //) c = 0; next } + { + l = $0; gsub(/^[ \t>*_-]+/, "", l); gsub(/\*/, "", l) + i = index(l, ":"); if (i == 0) next + key = tolower(substr(l, 1, i - 1)); gsub(/^[ \t]+|[ \t]+$/, "", key) + val = substr(l, i + 1); gsub(/^[ \t]+|[ \t]+$/, "", val) + if (val == "") next + if (index(key, "\t")) next # no lookup label contains a TAB; keep the record format unambiguous + print "\n" key "\t" val + }' "$reg")" + # Leading newline so every record, the first included, is preceded by one. + _ART_KV=$'\n'"$_ART_KV" + _ART_KV_REG="$reg" +} +_art_kv_unload() { _ART_KV_REG=""; _ART_KV=""; } +# _art_kv_get — sets _ART_KV_VAL; rc 1 when absent. Values never contain a +# newline (awk works per line), so "up to the next newline" is exactly the value. A key never +# contains a TAB it could be confused with: split at the first colon, trimmed. +_art_kv_get() { + local want="$1" rest + case "$_ART_KV" in + *$'\n'"$want"$'\t'*) ;; + *) return 1 ;; + esac + rest="${_ART_KV#*$'\n'"$want"$'\t'}" + _ART_KV_VAL="${rest%%$'\n'*}" + [ -n "$_ART_KV_VAL" ] +} + +# Lower-case a label without a process in the common case (sets _ART_LC). Manifest ids are +# lower-case ASCII; only the stage letter ("P") needs mapping, and tr stays as the fallback +# for anything else with an upper-case letter. +_ART_LC="" +_art_lc() { + case "$1" in + *[A-Z]*) _ART_LC="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" ;; + *) _ART_LC="$1" ;; + esac +} + # _art_waiver — prints the reason, exits 0 if waived. # Case-insensitive for the same reason obligation-check documents: a waiver that silently # missed on capitalisation is one the author believes is in force and the gate does not. +# Loaded-register form: sets _ART_WAIVER_REASON, prints nothing, forks nothing. Same two +# lookups, same order, same text as the printing form below. +_ART_WAIVER_REASON="" +_art_waiver_set() { + local reg="$1" id="$2" st="$3" lst="$3" + _ART_WAIVER_REASON="" + [ "$_ART_KV_REG" = "$reg" ] || return 2 + case "$st" in P) lst=p ;; esac + _art_lc "waived artifact $id" + _art_kv_get "$_ART_LC" && { _ART_WAIVER_REASON="$_ART_KV_VAL"; return 0; } + _art_lc "waived stage $lst" + _art_kv_get "$_ART_LC" \ + && { _ART_WAIVER_REASON="with its whole stage (Waived stage $st: $_ART_KV_VAL)"; return 0; } + return 1 +} _art_waiver() { local reg="$1" id="$2" st="$3" r + if [ "$_ART_KV_REG" = "$reg" ]; then + _art_waiver_set "$reg" "$id" "$st" && { printf '%s\n' "$_ART_WAIVER_REASON"; return 0; } + return 1 + fi r="$(_art_field "$reg" "$(printf 'waived artifact %s' "$id" | tr '[:upper:]' '[:lower:]')")" \ && { printf '%s\n' "$r"; return 0; } r="$(_art_field "$reg" "$(printf 'waived stage %s' "$st" | tr '[:upper:]' '[:lower:]')")" \ @@ -178,8 +257,10 @@ _art_register_rank() { # names it), mtime as the fallback for dateless names. Returns 0 for yes. _art_newer() { local ad bd am bm - ad="$(basename "$1" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)"; ad="${ad:-0000-00-00}" - bd="$(basename "$2" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)"; bd="${bd:-0000-00-00}" + # Pure bash: this ran basename + grep + head per file, twice, per comparison. + ad="${1##*/}"; bd="${2##*/}" + if [[ "$ad" =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then ad="${BASH_REMATCH[0]}"; else ad="0000-00-00"; fi + if [[ "$bd" =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then bd="${BASH_REMATCH[0]}"; else bd="0000-00-00"; fi [[ "$ad" > "$bd" ]] && return 0 [[ "$ad" < "$bd" ]] && return 1 # GNU first, BSD second — on GNU `stat -f` succeeds with the wrong meaning. @@ -270,6 +351,15 @@ mxtk_artifacts_report() { return 0 fi + # Parse the register once for every lookup below (#583); unloaded at the end. + _art_kv_load "$reg" + + # Stage limit (#583): rows for stages AFTER the one asked about are not evaluated. They can + # not change a verdict for the requested stage, and on a Stage 3 query they were most of the + # 120-row manifest. gate-check.sh exports MXTK_ROWS_UPTO (a stage token); unset = every row. + local limit_rank=99 skipped_n=0 + [ -n "${MXTK_ROWS_UPTO:-}" ] && limit_rank="$(_art_rank "$MXTK_ROWS_UPTO")" + local adopted adopted_rank=0 mode adopted="$(_art_adopted_stage "$reg" 2>/dev/null || true)" [ -n "$adopted" ] && adopted_rank="$(_art_rank "$adopted")" @@ -318,6 +408,8 @@ mxtk_artifacts_report() { MXTK_ART_STATUS="FAULT"; MXTK_ART_N_FAULT=$((MXTK_ART_N_FAULT+1)); continue fi + if [ "$r" -gt "$limit_rank" ]; then skipped_n=$((skipped_n+1)); continue; fi + # Entry mode: excused stages are said out loud, never skipped silently. An unknown mode # (young register, no line yet) excuses nothing. if [ -n "$mode" ]; then @@ -352,8 +444,8 @@ mxtk_artifacts_report() { fi local reason - if reason="$(_art_waiver "$reg" "$id" "$stage")"; then - printf 'Artifact %-24s WAIVED — %s\n' "$id" "$reason" + if _art_waiver_set "$reg" "$id" "$stage"; then + printf 'Artifact %-24s WAIVED — %s\n' "$id" "$_ART_WAIVER_REASON" continue fi @@ -385,12 +477,18 @@ mxtk_artifacts_report() { fi done < "$ARTIFACT_TSV" + if [ "$skipped_n" -gt 0 ]; then + printf 'Artifacts: %s row(s) for stages after %s not evaluated — a stage query reads its own stage and earlier; run without a stage for every row\n' \ + "$skipped_n" "$MXTK_ROWS_UPTO" + fi + # The one line for everything on offer and untaken — said once so it is not silent, said # once so eight optional rows do not read as eight things missing. if [ -n "$_art_offered" ]; then printf 'Artifacts optional, not opted in: %s — offered at each stage open by gate-check.sh --closeout; opt in with a register line "Opt-in artifact : "\n' "$_art_offered" fi + _art_kv_unload return 0 } diff --git a/bin/lib/closeout.sh b/bin/lib/closeout.sh index a0daf45c..d46d85ea 100755 --- a/bin/lib/closeout.sh +++ b/bin/lib/closeout.sh @@ -107,7 +107,18 @@ _co_checkpoints() { esac } -_co_trim() { printf '%s' "$1" | sed -E 's/^[[:blank:]]+//; s/[[:blank:]]+$//; s/^\*+//; s/\*+$//; s/^[[:blank:]]+//; s/[[:blank:]]+$//'; } +_co_trim() { + # Pure bash for the one-line case (a sed process per cell: 114 starts in one Stage 3 close-out, + # #583); same six steps as the sed script kept below for multi-line input, where sed works per line. + case "$1" in + *$'\n'*) printf '%s' "$1" | sed -E 's/^[[:blank:]]+//; s/[[:blank:]]+$//; s/^\*+//; s/\*+$//; s/^[[:blank:]]+//; s/[[:blank:]]+$//'; return ;; + esac + _CO_T="$1" + _CO_T="${_CO_T#"${_CO_T%%[![:blank:]]*}"}"; _CO_T="${_CO_T%"${_CO_T##*[![:blank:]]}"}" + _CO_T="${_CO_T#"${_CO_T%%[!*]*}"}"; _CO_T="${_CO_T%"${_CO_T##*[!*]}"}" + _CO_T="${_CO_T#"${_CO_T%%[![:blank:]]*}"}"; _CO_T="${_CO_T%"${_CO_T##*[![:blank:]]}"}" + printf '%s' "$_CO_T" +} # Truncate to N chars on a word boundary, with an ellipsis. Cells in a real register run to # 2,000 characters; the block is a recap, the register is the record. _co_trunc() { @@ -216,7 +227,9 @@ _co_owed() { [ -z "$2" ] && return 0; case ",$1," in *",$2,"*) return 0 ;; esac; # technical text carries that has a known plain translation, then what is still missing on # the manifest for this stage. Written for the person in the chat, not the agent. mxtk_plain_verdict() { - local st="$1" v="$2" root="$3" reg="${4:-$3/PROJECT.md}" status low + local st="$1" v="$2" root="$3" reg="${4:-$3/PROJECT.md}" status low _own_kv=0 + # Own the register parse unless the close-out wrapper already holds one (#583). + [ "$_ART_KV_REG" = "$reg" ] || { _art_kv_load "$reg"; _own_kv=1; } status="$(printf '%s' "$v" | sed -E 's/^Stage [P0-7]: *//' | awk '{print toupper($1)}' | tr -d ':,;—-')" low="$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" printf '**In plain words:** ' @@ -271,12 +284,22 @@ mxtk_plain_verdict() { PASS|WAIVED) [ "$said" -eq 0 ] && printf 'Nothing is holding it.\n' ;; *) [ "$said" -eq 0 ] && printf 'The technical line above names the exact file or row; nothing in it has a plainer translation yet — say so and one gets added.\n' ;; esac + [ "$_own_kv" = 1 ] && _art_kv_unload return 0 } # ── the block ─────────────────────────────────────────────────────────────── # mxtk_closeout_report [register] +# Wrapper: parse the register ONCE for the many _art_waiver / _art_field lookups below (#583), +# then drop it again so a later caller that edits the register is never answered from a stale parse. mxtk_closeout_report() { + local _co_rc + _art_kv_load "${3:-$1/PROJECT.md}" + _mxtk_closeout_report_body "$@"; _co_rc=$? + _art_kv_unload + return "$_co_rc" +} +_mxtk_closeout_report_body() { local root="$1" st="$2" reg="${3:-$1/PROJECT.md}" local next title ntitle case "$st" in P|p) st=P ;; [0-7]) ;; *) echo "closeout: stage must be P or 0-7 (got '$st')" >&2; return 2 ;; esac diff --git a/bin/lib/gate-cache.sh b/bin/lib/gate-cache.sh new file mode 100644 index 00000000..445be051 --- /dev/null +++ b/bin/lib/gate-cache.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# gate-cache.sh — let `gate-check.sh --closeout ` reuse the evaluation that +# `gate-check.sh ` just finished, instead of running all of it a second time. +# +# WHY (#583, reported by OlafFede). A Stage 3 close is `gate-check 3` (the verdict the +# agent reads) and then `gate-check --closeout 3` (the block the user reads). The second +# one re-ran the ENTIRE evaluation — every stage checker, the obligation and artifact checks, +# the source ledger — to arrive at the verdict the first had already printed: ~850 process +# starts the second time round, a large part of a 12-minute close under Git Bash on Windows. +# +# WHAT IS CACHED. Not the printed text. The VARIABLES the evaluation leaves behind (results +# table, notes, drift/sync/open-question statuses, counts…): bin/gate-check.sh snapshots +# `declare -p` before and after the evaluation block, and this file keeps exactly the records +# that changed. A hit replays them with `eval` and the script continues into the code that +# follows the evaluation (the board write and the close-out block) exactly as if it had run it. +# The close-out block itself is always produced fresh from the files on disk. +# +# WHEN IT IS VALID — all of these, else the evaluation simply runs (a miss costs nothing but +# the evaluation that would have run anyway; a wrong hit would be a wrong verdict, so every +# doubt is a miss): +# - same key: stage, absolute project dir, register, the flags that alter a verdict +# (--strict-protocol, --force-stale, ...), the calendar day, the project's git HEAD and the +# environment variables a verdict can read (MXTK_*, PATH, ...); +# - the snapshot is complete (end marker) and replays without error in a trial subshell; +# - younger than MXTK_GATE_CACHE_TTL_MIN minutes (default 30; 0 turns the cache off); +# - NOTHING under the project (outside .git, node_modules and the cache itself) and nothing +# under the toolkit (outside .git) has a modification time later than the moment the +# cached run STARTED. That is the "key on the project files' state": a changed +# PROJECT.md, stage artifact, source file, mpr, deleted file (its directory's mtime moves) +# or a toolkit pull all end the cache. The reference time is backdated two seconds so a +# coarse-timestamp filesystem cannot hide an edit made in the same second. +# - only for a plain stage query or --closeout: never with --adopt, --waive, +# --ack-protocol (they write the register, so there is nothing to reuse). +# +# Written by bin/gate-check.sh after a stage-requested evaluation, into /.mxtk/ (the +# per-machine, gitignored state directory; created only when the project's .gitignore already +# names it). Opt-out: MXTK_GATE_CACHE_TTL_MIN=0. Sourced by bin/gate-check.sh; no side effects +# on source. + +# gc_cache_enabled — are we in a run that may read or write the cache? +gc_cache_enabled() { + case "${MXTK_GATE_CACHE_TTL_MIN:-30}" in ''|*[!0-9]*|0) return 1 ;; esac + case "${REQUESTED_STAGE:-}" in P|p|[0-7]) ;; *) return 1 ;; esac + [ -z "${ADOPT_STAGE:-}" ] && [ -z "${WAIVE_STAGE:-}" ] && [ "${ACK_PROTOCOL:-0}" = "0" ] || return 1 + [ -d "${PROJECT_DIR:-/nonexistent}" ] || return 1 + return 0 +} + +GC_CACHE_DIR="" +gc_cache_paths() { + GC_CACHE_DIR="$PROJECT_DIR/.mxtk" + GC_CACHE_FILE="$GC_CACHE_DIR/gate-cache" + GC_CACHE_REF="$GC_CACHE_DIR/gate-cache.ref" + GC_CACHE_REF2="$GC_CACHE_DIR/gate-cache.ref2" +} + +# gc_cache_key — sets GC_CKEY, one line. +gc_cache_key() { + local abs v k head="" hf ref today + abs="$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P)" || abs="$PROJECT_DIR" + today="$(date +%F 2>/dev/null)" + hf="$PROJECT_DIR/.git/HEAD" + if [ -f "$hf" ]; then + IFS= read -r head < "$hf" 2>/dev/null || true + case "$head" in + "ref: "*) IFS= read -r ref < "$PROJECT_DIR/.git/${head#ref: }" 2>/dev/null || ref="packed"; head="$head@$ref" ;; + esac + fi + GC_CKEY="v1|stage=${REQUESTED_STAGE}|dir=$abs|reg=${REGISTER:-}|strict=${STRICT_PROTOCOL:-0}|force=${FORCE_STALE:-0}|chat=${APPROVED_IN_CHAT:-0}|verbose=${PROTOCOL_VERBOSE:-0}|day=$today|head=$head|tk=${TOOLKIT_DIR:-}" + # Environment a verdict can read. Names matched by pattern so a new MXTK_ knob needs no edit here. + for k in $(compgen -e); do + case "$k" in + MXTK_GATE_CACHE_*|MXTK_ROWS_UPTO) continue ;; + MXTK_*|MXCLI*|MX_*|PROJECT_*|JOURNEY_DIR|PATH|ARTIFACT_TSV|OBLIGATIONS_TSV|ROUTING_TSV|SKILL_ROUTING_TSV|TMPDIR) + v="${!k}"; GC_CKEY="$GC_CKEY|$k=$v" ;; + esac + done + # A key must be one line. + GC_CKEY="${GC_CKEY//$'\n'/ }" +} + +# gc_cache_clean — true when nothing under the project or the toolkit +# changed since the cached run started, other than the files that run itself wrote (generated +# reports such as analysis/source-sufficiency.html, recorded in , "|"-separated), +# and none of THOSE changed after the run finished (ref2). +gc_cache_clean() { + local ref="$1" ref2="$2" own="$3" f hit + local P="$PROJECT_DIR" + # The cache directory's own mtime moves whenever the cache is rewritten; exclude it (not its siblings). + while IFS= read -r f; do + [ -n "$f" ] || continue + case "|$own|" in *"|$f|"*) ;; *) return 1 ;; esac + done < <(find "$P" \( -name .git -o -name node_modules -o -name 'gate-cache*' \) -prune -o -newer "$ref" ! -path "$P/.mxtk" -print 2>/dev/null) + hit="$(find "$P" \( -name .git -o -name node_modules -o -name 'gate-cache*' \) -prune -o -newer "$ref2" ! -path "$P/.mxtk" -print -quit 2>/dev/null)" || return 1 + [ -z "$hit" ] || return 1 + if [ -n "${TOOLKIT_DIR:-}" ] && [ -d "$TOOLKIT_DIR" ]; then + hit="$(find "$TOOLKIT_DIR" \( -name .git -o -name node_modules \) -prune -o -newer "$ref" -print -quit 2>/dev/null)" || return 1 + [ -z "$hit" ] || return 1 + fi + return 0 +} + +# gc_cache_try — on a valid cache, replays the snapshot into THIS shell and sets GC_CACHE_HIT=1. +# Must be called from the top level of the script (not from a function): `declare` inside a +# function would make the replayed variables local to it. +GC_CACHE_HIT=0 +GC_CACHE_SNAPSHOT="" +gc_cache_try() { + GC_CACHE_HIT=0; GC_CACHE_SNAPSHOT="" + gc_cache_enabled || return 0 + gc_cache_paths + [ -f "$GC_CACHE_FILE" ] && [ -f "$GC_CACHE_REF" ] && [ -f "$GC_CACHE_REF2" ] || return 0 + [ -n "$(find "$GC_CACHE_FILE" -mmin "-${MXTK_GATE_CACHE_TTL_MIN:-30}" 2>/dev/null)" ] || return 0 + local all first + IFS= read -r -d '' all < "$GC_CACHE_FILE" || true + first="${all%%$'\n'*}" + gc_cache_key + [ "$first" = "$GC_CKEY" ] || return 0 + case "$all" in *$'\n# gate-cache-end'*) ;; *) return 0 ;; esac + GC_CACHE_SNAPSHOT="${all#*$'\n'}" + local own="${GC_CACHE_SNAPSHOT%%$'\n'*}" + GC_CACHE_SNAPSHOT="${GC_CACHE_SNAPSHOT#*$'\n'}" + case "$own" in "# own-writes:"*) own="${own#\# own-writes:}" ;; *) return 0 ;; esac + gc_cache_clean "$GC_CACHE_REF" "$GC_CACHE_REF2" "$own" || return 0 + # Trial replay in a subshell: a snapshot that does not replay cleanly must not half-apply. + ( eval "$GC_CACHE_SNAPSHOT" ) >/dev/null 2>&1 || return 0 + GC_CACHE_HIT=1 +} + +# gc_cache_begin — call just BEFORE the evaluation block runs (a miss). Takes the reference +# time and the "before" variable dump. +GC_SNAP_ON=0; GC_SNAP_BEFORE=""; GC_BKEY="" +gc_cache_begin() { + GC_SNAP_ON=0 + gc_cache_enabled || return 0 + gc_cache_paths + if [ ! -d "$GC_CACHE_DIR" ]; then + grep -qsE '^/?\.mxtk/?[[:space:]]*$' "$PROJECT_DIR/.gitignore" 2>/dev/null || return 0 + mkdir -p "$GC_CACHE_DIR" 2>/dev/null || return 0 + fi + local ts + ts="$(TZ=UTC date -u -d '2 seconds ago' +%Y%m%d%H%M.%S 2>/dev/null || TZ=UTC date -u -v-2S +%Y%m%d%H%M.%S 2>/dev/null)" || return 0 + [ -n "$ts" ] || return 0 + GC_SNAP_BEFORE="$(mktemp "${TMPDIR:-/tmp}/gate-cache-before.XXXXXX" 2>/dev/null)" || return 0 + TZ=UTC touch -t "$ts" "$GC_CACHE_REF.new" 2>/dev/null || { rm -f "$GC_SNAP_BEFORE"; return 0; } + # The key is taken HERE, before the evaluation exports anything of its own (MXTK_ROWS_UPTO...), + # so the key a later --closeout computes at the same point matches. + gc_cache_key; GC_BKEY="$GC_CKEY" + declare -p > "$GC_SNAP_BEFORE" 2>/dev/null + GC_SNAP_ON=1 +} + +# gc_cache_end — call just AFTER the evaluation block. Writes the cache from the variables that +# changed. Never fails the run; any trouble leaves no cache (the safe state). +gc_cache_end() { + [ "$GC_SNAP_ON" = "1" ] || return 0 + GC_SNAP_ON=0 + local after out + after="$(mktemp "${TMPDIR:-/tmp}/gate-cache-after.XXXXXX" 2>/dev/null)" || { rm -f "$GC_SNAP_BEFORE" "$GC_CACHE_REF.new"; return 0; } + out="$GC_CACHE_FILE.new" + declare -p > "$after" 2>/dev/null + local own="" f + while IFS= read -r f; do own="$own|$f"; done < <(find "$PROJECT_DIR" \( -name .git -o -name node_modules -o -name 'gate-cache*' \) -prune -o -newer "$GC_CACHE_REF.new" ! -path "$PROJECT_DIR/.mxtk" -print 2>/dev/null) + { + printf '%s\n' "$GC_BKEY" + printf '# own-writes:%s\n' "$own" + awk ' + function flush() { + if (name != "") { + if (pass == 1) before[name] = rec + else if (flags !~ /r/ && name !~ skip && (!(name in before) || before[name] != rec)) print rec + } + name = ""; rec = "" + } + FNR == 1 { flush(); pass++ } + /^declare -/ { + flush() + split($0, a, " "); flags = a[2]; name = a[3]; sub(/=.*/, "", name) + rec = $0; next + } + { if (name != "") rec = rec "\n" $0 } + END { flush() } + ' skip='^(BASH|FUNCNAME$|LINENO$|SECONDS$|RANDOM$|SRANDOM$|EPOCH|PIPESTATUS$|_$|PPID$|UID$|EUID$|GROUPS$|SHLVL$|PWD$|OLDPWD$|COLUMNS$|LINES$|IFS$|REPLY$|OPTIND$|OPTARG$|DIRSTACK$|HISTCMD$|COMP|GC_C|GC_SNAP|GC_FETCH)' \ + "$GC_SNAP_BEFORE" "$after" + printf '# gate-cache-end\n' + } > "$out" 2>/dev/null + rm -f "$GC_SNAP_BEFORE" "$after" + # Publish: ref first (its mtime is the reference), then the payload, atomically by rename. + : > "$GC_CACHE_REF2" 2>/dev/null + if [ -s "$out" ] && mv -f "$GC_CACHE_REF.new" "$GC_CACHE_REF" 2>/dev/null && mv -f "$out" "$GC_CACHE_FILE" 2>/dev/null; then + return 0 + fi + rm -f "$out" "$GC_CACHE_REF.new" "$GC_CACHE_FILE" "$GC_CACHE_REF" "$GC_CACHE_REF2" 2>/dev/null + return 0 +} diff --git a/bin/lib/obligation-check.sh b/bin/lib/obligation-check.sh index 7bc04164..865a8afb 100755 --- a/bin/lib/obligation-check.sh +++ b/bin/lib/obligation-check.sh @@ -453,10 +453,20 @@ mxtk_obligations_report() { local modules; modules="$(_ob_modules "$root")" local ob performer scope match artifact denom degradable from_stage skill + # Stage limit (#583): rows owed from a stage AFTER the one asked about are not evaluated — + # they cannot change the requested stage's verdict, and each one walks every module's + # reports. gate-check.sh exports MXTK_ROWS_UPTO (a stage token); unset = every row. + local limit_rank=99 skipped_n=0 from_rank + [ -n "${MXTK_ROWS_UPTO:-}" ] && limit_rank="$(_ob_rank "$MXTK_ROWS_UPTO")" + while IFS=$'\t' read -r ob performer scope match artifact denom degradable from_stage skill; do case "$ob" in ''|'#'*|obligation) continue ;; esac - if [ "$(_ob_rank "$from_stage")" -lt "$adopted_rank" ]; then + # _ob_rank inline: it was a command substitution (a fork) three times per row. + case "$from_stage" in P|p) from_rank=0 ;; [0-7]) from_rank=$(( from_stage + 1 )) ;; *) from_rank=99 ;; esac + if [ "$from_rank" -gt "$limit_rank" ]; then skipped_n=$((skipped_n+1)); continue; fi + + if [ "$from_rank" -lt "$adopted_rank" ]; then printf 'Obligation %-10s ADOPTED — project joined at stage %s, above this pass'"'"'s stage %s (%s)\n' \ "$ob" "$adopted" "$from_stage" "$skill" continue @@ -466,7 +476,7 @@ mxtk_obligations_report() { # keeps its verdict whatever the readout says — an early stale FAIL holds the derived # readout back while the build goes on, and a general rule would hide owed passes behind it # (review of #321). - if [ "$ob" = "final-look" ] && [ "$(_ob_rank "$from_stage")" -gt "$current_rank" ]; then + if [ "$ob" = "final-look" ] && [ "$from_rank" -gt "$current_rank" ]; then printf 'Obligation %-10s NOT YET DUE — owed from stage %s; the register'"'"'s Current stage is %s (%s)\n' \ "$ob" "$from_stage" "$current" "$skill" continue @@ -576,6 +586,10 @@ mxtk_obligations_report() { "$ob" "$noproof" done < "$OBLIGATIONS_TSV" + if [ "$skipped_n" -gt 0 ]; then + printf 'Obligations: %s row(s) owed from stages after %s not evaluated — a stage query reads its own stage and earlier; run without a stage for every row\n' \ + "$skipped_n" "$MXTK_ROWS_UPTO" + fi return 0 } @@ -597,16 +611,19 @@ mxtk_obligations_reverse_check() { [ -f "$OBLIGATIONS_TSV" ] || { printf '⚠ Obligations reverse check: FAULT — %s not found; no skill was checked\n' "$OBLIGATIONS_TSV" >&2 return 0; } - local named unnamed="" name path when agents stages tier group + local named unnamed="" name path when agents stages tier group noroute named="$(awk -F'\t' '!/^#/ && NF>=9 && $1!="obligation"{print $9}' "$OBLIGATIONS_TSV")" + # Both lists once, tested with case below — two grep processes per routing row before (#583). + noroute="$(awk '{print $1}' <<<"$MXTK_OBLIGATIONS_NOROUTE")" + named=$'\n'"$named"$'\n'; noroute=$'\n'"$noroute"$'\n' while IFS=$'\t' read -r name path when agents stages tier group; do case "$name" in ''|'#'*) continue ;; esac # Per-module passes live in the `verify` group. Filtering on stage 5/6 alone caught 46 # skills — an MDL cookbook and a datagrid note among them — and a warning listing 46 # names is one nobody reads, which is the same as no check at all. [ "$group" = "verify" ] || continue - grep -qxF -- "$path" <<<"$named" && continue - grep -qxF -- "$name" <<<"$(awk '{print $1}' <<<"$MXTK_OBLIGATIONS_NOROUTE")" && continue + case "$named" in *$'\n'"$path"$'\n'*) continue ;; esac + case "$noroute" in *$'\n'"$name"$'\n'*) continue ;; esac unnamed="$unnamed $name" done < "$tsv" if [ -n "$unnamed" ]; then From 8eae8058356ac62c8c3211a6846eb7bb305b391e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:01:49 +0000 Subject: [PATCH 2/3] fix(gate-check, gate-cache): key the verdict cache on file content, not only mtimes (#589 review) An overwrite that keeps an old mtime (cp -p, rsync -t, tar x, unzip, touch -d) escaped the find -newer freshness test: a signed triage.md copied over a FAIL one still replayed the cached Stage 0 FAIL. The cache now stores a cksum signature of the project files (one find|sort|xargs cksum|cksum pipeline; skips .git, node_modules, .mxtk, mprcontents, deployment, *.mpr, files >1 MB) and any difference, or an uncomputable signature, re-evaluates. Adds tests/wave2/test-gate-cache.sh and MXTK_GATE_CACHE_TRACE=1. Field run: copy of /home/user/ab-581/base (197 MB, 633 hashed files), Stage 3: signature 28 ms; --closeout cache hit 1464 ms before, 1498 ms after; full evaluation 2567-2786 ms. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013jQvaonym9V1wKKfAEPmRr --- CHANGELOG.md | 1 + bin/gate-check.sh | 1 + bin/lib/gate-cache.sh | 27 +++++++++- tests/wave2/test-gate-cache.sh | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100755 tests/wave2/test-gate-cache.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a56213f3..7cefc3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- fix(gate-check, gate-cache): **the closeout verdict cache also keys on file CONTENT, so an overwrite that keeps an old mtime (`cp -p`, `rsync -t`, `tar x`, `unzip`, `touch -d`) can no longer replay a stale verdict**: a signed triage.md copied over a FAIL one still printed the cached Stage 0 FAIL (PR #589 review); the cache now stores one `find | sort | xargs cksum | cksum` signature of the project files (skipping .git, node_modules, .mxtk, mprcontents, deployment, *.mpr, files over 1 MB) and any difference, or a signature that cannot be computed, re-evaluates; new `tests/wave2/test-gate-cache.sh` (hit, edit/delete/waiver miss, cp -p miss, TTL 0, 'not evaluated' line); `MXTK_GATE_CACHE_TRACE=1` prints `gate-cache: hit|miss` on --closeout. Field run: ab-581/base copy (197 MB, 633 hashed files) - signature 28 ms, cache hit 1464 -> 1498 ms vs 2567-2786 ms full evaluation - PR #589 reviewer, OlafFede (#583) - perf(gate-check, closeout): **a Stage close starts about 70% fewer processes (Stage 3 on a small project: `--closeout` 820 -> 127 starts, `gate-check 3` 560 -> 275, no-stage run 626 -> 338; verdict lines unchanged)**: (1) `--closeout` replays the evaluation of the stage query that just ran (variables snapshotted in `/.mxtk/gate-cache`, valid only while nothing under the project or the toolkit is newer than that run and the key matches; any doubt re-evaluates; `MXTK_GATE_CACHE_TTL_MIN=0` turns it off); (2) a stage query no longer evaluates obligation/artifact rows owed from later stages and says how many it skipped (the summary line is labelled the same way); (3) the register is parsed once per run instead of one awk per lookup; (4) the toolkit `git fetch` runs at most once per 15 min (`MXTK_FETCH_TTL_MIN`, `MXTK_NO_FETCH=1` unchanged); plus pure-bash trimming in the close-out block and the obligation reverse check. Reported by OlafFede (#583). - process(design-artifacts, conversion-runbook): **Stage 3 runs in parallel by default, from one brief and a mechanical task list**: the lead fixes modules, canonical names and design tokens, writes `docs/progress/stage3-brief.md`, and takes the tasks from the stage-3 lines of `bin/lib/artifact-check.sh` (so no artifact is forgotten); one subagent per 1–2 screens or artifact group, a shared opening line so the start is cached, mechanical tasks on the cheap tier, 5-line replies, the lead the only writer of `PROJECT.md`. The runbook's Stage 3 fan-out row points there, and its Stage 2 row names `facts-lock.sh resolve`. Measured on one project (single runs, Sonnet, 250k auto-compact): serial 13 min / $3.60, parallel 10 min / $4.44, parallel with a brief 6 min / $4.32; 0 name mismatches in the parallel runs (serial dropped 3 entities); both parallel runs forgot the process map and swimlanes, which the task list now covers. Baseline words 64,004 before and after — Stage 3 experiment 2026-10-28 - fix(facts-lock, gate-check): **parallel BRDs keep one spelling per identifier**: `bin/facts-lock.sh resolve` freezes each casing conflict (`WIPStatus`/`WipStatus`) whose majority spelling is used by strictly more BRD sources than any other and records the count in the lock (`resolvedBy`); a tie stays contested, and `build` still never picks on its own. Stage 2 of `gate-check.sh` now reads the lock once there are 2+ BRDs: a BRD that contradicts a frozen identifier FAILs and is quoted; no lock yet is a note, never a FAIL. Measured cause: 20 parallel BRDs on PROJECT-C carried 10 casing conflicts into Stage 3 as open questions, and facts-lock.sh, documented only as advice, reached 1 of 6 projects. Fixtures: `test-facts-lock.sh` resolve cases (4), `test-bug03-gates.sh` T19 (2 of 4 fail on the previous gate-check) — speed/parallel experiments 2026-10-28 diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 40311748..b75f39d9 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -1953,6 +1953,7 @@ protocol_is_relevant() { . "$TOOLKIT_DIR/bin/lib/gate-cache.sh" GC_CACHE_HIT=0 [ "$CLOSEOUT" = "1" ] && gc_cache_try +[ -n "${MXTK_GATE_CACHE_TRACE:-}" ] && [ "$CLOSEOUT" = "1" ] && echo "gate-cache: $([ "$GC_CACHE_HIT" = 1 ] && echo hit || echo miss)" >&4 if [ "$GC_CACHE_HIT" = "1" ]; then eval "$GC_CACHE_SNAPSHOT" else diff --git a/bin/lib/gate-cache.sh b/bin/lib/gate-cache.sh index 445be051..d02109ac 100644 --- a/bin/lib/gate-cache.sh +++ b/bin/lib/gate-cache.sh @@ -29,6 +29,13 @@ # PROJECT.md, stage artifact, source file, mpr, deleted file (its directory's mtime moves) # or a toolkit pull all end the cache. The reference time is backdated two seconds so a # coarse-timestamp filesystem cannot hide an edit made in the same second. +# - CONTENT SIGNATURE (review of #589): the project's file contents, cksum'd, equal what they were +# when the cached run finished. mtime alone is blind to an overwrite that keeps an old mtime +# (`cp -p`, `rsync -t`, `tar x`, `unzip`, `touch -d`): a signed triage.md copied over a FAIL +# one still replayed the cached FAIL. Skipped trees: .git node_modules .mxtk mprcontents +# deployment theme-cache themesource userlib widgets, *.mpr, and any file over 1 MB (those +# stay covered by the mtime test). One `find | xargs cksum` pipeline, not a process per file; +# if it cannot be computed the cache is a miss. # - only for a plain stage query or --closeout: never with --adopt, --waive, # --ack-protocol (they write the register, so there is nothing to reuse). # @@ -66,7 +73,7 @@ gc_cache_key() { "ref: "*) IFS= read -r ref < "$PROJECT_DIR/.git/${head#ref: }" 2>/dev/null || ref="packed"; head="$head@$ref" ;; esac fi - GC_CKEY="v1|stage=${REQUESTED_STAGE}|dir=$abs|reg=${REGISTER:-}|strict=${STRICT_PROTOCOL:-0}|force=${FORCE_STALE:-0}|chat=${APPROVED_IN_CHAT:-0}|verbose=${PROTOCOL_VERBOSE:-0}|day=$today|head=$head|tk=${TOOLKIT_DIR:-}" + GC_CKEY="v2|stage=${REQUESTED_STAGE}|dir=$abs|reg=${REGISTER:-}|strict=${STRICT_PROTOCOL:-0}|force=${FORCE_STALE:-0}|chat=${APPROVED_IN_CHAT:-0}|verbose=${PROTOCOL_VERBOSE:-0}|day=$today|head=$head|tk=${TOOLKIT_DIR:-}" # Environment a verdict can read. Names matched by pattern so a new MXTK_ knob needs no edit here. for k in $(compgen -e); do case "$k" in @@ -79,6 +86,16 @@ gc_cache_key() { GC_CKEY="${GC_CKEY//$'\n'/ }" } +# gc_cache_sig — sets GC_SIG to a checksum of the names and contents of the files the checks read. +# Returns 1 (=> miss / no cache) if it cannot be computed. +gc_cache_sig() { + GC_SIG="" + local s + s="$(cd "$PROJECT_DIR" 2>/dev/null && LC_ALL=C find . \( -name .git -o -name node_modules -o -name .mxtk -o -name mprcontents -o -name deployment -o -name theme-cache -o -name themesource -o -name userlib -o -name widgets \) -prune -o -type f ! -name '*.mpr' -size -1025k -print0 2>/dev/null | LC_ALL=C sort -z 2>/dev/null | xargs -0 cksum 2>/dev/null | cksum 2>/dev/null)" || return 1 + [ -n "$s" ] || return 1 + GC_SIG="${s// /-}" +} + # gc_cache_clean — true when nothing under the project or the toolkit # changed since the cached run started, other than the files that run itself wrote (generated # reports such as analysis/source-sufficiency.html, recorded in , "|"-separated), @@ -121,7 +138,11 @@ gc_cache_try() { local own="${GC_CACHE_SNAPSHOT%%$'\n'*}" GC_CACHE_SNAPSHOT="${GC_CACHE_SNAPSHOT#*$'\n'}" case "$own" in "# own-writes:"*) own="${own#\# own-writes:}" ;; *) return 0 ;; esac + local sig="${GC_CACHE_SNAPSHOT%%$'\n'*}" + GC_CACHE_SNAPSHOT="${GC_CACHE_SNAPSHOT#*$'\n'}" + case "$sig" in "# sig:"*) sig="${sig#\# sig:}" ;; *) return 0 ;; esac gc_cache_clean "$GC_CACHE_REF" "$GC_CACHE_REF2" "$own" || return 0 + gc_cache_sig && [ "$GC_SIG" = "$sig" ] || return 0 # Trial replay in a subshell: a snapshot that does not replay cleanly must not half-apply. ( eval "$GC_CACHE_SNAPSHOT" ) >/dev/null 2>&1 || return 0 GC_CACHE_HIT=1 @@ -160,10 +181,12 @@ gc_cache_end() { out="$GC_CACHE_FILE.new" declare -p > "$after" 2>/dev/null local own="" f + gc_cache_sig || { rm -f "$GC_SNAP_BEFORE" "$after" "$GC_CACHE_REF.new"; return 0; } while IFS= read -r f; do own="$own|$f"; done < <(find "$PROJECT_DIR" \( -name .git -o -name node_modules -o -name 'gate-cache*' \) -prune -o -newer "$GC_CACHE_REF.new" ! -path "$PROJECT_DIR/.mxtk" -print 2>/dev/null) { printf '%s\n' "$GC_BKEY" printf '# own-writes:%s\n' "$own" + printf '# sig:%s\n' "$GC_SIG" awk ' function flush() { if (name != "") { @@ -180,7 +203,7 @@ gc_cache_end() { } { if (name != "") rec = rec "\n" $0 } END { flush() } - ' skip='^(BASH|FUNCNAME$|LINENO$|SECONDS$|RANDOM$|SRANDOM$|EPOCH|PIPESTATUS$|_$|PPID$|UID$|EUID$|GROUPS$|SHLVL$|PWD$|OLDPWD$|COLUMNS$|LINES$|IFS$|REPLY$|OPTIND$|OPTARG$|DIRSTACK$|HISTCMD$|COMP|GC_C|GC_SNAP|GC_FETCH)' \ + ' skip='^(BASH|FUNCNAME$|LINENO$|SECONDS$|RANDOM$|SRANDOM$|EPOCH|PIPESTATUS$|_$|PPID$|UID$|EUID$|GROUPS$|SHLVL$|PWD$|OLDPWD$|COLUMNS$|LINES$|IFS$|REPLY$|OPTIND$|OPTARG$|DIRSTACK$|HISTCMD$|COMP|GC_C|GC_SNAP|GC_SIG|GC_FETCH)' \ "$GC_SNAP_BEFORE" "$after" printf '# gate-cache-end\n' } > "$out" 2>/dev/null diff --git a/tests/wave2/test-gate-cache.sh b/tests/wave2/test-gate-cache.sh new file mode 100755 index 00000000..71626f81 --- /dev/null +++ b/tests/wave2/test-gate-cache.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Fixture for the gate-check verdict cache (bin/lib/gate-cache.sh): `gate-check.sh N` then +# `gate-check.sh --closeout N` reuses the evaluation only while the project is unchanged. +# Added with the #589 review fix: freshness used `find -newer` alone, which an overwrite that +# keeps an old mtime (`cp -p`, rsync -t, tar x, unzip, touch -d) escaped — a signed file copied +# over a FAIL one still replayed the cached FAIL. The content signature closes that. +# +# tests/wave2/test-gate-cache.sh /path/to/gate-check.sh +# +# Hit/miss is observed through MXTK_GATE_CACHE_TRACE=1, which makes --closeout print +# "gate-cache: hit|miss" on stderr. Field run: see the commit that adds this file. +set -uo pipefail +GATE="${1:?usage: test-gate-cache.sh /path/to/gate-check.sh}" +WORK="$(mktemp -d /tmp/gatecache.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } +export MXTK_NO_FETCH=1 MXTK_GATE_CACHE_TRACE=1 + +mkproj() { + d="$WORK/$1"; mkdir -p "$d/architecture" "$d/design/wireframes" "$d/knowledge-base/brd" + printf '.mxtk/\n' > "$d/.gitignore" + cat > "$d/PROJECT.md" <<'MD' +# PROJECT.md — fixture +Toolkit commit: none +Entry mode: requirements-driven + +## Decisions + +| Stage | Decision | Status | Notes | +|---|---|---|---| +| 3 | Module structure: one module | CONFIRMED 2026-09-02 | matches the BRD | +| 3 | Cross-module strategy: none | CONFIRMED | single module | + +## Open questions + +| # | Question | Raised at | Status | +|---|---|---|---| + +## Assumptions (ASSUMED, unresolved) + +None yet. +MD + printf '{"openQuestions":[]}\n' > "$d/knowledge-base/brd/F001.brd.json" + printf '# fit-gap\nrow\n' > "$d/architecture/fit-gap.md" + echo "$d" +} +# query then closeout; sets Q_ERR (closeout stderr) and Q_OUT (closeout stdout) +run() { "$GATE" --no-html "$1" 3 >/dev/null 2>&1; Q_OUT="$("$GATE" --closeout "$1" 3 2>"$WORK/err")"; Q_ERR="$(cat "$WORK/err")"; } +hit() { case "$Q_ERR" in *"gate-cache: hit"*) return 0 ;; esac; return 1; } +miss() { case "$Q_ERR" in *"gate-cache: miss"*) return 0 ;; esac; return 1; } + +echo "== T1: unchanged tree -> hit ==" +P="$(mkproj t1)" +run "$P"; hit && ok "closeout right after the query hits" || bad "no hit: $Q_ERR" +"$GATE" --closeout "$P" 3 >/dev/null 2>"$WORK/err"; grep -q 'gate-cache: hit' "$WORK/err" && ok "second closeout still hits" || bad "second closeout: $(cat "$WORK/err")" + +echo "== T2: edit / deletion / waiver line -> miss ==" +P="$(mkproj t2)"; run "$P"; hit || bad "T2 setup did not hit" +sleep 1; printf 'extra\n' >> "$P/architecture/fit-gap.md" +"$GATE" --closeout "$P" 3 >/dev/null 2>"$WORK/err"; grep -q 'gate-cache: miss' "$WORK/err" && ok "edit -> miss" || bad "edit: $(cat "$WORK/err")" +P="$(mkproj t2d)"; printf 'x\n' > "$P/architecture/other.md"; run "$P"; hit || bad "T2d setup did not hit" +sleep 1; rm "$P/architecture/other.md" +"$GATE" --closeout "$P" 3 >/dev/null 2>"$WORK/err"; grep -q 'gate-cache: miss' "$WORK/err" && ok "deletion -> miss" || bad "deletion: $(cat "$WORK/err")" +P="$(mkproj t2w)"; run "$P"; hit || bad "T2w setup did not hit" +sleep 1; printf 'Opt-in artifact test-plan: demo needs it\n' >> "$P/PROJECT.md" +"$GATE" --closeout "$P" 3 >/dev/null 2>"$WORK/err"; grep -q 'gate-cache: miss' "$WORK/err" && ok "waiver/opt-in line in PROJECT.md -> miss" || bad "waiver: $(cat "$WORK/err")" + +echo "== T3: cp -p overwrite with an old mtime must miss and give the right verdict ==" +P="$(mkproj t3)" +cp -p "$P/PROJECT.md" "$WORK/good.md"; touch -d '2020-01-01' "$WORK/good.md" +sed -i.bak 's/| 3 | Module structure: one module | CONFIRMED 2026-09-02 |/| 3 | Module structure: one module | RAISED |/' "$P/PROJECT.md"; rm -f "$P/PROJECT.md.bak" +run "$P"; hit || bad "T3 setup did not hit" +BEFORE="$Q_OUT" +sleep 1; cp -p "$WORK/good.md" "$P/PROJECT.md" +"$GATE" --closeout "$P" 3 >"$WORK/out" 2>"$WORK/err" +grep -q 'gate-cache: miss' "$WORK/err" && ok "old-mtime overwrite -> miss" || bad "old-mtime overwrite hit: $(cat "$WORK/err")" +[ "$(cat "$WORK/out")" != "$BEFORE" ] && ok "verdict changed with the content" || bad "stale verdict replayed" +MXTK_GATE_CACHE_TTL_MIN=0 "$GATE" --closeout "$P" 3 >"$WORK/out0" 2>/dev/null +cmp -s "$WORK/out" "$WORK/out0" && ok "matches an uncached evaluation" || bad "differs from uncached" + +echo "== T4: MXTK_GATE_CACHE_TTL_MIN=0 turns the cache off ==" +P="$(mkproj t4)"; "$GATE" --no-html "$P" 3 >/dev/null 2>&1 +MXTK_GATE_CACHE_TTL_MIN=0 "$GATE" --closeout "$P" 3 >/dev/null 2>"$WORK/err" +grep -q 'gate-cache: miss' "$WORK/err" && ok "TTL 0 -> miss even on a clean tree" || bad "TTL 0: $(cat "$WORK/err")" + +echo "== T5: a stage query prints the 'not evaluated' line ==" +P="$(mkproj t5)" +"$GATE" --no-html "$P" 3 >"$WORK/o5" 2>&1 +grep -q 'for stages after 3 not evaluated' "$WORK/o5" && ok "N … not evaluated line present" || bad "line missing: $(grep -c . "$WORK/o5") lines" + +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] From df8d23a0f50de97fb2b0dfd3c2ed0812418635c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:06:14 +0000 Subject: [PATCH 3/3] docs(CHANGELOG): credit segment on the gate-check entries (#583) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jQvaonym9V1wKKfAEPmRr --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cefc3d4..79cb4c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- fix(gate-check, gate-cache): **the closeout verdict cache also keys on file CONTENT, so an overwrite that keeps an old mtime (`cp -p`, `rsync -t`, `tar x`, `unzip`, `touch -d`) can no longer replay a stale verdict**: a signed triage.md copied over a FAIL one still printed the cached Stage 0 FAIL (PR #589 review); the cache now stores one `find | sort | xargs cksum | cksum` signature of the project files (skipping .git, node_modules, .mxtk, mprcontents, deployment, *.mpr, files over 1 MB) and any difference, or a signature that cannot be computed, re-evaluates; new `tests/wave2/test-gate-cache.sh` (hit, edit/delete/waiver miss, cp -p miss, TTL 0, 'not evaluated' line); `MXTK_GATE_CACHE_TRACE=1` prints `gate-cache: hit|miss` on --closeout. Field run: ab-581/base copy (197 MB, 633 hashed files) - signature 28 ms, cache hit 1464 -> 1498 ms vs 2567-2786 ms full evaluation - PR #589 reviewer, OlafFede (#583) -- perf(gate-check, closeout): **a Stage close starts about 70% fewer processes (Stage 3 on a small project: `--closeout` 820 -> 127 starts, `gate-check 3` 560 -> 275, no-stage run 626 -> 338; verdict lines unchanged)**: (1) `--closeout` replays the evaluation of the stage query that just ran (variables snapshotted in `/.mxtk/gate-cache`, valid only while nothing under the project or the toolkit is newer than that run and the key matches; any doubt re-evaluates; `MXTK_GATE_CACHE_TTL_MIN=0` turns it off); (2) a stage query no longer evaluates obligation/artifact rows owed from later stages and says how many it skipped (the summary line is labelled the same way); (3) the register is parsed once per run instead of one awk per lookup; (4) the toolkit `git fetch` runs at most once per 15 min (`MXTK_FETCH_TTL_MIN`, `MXTK_NO_FETCH=1` unchanged); plus pure-bash trimming in the close-out block and the obligation reverse check. Reported by OlafFede (#583). +- fix(gate-check, gate-cache): **the closeout verdict cache also keys on file CONTENT, so an overwrite that keeps an old mtime (`cp -p`, `rsync -t`, `tar x`, `unzip`, `touch -d`) can no longer replay a stale verdict**: a signed triage.md copied over a FAIL one still printed the cached Stage 0 FAIL (PR #589 review); the cache now stores one `find | sort | xargs cksum | cksum` signature of the project files (skipping .git, node_modules, .mxtk, mprcontents, deployment, *.mpr, files over 1 MB) and any difference, or a signature that cannot be computed, re-evaluates; new `tests/wave2/test-gate-cache.sh` (hit, edit/delete/waiver miss, cp -p miss, TTL 0, 'not evaluated' line); `MXTK_GATE_CACHE_TRACE=1` prints `gate-cache: hit|miss` on --closeout. Field run: ab-581/base copy (197 MB, 633 hashed files) - signature 28 ms, cache hit 1464 -> 1498 ms vs 2567-2786 ms full evaluation - PR #589 reviewer, OlafFede (#583) — OlafFede (#583) +- perf(gate-check, closeout): **a Stage close starts about 70% fewer processes (Stage 3 on a small project: `--closeout` 820 -> 127 starts, `gate-check 3` 560 -> 275, no-stage run 626 -> 338; verdict lines unchanged)**: (1) `--closeout` replays the evaluation of the stage query that just ran (variables snapshotted in `/.mxtk/gate-cache`, valid only while nothing under the project or the toolkit is newer than that run and the key matches; any doubt re-evaluates; `MXTK_GATE_CACHE_TTL_MIN=0` turns it off); (2) a stage query no longer evaluates obligation/artifact rows owed from later stages and says how many it skipped (the summary line is labelled the same way); (3) the register is parsed once per run instead of one awk per lookup; (4) the toolkit `git fetch` runs at most once per 15 min (`MXTK_FETCH_TTL_MIN`, `MXTK_NO_FETCH=1` unchanged); plus pure-bash trimming in the close-out block and the obligation reverse check. Reported by OlafFede (#583). — OlafFede (#583) - process(design-artifacts, conversion-runbook): **Stage 3 runs in parallel by default, from one brief and a mechanical task list**: the lead fixes modules, canonical names and design tokens, writes `docs/progress/stage3-brief.md`, and takes the tasks from the stage-3 lines of `bin/lib/artifact-check.sh` (so no artifact is forgotten); one subagent per 1–2 screens or artifact group, a shared opening line so the start is cached, mechanical tasks on the cheap tier, 5-line replies, the lead the only writer of `PROJECT.md`. The runbook's Stage 3 fan-out row points there, and its Stage 2 row names `facts-lock.sh resolve`. Measured on one project (single runs, Sonnet, 250k auto-compact): serial 13 min / $3.60, parallel 10 min / $4.44, parallel with a brief 6 min / $4.32; 0 name mismatches in the parallel runs (serial dropped 3 entities); both parallel runs forgot the process map and swimlanes, which the task list now covers. Baseline words 64,004 before and after — Stage 3 experiment 2026-10-28 - fix(facts-lock, gate-check): **parallel BRDs keep one spelling per identifier**: `bin/facts-lock.sh resolve` freezes each casing conflict (`WIPStatus`/`WipStatus`) whose majority spelling is used by strictly more BRD sources than any other and records the count in the lock (`resolvedBy`); a tie stays contested, and `build` still never picks on its own. Stage 2 of `gate-check.sh` now reads the lock once there are 2+ BRDs: a BRD that contradicts a frozen identifier FAILs and is quoted; no lock yet is a note, never a FAIL. Measured cause: 20 parallel BRDs on PROJECT-C carried 10 casing conflicts into Stage 3 as open questions, and facts-lock.sh, documented only as advice, reached 1 of 6 projects. Fixtures: `test-facts-lock.sh` resolve cases (4), `test-bug03-gates.sh` T19 (2 of 4 fail on the previous gate-check) — speed/parallel experiments 2026-10-28 - fix(row-groups): **a build plan whose number and Kind cells are formatted as code (`` `BUILD` ``, `` `12` ``) is read like a plain one**: before, it read 0 BUILD rows, so with `Row groups: on` grouping silently did nothing (the fast-lane agent in the 2026-10-28 speed experiment had to strip the backticks by hand). Fixture: `test-row-groups.sh` G5 (both cases fail on the previous row-groups.sh) — speed experiment 2026-10-28