Skip to content

Commit 2059e68

Browse files
committed
fix(restatement-guard, hooks): mark the last-writer ledger on a model-changing git pull/checkout
The restatement guard's ledger only saw exec.sh writes, so a pull that brought newer model work in left it saying "05 wrote last" and a replay of 05 over that work stayed quiet. install-project-hooks.sh now installs post-merge + post-checkout hooks that append a RESTORE row (as restore-mpr.sh does) when the operation changed the .mpr or mprcontents/. Squash merges and file checkouts give the hook no range, so they always mark: the cost is one capped DESCRIBE round. Fixture test-restatement-git-hooks.sh: real git, both layouts, chained foreign hook, idempotent re-install; 26/26, and the #504 repro fails on master. test-model-stamp 35/35, test-mx-metadata-note 40/40, test-restatement-guard 58/58. Not done: restore-mpr.sh taking the exec lock (optional ask) — draft PR #379 moves the lock into _common.sh; do it on top of that. Fixes #504 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jQvaonym9V1wKKfAEPmRr
1 parent 6615869 commit 2059e68

4 files changed

Lines changed: 135 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m
1919

2020
## Unreleased
2121

22+
- fix(restatement-guard, hooks): **a `git pull` / `git checkout` that changes the model now resets the restatement guard's last-writer ledger** (#504). The ledger (`.claude/loop/script-owners/written.tsv`) only saw `exec.sh` writes, so after a pull that brought 09's work in it still said "05 wrote last" and a replay of 05 over 09 stayed quiet (reproduced in `test-restatement-git-hooks.sh` against master: `last-writer` still `05` after the pull). `install-project-hooks.sh` now also installs post-merge and post-checkout hooks that append `RESTORE git-<hook>:<sha>` when the operation touched the `.mpr` or `mprcontents/` (a squash merge or a file checkout gives no range, so it always marks), exactly the row `restore-mpr.sh` writes; the next replay check asks the model (DESCRIBE, capped at 5). No ledger → no row; never blocks git; a foreign hook is chained. `sync-project.sh` installs them on the next sync (its `--check` now lists them). New fixture: real git merges, pulls and checkouts, both layouts, 26 checks — field-run on scratch repos only, not yet on a live project — review of PR #498
2223
- fix(lint): **the toolkit's CONV020 (page actions give user feedback) is now `MXTK-UX003` (`mxtk_ux003_action_user_feedback.star`), because mxcli v0.28.0 ships a built-in CONV020 ("Created object never persisted", mendixlabs/mxcli#1518) and has no duplicate-ID guard.** Captured on a public model (`tests/wave2/fixtures/lint-conv020-v028/`): v0.28.0 reported two CONV020 findings, one per rule, `-l` listed CONV020 twice, and `CONV020: enabled: false` switched both off. `bin/sync-project.sh` migrates projects: an unedited old copy (md5 of any shipped version, CR-insensitive, or the receipt row) is replaced, `lint-baseline.json` findings whose message is the toolkit rule's are rekeyed to MXTK-UX003 (the built-in's stay CONV020), and a `lint-rules-disabled.txt` line follows the file. A lint-config opt-out of CONV020 is carried over too: a `lint-rules-disabled.txt` line for the new file keeps MXTK-UX003 off (the key stays; on v0.28.0 it switches off only the built-in). It is read the way mxcli v0.28.0 reads it (yaml.v3, captured per spelling): `enabled:` `false`/`False`/`FALSE` or `n`/`no`/`off` in any of yaml.v3's casings (`No`, `NO`, `Off`, `OFF`…, plain or quoted), as a direct child of a bare, single- or double-quoted `CONV020` key or in its flow map, from the first of `.claude/lint-config.yaml`, `lint-config.yaml`, `.lint-config.yaml`; an `enabled` nested under `options:` and a quoted `"false"` (mxcli fails to load that config) are not opt-outs. An edited copy is left in place with a warning and manual steps (with the `lint-rules-disabled.txt` line to keep such an opt-out), and the new rule is not installed beside it. A counts-only baseline, `lint-config.yaml` keys and `PROJECT.md` lines that name CONV020 get a NOTE, not a guess, printed once. New toolkit rules take an `MXTK-` id (`lint-rules/README.md` "Rule IDs"). After any mxcli upgrade, `lint-gate.sh --update-baseline` now records `mxcli_version`, and `bin/doctor.sh` prints the re-baseline hint when the installed mxcli differs from the recorded one (a baseline with no recorded version gets a note, no command; `skills/lint-that-actually-runs.md`). Decision #12 — mxcli v0.28.0 test pass
2324
- process(routing, mdl-agent): **the mdl helper no longer preloads the kind-specific MDL skills at Stage 5; it loads them per script through `bin/mdl-rules.sh`** — lazy-load PR 2b. `learned-microflow-patterns.md`, `microflow-preflight.md` and `ui-preflight-pages.md` move to on-demand; `learned-mdl-preflight.md`, `design-spacing.md`, `learned-detection-gaps.md`, `security-is-not-a-later-script.md` and `retesting-learned-rules.md` become on-demand for mdl only (`mdl?`), unchanged for every other role; a new baseline `mdl-rules` row (Stages 5 and 6, so a Stage 6 mdl pack keeps the detection-gaps and retesting rules; mdl Stage 6 preload 9,476 words) replaces them. Baseline 72,891 → 63,877 words; mdl Stage 5 preload 29,388 → 14,600 (per script, after reading the `mdl-rules` output, the helper reads ≈21.4k words for page only, ≈22.5k for microflow+access, ≈28.1k for page+microflow, ≈29.5k for all kinds: ≈0–8k saved by kind; the A/B covered rule sufficiency with kinds pre-selected, not step 4a) (the 3,347-word core of `bin/mdl-rules.sh` counted, like the runbook slice). The mdl stub's workflow step 4a and report line ask for "STOP rows for kinds K: n/n checked". `context-pack.sh`'s "Rules" list names `<toolkit>/bin/mdl-rules.sh <kinds>` with the step's kinds (page / microflow / entity) — after this change the only route `microflow-preflight`, `security-is-not-a-later-script`, `design-spacing`, `learned-detection-gaps` and `retesting-learned-rules` have to a pack-fed helper (fixture: a page step and a logic step). `gate-check.sh` build-ready item 2 accepts `bin/mdl-rules.sh` as well as `ui-preflight-pages`, so a project synced after this change does not flip to FAIL (fixture: both layouts). No skill text changed — lazy-load plan, option C (maintainer)
2425
- new(mdl-rules): **`bin/mdl-rules.sh <kind...> | --for <script.mdl>` prints the MDL rules one script needs, by the kinds of statement it writes** — lazy-load PR 2a. `bin/lib/mdl-rule-kinds.tsv` maps page / microflow / access / workflow / entity / any to sections located by file and heading text (never line numbers): the STOP rows by their `Kind` column, the kind-specific gotchas, `learned-microflow-patterns.md`, `microflow-preflight.md`, `ui-preflight-pages.md`, `design-spacing.md` §1–3, the detection-gaps register rows by construct, `security-is-not-a-later-script.md` and `retesting-learned-rules.md`. Output is the core (Step 0, the `any` STOP rows, gotchas, final self-check: 3,347 words) plus the kind sections, closed by "Rules for kinds K: STOP rows n/n listed" (page 10,223 words, microflow 10,186, access 4,730, entity 5,183, workflow 3,732), against the 29,388 words the mdl helper preloads at Stage 5 today. `--for` reuses exec.sh's statement classifier (`exec.sh --subjects`; Java actions and mappings count as microflow, demo users as access) and exits 1 if it fails; a heading that is gone fails loudly, never prints empty. `skills/generated/rules-<kind>.md` are the same output for harnesses that read files (Copilot, Cursor, Windsurf); `--check` (CI) fails on drift, on any STOP row, gotcha, register row or `##` section that reaches no kind (a section left out on purpose needs an `exclude` row with its reason), and on any register ERE that matches no row by itself, and on an unclosed code fence in a file it reads. No routing change yet: PR 2b demotes the preloads. Behaviour suite: `build-packs.sh --rules "<kinds>"` expands the script in a pack. Fixture `tests/wave2/test-mdl-rules.sh` — lazy-load plan, option C (maintainer)

‎project-bin/install-project-hooks.sh‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ./bin/install-project-hooks.sh # install/refresh the pre-commit + revision-note hooks
55
# ./bin/install-project-hooks.sh --check # exit 1 if any is missing or stale
66
#
7-
# Two jobs. (1) pre-commit: the mxbuild-gate backstop below. (2) post-commit + post-rewrite: the
7+
# Three jobs. (1) pre-commit: the mxbuild-gate backstop below. (2) post-commit + post-rewrite: the
88
# Studio Pro revision note (2026-10-12, field project). Studio Pro's History pane lists a commit's
99
# changed documents from refs/notes/mx_metadata "ModelChanges", which only Studio Pro filled; CLI
1010
# commits showed "Model changes 0". These hooks run bin/mx-metadata-note.py write HEAD after every
@@ -21,6 +21,15 @@
2121
# push exactly as they do without this toolkit. The installer only fetches the remote's notes
2222
# (non-forced, creates the ref) and removes the exact pair that earlier revision wrote.
2323
#
24+
# (3) post-merge + post-checkout: the restatement-guard marker (#504). script-owners.sh's
25+
# last-writer ledger (.claude/loop/script-owners/written.tsv, machine-local) sees only exec.sh's
26+
# writes, so a `git pull` / `git checkout` that brought newer model work in left it saying "S wrote
27+
# last", and a replay of S over that work stayed quiet. When the operation changed the .mpr or
28+
# mprcontents/, the hook appends `<epoch> RESTORE git-<op>:<sha>` exactly as restore-mpr.sh does;
29+
# rows before it stop counting and the next replay check asks the model (DESCRIBE, at most 5).
30+
# A file checkout (`git checkout -- <paths>`) names no paths to the hook, so it always marks:
31+
# the cost is at most one capped DESCRIBE round. No ledger yet → nothing to mark. Never blocks.
32+
#
2433
# WHY (2026-09-17, field project). A model can change without the mxbuild gate ever running — a
2534
# bare `./mxcli exec`, an exec.sh run on a machine with no mxbuild, a Studio Pro save —
2635
# and until now every such change committed and pushed like any other. The hook refuses
@@ -53,6 +62,7 @@ MARK="# mxtk-model-stamp hook v1"
5362
BIN_REL="$(git rev-parse --show-prefix)bin"
5463

5564
NOTE_MARK="# mxtk-mx-metadata hook v1"
65+
RESTATE_MARK="# mxtk-restatement-mark hook v1"
5666
# Team Server remotes: the platform's git host, plus any remote named in MXTK_TS_REMOTES.
5767
# The configured URL, not `git remote -v`: that one shows the URL after url.*.insteadOf rewriting.
5868
ts_remotes() {
@@ -90,6 +100,10 @@ if [ "${1:-}" = "--check" ]; then
90100
if [ -f "$HOOKS/$h" ] && grep -q "^$NOTE_MARK" "$HOOKS/$h"; then echo " ✓ $h revision-note hook installed"
91101
else echo " ✗ $h revision-note hook missing — run ./bin/install-project-hooks.sh"; rc=1; fi
92102
done
103+
for h in post-merge post-checkout; do
104+
if [ -f "$HOOKS/$h" ] && grep -q "^$RESTATE_MARK" "$HOOKS/$h"; then echo " ✓ $h restatement-ledger hook installed"
105+
else echo " ✗ $h restatement-ledger hook missing — run ./bin/install-project-hooks.sh"; rc=1; fi
106+
done
93107
for r in $(ts_remotes); do
94108
case "$(leftover "$r")" in
95109
ours) echo " ✗ remote.$r.push still holds the HEAD + notes refspecs an earlier toolkit revision added — run ./bin/install-project-hooks.sh to remove them"; rc=1 ;;
@@ -192,6 +206,39 @@ HOOKEOF
192206
done
193207
echo " ✓ post-commit + post-rewrite revision-note hooks installed (Studio Pro History lists each commit's changed documents)"
194208

209+
# --- restatement-guard ledger marker (post-merge + post-checkout), #504 --------------------------
210+
# post-merge: $1 = 1 for a squash merge (HEAD unmoved: always mark), else the range ORIG_HEAD..HEAD.
211+
# post-checkout: $1 prev, $2 new, $3 = 1 for a branch checkout, 0 for a file checkout (no paths
212+
# given to the hook: always mark).
213+
for h in post-merge post-checkout; do
214+
HF="$HOOKS/$h"
215+
if [ -f "$HF" ] && ! grep -q "^$RESTATE_MARK" "$HF"; then
216+
mv "$HF" "$HF.pre-mxtk"
217+
echo " existing $h hook kept as $h.pre-mxtk and chained first"
218+
fi
219+
if [ "$h" = post-merge ]; then RANGE='ORIG_HEAD HEAD'; FILEMODE='[ "${1:-0}" = 1 ]'
220+
else RANGE='"$1" "$2"'; FILEMODE='[ "${3:-1}" = 0 ]'; fi
221+
cat > "$HF" <<HOOKEOF
222+
#!/usr/bin/env bash
223+
$RESTATE_MARK — written by bin/install-project-hooks.sh (mxcli-project-toolkit). Re-run it to refresh.
224+
# Marks the restatement guard's last-writer ledger when this $h changed the model (.mpr or
225+
# mprcontents/), so a replay of an older script asks the model again. Never fails the git operation.
226+
[ -x "\$(dirname "\$0")/$h.pre-mxtk" ] && "\$(dirname "\$0")/$h.pre-mxtk" "\$@"
227+
top="\$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
228+
ledger="\$top/$BIN_REL/../.claude/loop/script-owners/written.tsv"
229+
[ -f "\$ledger" ] || exit 0
230+
if $FILEMODE; then why="unscoped"
231+
else
232+
git diff --name-only $RANGE 2>/dev/null | grep -qE '(\.mpr\$|(^|/)mprcontents/)' || exit 0
233+
why="\$(git rev-parse --short HEAD 2>/dev/null)"
234+
fi
235+
printf '%s\tRESTORE\tgit-$h:%s\n' "\$(date +%s)" "\$why" >> "\$ledger" 2>/dev/null || true
236+
exit 0
237+
HOOKEOF
238+
chmod +x "$HF"
239+
done
240+
echo " ✓ post-merge + post-checkout restatement-ledger hooks installed (a pull/checkout that changes the model resets the last-writer ledger)"
241+
195242
for r in $(ts_remotes); do
196243
if ! has_notes && GIT_TERMINAL_PROMPT=0 git fetch -q "$r" refs/notes/mx_metadata:refs/notes/mx_metadata >/dev/null 2>&1; then
197244
echo " ✓ $r: fetched its refs/notes/mx_metadata (non-forced; Studio Pro's notes, so ours merge onto them)"

‎project-bin/script-owners.sh‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
# DESCRIBE per document (absent → nothing to revert, e.g. a fresh model), at most 5 per check; the
4040
# rest print as UNCHECKED. No ./mxcli → REVERT. The ledger knows only exec.sh's writes: restore-mpr.sh
4141
# appends a RESTORE row and rows before the last one are ignored (DESCRIBE decides again). It cannot
42-
# see a Studio Pro edit, nor a `git pull` / `git checkout` that changes the .mpr.
42+
# see a Studio Pro edit. A `git pull` / `git checkout` that changes the .mpr appends a RESTORE row
43+
# through the post-merge / post-checkout hooks bin/install-project-hooks.sh installs (#504).
4344
#
4445
# LEDGER: <root>/.claude/loop/script-owners/written.tsv, epoch <tab> key <tab> KIND Module.Name
4546
# (key = root-relative path, done- removed), or epoch <tab> RESTORE <tab> <snapshot>. MACHINE-LOCAL,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env bash
2+
# test-restatement-git-hooks.sh — #504: the restatement guard's last-writer ledger only saw exec.sh's
3+
# writes, so a `git pull` / `git checkout` that brought newer model work in left it saying "05 wrote
4+
# last" and a replay of 05 over 09's work stayed quiet. install-project-hooks.sh now installs
5+
# post-merge + post-checkout hooks that append a RESTORE row when the operation changed the model.
6+
#
7+
# Usage: bash tests/wave2/test-restatement-git-hooks.sh [path-to-install-project-hooks.sh]
8+
# script-owners.sh and _common.sh are taken from the same directory.
9+
# Real git (merge, fast-forward pull, branch checkout, file checkout), both layouts (.mpr at the
10+
# root, and two-tree app/App.mpr), a pre-existing foreign hook chained. No mxcli, no mxbuild.
11+
set -uo pipefail
12+
TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
13+
SUBJECT="${1:-$TK/project-bin/install-project-hooks.sh}"
14+
case "$SUBJECT" in /*) ;; *) SUBJECT="$PWD/$SUBJECT" ;; esac
15+
BIN="$(dirname "$SUBJECT")"
16+
WORK="$(mktemp -d "${TMPDIR:-/tmp}/restate-hooks.XXXXXX")" || exit 2
17+
trap 'rm -rf "$WORK"' EXIT
18+
PASS=0; FAIL=0
19+
ok() { PASS=$((PASS+1)); echo " ok — $1"; }
20+
bad() { FAIL=$((FAIL+1)); echo " FAIL — $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ /'; }
21+
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
22+
export MODEL_UNVERIFIED_OK=1 # the pre-commit gate hook is not what this fixture tests
23+
DOC="MICROFLOW Sales.ACT_Reject"
24+
25+
run_layout() { # $1 = label, $2 = model dir relative to the root ("" or "app")
26+
local L="$1" MD="$2" R="$WORK/$1" C="$WORK/$1-clone" lg out mdir cm
27+
echo "== layout: $L"
28+
mkdir -p "$R/bin"; cd "$R" || return
29+
git init -q -b main .
30+
cp "$BIN/_common.sh" "$BIN/install-project-hooks.sh" "$BIN/script-owners.sh" "$BIN/model-stamp.sh" bin/ 2>/dev/null
31+
mdir="$R${MD:+/$MD}"; mkdir -p "$mdir/mprcontents/aa"
32+
echo v1 > "$mdir/App.mpr"; echo u1 > "$mdir/mprcontents/aa/unit.mxunit"; echo doc > README.md
33+
printf '.claude/\n' > .gitignore
34+
git add -A >/dev/null; git commit -qm base
35+
# The clone is the local machine: it ran 05, so its ledger says 05 wrote last.
36+
git clone -q "$R" "$C"; cd "$C" || return
37+
git config user.name t; git config user.email t@t
38+
mkdir -p .claude/loop/script-owners; lg="$C/.claude/loop/script-owners/written.tsv"
39+
printf '100\tmdlsource/05-reject.mdl\t%s\n' "$DOC" > "$lg"
40+
printf '#!/usr/bin/env bash\necho foreign >> "%s/foreign.log"\n' "$WORK" > .git/hooks/post-merge; chmod +x .git/hooks/post-merge
41+
./bin/install-project-hooks.sh >"$WORK/$L-install.out" 2>&1 || bad "$L: install failed" "$(cat "$WORK/$L-install.out")"
42+
./bin/install-project-hooks.sh --check >/dev/null 2>&1 && ok "$L: --check sees all hooks" || bad "$L: --check" "$(./bin/install-project-hooks.sh --check 2>&1)"
43+
[ -f .git/hooks/post-merge.pre-mxtk ] && ok "$L: foreign post-merge kept and chained" || bad "$L: foreign hook not kept"
44+
out="$(PROJECT_ROOT="$C" bash bin/script-owners.sh last-writer "$DOC")"
45+
[ "$out" = "mdlsource/05-reject.mdl" ] && ok "$L: precondition — ledger says 05 wrote last" || bad "$L: precondition '$out'"
46+
47+
# 1. a pull that touches no model file: no marker
48+
cd "$R"; echo more >> README.md; git commit -qam docs; cd "$C"
49+
git pull -q --no-rebase origin main >/dev/null 2>&1
50+
grep -q RESTORE "$lg" && bad "$L: docs-only pull marked the ledger" || ok "$L: docs-only pull leaves the ledger alone"
51+
grep -q foreign "$WORK/foreign.log" 2>/dev/null && ok "$L: chained foreign hook ran" || bad "$L: chained foreign hook did not run"
52+
53+
# 2. #504's repro: upstream carries 09's work on the model; pull brings it in
54+
cd "$R"; echo "v2 (09 work)" > "$mdir/mprcontents/aa/unit.mxunit"; git commit -qam "09 work"; cd "$C"
55+
git pull -q --no-rebase origin main >/dev/null 2>&1
56+
grep -q 'RESTORE git-post-merge:' "$lg" && ok "$L: model-changing pull appends a RESTORE row" || bad "$L: no RESTORE row after pull" "$(cat "$lg")"
57+
out="$(PROJECT_ROOT="$C" bash bin/script-owners.sh last-writer "$DOC")"
58+
[ -z "$out" ] && ok "$L: last-writer forgets 05 → the next replay check asks the model" || bad "$L: last-writer still '$out'"
59+
60+
# 3. branch checkout: same model → nothing; different model → marker
61+
printf '200\tmdlsource/05-reject.mdl\t%s\n' "$DOC" >> "$lg"; n0="$(grep -c RESTORE "$lg")"
62+
git checkout -q -b side; n1="$(grep -c RESTORE "$lg")"
63+
[ "$n0" = "$n1" ] && ok "$L: checkout to a branch at the same commit adds nothing" || bad "$L: same-commit checkout marked"
64+
cm="$C${MD:+/$MD}"
65+
echo "side" > "$cm/App.mpr"; git commit -qam side; git checkout -q main
66+
[ "$(grep -c RESTORE "$lg")" -gt "$n1" ] && ok "$L: checkout that changes the .mpr marks" || bad "$L: model-changing checkout did not mark"
67+
# 4. file checkout (the documented remedy `git checkout HEAD -- mprcontents/`): always marks
68+
n2="$(grep -c RESTORE "$lg")"; echo dirty > "$cm/mprcontents/aa/unit.mxunit"
69+
git checkout -q HEAD -- "${MD:+$MD/}mprcontents"
70+
[ "$(grep -c RESTORE "$lg")" -gt "$n2" ] && ok "$L: file checkout marks (hook gets no paths)" || bad "$L: file checkout did not mark"
71+
[ "$(cat "$cm/mprcontents/aa/unit.mxunit")" = "v2 (09 work)" ] && ok "$L: file checkout restored the unit" || bad "$L: unit not restored"
72+
# 5. no ledger: never created by the hook
73+
rm -f "$lg"; git checkout -q side; git checkout -q main
74+
[ ! -f "$lg" ] && ok "$L: no ledger → hook creates none" || bad "$L: hook created a ledger"
75+
# 6. idempotent re-install
76+
./bin/install-project-hooks.sh >/dev/null 2>&1
77+
[ ! -f .git/hooks/post-merge.pre-mxtk.pre-mxtk ] && grep -c 'mxtk-restatement-mark' .git/hooks/post-merge | grep -qx 1 \
78+
&& ok "$L: re-install is idempotent" || bad "$L: re-install re-chained"
79+
}
80+
81+
run_layout single ""
82+
run_layout twotree "app"
83+
echo "== $PASS passed, $FAIL failed"
84+
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)