diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a253d7..e3d036a8 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(gates): **a decision taken during a stage no longer reads as that stage's approval** (#477). The #207 flag prefixed a FAIL with `APPROVED OVER A FAILING GATE` whenever any CONFIRMED row carried the stage's number, so a Stage-5 build with `| 5 | Sub-agents run on Sonnet | CONFIRMED |` was flagged, and `status.sh` told the lead to re-open an approval nobody gave while the real gap (PENDING obligations) sat underneath. The register has no dedicated exit row, so `gate-check.sh` now counts a stage as approved on either of two pieces of evidence in the Decisions table: an explicit exit row ("Stage N approved", "Stage 4 — approved with conditions", "Approved: Stage N …", "Gate approved", or " approved" for a fixed artifact list such as "Build plan approved"), or the stage having CONFIRMED rows of its own plus a CONFIRMED decision for a later stage up to 6 (the #207 field case); an early Stage-7 "no cutover" row or an N/A/skip row is not evidence. The Stage 3/4 gate checks and every verdict and exit code are unchanged. `test-bug03-gates.sh` T17 (16 cases, checked under gawk, mawk and original-awk): 7 FAIL on master, 47/47 with the fix. — MendixMau - fix(progress-report): **the build plan is shown once: the phase cards are the plan.** Expanding a card now shows its full rows (#, Kind, Step, Produces/Proves, Depends on, State, Scripts) and Claims, which only the duplicate "Full plan tables" block carried; that block is gone and views A (script folders) and B (module proof) are their own section. The `now` phase renders open with a selected style, opening another phase closes the previous one (accordion; `#phase-N` links still open their card), and a viewer's saved open/closed state survives the 30 s reload and wins over the default (#474) — field feedback from a requirements-driven project at Stage 5 — MendixMau (field feedback 2026-10-20) ## v2026.10.19 diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 9ec15e29..7759f539 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -1059,6 +1059,83 @@ has_confirmed_decision() { ' "$f" } +# stage_exit_recorded — does the register say Stage N was APPROVED AS DONE, not merely that +# some decision was taken during it? Used only by the "approved over a failing gate" flag (#207); +# the Stage 3/4 checks keep has_confirmed_decision, where any CONFIRMED row is the evidence that +# the stage's interview was held. +# +# Why not has_confirmed_decision (#477). Any CONFIRMED row whose Stage cell is N counted as the +# approval, so a stage still being worked on raised the flag the moment the user answered any +# question in it: a Stage-5 build with "| 5 | Sub-agents run on Sonnet | CONFIRMED |" read +# "APPROVED OVER A FAILING GATE", and status.sh told the lead to re-open an approval nobody gave, +# burying the real gap (PENDING obligations) under a wrong diagnosis. +# +# The register has no dedicated exit row (init-project.sh writes `| Stage | Decision | Status | +# Notes |`; conversion-runbook.md §2 closes a gate on "its ✋ decisions CONFIRMED and gate-check +# run"), so a stage counts as approved on either of two pieces of evidence, both in CONFIRMED +# rows of the Decisions table (same table scoping and status anchoring as above): +# 1. an explicit exit row for Stage N, in one of four wordings: +# "Stage N approved|exit…|gate: signed off|closed|complete|done|passed" (separators may be +# spaces, ":", "-", or an em/en dash: "Stage 4 — approved with conditions"); +# "Approved: Stage N …"; +# "Gate approved|signed off|passed|closed" ("gate" as the first word); +# " approved|signed off", the artifact from a fixed list (build plan, plan, +# blueprint, architecture, design (system), wireframes, scope, triage, BRDs, +# requirements), so domain decisions ("Requests over 5k must be approved") never match. +# No checkpoint writes such a row today, so in practice rule 2 does the work; having the +# CAC checkpoint write one is the follow-up. +# 2. the project moved past it: Stage N has CONFIRMED rows of its own AND a CONFIRMED row for +# a later stage up to 6 exists (the #207 field case: a Stage-4 approval, then 47 scripts +# built on it). Stage 7 is excluded and so is any row reading N/A / skip / no cutover: +# checkpoint-cutover.md and existing-app-change.md tell projects to record "No legacy +# system, no cutover" early, and that is not evidence that Stage 5 was signed off. A stage +# with no rows of its own is never "approved" by a forward-filed decision either. +# A mid-stage decision is neither, so it no longer raises the flag. +stage_exit_recorded() { + local stage="$1" + local f="$REGISTER" + [ -n "$f" ] && [ -f "$f" ] || return 1 + awk -F'|' -v want="$stage" ' + function rank(s) { s = toupper(s); if (s == "P") return 0; if (s ~ /^[0-7]$/) return s + 1; return -1 } + BEGIN { + wr = rank(want); w = tolower(want); r6 = rank("6") + # Separators: space, tab, colon, hyphen, or an em/en dash. The dashes are multi-byte, so + # they are an alternation, never inside a bracket class (POSIX awk reads bytes there). + SEP = "([ \t:-]|—|–)*" + ARTS = "^(the )?(build plan|plan|blueprint|architecture|design( system)?|wireframes?|scope|triage|brds?|requirements)" + } + !/^\|/ { intab = 0; next } + { + h = tolower($2); gsub(/^[ \t]+|[ \t]+$/, "", h) + if (h == "stage") { intab = 1; hdr = 1; next } + if ($2 ~ /^[ \t:]*-+[ \t:-]*$/) next + s = $2 + gsub(/^[ \t]+|[ \t]+$/, "", s) + sub(/^[Ss]tage[ \t]*/, "", s) + confirmed = 0 + for (i = 3; i <= NF; i++) { + f = toupper($i); gsub(/^[ \t]+|[ \t]+$/, "", f) + if (f ~ /^CONFIRMED([ \t]|$)/) confirmed = 1 + } + if (!confirmed) next + d = tolower($3); gsub(/^[ \t]+|[ \t.]+$/, "", d) + r = rank(s) + if (wr >= 0 && r > wr && r <= r6 && d !~ /(^|[^a-z])(n\/a|not applicable|skip(ped|s)?|no cutover)([^a-z]|$)/) { + if (intab) tlater = 1; alater = 1 + } + if (tolower(s) != w) next + if (intab) town = 1; aown = 1 + if (d ~ ("(^|[^a-z0-9])stage[ \t]*" w SEP "((gate|exit)" SEP ")?(approv|sign|closed|complete|done|passed)") || + d ~ ("^approved" SEP "stage[ \t]*" w "([^0-9]|$)") || + d ~ ("^gate" SEP "(approved|signed[ -]off|sign-off|passed|closed)") || + d ~ (ARTS SEP "(approved|signed[ -]off)$")) { + if (intab) texit = 1; aexit = 1 + } + } + END { exit !(hdr ? (texit || (town && tlater)) : (aexit || (aown && alater))) } + ' "$f" +} + # Counts the Screen Inventory table in design/target-ui.md (design-artifacts.md Step 2): a # markdown table under a "## Screen Inventory" heading, one row per screen. Prints two numbers # separated by a space: total inventoried rows, and how many of those rows record that the @@ -2825,8 +2902,10 @@ for stage in "${STAGE_NAMES[@]}"; do # and 47 scripts were built on that plan. Delegating the approval is legitimate; an approval # nobody compared to its gate is not. Same verdict, same exit code — the note names the # mismatch, and the summary repeats it so status.sh can quote it. - if [ "$status" = "FAIL" ] && ! reg_unavailable && has_confirmed_decision "$stage"; then - note="APPROVED OVER A FAILING GATE — Stage $stage is CONFIRMED in $(basename "${REGISTER:-PROJECT.md}"), but: $note" + # Approval means an exit row or a later stage's decision (stage_exit_recorded, #477), not any + # decision taken while the stage is still open. + if [ "$status" = "FAIL" ] && ! reg_unavailable && stage_exit_recorded "$stage"; then + note="APPROVED OVER A FAILING GATE — Stage $stage is approved in $(basename "${REGISTER:-PROJECT.md}"), but: $note" APPROVED_OVER_FAIL="${APPROVED_OVER_FAIL:+$APPROVED_OVER_FAIL, }Stage $stage" fi case "$status" in diff --git a/tests/wave2/test-bug03-gates.sh b/tests/wave2/test-bug03-gates.sh index ed97e13d..8b92fe53 100755 --- a/tests/wave2/test-bug03-gates.sh +++ b/tests/wave2/test-bug03-gates.sh @@ -282,6 +282,74 @@ V="$(verdict "$P" 4)" case "$V" in *FAIL*'no module has one'*) ok "with no briefs yet, a plan still owes at least one module close row" ;; *) bad "a plan with no module dirs and no close row passed: $V" ;; esac +echo "== T17: a mid-stage decision is not the stage approval (#477) ==" +# #207 prefixes a FAIL with APPROVED OVER A FAILING GATE when the register says the stage was +# approved. It read ANY CONFIRMED row of that stage as the approval, so the field project's +# mid-Stage-5 "| 5 | Sub-agents run on Sonnet | CONFIRMED | user in chat |" (verbatim from the +# issue) raised it on a stage still being built, and status.sh told the lead to re-open an +# approval nobody gave. Stage 4 stands in for 5 here: its FAIL needs only a plan with no closing +# rows (T16), so no harness obligations have to be staged. The approval must still be flagged +# when it is real: an explicit exit row, or the project having moved on to a later stage. +# Positive control: on the pre-fix script t17a and t17f print the flag. +t17() { # t17 -> the Stage 4 line, and the summary line if any + local P; P="$(mkproj "$1")"; shift + printf '%s\n' "$@" >> "$P/PROJECT.md" + mkdir -p "$P/architecture/modules/Orders" + printf "# Plan\n\n$HDR| 1 | BUILD | 10-orders.mdl | | | none | built |\n" > "$P/architecture/build-plan.md" + "$GATE" --no-html "$P" 2>&1 | grep -E '^Stage 4 |^Approved over a failing gate:' +} +flagged() { case "$1" in *'APPROVED OVER A FAILING GATE'*'Approved over a failing gate: Stage 4'*) return 0 ;; esac; return 1; } +MID='| 4 | Sub-agents run on Sonnet | CONFIRMED | user in chat |' +V="$(t17 t17a "$MID")" +case "$V" in *FAIL*) ;; *) bad "t17a setup: Stage 4 did not FAIL: $V" ;; esac +case "$V" in *'APPROVED OVER'*|*'Approved over'*) bad "a mid-stage decision raised APPROVED OVER A FAILING GATE (#477): $V" ;; + *) ok "a mid-stage CONFIRMED decision does not read as the stage approval" ;; esac +V="$(t17 t17b "| 4 | Purchase requests above 5k need approval | CONFIRMED | |")" +case "$V" in *'APPROVED OVER'*) bad "a domain decision mentioning approval raised the flag: $V" ;; + *) ok "a domain decision that mentions approval is not a stage approval" ;; esac +V="$(t17 t17c "$MID" "| 4 | Stage 4 approved | CONFIRMED | user in chat |")" +flagged "$V" && ok "an explicit 'Stage 4 approved' row over a FAIL is still flagged, in the note and the summary" \ + || bad "an explicit Stage-4 approval over a FAIL was not flagged: $V" +V="$(t17 t17d "| 4 | Stage 4 gate: signed off | CONFIRMED 2026-10-20 | |")" +flagged "$V" && ok "'Stage 4 gate: signed off' with a dated CONFIRMED is an approval" || bad "dated gate sign-off not flagged: $V" +V="$(t17 t17e "| 4 | Build plan approved | CONFIRMED | |")" +flagged "$V" && ok "a short ' approved' row is an approval" || bad "'Build plan approved' not flagged: $V" +V="$(t17 t17f "$MID" "| 5 | Test depth: journeys per module | CONFIRMED | |")" +flagged "$V" && ok "a CONFIRMED decision for a later stage means the project moved past Stage 4: flagged" \ + || bad "moving on to Stage 5 over a failing Stage 4 was not flagged: $V" +V="$(t17 t17g "| 4 | Stage 4 approved | ASSUMED | |" "| 3 | Stage 4 approved | CONFIRMED | |")" +case "$V" in *'APPROVED OVER'*) bad "an ASSUMED exit row, or another stage's row, raised the flag: $V" ;; + *) ok "only a CONFIRMED exit row of Stage 4 itself counts" ;; esac +V="$(t17 t17h "$MID" "## Open questions" "" "| # | Question | Raised at | Status |" "|---|---|---|---|" "| 5 | Stage 4 approved? | Stage 4 | CONFIRMED |")" +case "$V" in *'APPROVED OVER'*) bad "an Open-questions row stood in for the approval: $V" ;; + *) ok "an Open-questions row is not a stage approval (same table scoping as T13)" ;; esac + +# Review of #483: the exit wordings people actually write, and rows that must not count. +V="$(t17 t17i "| 4 | Stage 4 — approved with conditions | CONFIRMED | |")" +flagged "$V" && ok "'Stage 4 — approved with conditions' (em dash) is an approval" || bad "em-dash exit row not flagged: $V" +V="$(t17 t17j "| 4 | Approved: Stage 4 build plan | CONFIRMED | |")" +flagged "$V" && ok "'Approved: Stage 4 build plan' is an approval" || bad "'Approved: Stage N' form not flagged: $V" +for d in 'Status values: draft, submitted, approved' 'Requests over 5k must be approved' 'Approval gate closed after 30 days' 'Stage 40 approved'; do + V="$(t17 t17k "| 4 | $d | CONFIRMED | |")" + case "$V" in *'APPROVED OVER'*) bad "a domain decision raised the flag: '$d'" ;; + *) ok "domain wording is not an approval: '$d'" ;; esac +done +# A failing Stage 5 with no rows of its own: the LOOK obligation owes one unseen page. +t17s5() { # t17s5 -> the Stage 5 line, and the summary line if any + local P; P="$(mkproj "$1")"; shift + printf '%s\n' "$@" >> "$P/PROJECT.md" + mkdir -p "$P/.claude/loop/look" + printf '%s\t%s\t%s\t%s\n' "$(date +%s)" "2026-10-20T10:00:00Z" "Orders.Order_Overview" "10-orders.mdl" > "$P/.claude/loop/look/owed.tsv" + "$GATE" --no-html "$P" 2>&1 | grep -E '^Stage 5 |^Approved over a failing gate:' +} +V="$(t17s5 t17l "| 7 | No legacy system, no cutover | CONFIRMED | |")" +case "$V" in *FAIL*) ;; *) bad "t17l setup: Stage 5 did not FAIL: $V" ;; esac +case "$V" in *'APPROVED OVER'*|*'Approved over'*) bad "an early 'no cutover' Stage-7 row flagged a mid-build Stage 5: $V" ;; + *) ok "a Stage-7 'No legacy system, no cutover' row is not evidence Stage 5 was approved" ;; esac +V="$(t17s5 t17m "| 6 | Test depth: journeys per module | CONFIRMED | |")" +case "$V" in *'APPROVED OVER'*|*'Approved over'*) bad "a forward-filed Stage-6 row flagged a Stage 5 with no rows of its own: $V" ;; + *) ok "a forward-filed Stage-6 decision does not approve a Stage 5 with no rows of its own" ;; esac + printf '\n%s: %d ok, %d FAIL\n' "$(basename "$0")" "$PASS" "$FAIL" rm -rf "$WORK" [ "$FAIL" -eq 0 ]