diff --git a/CHANGELOG.md b/CHANGELOG.md index df3d67a9..ba0a260e 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 +- 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=` (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-.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 diff --git a/project-bin/_common.sh b/project-bin/_common.sh index 3a58af7a..2e45e32d 100755 --- a/project-bin/_common.sh +++ b/project-bin/_common.sh @@ -787,3 +787,57 @@ mxtk_sha256() { else openssl dgst -sha256 | sed 's/^.*= //' fi } + +# --------------------------------------------------------------------------- +# mxtk_proc_list — every process as "pidppidargs", 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 — print "pidargs" 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 +} diff --git a/project-bin/exec.sh b/project-bin/exec.sh index 5e84a87f..4c88740e 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -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, @@ -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/ --reason \"...\" · a '-- PROOF-OF-LOOK: ' 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, diff --git a/project-bin/look-ledger.sh b/project-bin/look-ledger.sh index 3ae98979..b014de08 100755 --- a/project-bin/look-ledger.sh +++ b/project-bin/look-ledger.sh @@ -4,6 +4,8 @@ # bin/look-ledger.sh owe 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