Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are.

## Unreleased

- feat(exec): **exec.sh refuses a script that CREATEs pages while more than 5 built pages were never looked at.** The LOOK was a printed notice plus a Stage 5 gate claim, so unattended builds wrote every page and opened no screenshot (#232, supersedes #188). New `look-ledger.sh unseen` (owed minus seen, PROOF-OF-LOOK reports and register waivers) and `creates`; exit 3 and a BUILD-LOG row, ALTER-only scripts and `--patch` always pass, a `-- PROOF-OF-LOOK` header line, `FORCE_EXEC=1`, `LOOK_OWED_MAX=<n>` (0 = off) or `MXTK_NO_LOOK_GUARD=1` lift it, and with no Read hook installed it warns once and steps aside. Fixture `test-exec-look-guard.sh` — MendixMau
- fix(exec): **The raw-exec guard matches the actual process, not the string "mxcli exec".** It now lists processes with a portable `mxtk_proc_list` (ps on macOS/Linux, PowerShell on Windows), skips its own ancestry and descendants by exact pid, and refuses only when a process is `mxcli ... exec` naming this model; a refusal now writes a BUILD-LOG row, and a platform with no process list warns and continues instead of the guard silently not existing. Opt out `MXTK_NO_RAW_GUARD=1`. Fixture `test-exec-raw-guard.sh`: decoy on another model and a parent that merely mentions the string pass, a decoy on this model refuses and logs (#228) — MendixMau
- perf(routing): **CLAUDE.local.md routing carries triggers only.** The `baseline` view (the copy loaded into every session of every project) now cuts each row's "when" cell at its first " — " and replaces the three-paragraph preamble with two lines pointing at `ROUTING.md` for the full text; `readme-baseline` (README, runbook) is unchanged. Fresh scaffold: CLAUDE.local.md 17.5 KB → 11.7 KB (−33%). Field run 2026-10-07 (existing app, one main session per run, 2 runs per side, an entity + 2 microflows + 2 pages): 10/10 steps applied each side, new mx errors 5 vs 5 (the same CE2421 page-binding slip in both), cost $1.39 vs $1.38; the slim lanes opened the MDL pre-flight skill as often or more. Also drops two stray merge-artifact lines from this section. — MendixMau
- perf(ui-loop): **fewer mxbuilds, no fixed sleeps, legible screenshots.** Field run 2026-10-07 (a requirements-driven build): `exec.sh` was 22% of tool time at two mxbuilds per exec, journeys and page waits slept a fixed time, and screenshots were 11.5% of re-read tokens. `exec.sh` now skips the pre-flight baseline mxbuild when the model is byte-identical to its last *clean* pass: the verification stamp gains an `errors:` field (`model-stamp.sh check --clean` accepts only `errors: 0`, so a delta-gate pass over pre-existing errors never counts; `SKIP_BASELINE=0` is unchanged). New `tests/e2e/settle.js` waits for the page's own state (no visible Mendix progress/loading indicator, DOM size stable) instead of a fixed time; `journey-runner.js` uses it after each action (an explicit `settleMs` still wins) and `page-audit.js` per page. Probed in a real browser: on a page that loads in 1.8 s the old 1200 ms sleep and networkidle+600 both captured it half-loaded, settle waited for it; on a fast page settle took 775 ms vs 1200. `SETTLE_MODE=fixed` restores the old waits for an A/B run. `page-audit.js` also saves a viewport shot `page-audit-<Page>.top.png` beside the full-page one, and `ui-loop.md` says to look at viewport shots: a tall full-page shot reaches the model at ~1024 px high, text unreadable. `shrink-image-read.sh` now works off macOS (ImageMagick or Pillow, python when jq is absent; it was a silent no-op without `sips`). — MendixMau
- perf(ui-loop): **a stale app reloads instead of restarting, journeys stop sleeping, worst pages are looked at first.** Field run 2026-10-07, existing app. `test-stack-up.sh` records which model the running app was built from (`.claude/loop/served-model`); when the model has changed since, it hot-reloads with `mxcli docker reload` (adding `--skip-check` when the exec.sh gate already passed this model) and restarts only if that fails: 93 s instead of 179 s. `ui-loop.md` and the script header carry the measured table and point at `mxcli run --local --watch` as the fast loop (~18 s per page change). `journey-runner.js` waits on page state after a combobox action instead of fixed pauses, and a combobox step now opens the widget it names: it used to open the page's first combobox, so on a form with three every step but the first failed. `page-audit.js` clicks the first copy of a nav item a user could actually click, not just the first match: with the sidebar collapsed, every in-nav page faulted while the same item sat in the top menu bar. It also writes `page-audit-look-order.txt`, all pages worst first (`module-review.md` §4a: order, not selection, every page is still looked at). `design-audit.js` drops its fixed 1.2 s wait. Same 7-step journey on the same app: 56 s with `SETTLE_MODE=fixed`, 24 s with settle, 11/11 pass both ways, 3 runs each. — MendixMau
Expand Down
54 changes: 54 additions & 0 deletions project-bin/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -787,3 +787,57 @@ mxtk_sha256() {
else openssl dgst -sha256 | sed 's/^.*= //'
fi
}

# ---------------------------------------------------------------------------
# mxtk_proc_list — every process as "pid<TAB>ppid<TAB>args", one per line, from ONE snapshot;
# returns 1 when the platform gives no list (the caller must then warn and carry on, never block).
#
# WHY (#228). exec.sh's raw-exec guard used `pgrep -fl "mxcli exec"`: a substring search of
# every process on the machine, which also matched the caller's own `bash -c '... mxcli exec
# ...'` line, other projects' execs and greps, and which does not exist under Git Bash.
# This lists processes so the caller can compare argv exactly. macOS and Linux share
# `ps -A -o pid=,ppid=,args=`; Windows asks PowerShell (Git Bash's own ps shows MSYS pids only).
# ---------------------------------------------------------------------------
mxtk_proc_list() {
case "$(mxtk_platform)" in
windows)
command -v powershell.exe >/dev/null 2>&1 || return 1
powershell.exe -NoProfile -Command \
"Get-CimInstance Win32_Process | ForEach-Object { \"\$(\$_.ProcessId)\`t\$(\$_.ParentProcessId)\`t\$(\$_.CommandLine)\" }" \
2>/dev/null | tr -d '\r'
;;
*)
command -v ps >/dev/null 2>&1 || return 1
ps -A -o pid=,ppid=,args= 2>/dev/null \
| sed -E 's/^[[:space:]]*([0-9]+)[[:space:]]+([0-9]+)[[:space:]]+/\1 \2 /'
;;
esac
}

