From dde1473382d7ed645ef381c7421f2701eca633d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:00:41 +0000 Subject: [PATCH 1/2] fix(close-task): resolve checkpoint.sh instead of printing it bare (#358) checkpoint.sh ships at claude-hooks/bin/ and reaches ~/.claude/bin only via the opt-in install-claude-hooks.sh --basic. close-task.sh printed `checkpoint.sh --write` unconditionally. It now looks in PATH, then ~/.claude/bin, then $MXTK_ROOT/claude-hooks/bin, and prints the full path, or an install pointer with "skip: RESUME.md is the resume source". The exit code is unchanged (1 only when writes are owed). close-the-loop.md step 4 is updated to match. Fixture tests/wave2/test-close-task-checkpoint.sh: 10 cases, 7 fail on master. Closes #358 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + project-bin/close-task.sh | 23 +++++- skills/close-the-loop.md | 3 + tests/wave2/test-close-task-checkpoint.sh | 87 +++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100755 tests/wave2/test-close-task-checkpoint.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ff60ea83..4057f9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- 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. If none is found it prints `checkpoint.sh not installed — run /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. Fixture `tests/wave2/test-close-task-checkpoint.sh` (10 cases; 7 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) - 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 - 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 ` 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 ` 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..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 - 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 diff --git a/project-bin/close-task.sh b/project-bin/close-task.sh index 378597a1..9e3db6a7 100755 --- a/project-bin/close-task.sh +++ b/project-bin/close-task.sh @@ -138,8 +138,27 @@ cat <<'TABLE' Rules: verify before filing · one place only, link don't copy · a committed script is not an executed script. -### Then - checkpoint.sh --write # and fill the Narrative (append-only history) +TABLE + +# ---- checkpoint.sh: resolve it, never call it bare --------------------- +# checkpoint.sh ships in the toolkit at claude-hooks/bin/ and reaches ~/.claude/bin only via the +# opt-in `bin/install-claude-hooks.sh --basic`; init-project does not install it. Printing it bare +# sent agents to a command that did not exist on their machine (#358). Look in PATH, then +# ~/.claude/bin, then the toolkit clone; print the full path found, or say how to get it. A +# missing checkpoint.sh is not an owed write: it never changes the exit code. +CKPT_BIN=$(command -v checkpoint.sh 2>/dev/null) +for _c in "${HOME:-/nonexistent}/.claude/bin/checkpoint.sh" "$MXTK_ROOT/claude-hooks/bin/checkpoint.sh"; do + [ -n "$CKPT_BIN" ] && break + [ -f "$_c" ] && CKPT_BIN="$_c" +done +echo "### Then" +if [ -n "$CKPT_BIN" ]; then + echo " $CKPT_BIN --write # and fill the Narrative (append-only history)" +else + _tk="$MXTK_ROOT"; [ -d "$_tk" ] || _tk="" + echo " # checkpoint.sh not installed — run $_tk/bin/install-claude-hooks.sh --basic, or skip: RESUME.md is the resume source" +fi +cat <<'TABLE' OVERWRITE docs/progress/RESUME.md # <= 60 lines: where we are, next actions, # do-not-lose, and which file to read for what. # This is the ONLY file read after /clear. diff --git a/skills/close-the-loop.md b/skills/close-the-loop.md index f530ac8e..82509b69 100644 --- a/skills/close-the-loop.md +++ b/skills/close-the-loop.md @@ -110,6 +110,9 @@ forgotten* — they are on disk, not in working memory. 3. **Drain the `UNFILED:` lines** from the previous checkpoint — route or delete. 4. **Checkpoint**: `checkpoint.sh --write`, fill the Narrative, including any new `UNFILED:` lines. This file is append-only history. + `close-task.sh` prints the full path it found (PATH, then `~/.claude/bin`, then the + toolkit's `claude-hooks/bin/`). If it says `checkpoint.sh not installed`: run + `/bin/install-claude-hooks.sh --basic`, or skip this step: `RESUME.md` is the resume source. 5. **`mxcli brain check -p `** — `capture` and `promote` do not validate anchors; only `check` does. Exit 1 means a promoted entry points at something the model no longer has: fix the anchor or `brain drop` the entry before anything is committed. diff --git a/tests/wave2/test-close-task-checkpoint.sh b/tests/wave2/test-close-task-checkpoint.sh new file mode 100755 index 00000000..1748bb21 --- /dev/null +++ b/tests/wave2/test-close-task-checkpoint.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Fixture for #358: close-task.sh must resolve checkpoint.sh, never print it bare. +# +# usage: test-close-task-checkpoint.sh /path/to/project-bin/close-task.sh +# +# checkpoint.sh lives in the toolkit at claude-hooks/bin/ and reaches ~/.claude/bin only via +# the opt-in `bin/install-claude-hooks.sh --basic`. The old "### Then" block printed +# `checkpoint.sh --write` unconditionally, so a project without the hooks got a command that +# did not exist. Lookup order: PATH, ~/.claude/bin, $MXTK_ROOT/claude-hooks/bin. +# +# T1 found on PATH -> prints that full path +# T2 found in ~/.claude/bin -> prints that full path (fake HOME) +# T3 found in toolkit -> prints $MXTK_ROOT/claude-hooks/bin/checkpoint.sh +# T4 PATH beats ~/.claude/bin and toolkit +# T5 not found -> install pointer, rest of the block still printed, +# exit code unchanged (0 on a clean project) +# T6 not found, writes owed -> still exit 1 (owed-write semantics kept) + +set -uo pipefail + +SUT="${1:?usage: test-close-task-checkpoint.sh /path/to/project-bin/close-task.sh}" +case "$SUT" in /*) ;; *) SUT="$PWD/$SUT" ;; esac +[ -f "$SUT" ] || { echo "no such file: $SUT" >&2; exit 2; } + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +pass=0; fail=0 +ok() { pass=$((pass+1)); echo " PASS $1"; } +bad() { fail=$((fail+1)); echo " FAIL $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ | /'; } + +# A project that is not a git repo, with the script installed as bin/close-task.sh (no +# toolkit next to it, so the "installed-from" fallback cannot resolve a toolkit root). +PROJ="$TMP/proj"; mkdir -p "$PROJ/bin" +cp "$SUT" "$PROJ/bin/close-task.sh"; chmod +x "$PROJ/bin/close-task.sh" + +# A clean PATH: system tools only, no checkpoint.sh. +SYSPATH="/usr/bin:/bin" +mk_ckpt() { mkdir -p "$1"; printf '#!/bin/sh\nexit 0\n' > "$1/checkpoint.sh"; chmod +x "$1/checkpoint.sh"; } + +EMPTYHOME="$TMP/home-empty"; mkdir -p "$EMPTYHOME" +NOTK="$TMP/no-toolkit"; mkdir -p "$NOTK" + +run() { # $1=HOME $2=PATH $3=MXTK_ROOT [args...] + local h="$1" p="$2" m="$3"; shift 3 + ( cd "$PROJ" && env -i HOME="$h" PATH="$p" MXTK_ROOT="$m" GIT_CEILING_DIRECTORIES="$TMP" \ + bash "$PROJ/bin/close-task.sh" "$@" 2>&1 ) +} + +echo "T1 found on PATH" +mk_ckpt "$TMP/pathbin" +out=$(run "$EMPTYHOME" "$TMP/pathbin:$SYSPATH" "$NOTK") +grep -qF " $TMP/pathbin/checkpoint.sh --write" <<<"$out" && ok "T1 prints PATH hit" || bad "T1 prints PATH hit" "$(grep -n -A2 '### Then' <<<"$out")" + +echo "T2 found in ~/.claude/bin" +H2="$TMP/home2"; mk_ckpt "$H2/.claude/bin" +out=$(run "$H2" "$SYSPATH" "$NOTK") +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")" + +echo "T3 found in toolkit claude-hooks/bin" +TK="$TMP/toolkit"; mk_ckpt "$TK/claude-hooks/bin" +out=$(run "$EMPTYHOME" "$SYSPATH" "$TK") +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")" + +echo "T4 lookup order" +out=$(run "$H2" "$TMP/pathbin:$SYSPATH" "$TK") +grep -qF " $TMP/pathbin/checkpoint.sh --write" <<<"$out" && ok "T4a PATH wins" || bad "T4a PATH wins" "$(grep -n -A2 '### Then' <<<"$out")" +out=$(run "$H2" "$SYSPATH" "$TK") +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")" + +echo "T5 not found" +out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$? +grep -qF "checkpoint.sh not installed — run $NOTK/bin/install-claude-hooks.sh --basic, or skip: RESUME.md is the resume source" <<<"$out" \ + && ok "T5a install pointer printed" || bad "T5a install pointer printed" "$(grep -n -A2 '### Then' <<<"$out")" +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" +grep -qF 'OVERWRITE docs/progress/RESUME.md' <<<"$out" && grep -qF 'Shared toolkit:' <<<"$out" \ + && ok "T5c rest of the block still printed" || bad "T5c rest of the block still printed" +[ "$rc" = 0 ] && ok "T5d exit 0 on a clean project" || bad "T5d exit 0 on a clean project (got $rc)" + +echo "T6 not found, writes owed" +run "$EMPTYHOME" "$SYSPATH" "$NOTK" --add "exec 010-foo.mdl" >/dev/null +out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$? +[ "$rc" = 1 ] && ok "T6 exit 1 when writes owed" || bad "T6 exit 1 when writes owed (got $rc)" + +echo +echo "test-close-task-checkpoint: $pass passed, $fail failed" +[ "$fail" = 0 ] From 6164bc6e40b626ef641da9eaac689aff9d40c110 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:09:40 +0000 Subject: [PATCH 2/2] fix(close-task): quote printed checkpoint paths; hooks copy prints sibling path (#362 review) A HOME or toolkit path with a space broke copy-paste of the printed ` --write` and install-hint commands. Both are now single-quoted, with embedded ' escaped via sed for bash 3.2. The ~/.claude/bin copy (claude-hooks/bin/close-task.sh) printed a bare checkpoint.sh, though ~/.claude/bin is usually not on PATH; it now prints its sibling's full path, quoted. The fixture gains T7a-c (space in HOME, space and ' in the toolkit path, quoted install hint; each printed line is eval'd) and T8 (hooks copy): 14/14 pass, 11 fail on master. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 +- claude-hooks/bin/close-task.sh | 12 +++++-- project-bin/close-task.sh | 10 ++++-- tests/wave2/test-close-task-checkpoint.sh | 43 +++++++++++++++++++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4057f9fe..b3dd1b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- 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. If none is found it prints `checkpoint.sh not installed — run /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. Fixture `tests/wave2/test-close-task-checkpoint.sh` (10 cases; 7 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) +- 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 /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) - 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 - 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 ` 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 ` 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..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 - 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 diff --git a/claude-hooks/bin/close-task.sh b/claude-hooks/bin/close-task.sh index d5a702f1..79671b2a 100755 --- a/claude-hooks/bin/close-task.sh +++ b/claude-hooks/bin/close-task.sh @@ -104,8 +104,16 @@ cat <<'TABLE' Rules: verify before filing · one place only, link don't copy · a committed script is not an executed script. -### Then - checkpoint.sh --write # and fill the Narrative (append-only history) +TABLE + +# checkpoint.sh is installed beside this script in ~/.claude/bin, which is usually not on PATH: +# print the sibling's full path, single-quoted so a path with spaces survives copy-paste (#358). +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +_ck="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/checkpoint.sh" +if [ -f "$_ck" ]; then _ck=$(shq "$_ck"); else _ck="checkpoint.sh"; fi +echo "### Then" +echo " $_ck --write # and fill the Narrative (append-only history)" +cat <<'TABLE' OVERWRITE docs/progress/RESUME.md # <= 60 lines: where we are, next actions, # do-not-lose, and which file to read for what. # This is the ONLY file read after /clear. diff --git a/project-bin/close-task.sh b/project-bin/close-task.sh index 9e3db6a7..0afb1a8b 100755 --- a/project-bin/close-task.sh +++ b/project-bin/close-task.sh @@ -151,12 +151,16 @@ for _c in "${HOME:-/nonexistent}/.claude/bin/checkpoint.sh" "$MXTK_ROOT/claude-h [ -n "$CKPT_BIN" ] && break [ -f "$_c" ] && CKPT_BIN="$_c" done +# Single-quote a path so the printed command survives copy-paste with spaces (e.g. a HOME such as +# "John Doe"); an embedded ' becomes '\''. sed, not ${x//}, for bash 3.2. +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } echo "### Then" if [ -n "$CKPT_BIN" ]; then - echo " $CKPT_BIN --write # and fill the Narrative (append-only history)" + echo " $(shq "$CKPT_BIN") --write # and fill the Narrative (append-only history)" else - _tk="$MXTK_ROOT"; [ -d "$_tk" ] || _tk="" - echo " # checkpoint.sh not installed — run $_tk/bin/install-claude-hooks.sh --basic, or skip: RESUME.md is the resume source" + if [ -d "$MXTK_ROOT" ]; then _inst=$(shq "$MXTK_ROOT/bin/install-claude-hooks.sh") + else _inst="/bin/install-claude-hooks.sh"; fi + echo " # checkpoint.sh not installed — run $_inst --basic, or skip: RESUME.md is the resume source" fi cat <<'TABLE' OVERWRITE docs/progress/RESUME.md # <= 60 lines: where we are, next actions, diff --git a/tests/wave2/test-close-task-checkpoint.sh b/tests/wave2/test-close-task-checkpoint.sh index 1748bb21..c1044eda 100755 --- a/tests/wave2/test-close-task-checkpoint.sh +++ b/tests/wave2/test-close-task-checkpoint.sh @@ -15,6 +15,9 @@ # T5 not found -> install pointer, rest of the block still printed, # exit code unchanged (0 on a clean project) # T6 not found, writes owed -> still exit 1 (owed-write semantics kept) +# T7 paths with a space (and a ') in HOME / toolkit -> printed single-quoted, and the printed +# command actually runs when pasted into a shell +# T8 the claude-hooks/bin/close-task.sh copy prints its sibling checkpoint.sh full path, quoted set -uo pipefail @@ -50,27 +53,27 @@ run() { # $1=HOME $2=PATH $3=MXTK_ROOT [args...] echo "T1 found on PATH" mk_ckpt "$TMP/pathbin" out=$(run "$EMPTYHOME" "$TMP/pathbin:$SYSPATH" "$NOTK") -grep -qF " $TMP/pathbin/checkpoint.sh --write" <<<"$out" && ok "T1 prints PATH hit" || bad "T1 prints PATH hit" "$(grep -n -A2 '### Then' <<<"$out")" +grep -qF " '$TMP/pathbin/checkpoint.sh' --write" <<<"$out" && ok "T1 prints PATH hit" || bad "T1 prints PATH hit" "$(grep -n -A2 '### Then' <<<"$out")" echo "T2 found in ~/.claude/bin" H2="$TMP/home2"; mk_ckpt "$H2/.claude/bin" out=$(run "$H2" "$SYSPATH" "$NOTK") -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")" +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")" echo "T3 found in toolkit claude-hooks/bin" TK="$TMP/toolkit"; mk_ckpt "$TK/claude-hooks/bin" out=$(run "$EMPTYHOME" "$SYSPATH" "$TK") -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")" +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")" echo "T4 lookup order" out=$(run "$H2" "$TMP/pathbin:$SYSPATH" "$TK") -grep -qF " $TMP/pathbin/checkpoint.sh --write" <<<"$out" && ok "T4a PATH wins" || bad "T4a PATH wins" "$(grep -n -A2 '### Then' <<<"$out")" +grep -qF " '$TMP/pathbin/checkpoint.sh' --write" <<<"$out" && ok "T4a PATH wins" || bad "T4a PATH wins" "$(grep -n -A2 '### Then' <<<"$out")" out=$(run "$H2" "$SYSPATH" "$TK") -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")" +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")" echo "T5 not found" out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$? -grep -qF "checkpoint.sh not installed — run $NOTK/bin/install-claude-hooks.sh --basic, or skip: RESUME.md is the resume source" <<<"$out" \ +grep -qF "checkpoint.sh not installed — run '$NOTK/bin/install-claude-hooks.sh' --basic, or skip: RESUME.md is the resume source" <<<"$out" \ && ok "T5a install pointer printed" || bad "T5a install pointer printed" "$(grep -n -A2 '### Then' <<<"$out")" 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" grep -qF 'OVERWRITE docs/progress/RESUME.md' <<<"$out" && grep -qF 'Shared toolkit:' <<<"$out" \ @@ -82,6 +85,34 @@ run "$EMPTYHOME" "$SYSPATH" "$NOTK" --add "exec 010-foo.mdl" >/dev/null out=$(run "$EMPTYHOME" "$SYSPATH" "$NOTK"); rc=$? [ "$rc" = 1 ] && ok "T6 exit 1 when writes owed" || bad "T6 exit 1 when writes owed (got $rc)" +echo "T7 paths with spaces" +# Run the printed line through a shell: the path must resolve to the stub we made. +runs_line() { local line; line=$(grep -m1 -E "^ '.*' --write" <<<"$1") || return 1 + ( eval "${line%%#*}" ) >/dev/null 2>&1; } +H7="$TMP/John Doe"; mk_ckpt "$H7/.claude/bin" +out=$(run "$H7" "$SYSPATH" "$NOTK") +grep -qF " '$H7/.claude/bin/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \ + && ok "T7a space in HOME: quoted and runnable" || bad "T7a space in HOME: quoted and runnable" "$(grep -n -A1 '### Then' <<<"$out")" +TK7="$TMP/my tool's kit"; mk_ckpt "$TK7/claude-hooks/bin" +out=$(run "$EMPTYHOME" "$SYSPATH" "$TK7") +grep -qF " '$TMP/my tool'\\''s kit/claude-hooks/bin/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \ + && 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")" +TK7N="$TMP/no kit here"; mkdir -p "$TK7N" +out=$(run "$EMPTYHOME" "$SYSPATH" "$TK7N") +grep -qF "run '$TK7N/bin/install-claude-hooks.sh' --basic" <<<"$out" \ + && ok "T7c install hint quoted" || bad "T7c install hint quoted" "$(grep -n -A1 '### Then' <<<"$out")" + +echo "T8 claude-hooks copy" +HOOKS="$(cd "$(dirname "$SUT")/.." && pwd)/claude-hooks/bin/close-task.sh" +if [ -f "$HOOKS" ]; then + B8="$H7/.claude/bin"; cp "$HOOKS" "$B8/close-task.sh" + out=$( cd "$PROJ" && env -i HOME="$H7" PATH="$SYSPATH" GIT_CEILING_DIRECTORIES="$TMP" bash "$B8/close-task.sh" 2>&1 ) + grep -qF " '$B8/checkpoint.sh' --write" <<<"$out" && runs_line "$out" \ + && ok "T8 sibling path printed, quoted" || bad "T8 sibling path printed, quoted" "$(grep -n -A1 '### Then' <<<"$out")" +else + bad "T8 claude-hooks/bin/close-task.sh not found next to $SUT" +fi + echo echo "test-close-task-checkpoint: $pass passed, $fail failed" [ "$fail" = 0 ]