Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<artifact> 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
Expand Down
83 changes: 81 additions & 2 deletions bin/gate-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,83 @@ has_confirmed_decision() {
' "$f"
}

# stage_exit_recorded <N> — 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);
# "<artifact> 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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/wave2/test-bug03-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <register rows...> -> 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 '<artifact> 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 <name> <register rows...> -> 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 ]
Loading