From 50e69d093152ea76487b7d18201dbd40b720f3f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:45:31 +0000 Subject: [PATCH 1/3] Judge each PROOF-OF-LOOK by its own section's VALID AT; newest stamp by commit time (#155); no LOOK row kind (#68) obligation-check.sh: a review file with dated addenda FAULTed because every proof line was compared with the single newest stamp. A citation now belongs to the nearest stamp above it (headline's if above all). _ob_stale picks the stamp with the newest commit time, not the last one in the file. brd-to-build-plan.md: rationale for not adding a LOOK kind next to the Kind list. Fixture: tests/wave2/test-obligation-valid-at.sh T4-T8 (3 fail on master). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 + bin/lib/obligation-check.sh | 73 ++++++++++++++++++++----- skills/brd-to-build-plan.md | 11 ++++ tests/wave2/test-obligation-valid-at.sh | 51 +++++++++++++++++ 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20342404..c0dd1611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased +- fix(obligations): **Each `PROOF-OF-LOOK` line is checked against the `VALID AT` stamp of its own section, and the report's newest stamp is chosen by commit time ([#155](https://github.com/MendixMau/mxcli-project-toolkit/issues/155)).** A review file that keeps dated addenda was still FAULTing: every proof line was compared with the single newest stamp, so the first section's honest screenshots read "older than VALID AT". The field project's only way back to green was to re-shoot every page and delete its older proof lines. A citation now belongs to the nearest stamp above it (one above every stamp belongs to the headline's), on pretty-printed and one-line HTML alike. "Newest" was last-in-file, so an addendum written above the original stamp went STALE; it is now the stamp with the newest commit time. A stale shot inside a section still FAULTs. Fixture `test-obligation-valid-at.sh` T4-T8 (3 fail on master). Field run 2026-09-26, requirements-driven build — MendixMau +- learn(brd-to-build-plan): **No `LOOK` row kind; the paragraph next to the Kind list says why ([#68](https://github.com/MendixMau/mxcli-project-toolkit/issues/68), [#188](https://github.com/MendixMau/mxcli-project-toolkit/issues/188) item 6).** A look is a `HARNESS` row naming the LOOK pass. Whether it happened is read from the `look` and `fidelity` obligations and the look-ledger, never from a State cell a person can set by hand. A second record could say "looked at" while the gate says nobody did. — MendixMau - learn(testing-shape, test-result-audit): **three test-strength rules, each with a denominator: refusals probed on the server, every undeclared 4xx a failure, one clean-database final run** ([#172](https://github.com/MendixMau/mxcli-project-toolkit/issues/172)). A field comparison of two builds against one frozen spec found the toolkit build checked permission refusals only in the client (a hidden menu item), logged unexpected 400/422 responses as information, and reached 20/20 by stitching three runs. `testing-shape.md` §7 gains three report lines (`N of N restricted pairs probed server-side`, `undeclared 4xx/5xx: 0 of M requests — each fails its step`, `one clean-DB run : N of N scenarios incl. timers, not stitched`); `test-result-audit.md` gains check 2e, which carries the reasons and the field evidence and asks for those numbers. Both baseline skills stay inside the 80,000-word baseline budget (79,997): the reasons live in the on-demand audit skill, and two redundant sentences in `testing-shape.md` §4's verdict paragraphs were folded (the empty-set corollary is stated in the Trace row, `harness-architecture.md` and `report-schema.md`). The backing inbox note is trimmed to its unpromoted "Keep" items. — MendixMau - learn(learned-detection-gaps, module-review): **two UI defects every model gate passes: a lazy-loaded combo box filter hides its preset, and a theme that pins row heights by position lets the next row cover an inserted widget** ([#299](https://github.com/MendixMau/mxcli-project-toolkit/issues/299)). Both passed check, mxbuild, lint and the scripted checks on a 127-module production app and were found only in the running app. Two register rows with the first oracle and the pre-build check (pluggable properties that change *when* data loads; grep the theme for fixed `height` on positional row classes before an insert), and two LOOK lines in `module-review.md` §4c with denominators (`N of N filters show their preset on first load`, `N of N widgets inserted into an existing container fully visible`). — jopterhorst - fix(app-restart, test-stack-up): **a port held by another process before `run --local` is named and reported as "instrument blocked" (exit 4), never killed** ([#262](https://github.com/MendixMau/mxcli-project-toolkit/issues/262)). Studio Pro had started its own runtime on the app/admin ports a restart needed; `mxcli run --local` then failed with "Authentication failed" (the admin API answering was Studio Pro's), the next journey ran against Studio Pro's runtime, and four more runs were NOT RUN. `app-restart.sh` now checks the app port and the admin port (record's `adminPort`, else app + 10) before stopping anything — a holder outside the recorded process tree exits 4 with its name, pid and parent — and again after the stop, where anything still holding a port is by definition not ours. `test-stack-up.sh` runs the same check wherever it would tell you to start `run --local` (`--no-docker`, no Docker daemon, `--check` with a leftover record), on the record's ports, `RUN_LOCAL_APP_PORT`/`RUN_LOCAL_ADMIN_PORT`, or mxcli's defaults 8080/8090. Holder lookup: lsof, then ss, then netstat + tasklist on Windows; unnamed holders still block when nothing of ours is running. Fixtures: `test-app-restart.sh` +2 cases (14 assertions, all fail on the old script), `test-stack-up-local.sh` T7 (4 of 6 fail on the old script). — AlvinnYeo diff --git a/bin/lib/obligation-check.sh b/bin/lib/obligation-check.sh index af7b25ac..26dde2fd 100755 --- a/bin/lib/obligation-check.sh +++ b/bin/lib/obligation-check.sh @@ -244,11 +244,31 @@ _ob_has_denominator() { # quietly stale, is the failure being retired). Unmeasurable (no git, hash unknown here) also # returns 1: an instrument reports what it measured, and "could not measure" must not read as # "expired". +# +# "Newest" is by COMMIT TIME, not by position (#155). A report kept as one dated file with an +# addendum per build row carries several stamps; whether the author appends addenda at the +# bottom or prepends them under the headline, the stamp that says the most recent thing is the +# one whose commit is newest. Unresolvable stamps are skipped; with none resolvable, the last +# stamp in the file is returned (and the callers then treat it as unmeasurable). +_ob_valid_at_stamps() { + tr -d '\r' < "$1" 2>/dev/null \ + | grep -oiE 'VALID[[:space:]]+AT:?[[:space:]]*[0-9a-f]{7,40}' \ + | grep -oiE '[0-9a-f]{7,40}$' +} +_ob_newest_stamp() { + local root="$1" hit="$2" h t best="" best_t=-1 last="" + while IFS= read -r h; do + [ -n "$h" ] || continue + last="$h" + t="$(git -C "$root" show -s --format=%ct "${h}^{commit}" 2>/dev/null || true)" + [ -n "$t" ] || continue + if [ "$t" -ge "$best_t" ]; then best="$h"; best_t="$t"; fi + done <<< "$(_ob_valid_at_stamps "$hit")" + printf '%s' "${best:-$last}" +} _ob_stale() { local root="$1" hit="$2" hash n - hash="$(cat "$hit" 2>/dev/null | tr -d '\r' \ - | grep -oiE 'VALID[[:space:]]+AT:?[[:space:]]*[0-9a-f]{7,40}' \ - | grep -oiE '[0-9a-f]{7,40}$' | tail -1)" + hash="$(_ob_newest_stamp "$root" "$hit")" [ -n "$hash" ] || return 1 git -C "$root" cat-file -e "${hash}^{commit}" 2>/dev/null || return 1 n="$(git -C "$root" log --oneline "${hash}..HEAD" -- '*.mpr' mdlsource 2>/dev/null | wc -l | tr -d ' ')" @@ -273,8 +293,18 @@ _ob_stale() { # 2. every cited file EXISTS on disk and is ≥ 10 KB — a real full-page PNG is hundreds of # KB; a blank capture or a placeholder is not (base64 embeds in the report are display, # not proof: they cannot be checked for freshness); -# 3. every cited file is NEWER than the report's VALID AT commit — shots are taken during -# the review, after the last model commit; citing last month's screenshots fails here. +# 3. every cited file is NEWER than the VALID AT commit OF ITS OWN SECTION — shots are +# taken during the review, after the last model commit; citing last month's screenshots +# fails here. "Its section" = the nearest VALID AT stamp above the citation (a citation +# above every stamp belongs to the first stamp: the headline's). +# +# Why per-section and not "one report per pass" (#155, field run 2026-09-26): projects keep +# one dated review file and append an addendum per build row, each with its own VALID AT and +# its own PROOF-OF-LOOK lines. Judging every citation against the single newest stamp made the +# first section's honest proofs "older than VALID AT", so the only way back to green was to +# re-shoot every page and delete the earlier proof lines — erasing the review history to +# satisfy the checker. Each section is a pass; each pass is judged by its own stamp. Staleness +# of the report as a whole is still decided by the newest stamp (_ob_stale). # A report that fails is treated exactly like no report (FAULT): a visual verdict with no # verifiable screenshot behind it is a CSS review wearing a LOOK report's clothes. # @@ -290,7 +320,12 @@ _ob_look_proof_broken() { fdate="$(basename "$hit" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)" if [ -n "$fdate" ] && [[ "$fdate" < "$_OB_LOOK_PROOF_SINCE" ]]; then return 1; fi - entries="$(tr -d '\r' < "$hit" | grep -oE 'PROOF-OF-LOOK:[[:space:]]*[^=<]+=[[:space:]]*[^<>[:space:]]+')" + # One ordered token stream of stamps and citations, so each citation knows its section. + # grep -o emits matches left to right within a line, lines in order: a minified one-line + # report sections the same way as a pretty-printed one. + local tokens + tokens="$(tr -d '\r' < "$hit" | grep -oiE 'VALID[[:space:]]+AT:?[[:space:]]*[0-9a-f]{7,40}|PROOF-OF-LOOK:[[:space:]]*[^=<]+=[[:space:]]*[^<>[:space:]]+')" + entries="$(printf '%s\n' "$tokens" | grep -E '^PROOF-OF-LOOK:' || true)" if [ -z "$entries" ]; then printf 'no PROOF-OF-LOOK citations — verdicts with no screenshot on disk are a CSS review' return 0 @@ -301,17 +336,29 @@ _ob_look_proof_broken() { claimed="$(head -c 4000 "$hit" 2>/dev/null | tr -d '\r' \ | grep -oiE '[0-9]+[[:space:]]+of[[:space:]]+[0-9]+' | head -1 | awk '{print $1}')" - # VALID AT commit time, for freshness. Unresolvable → freshness unmeasured, not failed. - local vhash vtime="" - vhash="$(cat "$hit" 2>/dev/null | tr -d '\r' \ - | grep -oiE 'VALID[[:space:]]+AT:?[[:space:]]*[0-9a-f]{7,40}' \ - | grep -oiE '[0-9a-f]{7,40}$' | tail -1)" + # Section stamp for freshness: starts at the FIRST stamp in the file (so citations above + # every stamp belong to the headline's), then each stamp met in the stream opens a section. + # Unresolvable stamp → that section's freshness is unmeasured, not failed. + local vhash="" vtime="" first_stamp + first_stamp="$(printf '%s\n' "$tokens" | grep -iE '^VALID' | head -1 | grep -oiE '[0-9a-f]{7,40}$')" + vhash="$first_stamp" [ -n "$vhash" ] && vtime="$(git -C "$root" show -s --format=%ct "${vhash}^{commit}" 2>/dev/null || true)" reldir="$(dirname "$hit")" local line page shot f sz mt while IFS= read -r line; do [ -n "$line" ] || continue + case "$line" in + PROOF-OF-LOOK:*) ;; + [Vv][Aa][Ll][Ii][Dd]*) + line="$(printf '%s' "$line" | grep -oiE '[0-9a-f]{7,40}$')" + if [ "$line" != "$vhash" ]; then + vhash="$line"; vtime="" + [ -n "$vhash" ] && vtime="$(git -C "$root" show -s --format=%ct "${vhash}^{commit}" 2>/dev/null || true)" + fi + continue ;; + *) continue ;; # a lower-case "proof-of-look:" was never a citation (case-sensitive, as before) + esac page="$(printf '%s' "$line" | sed 's/^PROOF-OF-LOOK:[[:space:]]*//;s/[[:space:]]*=.*$//')" shot="$(printf '%s' "$line" | sed 's/^.*=[[:space:]]*//')" n_cited=$((n_cited+1)) @@ -323,12 +370,12 @@ _ob_look_proof_broken() { mt="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || true)" [ -n "$mt" ] && [ "$mt" -lt "$vtime" ] && oldshots="$oldshots $page" fi - done <<< "$entries" + done <<< "$tokens" local why="" [ -n "$missing" ] && why="cited screenshots not on disk:$missing" [ -n "$small" ] && why="${why:+$why; }cited screenshots under 10KB (blank/placeholder):$small" - [ -n "$oldshots" ] && why="${why:+$why; }screenshots older than VALID AT (a previous review's shots):$oldshots" + [ -n "$oldshots" ] && why="${why:+$why; }screenshots older than their section's VALID AT (a previous review's shots):$oldshots" if [ -n "$claimed" ] && [ "$n_cited" -lt "$claimed" ]; then why="${why:+$why; }$n_cited citation(s) for $claimed reviewed page(s)" fi diff --git a/skills/brd-to-build-plan.md b/skills/brd-to-build-plan.md index c190c617..2a75cdbe 100644 --- a/skills/brd-to-build-plan.md +++ b/skills/brd-to-build-plan.md @@ -349,6 +349,17 @@ Every row is one of five kinds, sharing one numbering and one dependency graph. - **`RUN`** — deploy, reopen Studio Pro, walk the happy path as a demo user. - **`HARNESS`** — invoke a named harness or skill (`journey-runner.js`, `design-audit.js`, …). +**There is no `LOOK` kind, on purpose.** A look at a page is a `HARNESS` row whose Step names the +LOOK pass (`module-review.md` stage 4, as in the module-close row below). Whether the look +happened is never read from this plan: it is recorded by the files a look leaves behind, and +`gate-check.sh` counts those — the `look` obligation (a `ui-review-*.html` naming the module, with +a denominator, `VALID AT` and a `PROOF-OF-LOOK` screenshot per page), the `fidelity` obligation (a +`docs/PAGE-FIDELITY.tsv` row per page) and the look-ledger (each page a script built is owed until +a screenshot newer than the build is seen). A `LOOK` row with its own State cell would be a second +record of the same fact that a person can set by hand, so the plan could say "looked at" while the +gate says nobody did. Write the row; read the answer from `gate-check.sh 5`. (Decided +for #68 / #188 item 6.) + `PROVE`/`RUN`/`HARNESS` rows carry **no test content** — only what to run, what counts as a pass, and what they depend on. Content lives in `testing-shape.md`, the brief's Test plan, and the harness. This is the *moment*; the shape is Step 1's column and the detail is the brief. diff --git a/tests/wave2/test-obligation-valid-at.sh b/tests/wave2/test-obligation-valid-at.sh index c949bc4a..8f9953b4 100755 --- a/tests/wave2/test-obligation-valid-at.sh +++ b/tests/wave2/test-obligation-valid-at.sh @@ -8,6 +8,12 @@ # FIRST stamp in the first 8000 bytes. A report that keeps one dated headline and appends an # addendum per build row (each with its own VALID AT) was judged by the headline's original # stamp and went STALE after later model commits, though every page was re-looked-at. +# THE REMAINDER (#155, T4-T8): the PROOF-OF-LOOK freshness rule then compared EVERY citation +# with that one newest stamp, so the first section's honest proofs read "older than VALID AT" +# and the report FAULTed; and "newest" meant last-in-file, so an addendum prepended under the +# headline lost to the older stamp below it. Now each citation is judged by its own section's +# stamp (the nearest stamp above it) and the report's staleness by the stamp with the newest +# commit time. # Subject: obligation-check.sh. Producer of the stamp: module-review.md stage 5 (the reviewer). set -uo pipefail ARG="${1:?usage: test-obligation-valid-at.sh /path/to/bin/gate-check.sh (or lib/obligation-check.sh)}" @@ -44,5 +50,50 @@ echo "== T3: newest stamp sits past byte 8000 -> still seen ==" { printf '

ModuleX: 1 of 1 pages reviewed

\nVALID AT: %s\n' "$OLD"; head -c 9000 /dev/zero | tr '\0' 'x'; printf '\nVALID AT: %s\n' "$NEW"; } > "$REP" [ "$(stale)" -eq 0 ] && ok "stamp past 8000 bytes seen" || bad "stamp past 8000 bytes ignored (STALE)" +# ── T4-T8: dated report (owes PROOF-OF-LOOK), commits and shot mtimes at fixed times ── +Q="$WORK/q"; mkdir -p "$Q/architecture/modules/ModuleX" "$Q/mdlsource" "$Q/design/ui-reviews/shots" +git -C "$Q" init -q . 2>/dev/null +git -C "$Q" config user.email t@t; git -C "$Q" config user.name t +echo a > "$Q/mdlsource/a.mdl"; git -C "$Q" add -A +GIT_AUTHOR_DATE="2026-01-01T00:00:00Z" GIT_COMMITTER_DATE="2026-01-01T00:00:00Z" git -C "$Q" commit -qm one +S1="$(git -C "$Q" rev-parse --short HEAD)" +echo b >> "$Q/mdlsource/a.mdl" +GIT_AUTHOR_DATE="2026-03-01T00:00:00Z" GIT_COMMITTER_DATE="2026-03-01T00:00:00Z" git -C "$Q" commit -qam two +S2="$(git -C "$Q" rev-parse --short HEAD)" +SH="$Q/design/ui-reviews/shots" +for n in before mid after; do head -c 20000 /dev/urandom > "$SH/$n.png"; done +touch -t 202512010000 "$SH/before.png" # older than S1 +touch -t 202602010000 "$SH/mid.png" # between S1 and S2 +touch -t 202604010000 "$SH/after.png" # newer than S2 +QREP="$Q/design/ui-reviews/ui-review-2026-09-26.html" +look() { "$OBCHECK" "$Q" 2>&1 | grep 'Obligation look '; } + +echo "== T4: headline section (S1, shot after S1) + addendum (S2, shot after S2) -> PASS ==" +printf '

ModuleX: 2 of 2 pages reviewed

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Home = shots/mid.png
\n

Addendum

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Edit = shots/after.png
\n' "$S1" "$S2" > "$QREP" +out="$(look)" +case "$out" in *"look PASS"*) ok "each proof judged by its own section's stamp" ;; *) bad "sectioned report not PASS: $out" ;; esac + +echo "== T5: headline section cites a shot older than ITS stamp -> FAULT (control) ==" +printf '

ModuleX: 2 of 2 pages reviewed

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Home = shots/before.png
\n

Addendum

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Edit = shots/after.png
\n' "$S1" "$S2" > "$QREP" +out="$("$OBCHECK" "$Q" 2>&1 | grep -A3 'Obligation look ')" +case "$out" in *FAULT*ModuleX.Home*) ok "a shot older than its own section's stamp still FAULTs" ;; *) bad "stale shot in section 1 not caught: $out" ;; esac + +echo "== T6: addendum cites a shot older than the addendum's stamp -> FAULT (not judged by the min stamp) ==" +printf '

ModuleX: 2 of 2 pages reviewed

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Home = shots/mid.png
\n

Addendum

\nVALID AT: %s\n
PROOF-OF-LOOK: ModuleX.Edit = shots/mid.png
\n' "$S1" "$S2" > "$QREP" +out="$("$OBCHECK" "$Q" 2>&1 | grep -A3 'Obligation look ')" +case "$out" in *FAULT*ModuleX.Edit*) ok "addendum's shot judged by the addendum's stamp" ;; *) bad "addendum's stale shot not caught: $out" ;; esac + +echo "== T7: the T4 report minified onto one line -> PASS ==" +printf '

ModuleX: 2 of 2 pages reviewed

VALID AT: %s
PROOF-OF-LOOK: ModuleX.Home = shots/mid.png

Addendum

VALID AT: %s
PROOF-OF-LOOK: ModuleX.Edit = shots/after.png
' "$S1" "$S2" > "$QREP" +out="$(look)" +case "$out" in *"look PASS"*) ok "one-line report sectioned left to right" ;; *) bad "one-line report not PASS: $out" ;; esac + +echo "== T8: addendum PREPENDED above the older stamp -> newest by commit time, not STALE ==" +rm -f "$QREP" +printf '

ModuleX: 1 of 1 pages reviewed

\nVALID AT: %s\n

Original

\nVALID AT: %s\n' "$S2" "$S1" \ + > "$Q/design/ui-reviews/ui-review-2026-01-01.html" +n="$("$OBCHECK" "$Q" 2>&1 | grep -c 'STALE')" +[ "$n" -eq 0 ] && ok "newest stamp chosen by commit time" || bad "last-in-file stamp was used (STALE)" + echo "pass=$PASS fail=$FAIL" [ "$FAIL" -eq 0 ] From 8c0b176c6aaaf0e16bdccb5e58b09cad988a6ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:55:48 +0000 Subject: [PATCH 2/3] final-look: PROOF-OF-LOOK + VALID AT required; project/cluster rows NOT YET DUE before their stage From an independent review of #303. obligation-check.sh compared from-stage only with --adopt, so final-look (stage 6) read PENDING from Stage P. A project/cluster row above the register's Current stage now reads NOT YET DUE (module rows unchanged; an unreadable stage line keeps the old owed verdict). The PROOF-OF-LOOK check now covers final-look as module-review.md requires, and an unstamped final-look report is FAULT (it could never go stale). Fixture: tests/wave2/test-obligation-final-look.sh T4-T10 (4 fail on master). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + bin/lib/obligation-check.sh | 54 +++++++++++++++++++++-- tests/wave2/test-obligation-final-look.sh | 53 ++++++++++++++++++++-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0dd1611..f357a7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased - fix(obligations): **Each `PROOF-OF-LOOK` line is checked against the `VALID AT` stamp of its own section, and the report's newest stamp is chosen by commit time ([#155](https://github.com/MendixMau/mxcli-project-toolkit/issues/155)).** A review file that keeps dated addenda was still FAULTing: every proof line was compared with the single newest stamp, so the first section's honest screenshots read "older than VALID AT". The field project's only way back to green was to re-shoot every page and delete its older proof lines. A citation now belongs to the nearest stamp above it (one above every stamp belongs to the headline's), on pretty-printed and one-line HTML alike. "Newest" was last-in-file, so an addendum written above the original stamp went STALE; it is now the stamp with the newest commit time. A stale shot inside a section still FAULTs. Fixture `test-obligation-valid-at.sh` T4-T8 (3 fail on master). Field run 2026-09-26, requirements-driven build — MendixMau +- fix(obligations): **The final LOOK is held to the per-module LOOK's proof rules, and project-wide obligations are not owed before their stage.** Found in an independent review of #303. `final-look` (from-stage 6) read PENDING on a project still at Stage P, because the from-stage column was only compared with `--adopt`. A project or cluster row whose from-stage is above the register's `## Current stage` now reads `NOT YET DUE` and is not counted as pending. Module rows are unchanged, and an unreadable stage line still means owed. `module-review.md` § "The final LOOK" requires `PROOF-OF-LOOK` lines, but the check ran only for `look`; it now runs for `final-look` too. An unstamped final-look report could never go stale and so passed forever; it is now FAULT ("NO VALID AT"). Fixture `test-obligation-final-look.sh` T4-T10 (4 fail on master). — MendixMau - learn(brd-to-build-plan): **No `LOOK` row kind; the paragraph next to the Kind list says why ([#68](https://github.com/MendixMau/mxcli-project-toolkit/issues/68), [#188](https://github.com/MendixMau/mxcli-project-toolkit/issues/188) item 6).** A look is a `HARNESS` row naming the LOOK pass. Whether it happened is read from the `look` and `fidelity` obligations and the look-ledger, never from a State cell a person can set by hand. A second record could say "looked at" while the gate says nobody did. — MendixMau - learn(testing-shape, test-result-audit): **three test-strength rules, each with a denominator: refusals probed on the server, every undeclared 4xx a failure, one clean-database final run** ([#172](https://github.com/MendixMau/mxcli-project-toolkit/issues/172)). A field comparison of two builds against one frozen spec found the toolkit build checked permission refusals only in the client (a hidden menu item), logged unexpected 400/422 responses as information, and reached 20/20 by stitching three runs. `testing-shape.md` §7 gains three report lines (`N of N restricted pairs probed server-side`, `undeclared 4xx/5xx: 0 of M requests — each fails its step`, `one clean-DB run : N of N scenarios incl. timers, not stitched`); `test-result-audit.md` gains check 2e, which carries the reasons and the field evidence and asks for those numbers. Both baseline skills stay inside the 80,000-word baseline budget (79,997): the reasons live in the on-demand audit skill, and two redundant sentences in `testing-shape.md` §4's verdict paragraphs were folded (the empty-set corollary is stated in the Trace row, `harness-architecture.md` and `report-schema.md`). The backing inbox note is trimmed to its unpromoted "Keep" items. — MendixMau - learn(learned-detection-gaps, module-review): **two UI defects every model gate passes: a lazy-loaded combo box filter hides its preset, and a theme that pins row heights by position lets the next row cover an inserted widget** ([#299](https://github.com/MendixMau/mxcli-project-toolkit/issues/299)). Both passed check, mxbuild, lint and the scripted checks on a 127-module production app and were found only in the running app. Two register rows with the first oracle and the pre-build check (pluggable properties that change *when* data loads; grep the theme for fixed `height` on positional row classes before an insert), and two LOOK lines in `module-review.md` §4c with denominators (`N of N filters show their preset on first load`, `N of N widgets inserted into an existing container fully visible`). — jopterhorst diff --git a/bin/lib/obligation-check.sh b/bin/lib/obligation-check.sh index 26dde2fd..6ced1eef 100755 --- a/bin/lib/obligation-check.sh +++ b/bin/lib/obligation-check.sh @@ -19,6 +19,7 @@ # FAULT a mark exists but cannot be read as evidence (no denominator on a pass that owes one) # WAIVED the register says this one is out of scope, with a reason # ADOPTED the project joined the pipeline above this obligation's stage +# NOT YET DUE a project/cluster row whose from-stage the register's Current stage has not reached # N/A this project is not running the pipeline at all (a-la-carte; existing-app-assurance.md) set -uo pipefail @@ -163,6 +164,27 @@ _ob_adopted_stage() { _ob_rank() { case "$1" in P|p) echo 0 ;; [0-7]) echo $(( $1 + 1 )) ;; *) echo 99 ;; esac; } +# _ob_current_stage → prints P or 0-7 from the register's "## Current stage" line, or +# nothing when it cannot be read (no section, or a hand-written line that names no stage). +# +# Why: the from-stage column was read only against --adopt, so a project/cluster row was owed +# from the day PROJECT.md existed. final-look (from-stage 6, the whole-app LOOK run LAST) read +# PENDING on a project still at Stage P — an obligation reported before it can be performed is +# noise, and noise teaches people to skip the obligation lines (review of #303, 2026-10-13). +# The readout is gate-check's own ("**Stage N — Title**, …", or "**All gates passed**"); the +# scaffold line init-project.sh writes ("**Stage P — Kickoff**") parses the same way. +# Unreadable → empty → the caller keeps the old behaviour (owed): "could not tell the stage" +# must never read as "not due yet". +_ob_current_stage() { + local reg="$1" line + [ -f "$reg" ] || return 0 + line="$(awk '/^## Current stage/{f=1;next} f && /^## /{exit} f && /^\*\*/{print;exit}' "$reg" 2>/dev/null | tr -d '\r')" + case "$line" in + "**All gates passed"*) printf '7'; return 0 ;; + esac + printf '%s' "$line" | sed -nE 's/^\*\*Stage[[:space:]]+([Pp0-7])([^0-9A-Za-z].*)?$/\1/p' | tr 'p' 'P' +} + # The scope unit set. A module is IN SCOPE once it has been opened for work — a brief directory # under architecture/modules/, or a verify run. NOT every module in the MPR: an app that predates # the toolkit owes nothing for modules nobody is building (obligations.tsv header, and @@ -414,6 +436,10 @@ mxtk_obligations_report() { local adopted_rank=0 [ -n "$adopted" ] && adopted_rank="$(_ob_rank "$adopted")" + local current; current="$(_ob_current_stage "$reg")" + local current_rank=99 + [ -n "$current" ] && current_rank="$(_ob_rank "$current")" + local modules; modules="$(_ob_modules "$root")" local ob performer scope match artifact denom degradable from_stage skill @@ -426,6 +452,15 @@ mxtk_obligations_report() { continue fi + # Not yet due: a project/cluster row whose stage the register has not reached. Module rows + # are not touched — they are owed by a module being OPENED FOR WORK (_ob_modules), which is + # its own "due" test, and their verdicts stay exactly as before. + if [ "$scope" != "module" ] && [ "$(_ob_rank "$from_stage")" -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 + fi + local units case "$scope" in module) units="$modules" ;; @@ -439,7 +474,14 @@ mxtk_obligations_report() { continue fi - local total=0 done_n=0 waived_n=0 pending="" faulted="" stale="" noproof="" u hit reason stale_at proof_why candidates c newest + # The two LOOK rows share the pixel rule: module-review.md's per-module LOOK and its "final + # LOOK" both require PROOF-OF-LOOK lines, and the final one must also be stamped (VALID AT is + # what makes it "final" — an unstamped whole-app report could never go stale, so it would + # discharge the pass forever after any later change). Review of #303, 2026-10-13. + local is_look=0 needs_stamp=0 + case "$ob" in look|final-look) is_look=1 ;; esac + [ "$ob" = "final-look" ] && needs_stamp=1 + local total=0 done_n=0 waived_n=0 pending="" faulted="" stale="" noproof="" unstamped="" u hit reason stale_at proof_why candidates c newest while IFS= read -r u; do [ -n "$u" ] || continue total=$((total+1)) @@ -468,7 +510,8 @@ mxtk_obligations_report() { [ -n "$c" ] || continue [ -n "$newest" ] || newest="$c" if [ "$denom" = "yes" ] && ! _ob_has_denominator "$c"; then continue; fi - if [ "$ob" = "look" ] && _ob_look_proof_broken "$root" "$c" >/dev/null; then continue; fi + if [ "$needs_stamp" = 1 ] && [ -z "$(_ob_valid_at_stamps "$c")" ]; then continue; fi + if [ "$is_look" = 1 ] && _ob_look_proof_broken "$root" "$c" >/dev/null; then continue; fi if _ob_stale "$root" "$c" >/dev/null; then continue; fi hit="$c"; break done <<< "$candidates" @@ -482,7 +525,9 @@ mxtk_obligations_report() { # would send them to the wrong place. elif [ "$denom" = "yes" ] && ! _ob_has_denominator "$newest"; then faulted="$faulted $u" - elif [ "$ob" = "look" ] && proof_why="$(_ob_look_proof_broken "$root" "$newest")"; then + elif [ "$needs_stamp" = 1 ] && [ -z "$(_ob_valid_at_stamps "$newest")" ]; then + unstamped="$unstamped $u" + elif [ "$is_look" = 1 ] && proof_why="$(_ob_look_proof_broken "$root" "$newest")"; then # A LOOK whose screenshots cannot be verified is a CSS review — treated as no report. noproof="$noproof $u($proof_why)" elif stale_at="$(_ob_stale "$root" "$newest")"; then @@ -496,9 +541,10 @@ mxtk_obligations_report() { local scoped=$((total - waived_n)) tail="" [ "$waived_n" -gt 0 ] && tail=" ($waived_n waived)" - if [ -n "$faulted" ] || [ -n "$noproof" ]; then + if [ -n "$faulted" ] || [ -n "$noproof" ] || [ -n "$unstamped" ]; then local why_bits="" [ -n "$faulted" ] && why_bits="NO DENOMINATOR:$faulted — a pass that cannot say what it covered has not covered it" + [ -n "$unstamped" ] && why_bits="${why_bits:+$why_bits; }NO VALID AT:$unstamped — an unstamped final LOOK can never go stale, so it is treated as no report (stamp it with the commit it was true at)" [ -n "$noproof" ] && why_bits="${why_bits:+$why_bits; }NO PROOF-OF-LOOK — a visual verdict with no verifiable screenshot is a CSS review, treated as no report" printf 'Obligation %-10s FAULT — %d of %d discharged%s; %s (%s)\n' \ "$ob" "$done_n" "$scoped" "$tail" "$why_bits" "$skill" diff --git a/tests/wave2/test-obligation-final-look.sh b/tests/wave2/test-obligation-final-look.sh index 7693cad7..de80246d 100755 --- a/tests/wave2/test-obligation-final-look.sh +++ b/tests/wave2/test-obligation-final-look.sh @@ -8,6 +8,12 @@ # status fix on four pages. Every per-task review passed; only a read-only whole-app LOOK, run # after the last task, found it — and nothing owed that pass. The row makes it owed; "final" is # enforced by the existing VALID AT rule (_ob_stale), not by new code. +# +# Review of #303 (2026-10-13), T4-T10: the final LOOK owes the same PROOF-OF-LOOK lines as the +# per-module LOOK (module-review.md § "The final LOOK" requires them; only ob=look was checked); +# an unstamped final report is FAULT (with no VALID AT it could never go stale, so it would pass +# forever); and the row is NOT YET DUE until the register's Current stage reaches its from-stage +# (6) — it read PENDING from the day PROJECT.md existed. Module rows keep their verdicts. # Subject: obligation-check.sh. Producer of the report: module-review.md § "The final LOOK". set -uo pipefail ARG="${1:?usage: test-obligation-final-look.sh /path/to/bin/gate-check.sh (or lib/obligation-check.sh)}" @@ -47,16 +53,57 @@ printf '

Whole app looked at, all fine.

\nVALID AT: %s\n' "$NEW" > "$REP" l="$(line)" case "$l" in *FAULT*DENOMINATOR*) ok "no denominator is FAULT" ;; *) bad "expected FAULT, got: $l" ;; esac -echo "== T4: denominator, stamped at HEAD -> PASS ==" -printf '

Final LOOK: 38 of 38 pages, 4 of 4 roles

\nVALID AT: %s\n' "$NEW" > "$REP" +mkdir -p "$P/design/ui-reviews/shots" +head -c 20000 /dev/urandom > "$P/design/ui-reviews/shots/home.png" # written after both commits +PROOF='
PROOF-OF-LOOK: ModuleX.Home = shots/home.png
' + +echo "== T4: denominator, stamped at HEAD, proof on disk -> PASS ==" +printf '

Final LOOK: 1 of 1 pages, 1 of 1 roles

\nVALID AT: %s\n%s\n' "$NEW" "$PROOF" > "$REP" l="$(line)" case "$l" in *PASS*) ok "current final LOOK is PASS" ;; *) bad "expected PASS, got: $l" ;; esac echo "== T5: stamped before the last model commit -> owed again (STALE) ==" -printf '

Final LOOK: 38 of 38 pages, 4 of 4 roles

\nVALID AT: %s\n' "$OLD" > "$REP" +printf '

Final LOOK: 1 of 1 pages, 1 of 1 roles

\nVALID AT: %s\n%s\n' "$OLD" "$PROOF" > "$REP" l="$(line)" case "$l" in *PENDING*) ok "stale final LOOK is PENDING" ;; *) bad "expected PENDING, got: $l" ;; esac [ "$(stale)" -gt 0 ] && ok "STALE line names it" || bad "no STALE line for final-look" +echo "== T6: stamped at HEAD, denominator, but no PROOF-OF-LOOK line -> FAULT ==" +printf '

Final LOOK: 1 of 1 pages, 1 of 1 roles

\nVALID AT: %s\n' "$NEW" > "$REP" +l="$(line)" +case "$l" in *FAULT*PROOF-OF-LOOK*) ok "final LOOK without screenshots is FAULT" ;; *) bad "expected FAULT (no proof), got: $l" ;; esac + +echo "== T7: denominator and proof but no VALID AT -> FAULT naming the stamp ==" +printf '

Final LOOK: 1 of 1 pages, 1 of 1 roles

\n%s\n' "$PROOF" > "$REP" +l="$(line)" +case "$l" in *FAULT*"NO VALID AT"*) ok "unstamped final LOOK is FAULT" ;; *) bad "expected FAULT (no VALID AT), got: $l" ;; esac + +echo "== T8: register at Stage 5 -> final-look NOT YET DUE (owed from 6) ==" +rm -f "$REP" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 5 — Build**, in progress (derived by gate-check on 2026-10-13; re-run it rather than editing this line).\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *"NOT YET DUE"*) ok "final-look not owed before stage 6" ;; *) bad "expected NOT YET DUE, got: $l" ;; esac + +echo "== T9: register at Stage 6, or all gates passed -> owed (PENDING) ==" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 6 — Test**, in progress.\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *PENDING*) ok "owed at stage 6" ;; *) bad "expected PENDING at stage 6, got: $l" ;; esac +printf '# PROJECT.md\n\n## Current stage\n\n**All gates passed** — through Stage 7 (Cutover)\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *PENDING*) ok "owed when all gates passed" ;; *) bad "expected PENDING after all gates, got: $l" ;; esac +printf '# PROJECT.md\n\n## Current stage\n\nWe are somewhere in the build.\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *PENDING*) ok "unreadable stage keeps the old verdict (owed)" ;; *) bad "unreadable stage changed the verdict: $l" ;; esac + +echo "== T10: module rows keep their verdicts at Stage P ==" +mod() { "$OBCHECK" "$P" 2>&1 | grep -E '^Obligation (look|sweep|journeys|fidelity|dispatch) ' | sed -E 's/ +/ /g'; } +rm -f "$P/PROJECT.md"; before="$(mod)" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage P — Kickoff**, in progress.\n' > "$P/PROJECT.md" +after="$(mod)" +[ -n "$before" ] && [ "$before" = "$after" ] && ok "module verdicts identical with and without a Stage P register" \ + || bad "module verdicts changed: before=[$before] after=[$after]" +l="$("$OBCHECK" "$P" 2>&1 | grep -E '^Obligation skeleton ')" +case "$l" in *"NOT YET DUE"*) ok "project row from stage 5 not due at Stage P" ;; *) bad "skeleton at Stage P: $l" ;; esac + echo "pass=$PASS fail=$FAIL" [ "$FAIL" -eq 0 ] From 9b73def4b86d1dfc2d38bb6cc44cb9c6a8e7228f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:12:27 +0000 Subject: [PATCH 3/3] Review of #321: NOT YET DUE for final-look only and only on the derived readout; unresolvable stamp falls back to last-in-file - NOT YET DUE now applies to final-look alone, and only when the Current stage line is gate-check's own derived readout. Hand-written and scaffold lines read as unreadable, so the row stays owed. Every other obligation keeps master's verdict. - _ob_newest_stamp: if any VALID AT stamp does not resolve (squash, rebase, shallow clone), use the last stamp in the file as master did. Before, the older resolvable stamp won and the report read STALE. - CHANGELOG: cite #155's field report as the motivation and say the checker is proven by fixtures only. Fixtures: valid-at T3b; final-look T9-T11 (each fails on the previous head). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 4 +-- bin/lib/obligation-check.sh | 43 ++++++++++++++--------- tests/wave2/test-obligation-final-look.sh | 37 ++++++++++++------- tests/wave2/test-obligation-valid-at.sh | 4 +++ 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f357a7da..cddaadbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased -- fix(obligations): **Each `PROOF-OF-LOOK` line is checked against the `VALID AT` stamp of its own section, and the report's newest stamp is chosen by commit time ([#155](https://github.com/MendixMau/mxcli-project-toolkit/issues/155)).** A review file that keeps dated addenda was still FAULTing: every proof line was compared with the single newest stamp, so the first section's honest screenshots read "older than VALID AT". The field project's only way back to green was to re-shoot every page and delete its older proof lines. A citation now belongs to the nearest stamp above it (one above every stamp belongs to the headline's), on pretty-printed and one-line HTML alike. "Newest" was last-in-file, so an addendum written above the original stamp went STALE; it is now the stamp with the newest commit time. A stale shot inside a section still FAULTs. Fixture `test-obligation-valid-at.sh` T4-T8 (3 fail on master). Field run 2026-09-26, requirements-driven build — MendixMau -- fix(obligations): **The final LOOK is held to the per-module LOOK's proof rules, and project-wide obligations are not owed before their stage.** Found in an independent review of #303. `final-look` (from-stage 6) read PENDING on a project still at Stage P, because the from-stage column was only compared with `--adopt`. A project or cluster row whose from-stage is above the register's `## Current stage` now reads `NOT YET DUE` and is not counted as pending. Module rows are unchanged, and an unreadable stage line still means owed. `module-review.md` § "The final LOOK" requires `PROOF-OF-LOOK` lines, but the check ran only for `look`; it now runs for `final-look` too. An unstamped final-look report could never go stale and so passed forever; it is now FAULT ("NO VALID AT"). Fixture `test-obligation-final-look.sh` T4-T10 (4 fail on master). — MendixMau +- fix(obligations): **Each `PROOF-OF-LOOK` line is checked against the `VALID AT` stamp of its own section, and the report's newest stamp is chosen by commit time ([#155](https://github.com/MendixMau/mxcli-project-toolkit/issues/155)).** A review file that keeps dated addenda was still FAULTing: every proof line was compared with the single newest stamp, so the first section's honest screenshots read "older than VALID AT". The field project's only way back to green was to re-shoot every page and delete its older proof lines. A citation now belongs to the nearest stamp above it (one above every stamp belongs to the headline's), on pretty-printed and one-line HTML alike. "Newest" was last-in-file, so an addendum written above the original stamp went STALE; it is now the stamp with the newest commit time. If any stamp no longer resolves (squash, rebase, shallow clone), the last stamp in the file is used, as before, and staleness reads as unmeasured. A stale shot inside a section still FAULTs. Motivated by the field report in #155 (a requirements-driven build, 2026-09-26). The checker is proven by fixtures only (`test-obligation-valid-at.sh` T3b, T4-T8; 3 fail on master); no field run against a real report yet — MendixMau +- fix(obligations): **The final LOOK is held to the per-module LOOK's proof rules, and is not owed before Stage 6.** Found in an independent review of #303. `final-look` (from-stage 6) read PENDING on a project still at Stage P, because the from-stage column was only compared with `--adopt`. It now reads `NOT YET DUE` while gate-check's own derived `## Current stage` readout (the line carrying "(derived by gate-check") is below 6. A hand-written or scaffold stage line counts as unreadable, so the row stays owed; hand-written lines disagreed with the gates in 8 of 12 field projects. Every other obligation keeps its verdict. `module-review.md` § "The final LOOK" requires `PROOF-OF-LOOK` lines, but the check ran only for `look`; it now runs for `final-look` too. An unstamped final-look report could never go stale and so passed forever; it is now FAULT ("NO VALID AT"). Fixture `test-obligation-final-look.sh` T4-T11 (3 fail on master). — MendixMau - learn(brd-to-build-plan): **No `LOOK` row kind; the paragraph next to the Kind list says why ([#68](https://github.com/MendixMau/mxcli-project-toolkit/issues/68), [#188](https://github.com/MendixMau/mxcli-project-toolkit/issues/188) item 6).** A look is a `HARNESS` row naming the LOOK pass. Whether it happened is read from the `look` and `fidelity` obligations and the look-ledger, never from a State cell a person can set by hand. A second record could say "looked at" while the gate says nobody did. — MendixMau - learn(testing-shape, test-result-audit): **three test-strength rules, each with a denominator: refusals probed on the server, every undeclared 4xx a failure, one clean-database final run** ([#172](https://github.com/MendixMau/mxcli-project-toolkit/issues/172)). A field comparison of two builds against one frozen spec found the toolkit build checked permission refusals only in the client (a hidden menu item), logged unexpected 400/422 responses as information, and reached 20/20 by stitching three runs. `testing-shape.md` §7 gains three report lines (`N of N restricted pairs probed server-side`, `undeclared 4xx/5xx: 0 of M requests — each fails its step`, `one clean-DB run : N of N scenarios incl. timers, not stitched`); `test-result-audit.md` gains check 2e, which carries the reasons and the field evidence and asks for those numbers. Both baseline skills stay inside the 80,000-word baseline budget (79,997): the reasons live in the on-demand audit skill, and two redundant sentences in `testing-shape.md` §4's verdict paragraphs were folded (the empty-set corollary is stated in the Trace row, `harness-architecture.md` and `report-schema.md`). The backing inbox note is trimmed to its unpromoted "Keep" items. — MendixMau - learn(learned-detection-gaps, module-review): **two UI defects every model gate passes: a lazy-loaded combo box filter hides its preset, and a theme that pins row heights by position lets the next row cover an inserted widget** ([#299](https://github.com/MendixMau/mxcli-project-toolkit/issues/299)). Both passed check, mxbuild, lint and the scripted checks on a 127-module production app and were found only in the running app. Two register rows with the first oracle and the pre-build check (pluggable properties that change *when* data loads; grep the theme for fixed `height` on positional row classes before an insert), and two LOOK lines in `module-review.md` §4c with denominators (`N of N filters show their preset on first load`, `N of N widgets inserted into an existing container fully visible`). — jopterhorst diff --git a/bin/lib/obligation-check.sh b/bin/lib/obligation-check.sh index 6ced1eef..1f59895d 100755 --- a/bin/lib/obligation-check.sh +++ b/bin/lib/obligation-check.sh @@ -19,7 +19,7 @@ # FAULT a mark exists but cannot be read as evidence (no denominator on a pass that owes one) # WAIVED the register says this one is out of scope, with a reason # ADOPTED the project joined the pipeline above this obligation's stage -# NOT YET DUE a project/cluster row whose from-stage the register's Current stage has not reached +# NOT YET DUE final-look only: gate-check's derived Current stage has not reached its from-stage # N/A this project is not running the pipeline at all (a-la-carte; existing-app-assurance.md) set -uo pipefail @@ -167,18 +167,23 @@ _ob_rank() { case "$1" in P|p) echo 0 ;; [0-7]) echo $(( $1 + 1 )) ;; *) echo 99 # _ob_current_stage → prints P or 0-7 from the register's "## Current stage" line, or # nothing when it cannot be read (no section, or a hand-written line that names no stage). # -# Why: the from-stage column was read only against --adopt, so a project/cluster row was owed -# from the day PROJECT.md existed. final-look (from-stage 6, the whole-app LOOK run LAST) read -# PENDING on a project still at Stage P — an obligation reported before it can be performed is -# noise, and noise teaches people to skip the obligation lines (review of #303, 2026-10-13). -# The readout is gate-check's own ("**Stage N — Title**, …", or "**All gates passed**"); the -# scaffold line init-project.sh writes ("**Stage P — Kickoff**") parses the same way. -# Unreadable → empty → the caller keeps the old behaviour (owed): "could not tell the stage" -# must never read as "not due yet". +# Why: the from-stage column was read only against --adopt, so final-look (from-stage 6, the +# whole-app LOOK run LAST) read PENDING on a project still at Stage P (review of #303). +# +# ONLY gate-check's own derived readout counts — a line containing "(derived by gate-check". +# A hand-written line, and the scaffold line init-project.sh writes ("**Stage P — Kickoff**"), +# read as unreadable → empty → owed. Field evidence (review of #321): hand-written lines +# disagreed with the gates in 8 of 12 projects and gate-check never overwrites them; the +# scaffold line often never advances because projects query `gate-check.sh . 5` directly. +# "Could not tell the stage" must never read as "not due yet". _ob_current_stage() { local reg="$1" line [ -f "$reg" ] || return 0 line="$(awk '/^## Current stage/{f=1;next} f && /^## /{exit} f && /^\*\*/{print;exit}' "$reg" 2>/dev/null | tr -d '\r')" + case "$line" in + *"(derived by gate-check"*) ;; + *) return 0 ;; + esac case "$line" in "**All gates passed"*) printf '7'; return 0 ;; esac @@ -270,22 +275,25 @@ _ob_has_denominator() { # "Newest" is by COMMIT TIME, not by position (#155). A report kept as one dated file with an # addendum per build row carries several stamps; whether the author appends addenda at the # bottom or prepends them under the headline, the stamp that says the most recent thing is the -# one whose commit is newest. Unresolvable stamps are skipped; with none resolvable, the last -# stamp in the file is returned (and the callers then treat it as unmeasurable). +# one whose commit is newest. If ANY stamp does not resolve here (history rewritten by a squash +# or rebase, a shallow clone), the order of the stamps cannot be measured, so the last stamp in +# the file is returned, as before: picking an older resolvable stamp instead would report STALE +# on a report this checker cannot actually date (review of #321). _ob_valid_at_stamps() { tr -d '\r' < "$1" 2>/dev/null \ | grep -oiE 'VALID[[:space:]]+AT:?[[:space:]]*[0-9a-f]{7,40}' \ | grep -oiE '[0-9a-f]{7,40}$' } _ob_newest_stamp() { - local root="$1" hit="$2" h t best="" best_t=-1 last="" + local root="$1" hit="$2" h t best="" best_t=-1 last="" unresolved=0 while IFS= read -r h; do [ -n "$h" ] || continue last="$h" t="$(git -C "$root" show -s --format=%ct "${h}^{commit}" 2>/dev/null || true)" - [ -n "$t" ] || continue + if [ -z "$t" ]; then unresolved=1; continue; fi if [ "$t" -ge "$best_t" ]; then best="$h"; best_t="$t"; fi done <<< "$(_ob_valid_at_stamps "$hit")" + [ "$unresolved" = 1 ] && best="" printf '%s' "${best:-$last}" } _ob_stale() { @@ -452,10 +460,11 @@ mxtk_obligations_report() { continue fi - # Not yet due: a project/cluster row whose stage the register has not reached. Module rows - # are not touched — they are owed by a module being OPENED FOR WORK (_ob_modules), which is - # its own "due" test, and their verdicts stay exactly as before. - if [ "$scope" != "module" ] && [ "$(_ob_rank "$from_stage")" -gt "$current_rank" ]; then + # Not yet due: final-look ONLY, the one row that by definition runs last. Every other row + # 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 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 diff --git a/tests/wave2/test-obligation-final-look.sh b/tests/wave2/test-obligation-final-look.sh index de80246d..bc737313 100755 --- a/tests/wave2/test-obligation-final-look.sh +++ b/tests/wave2/test-obligation-final-look.sh @@ -12,8 +12,9 @@ # Review of #303 (2026-10-13), T4-T10: the final LOOK owes the same PROOF-OF-LOOK lines as the # per-module LOOK (module-review.md § "The final LOOK" requires them; only ob=look was checked); # an unstamped final report is FAULT (with no VALID AT it could never go stale, so it would pass -# forever); and the row is NOT YET DUE until the register's Current stage reaches its from-stage -# (6) — it read PENDING from the day PROJECT.md existed. Module rows keep their verdicts. +# forever); and final-look (ONLY) is NOT YET DUE until gate-check's DERIVED Current stage +# reaches 6. A hand-written or scaffold stage line is unreadable → owed; every other row keeps +# its verdict (review of #321: hand-written lines disagree with the gates in 8 of 12 projects). # Subject: obligation-check.sh. Producer of the report: module-review.md § "The final LOOK". set -uo pipefail ARG="${1:?usage: test-obligation-final-look.sh /path/to/bin/gate-check.sh (or lib/obligation-check.sh)}" @@ -84,26 +85,38 @@ printf '# PROJECT.md\n\n## Current stage\n\n**Stage 5 — Build**, in progress ( l="$(line)" case "$l" in *"NOT YET DUE"*) ok "final-look not owed before stage 6" ;; *) bad "expected NOT YET DUE, got: $l" ;; esac -echo "== T9: register at Stage 6, or all gates passed -> owed (PENDING) ==" -printf '# PROJECT.md\n\n## Current stage\n\n**Stage 6 — Test**, in progress.\n' > "$P/PROJECT.md" +D=' (derived by gate-check on 2026-10-13; re-run it rather than editing this line).' +echo "== T9: derived Stage 6, or all gates passed -> owed (PENDING) ==" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 6 — Test**, in progress%s\n' "$D" > "$P/PROJECT.md" l="$(line)" case "$l" in *PENDING*) ok "owed at stage 6" ;; *) bad "expected PENDING at stage 6, got: $l" ;; esac -printf '# PROJECT.md\n\n## Current stage\n\n**All gates passed** — through Stage 7 (Cutover)\n' > "$P/PROJECT.md" +printf '# PROJECT.md\n\n## Current stage\n\n**All gates passed** — through Stage 7 (Cutover) — gates passed: P, 0%s\n' "$D" > "$P/PROJECT.md" l="$(line)" case "$l" in *PENDING*) ok "owed when all gates passed" ;; *) bad "expected PENDING after all gates, got: $l" ;; esac printf '# PROJECT.md\n\n## Current stage\n\nWe are somewhere in the build.\n' > "$P/PROJECT.md" l="$(line)" case "$l" in *PENDING*) ok "unreadable stage keeps the old verdict (owed)" ;; *) bad "unreadable stage changed the verdict: $l" ;; esac -echo "== T10: module rows keep their verdicts at Stage P ==" -mod() { "$OBCHECK" "$P" 2>&1 | grep -E '^Obligation (look|sweep|journeys|fidelity|dispatch) ' | sed -E 's/ +/ /g'; } -rm -f "$P/PROJECT.md"; before="$(mod)" +echo "== T10: a hand-written stage line is not honoured -> owed ==" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 5 — Build**, in progress.\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *PENDING*) ok "hand-written Stage 5 line ignored, final-look owed" ;; *) bad "hand-written line honoured: $l" ;; esac +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 5 verdict: COMPLETE**\n' > "$P/PROJECT.md" +l="$(line)" +case "$l" in *PENDING*) ok "free-form hand-written line ignored, final-look owed" ;; *) bad "free-form line honoured: $l" ;; esac + +echo "== T11: scaffold line with a module open -> final-look owed; module and other rows unchanged ==" +others() { "$OBCHECK" "$P" 2>&1 | grep -E '^Obligation ' | grep -vE '^Obligation final-look ' | sed -E 's/ +/ /g'; } +rm -f "$P/PROJECT.md"; before="$(others)" printf '# PROJECT.md\n\n## Current stage\n\n**Stage P — Kickoff**, in progress.\n' > "$P/PROJECT.md" -after="$(mod)" -[ -n "$before" ] && [ "$before" = "$after" ] && ok "module verdicts identical with and without a Stage P register" \ - || bad "module verdicts changed: before=[$before] after=[$after]" +after="$(others)" +l="$(line)" +case "$l" in *PENDING*) ok "scaffold Stage P line ignored, final-look owed" ;; *) bad "scaffold line honoured: $l" ;; esac +[ -n "$before" ] && [ "$before" = "$after" ] && ok "every other obligation line identical with and without the register" \ + || bad "other verdicts changed: before=[$before] after=[$after]" +printf '# PROJECT.md\n\n## Current stage\n\n**Stage 3 — Architecture**, in progress%s\n' "$D" > "$P/PROJECT.md" l="$("$OBCHECK" "$P" 2>&1 | grep -E '^Obligation skeleton ')" -case "$l" in *"NOT YET DUE"*) ok "project row from stage 5 not due at Stage P" ;; *) bad "skeleton at Stage P: $l" ;; esac +case "$l" in *PENDING*) ok "skeleton (from 5) still owed under a derived Stage 3 readout" ;; *) bad "skeleton hidden: $l" ;; esac echo "pass=$PASS fail=$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-obligation-valid-at.sh b/tests/wave2/test-obligation-valid-at.sh index 8f9953b4..da77767a 100755 --- a/tests/wave2/test-obligation-valid-at.sh +++ b/tests/wave2/test-obligation-valid-at.sh @@ -50,6 +50,10 @@ echo "== T3: newest stamp sits past byte 8000 -> still seen ==" { printf '

ModuleX: 1 of 1 pages reviewed

\nVALID AT: %s\n' "$OLD"; head -c 9000 /dev/zero | tr '\0' 'x'; printf '\nVALID AT: %s\n' "$NEW"; } > "$REP" [ "$(stale)" -eq 0 ] && ok "stamp past 8000 bytes seen" || bad "stamp past 8000 bytes ignored (STALE)" +echo "== T3b: one stamp unresolvable here (squash/rebase/shallow) -> last-in-file, unmeasurable, not STALE ==" +printf '

ModuleX: 1 of 1 pages reviewed

\nVALID AT: %s\n

Addendum

\nVALID AT: deadbee1\n' "$OLD" > "$REP" +[ "$(stale)" -eq 0 ] && ok "unresolvable stamp falls back to last-in-file (unmeasured)" || bad "older resolvable stamp picked over an unresolvable one (STALE)" + # ── T4-T8: dated report (owes PROOF-OF-LOOK), commits and shot mtimes at fixed times ── Q="$WORK/q"; mkdir -p "$Q/architecture/modules/ModuleX" "$Q/mdlsource" "$Q/design/ui-reviews/shots" git -C "$Q" init -q . 2>/dev/null