# mxtk_raw_exec_on <model.mpr> — print "pid<TAB>args" for each process that is `mxcli exec`
# against that model and is NOT this shell, one of its ancestors, or one of its descendants
# (the $(...) subshells running this very check appear in ps under the caller's argv). Pids
# are compared exactly, so 123 never hides 1234. Exit 0 = list obtained (output may be empty),
# 1 = no process list available.
mxtk_raw_exec_on() {
local mpr="$1" list
list=$(mxtk_proc_list) || return 1
[ -n "$list" ] || return 1
printf '%s\n' "$list" | awk -F'\t' -v me="$$" -v mpr="$mpr" -v base="$(basename "$mpr")" '
{ pp[$1]=$2; args[$1]=$3 }
END {
skip[me]=1; down[me]=1
for (p=me; p in pp && p > 1 && !(pp[p] in skip); p=pp[p]) skip[pp[p]]=1
for (r=0; r<6; r++) for (k in pp) if (pp[k] in down) { down[k]=1; skip[k]=1 }
for (k in pp) {
if (k in skip) continue
a=args[k]; m=split(a, w, /[[:space:]]+/)
b=w[1]; gsub(/"/, "", b); sub(/.*[\/\\]/, "", b)
if (b != "mxcli" && b != "mxcli.exe") continue
hasexec=0; for (i=2;i<=m;i++) if (w[i]=="exec") hasexec=1
if (!hasexec) continue
if (index(a, mpr) || index(a, base)) print k "\t" a
}
}'
return 0
}
62 changes: 58 additions & 4 deletions project-bin/exec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,27 @@ if [ -f "$LOCK" ]; then
fi
fi

# 3. No stray raw `mxcli exec` from another session.
if pgrep -fl "mxcli exec" 2>/dev/null | grep -qv "$$"; then
echo "✗ A raw 'mxcli exec' is already running elsewhere — refusing to write concurrently."
[ "$FORCE" = "1" ] || exit 1
# 3. No raw `mxcli exec` against THIS model running outside the wrapper (#228).
# Matches argv exactly (argv0 is mxcli, `exec` is an argument, the args name this .mpr)
# and skips this shell's own ancestry, so a caller whose command line merely mentions
# "mxcli exec", or an exec on another project's model, no longer refuses a correct run.
# A refusal is logged. If no process list can be had (no ps, no PowerShell) the check
# warns once and carries on: a check that cannot run must not block (rules 6/7).
# Opt out: MXTK_NO_RAW_GUARD=1. Override a genuine hit: FORCE_EXEC=1.
if [ "${MXTK_NO_RAW_GUARD:-0}" != "1" ]; then
RAW_HIT="$(mxtk_raw_exec_on "$MPR")" && RAW_RC=0 || RAW_RC=$?
if [ "$RAW_RC" -ne 0 ]; then
echo " (raw-exec guard skipped: no process list on this platform — step 2's lock is still in force)"
elif [ -n "$RAW_HIT" ]; then
echo "✗ A raw 'mxcli exec' is already running against $MPR_BASE — refusing to write concurrently."
printf '%s\n' "$RAW_HIT" | head -3 | sed 's/^/ pid /'
echo " → Wait for it, or kill it if it is stale. Override (NOT recommended): FORCE_EXEC=1 ./bin/exec.sh $SCRIPT"
if [ "$FORCE" != "1" ]; then
log_build "🚫 blocked" "raw mxcli exec already running against $MPR_BASE (pid $(printf '%s\n' "$RAW_HIT" | head -1 | cut -f1))"
exit 1
fi
echo " (FORCE_EXEC set — proceeding despite raw exec)"
fi
fi

# 4. Uncommitted model changes. The snapshot taken below would not cover them,
Expand All @@ -218,6 +235,43 @@ if [ -n "$MPR_DIRTY" ]; then
echo " (FORCE_EXEC set — proceeding despite uncommitted changes)"
fi

# 4b. LOOK debt (#232, supersedes #188). Every page a script builds is owed a LOOK
# (look-ledger.sh); until now that was a notice, and two unattended builds wrote every page
# without opening one screenshot. So: a script that CREATEs pages is refused while more than
# LOOK_OWED_MAX (default 5, 0 = off) built pages are still unseen. Not blocked, so fixes stay
# possible (rule 7): ALTER-only scripts, --patch, FORCE_EXEC=1, MXTK_NO_LOOK_GUARD=1. Evidence
# the guard did not create counts (rule 6): a PROOF-OF-LOOK citation in a ui-review report, a
# `Waived obligation look/...` register line, or a `-- PROOF-OF-LOOK:` line in this script's
# first 30 lines (the agent's statement that it looked, for the pages the script follows up).
# Without the Read hook that writes seen.tsv no page can ever be marked seen, so the guard
# warns once and steps aside instead of stalling the build.
LOOK_LEDGER="$(dirname "$0")/look-ledger.sh"
if [ "$PATCH_MODE" != "1" ] && [ "${MXTK_NO_LOOK_GUARD:-0}" != "1" ] && [ -f "$SCRIPT" ] && [ -f "$LOOK_LEDGER" ] \
&& [ "${LOOK_OWED_MAX:-5}" -gt 0 ] 2>/dev/null; then
if [ -n "$(bash "$LOOK_LEDGER" creates "$SCRIPT" 2>/dev/null)" ]; then
if ! grep -qs 'look-ledger.sh seen' "$PROJECT_ROOT/.claude/settings.json" "$PROJECT_ROOT/.claude/settings.local.json"; then
if [ ! -f "$PROJECT_ROOT/.claude/loop/look/.nohook-warned" ]; then
echo " (LOOK guard off: no Read hook records screenshots here — re-run install-claude-permissions.sh / sync-project.sh to enable it)"
mkdir -p "$PROJECT_ROOT/.claude/loop/look" 2>/dev/null && : > "$PROJECT_ROOT/.claude/loop/look/.nohook-warned" 2>/dev/null || true
fi
elif ! head -30 "$SCRIPT" | grep -q 'PROOF-OF-LOOK'; then
LOOK_UNSEEN="$(bash "$LOOK_LEDGER" unseen 2>/dev/null)"
LOOK_N=0; [ -n "$LOOK_UNSEEN" ] && LOOK_N=$(printf '%s\n' "$LOOK_UNSEEN" | wc -l | tr -d ' ')
if [ "$LOOK_N" -gt "${LOOK_OWED_MAX:-5}" ]; then
echo "✗ $LOOK_N built page(s) were never looked at (limit ${LOOK_OWED_MAX:-5}) — refusing a script that builds more pages."
printf '%s\n' "$LOOK_UNSEEN" | head -8 | sed 's/^/ /'
echo " → Screenshot each, open the PNG (file name containing the page name), compare it to its wireframe (skills/ui-loop.md)."
echo " Fixes to those pages (ALTER PAGE) still run. Or: --waive look/<Module> --reason \"...\" · a '-- PROOF-OF-LOOK: <what you saw>' header line · FORCE_EXEC=1 · MXTK_NO_LOOK_GUARD=1"
if [ "$FORCE" != "1" ]; then
log_build "🚫 blocked" "$LOOK_N built pages never looked at (limit ${LOOK_OWED_MAX:-5}) — script creates more pages"
exit 3
fi
echo " (FORCE_EXEC set — proceeding despite unseen pages)"
fi
fi
fi
fi

# 5. Module brief advisory (WARNS, never blocks).
#
# WHY THE BRIEF. It is the mdl-agent's single per-module input: the access-table slice,
Expand Down
47 changes: 43 additions & 4 deletions project-bin/look-ledger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# bin/look-ledger.sh owe <script.mdl> called by bin/exec.sh after a script lands
# bin/look-ledger.sh seen PostToolUse(Read) hook; reads the hook JSON on stdin
# bin/look-ledger.sh status prints one line per owed page: SEEN or OWED
# bin/look-ledger.sh unseen owed pages nothing accounts for (exec.sh's LOOK guard reads this)
# bin/look-ledger.sh creates <script> pages a script CREATEs (not ALTERs)
#
# WHY (2026-10-02). Two unattended builds, run side by side with the same toolkit, built every
# page and never once opened a screenshot of one. Both ran page-fidelity.js, a text score, and
Expand All @@ -16,8 +18,9 @@
# owed — exec.sh writes one row per page a script CREATEs or ALTERs, when the script lands.
# seen — the Read hook writes one row per image file the agent actually opened.
# gate-check.sh's Stage 5 check joins them: a page built after its last seen screenshot FAILs
# the Stage 5 "done" claim. Nothing here blocks a write, a script, or the next page — only the
# claim that a module is done while a page in it has never been looked at.
# the Stage 5 "done" claim. Since #232 bin/exec.sh also refuses a script that CREATEs pages while
# more than LOOK_OWED_MAX are unseen (`unseen` below); fixes (ALTER PAGE) always run. Otherwise
# nothing here blocks a write — only the claim that a module is done while a page in it has never been looked at.
#
# How a screenshot names its page: the image's file name must contain the page name, compared
# with case and punctuation removed — Orders.Order_Overview is seen by order-overview.png,
Expand Down Expand Up @@ -58,13 +61,14 @@ _sha() {
# either case, each half of the name quoted or bare. Block comments and -- lines are skipped so a
# commented-out page is not owed.
look_pages_in() {
awk '
awk -v creates_only="${LOOK_CREATES_ONLY:-0}" '
/^[[:space:]]*\/\*/ { inc=1 }
inc { if ($0 ~ /\*\//) inc=0; next }
/^[[:space:]]*--/ { next }
{ l=toupper($0) }
l ~ /^[[:space:]]*(CREATE([[:space:]]+OR[[:space:]]+(REPLACE|MODIFY))?|ALTER)[[:space:]]+PAGE[[:space:]]/ {
s=$0; sub(/^[[:space:]]*/, "", s)
if (creates_only == 1 && toupper(s) ~ /^ALTER[[:space:]]/) next
n=split(s, w, /[[:space:]]+/)
for (i=1; i<=n; i++) if (toupper(w[i])=="PAGE") { name=w[i+1]; break }
sub(/[(;{].*$/, "", name); gsub(/"/, "", name)
Expand Down Expand Up @@ -147,11 +151,46 @@ look_status() {
}' "$OWED" 2>/dev/null | sort -k2,2
}

# look_unseen — the owed pages nothing accounts for, one Module.Page per line (exec.sh refuses on
# the count; gate-check.sh Stage 5 computes the same join inline). A page is accounted for by a
# screenshot the Read hook saw (status SEEN), a PROOF-OF-LOOK citation in a ui-review report
# (screenshot >=10KB, newer than the build) — evidence this script did not create — or a
# `Waived obligation look/<Module or Module.Page>` register line.
look_unseen() {
local st page epoch rep line shot f sz mt proofs="" reg="$PROJECT_ROOT/PROJECT.md"
for rep in "$PROJECT_ROOT"/design/ui-reviews/ui-review-*.html; do
[ -f "$rep" ] || continue
while IFS= read -r line; do
[ -n "$line" ] || continue
page=$(printf '%s' "$line" | sed 's/^PROOF-OF-LOOK:[[:space:]]*//;s/[[:space:]]*=.*$//')
shot=$(printf '%s' "$line" | sed 's/^.*=[[:space:]]*//')
case "$shot" in /*) f="$shot" ;; *) f="$(dirname "$rep")/$shot" ;; esac
[ -f "$f" ] || continue
sz=$(wc -c < "$f" 2>/dev/null | tr -d ' '); [ "${sz:-0}" -ge 10240 ] || continue
proofs="$proofs$page $(_mtime "$f")
"
done <<EOF
$(tr -d '\r' < "$rep" | grep -oE 'PROOF-OF-LOOK:[[:space:]]*[^=<]+=[[:space:]]*[^<>[:space:]]+')
EOF
done
while IFS=$'\t' read -r st page epoch; do
[ "$st" = OWED ] || continue
if [ -n "$proofs" ] && printf '%s' "$proofs" \
| awk -F'\t' -v p="$page" -v e="$epoch" '$1==p && $2+0>=e+0 {f=1} END {exit !f}'; then continue; fi
if [ -f "$reg" ] && grep -qiE "Waived obligation look(/${page%%.*}(\.${page#*.})?)?[[:space:]]*:" "$reg" 2>/dev/null; then continue; fi
echo "$page"
done <<EOF
$(look_status)
EOF
}

case "${1:-}" in
owe) shift; cmd_owe "$@" ;;
seen) cmd_seen ;;
status) look_status ;;
pages) shift; look_pages_in "${1:-/dev/null}" ;;
*) echo "usage: $0 owe <script.mdl> | seen < hook-json | status | pages <script.mdl>" >&2; exit 2 ;;
creates) shift; LOOK_CREATES_ONLY=1 look_pages_in "${1:-/dev/null}" ;;
unseen) look_unseen ;;
*) echo "usage: $0 owe <script.mdl> | seen < hook-json | status | unseen | pages|creates <script.mdl>" >&2; exit 2 ;;
esac
exit 0
Loading
Loading