diff --git a/CHANGELOG.md b/CHANGELOG.md
index 074ca955..c8f65102 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +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) — 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)
- new(doctor, speed-setup): **`bin/doctor.sh --bench []`** times 50 process starts, git status, python/node start, one gate-check, an mxcli check and (on a COPY of the model) mx check against measured reference constants, prints one verdict line and writes `.claude/loop/bench-.tsv` for comparing machines; a **Machine health** section (normal run and `--bench`) warns on leftover mxbuild `deno` helpers and free memory under 2 GB (mxbuild is killed with exit 137 when memory runs out), `--cleanup` stops exactly those helpers after a typed y (no tty: prints the command); both runs end with ONE **Recommended fix** block (Gate mode: check, else WSL, dev container, or Claude in the cloud; antivirus-exclusion advice removed, it needed IT); new `skills/speed-setup.md`. Field run: ab-581/gk on Linux 4 cores, 46 orphan deno helpers found, bench 10.4 s vs 10.9 s reference — Maurits (mauritscgvisser)
- perf(exec): **~55% fewer forks and ~60% fewer process starts per exec** (Windows: ~100 ms each; follow-up to #584/#585). Measured on a two-module Purchasing/Approval app (Mendix 11.13, `Gate mode: check`), Linux, `strace -f` counting clone+execve for one `./bin/exec.sh`: forks 733 → 335, execs 350-375 → 135, 3 runs each; verdicts, exit codes, output and the BUILD-LOG row are byte-identical. What changed: **exec.sh** resolves the model, platform, Python and toolkit root once and hands them down as exported memos (`MXTK_MPR_MEMO`, `MXTK_MODEL_DIR_MEMO`, `MXTK_PLATFORM`, `MXTK_PY_MEMO`), uses `EPOCHSECONDS` / `printf %(…)T` / `${x##*/}` instead of `date` / `basename` / `dirname`, reads PROJECT.md and CLAUDE.local.md fields in-shell, and parses lint-gate's output with ONE awk instead of ~10 grep/sed/tr/sort/paste; **_common.sh** waits for an unbounded gate with `wait` instead of a `sleep 1` process per second (10 on a warm build, 150+ on a cold Windows one), reads the model version with one Python start instead of two, and no longer starts basename/cut/sed/grep to match a Studio Pro folder to a version; **model-stamp.sh** asks git once (it asked 6 times), keeps the file list in an array instead of mktemp/paste/rm, reads the stamp once, and `clear` answers before any set-up; **snapshot-mpr.sh**, **look-ledger.sh**, **script-owners.sh**, **metrics-ledger.sh**, **row-groups.sh**, **build-plan-status.sh**, **lint-gate.sh**, **exec-approval.sh**, **mdl-scan.sh** drop their dirname/basename/date/ls|wc|tr/find|wc forks; lint-gate skips its rule-receipt Python while no rule file, receipt or disabled-list is newer than the last clean check. `MXTK_PROFILE=2` traces the scripts exec.sh starts as well (=1 counts only exec.sh's own commands, so a started script is one line there), and the profile lines now name the file. Reported by mkr87. — mkr87 (#584, #585)
- new(doctor, init): **`bin/doctor.sh ` measures performance, not just presence**: a "Performance (measured)" section reads the last 20 `steps:` rows of the project's BUILD-LOG and reports the median exec time, the median gate and the slowest step against a normal machine (17-34 s per exec, gate 9-20 s), warning above 60 s per exec or 45 s gate with the lever to pull; on Windows with slow process starts it recommends (never requires) WSL2 or a dev container (Docker Engine in WSL2, Rancher Desktop, Podman Desktop). `Gate mode: check` is the default for every project, existing ones included after sync (`Gate mode: full` in PROJECT.md keeps a full build per exec). Windows field run (ExpenseTracker, Windows 11, Git Bash, 103 ms per process start): check-only execs took 90-103 s against 138-139 s with the full build (gate 60 s → 12-14 s); the 60-70 s of non-gate overhead is the next target. Reported by mkr87. — mkr87 (#584, #585)
diff --git a/bin/gate-check.sh b/bin/gate-check.sh
index 7a7f61a7..859cac5a 100755
--- a/bin/gate-check.sh
+++ b/bin/gate-check.sh
@@ -1947,6 +1947,42 @@ 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
+[ -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
+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 +2000,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 +2681,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 +3060,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 +3098,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..d02109ac
--- /dev/null
+++ b/bin/lib/gate-cache.sh
@@ -0,0 +1,218 @@
+#!/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.
+# - 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).
+#
+# 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="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
+ 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_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),
+# 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
+ 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
+}
+
+# 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
+ 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 != "") {
+ 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_SIG|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
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 ]
]