Skip to content

Commit f741b42

Browse files
committed
Merge #362: fix(close-task): resolve checkpoint.sh instead of printing it bare (#358)
Review: independent review MERGE-AFTER-FIX (printed paths broke on spaces; hooks copy printed a bare name with ~/.claude/bin off PATH) → shq() quoting + sibling full path in 6164bc6; confirmation MERGE: printed line executed correctly for paths with space, $HOME, backtick, !, ', ", \. Fixture 14/14 (11 fail on master). Closes community issue #358 (joshuamoesa). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
2 parents 2ef2b22 + 6164bc6 commit f741b42

5 files changed

Lines changed: 157 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

2222
- learn(learned-detection-gaps, testing-shape, ui-loop): **a class on a snippet call never reaches the DOM; a 403 bounce and a widget-pinned session probe both read as sign-outs; app restarts matched a "signs me out" report by timing.** Detection-gaps row: `Class` on a `SNIPPETCALL` is green through check, exec, mxbuild, lint and DESCRIBE, and absent in the browser ([#336](https://github.com/MendixMau/mxcli-project-toolkit/issues/336)). Testing-shape §4 gains two false-red rows: `login.html?messageCode=403` with the session kept ([#338](https://github.com/MendixMau/mxcli-project-toolkit/issues/338)), and instruments pinned to one grid name or a typed menu list (1/7 and 7/34 false FAILs after a redesign, [#339](https://github.com/MendixMau/mxcli-project-toolkit/issues/339)). ui-loop: say so before a restart while a human uses the app ([#337](https://github.com/MendixMau/mxcli-project-toolkit/issues/337)) — three-app field run, 2026-10-14
2323
- log(bug-logs): **upstream draft for `mxcli run --local --watch`: a theme-only rebuild deletes `dist/pages` and still reports a successful reload** ([#284](https://github.com/MendixMau/mxcli-project-toolkit/issues/284)). `bug-logs/pending-github-issues/watch-theme-rebuild-drops-dist-pages.md`, status DRAFT — NOT FILED: the repro (one SCSS edit, every page 404s on `dist/pages/<Page>.js`, a restart restores all 135 bundles; twice in one app on mxcli v0.26.0 / Mendix 11.15.0), the other change classes watch serves stale, the timing table (reload 35 builds median 3.8 s, restart 44 builds median 19.2 s, max 55.2 s, 928 s total) and the upstream questions: why a restart is about 5× a reload, and whether a recreated microflow keeps its old compiled version by name. The toolkit side is already on master (exec.sh restart verdicts, `app-restart.sh`, ui-loop.md "What watch picks up"). Field evidence: the 2026-10-12 three-app UX run and its 2026-10-14 recurrence, as recorded in #284. — field retro #284, MendixMau
24+
- fix(close-task): **`close-task.sh` resolves `checkpoint.sh` instead of printing it bare.** The script ships at `claude-hooks/bin/checkpoint.sh` and reaches `~/.claude/bin` only via the opt-in `bin/install-claude-hooks.sh --basic`, so a project without the hooks got a `checkpoint.sh --write` step it could not run. `close-task.sh` now looks in PATH, then `~/.claude/bin`, then `$MXTK_ROOT/claude-hooks/bin`, and prints the full path it found, single-quoted so a path with spaces survives copy-paste. If none is found it prints `checkpoint.sh not installed — run <toolkit>/bin/install-claude-hooks.sh --basic, or skip: RESUME.md is the resume source` and carries on; the exit code still means only "writes owed". `close-the-loop.md` step 4 says the same. The `~/.claude/bin` copy (`claude-hooks/bin/close-task.sh`) now prints its sibling `checkpoint.sh` by full quoted path, because `~/.claude/bin` is usually not on PATH. Fixture `tests/wave2/test-close-task-checkpoint.sh` (14 cases, including a space and `'` in HOME and the toolkit path; 11 fail on master). Field evidence: the reporter's existing-app run, where the closing agent had to skip the step with no pointer. — joshuamoesa (#358)
2425
- process(build): **Stage 5 drafts the ready set in parallel and applies serially.** The runbook dispatch row said "sequential within a module", which serialised drafting even for independent scripts; it now points to `iterative-build-loop.md` → Gate cost, the single statement of the rule. The unit is the build-plan row: every `BUILD` row whose "Depends on" rows are all applied, minus overlapping writers (two rows that replace, alter or drop+create the same document never share a ready set, and no row is drafted while it overlaps a queued unapplied one; `brd-to-build-plan.md` has Produces name every document a row writes), runs at once, its scripts in order, one `mdl-agent` per script. Applies stay one at a time through `bin/exec.sh`, and the ready set is recomputed after each. A queued draft made before a sibling apply landed is re-checked with `mxcli diff --format struct` (preflight STOP rule 23); any removal line (checked against `describe` per BUG-146) or STOP rule 21 target means re-draft, never patch, because a stale restatement of a shared document is exactly rule 21's silent seed-call loss and passes `check --references`. Report `ready set N, drafted N, applied k`; the `mdl-agent` template says a drafter never applies and never depends on an unapplied draft. Field evidence: none yet. — MendixMau
2526
- fix(platform-link): **CLI commits now show their changed documents in Studio Pro History.** Studio Pro's History → Model changes pane reads a `ModelChanges` array from the commit's `refs/notes/mx_metadata` note. Trap 6c's hand-written template set it to `[]`, so every mxcli/agent commit showed "Model changes 0" (field project: 270 of 270 notes empty). New `project-bin/mx-metadata-note.py` (stdlib only) computes the array from the commit's changed `.mxunit` files, in Studio Pro's entry shape: Status, UnitID, UnitType, UnitName and Module (walked up the `.mpr` Unit tree). It has `changes` / `write` / `backfill` / `sync <remote>` subcommands. `install-project-hooks.sh` now also installs post-commit and post-rewrite hooks that write the note and never block, and `--check` covers them, so `sync-project.sh` rolls them out to wired projects. Notes are published with `mx-metadata-note.py sync <remote>` after the branch push. Push config is never touched. With mxcli v0.26+ in the project root the hooks call `mxcli git note --write` instead (mendixlabs/mxcli#1337); the Python script is the fallback for older pins, and on 160 field commits both wrote the same 3,594 entries. Trap 6c rewritten. Measured against Studio Pro's own notes, 51 commits in 5 apps: 3387/3389 entries exact on all five fields. Field run on a 357-commit Mendix 11.15.0 app: 158 notes backfilled and pushed, and Studio Pro History lists the documents per revision. Review fixes: an earlier revision added `HEAD` + the notes ref to `remote.<r>.push`. It was dropped because any push refspec replaces `push.default`. A branch tracking a differently named upstream got a new same-named branch on Team Server where `simple` refuses, a missing local notes ref failed the whole push, and nobody knows whether Studio Pro's own push reads that config. The installer now only fetches the remote's notes (non-forced). It removes that exact `HEAD` + notes pair if present, and leaves and reports any other entry. `sync` handles a remote with no notes ref (it used to exit 1) and retries a rejected push once after re-merging. A note that already lists changes (Studio Pro's) is never overwritten by `write`/`backfill` without `--force`, and `sync` keeps a colleague's non-empty note over ours. The mxcli probe greps `git note --help` for `--write`. Hooks and docs call the script through `resolve_py`. Fixture `tests/wave2/test-mx-metadata-note.sh` (40 cases, over real captured template units; single- and two-tree layouts, amend, backfill and `--force`, push exit codes identical to a no-hooks clone, leftover-refspec cleanup, sync to an empty remote and its retry, notes fetch at install, a diverged notes merge; green under `init.defaultBranch` master and main). — field project (Mendix 11.15 app), MendixMau
2627
- learn(fixture-seeding): **explore the data structure first, choose rows by use case, scan actions for resets before any run.** Two seeding runs on public template apps: seeding raised journey passes 33 -> 58 and 20 -> 51 of the same sets (FAIL 93 -> 68, 163 -> 132; INVALID unchanged), and a "Reset all demodata" journey wiped accounts so 104 of 127 journeys reported `The username or password you entered is incorrect`, identical on empty and seeded databases. Skill gains three questions a seed must answer, a use-case checklist, a destructive-action scan, a generic traps table with symptoms, measured expectations and a denominator checklist; routing trigger now fires on seed/test/demo data and empty pages. Independent review fixes: object ids follow the Mendix bigint pattern with a business-key guard (no uuids), associations are seeded where the page's path stores them, four new traps (enum names, inheritance chain, calculated attributes, view/external entities), and the port-offset and container-label traps moved to `harness-architecture.md`. — MendixMau

‎claude-hooks/bin/close-task.sh‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,16 @@ cat <<'TABLE'
104104
Rules: verify before filing · one place only, link don't copy · a committed script is not
105105
an executed script.
106106
107-
### Then
108-
checkpoint.sh --write # and fill the Narrative (append-only history)
107+
TABLE
108+
109+
# checkpoint.sh is installed beside this script in ~/.claude/bin, which is usually not on PATH:
110+
# print the sibling's full path, single-quoted so a path with spaces survives copy-paste (#358).
111+
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
112+
_ck="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/checkpoint.sh"
113+
if [ -f "$_ck" ]; then _ck=$(shq "$_ck"); else _ck="checkpoint.sh"; fi
114+
echo "### Then"
115+
echo " $_ck --write # and fill the Narrative (append-only history)"
116+
cat <<'TABLE'
109117
OVERWRITE docs/progress/RESUME.md # <= 60 lines: where we are, next actions,
110118
# do-not-lose, and which file to read for what.
111119
# This is the ONLY file read after /clear.

‎project-bin/close-task.sh‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,31 @@ cat <<'TABLE'
138138
Rules: verify before filing · one place only, link don't copy · a committed script is not
139139
an executed script.
140140
141-
### Then
142-
checkpoint.sh --write # and fill the Narrative (append-only history)
141+
TABLE
142+
143+
# ---- checkpoint.sh: resolve it, never call it bare ---------------------
144+
# checkpoint.sh ships in the toolkit at claude-hooks/bin/ and reaches ~/.claude/bin only via the
145+
# opt-in `bin/install-claude-hooks.sh --basic`; init-project does not install it. Printing it bare
146+
# sent agents to a command that did not exist on their machine (#358). Look in PATH, then
147+
# ~/.claude/bin, then the toolkit clone; print the full path found, or say how to get it. A
148+
# missing checkpoint.sh is not an owed write: it never changes the exit code.
149+
CKPT_BIN=$(command -v checkpoint.sh 2>/dev/null)
150+
for _c in "${HOME:-/nonexistent}/.claude/bin/checkpoint.sh" "$MXTK_ROOT/claude-hooks/bin/checkpoint.sh"; do
151+
[ -n "$CKPT_BIN" ] && break
152+
[ -f "$_c" ] && CKPT_BIN="$_c"
153+
done
154+
# Single-quote a path so the printed command survives copy-paste with spaces (e.g. a HOME such as
155+
# "John Doe"); an embedded ' becomes '\''. sed, not ${x//}, for bash 3.2.
156+
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
157+
echo "### Then"
158+
if [ -n "$CKPT_BIN" ]; then
159+
echo " $(shq "$CKPT_BIN") --write # and fill the Narrative (append-only history)"
160+
else
161+
if [ -d "$MXTK_ROOT" ]; then _inst=$(shq "$MXTK_ROOT/bin/install-claude-hooks.sh")
162+
else _inst="<toolkit>/bin/install-claude-hooks.sh"; fi
163+
echo " # checkpoint.sh not installed — run $_inst --basic, or skip: RESUME.md is the resume source"
164+
fi
165+
cat <<'TABLE'
143166
OVERWRITE docs/progress/RESUME.md # <= 60 lines: where we are, next actions,
144167
# do-not-lose, and which file to read for what.
145168
# This is the ONLY file read after /clear.

‎skills/close-the-loop.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ forgotten* — they are on disk, not in working memory.
110110
3. **Drain the `UNFILED:` lines** from the previous checkpoint — route or delete.
111111
4. **Checkpoint**: `checkpoint.sh --write`, fill the Narrative, including any new
112112
`UNFILED:` lines. This file is append-only history.
113+
`close-task.sh` prints the full path it found (PATH, then `~/.claude/bin`, then the
114+
toolkit's `claude-hooks/bin/`). If it says `checkpoint.sh not installed`: run
115+
`<toolkit>/bin/install-claude-hooks.sh --basic`, or skip this step: `RESUME.md` is the resume source.
113116
5. **`mxcli brain check -p <app.mpr>`** — `capture` and `promote` do not validate anchors;
114117
only `check` does. Exit 1 means a promoted entry points at something the model no longer
115118
has: fix the anchor or `brain drop` the entry before anything is committed.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
# Fixture for #358: close-task.sh must resolve checkpoint.sh, never print it bare.
3+
#
4+
# usage: test-close-task-checkpoint.sh /path/to/project-bin/close-task.sh
5+
#
6+
# checkpoint.sh lives in the toolkit at claude-hooks/bin/ and reaches ~/.claude/bin only via
7+
# the opt-in `bin/install-claude-hooks.sh --basic`. The old "### Then" block printed
8+
# `checkpoint.sh --write` unconditionally, so a project without the hooks got a command that
9+
# did not exist. Lookup order: PATH, ~/.claude/bin, $MXTK_ROOT/claude-hooks/bin.
10+
#
11+
# T1 found on PATH -> prints that full path
12+
# T2 found in ~/.claude/bin -> prints that full path (fake HOME)
13+
# T3 found in toolkit -> prints $MXTK_ROOT/claude-hooks/bin/checkpoint.sh
14+
# T4 PATH beats ~/.claude/bin and toolkit
15+
# T5 not found -> install pointer, rest of the block still printed,
16+
# exit code unchanged (0 on a clean project)
17+
# T6 not found, writes owed -> still exit 1 (owed-write semantics kept)
18+
# T7 paths with a space (and a ') in HOME / toolkit -> printed single-quoted, and the printed
19+
# command actually runs when pasted into a shell
20+
# T8 the claude-hooks/bin/close-task.sh copy prints its sibling checkpoint.sh full path, quoted
21+
22+
set -uo pipefail
23+
24+
SUT="${1:?usage: test-close-task-checkpoint.sh /path/to/project-bin/close-task.sh}"
25+
case "$SUT" in /*) ;; *) SUT="$PWD/$SUT" ;; esac
26+
[ -f "$SUT" ] || { echo "no such file: $SUT" >&2; exit 2; }
27+
28+
TMP=$(mktemp -d)
29+
trap 'rm -rf "$TMP"' EXIT
30+
31+
pass=0; fail=0
32+
ok() { pass=$((pass+1)); echo " PASS $1"; }
33+
bad() { fail=$((fail+1)); echo " FAIL $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ | /'; }
34+
35+
# A project that is not a git repo, with the script installed as bin/close-task.sh (no
36+
# toolkit next to it, so the "installed-from" fallback cannot resolve a toolkit root).
37+
PROJ="$TMP/proj"; mkdir -p "$PROJ/bin"
38+
cp "$SUT" "$PROJ/bin/close-task.sh"; chmod +x "$PROJ/bin/close-task.sh"
39+
40+
# A clean PATH: system tools only, no checkpoint.sh.
41+
SYSPATH="/usr/bin:/bin"
42+
mk_ckpt() { mkdir -p "$1"; printf '#!/bin/sh\nexit 0\n' > "$1/checkpoint.sh"; chmod +x "$1/checkpoint.sh"; }
43+
44+
EMPTYHOME="$TMP/home-empty"; mkdir -p "$EMPTYHOME"
45+
NOTK="$TMP/no-toolkit"; mkdir -p "$NOTK"
46+
47+
run() { # $1=HOME $2=PATH $3=MXTK_ROOT [args...]
48+
local h="$1" p="$2" m="$3"; shift 3
49+
( cd "$PROJ" && env -i HOME="$h" PATH="$p" MXTK_ROOT="$m" GIT_CEILING_DIRECTORIES="$TMP" \
50+
bash "$PROJ/bin/close-task.sh" "$@" 2>&1 )
51+
}
52+
53+
echo "T1 found on PATH"
54+
mk_ckpt "$TMP/pathbin"
55+
out=$(run "$EMPTYHOME" "$TMP/pathbin:$SYSPATH" "$NOTK")
56+
grep -qF " '$TMP/pathbin/checkpoint.sh' --write" <<<"$out" && ok "T1 prints PATH hit" || bad "T1 prints PATH hit" "$(grep -n -A2 '### Then' <<<"$out")"
57+
58+
echo "T2 found in ~/.claude/bin"
59+
H2="$TMP/home2"; mk_ckpt "$H2/.claude/bin"
60+
out=$(run "$H2" "$SYSPATH" "$NOTK")
61+
grep -qF " '$H2/.claude/bin/checkpoint.sh' --write" <<<"$out" && ok "T2 prints ~/.claude/bin hit" || bad "T2 prints ~/.claude/bin hit" "$(grep -n -A2 '### Then' <<<"$out")"
62+
63+
echo "T3 found in toolkit claude-hooks/bin"
64+
TK="$TMP/toolkit"; mk_ckpt "$TK/claude-hooks/bin"
65+
out=$(run "$EMPTYHOME" "$SYSPATH" "$TK")
66+
grep -qF " '$TK/claude-hooks/bin/checkpoint.sh' --write" <<<"$out" && ok "T3 prints toolkit hit" || bad "T3 prints toolkit hit" "$(grep -n -A2 '### Then' <<<"$out")"
67+
68+
echo "T4 lookup order"
69+
out=$(run "$H2" "$TMP/pathbin:$SYSPATH" "$TK")
70+
grep -qF " '$TMP/pathbin/checkpoint.sh' --write" <<<"$out" && ok "T4a PATH wins" || bad "T4a PATH wins" "$(grep -n -A2 '### Then' <<<"$out")"
71+
out=$(run "$H2" "$SYSPATH" "$TK")
72+
grep -qF " '$H2/.claude/bin/checkpoint.sh' --write" <<<"$out" && ok "T4b ~/.claude/bin beats toolkit" || bad "T4b ~/.claude/bin beats toolkit" "$(grep -n -A2 '### Then' <<<"$out")"
73+
74+
echo "T5 not found"
75+
out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$?
76+
grep -qF "checkpoint.sh not installed — run '$NOTK/bin/install-claude-hooks.sh' --basic, or skip: RESUME.md is the resume source" <<<"$out" \
77+
&& ok "T5a install pointer printed" || bad "T5a install pointer printed" "$(grep -n -A2 '### Then' <<<"$out")"
78+
grep -qE '^ +checkpoint\.sh --write' <<<"$out" && bad "T5b no bare checkpoint.sh line" "$(grep -n 'checkpoint.sh --write' <<<"$out")" || ok "T5b no bare checkpoint.sh line"
79+
grep -qF 'OVERWRITE docs/progress/RESUME.md' <<<"$out" && grep -qF 'Shared toolkit:' <<<"$out" \
80+
&& ok "T5c rest of the block still printed" || bad "T5c rest of the block still printed"
81+
[ "$rc" = 0 ] && ok "T5d exit 0 on a clean project" || bad "T5d exit 0 on a clean project (got $rc)"
82+
83+
echo "T6 not found, writes owed"
84+
run "$EMPTYHOME" "$SYSPATH" "$NOTK" --add "exec 010-foo.mdl" >/dev/null
85+
out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$?
86+
[ "$rc" = 1 ] && ok "T6 exit 1 when writes owed" || bad "T6 exit 1 when writes owed (got $rc)"
87+
88+
echo "T7 paths with spaces"
89+
# Run the printed line through a shell: the path must resolve to the stub we made.
90+
runs_line() { local line; line=$(grep -m1 -E "^ '.*' --write" <<<"$1") || return 1
91+
( eval "${line%%#*}" ) >/dev/null 2>&1; }
92+
H7="$TMP/John Doe"; mk_ckpt "$H7/.claude/bin"
93+
out=$(run "$H7" "$SYSPATH" "$NOTK")
94+
grep -qF " '$H7/.claude/bin/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \
95+
&& ok "T7a space in HOME: quoted and runnable" || bad "T7a space in HOME: quoted and runnable" "$(grep -n -A1 '### Then' <<<"$out")"
96+
TK7="$TMP/my tool's kit"; mk_ckpt "$TK7/claude-hooks/bin"
97+
out=$(run "$EMPTYHOME" "$SYSPATH" "$TK7")
98+
grep -qF " '$TMP/my tool'\\''s kit/claude-hooks/bin/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \
99+
&& ok "T7b space and ' in toolkit path: quoted and runnable" || bad "T7b space and ' in toolkit path: quoted and runnable" "$(grep -n -A1 '### Then' <<<"$out")"
100+
TK7N="$TMP/no kit here"; mkdir -p "$TK7N"
101+
out=$(run "$EMPTYHOME" "$SYSPATH" "$TK7N")
102+
grep -qF "run '$TK7N/bin/install-claude-hooks.sh' --basic" <<<"$out" \
103+
&& ok "T7c install hint quoted" || bad "T7c install hint quoted" "$(grep -n -A1 '### Then' <<<"$out")"
104+
105+
echo "T8 claude-hooks copy"
106+
HOOKS="$(cd "$(dirname "$SUT")/.." && pwd)/claude-hooks/bin/close-task.sh"
107+
if [ -f "$HOOKS" ]; then
108+
B8="$H7/.claude/bin"; cp "$HOOKS" "$B8/close-task.sh"
109+
out=$( cd "$PROJ" && env -i HOME="$H7" PATH="$SYSPATH" GIT_CEILING_DIRECTORIES="$TMP" bash "$B8/close-task.sh" 2>&1 )
110+
grep -qF " '$B8/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \
111+
&& ok "T8 sibling path printed, quoted" || bad "T8 sibling path printed, quoted" "$(grep -n -A1 '### Then' <<<"$out")"
112+
else
113+
bad "T8 claude-hooks/bin/close-task.sh not found next to $SUT"
114+
fi
115+
116+
echo
117+
echo "test-close-task-checkpoint: $pass passed, $fail failed"
118+
[ "$fail" = 0 ]

0 commit comments

Comments
 (0)