From 5caeb59c9f4c181a49f205bc87d1c13c02a0ccbf Mon Sep 17 00:00:00 2001 From: pluginslab <57633278+pluginslab@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:22:38 +0100 Subject: [PATCH] fix(hooks): the planning layer was dead on Linux All three plan hooks derived a file's mtime with: stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0 Correct on BSD/macOS. On GNU/Linux, -f means "file system status", so %m is not a format string -- it is parsed as a SECOND FILE argument. GNU prints a six-line filesystem dump for $f, then exits 1 because no file named '%m' exists. So the || fallback runs too, and the capture holds both: six lines of "Block size: ..." with the real epoch on the end. Arithmetic on that blob is a syntax error. The comparison silently evaluates false. Every hook concluded there was no active feature. Net effect on any Linux machine: no plan injection on every turn, no session-start banner, no progress.md timestamping. The entire Descricao mechanism inert, and green on the author's laptop the whole time. Found by the new CI workflow on its first run -- 17 failures on Linux, 0 on macOS. Reproduced and fixed against debian:bookworm-slim. The logic was copy-pasted into all three hooks, with a comment asking the reader to keep them "in lockstep" by hand, which is exactly how they came to share one bug. It now lives once in .claude/hooks/lib.sh. hook_mtime probes for which stat this machine has and validates its own output is numeric, so no future platform can reintroduce this class of bug. Also fixes a flaky test: test-stop.sh's multi-feature case rewrote both fixtures with sed, making their mtimes equal to the second, then relied on touch to break a tie at a resolution that does not exist. macOS won that race; Linux lost it. It now uses touch -t. Verified: macOS 79 passed, Linux 79 passed. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/lib.sh | 89 +++++++++++++++++++++++ .claude/hooks/session-start.sh | 19 ++--- .claude/hooks/stop.sh | 24 +++---- .claude/hooks/user-prompt-submit.sh | 22 ++---- CHANGELOG.md | 14 ++++ tests/hooks/test-lib.sh | 106 ++++++++++++++++++++++++++++ tests/hooks/test-stop.sh | 9 ++- 7 files changed, 236 insertions(+), 47 deletions(-) create mode 100755 .claude/hooks/lib.sh create mode 100755 tests/hooks/test-lib.sh diff --git a/.claude/hooks/lib.sh b/.claude/hooks/lib.sh new file mode 100755 index 0000000..9876c02 --- /dev/null +++ b/.claude/hooks/lib.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Shared helpers for the kit's hooks. +# +# `session-start.sh`, `user-prompt-submit.sh` and `stop.sh` all need the same +# answer to the same question: which feature is active right now? That logic +# used to be copy-pasted into all three, with a comment in stop.sh asking the +# reader to keep them "in lockstep" by hand. They drifted the moment anyone +# touched one — and worse, they carried an identical portability bug that made +# every one of them silently no-op on Linux. One definition now. +# +# Sourced, not executed. No side effects at source time beyond defining +# functions and probing which `stat` this machine has. + +# --- Portable mtime ----------------------------------------------------- +# +# GNU and BSD `stat` disagree, and the disagreement is not a clean failure you +# can paper over with `||`. +# +# BSD/macOS: `stat -f %m FILE` -> epoch mtime. +# GNU/Linux: `-f` means "file system status", so `%m` is not a format string, +# it is parsed as a SECOND FILE argument. GNU prints a six-line +# filesystem dump for FILE, then exits 1 because no file named +# '%m' exists. +# +# So the obvious chain — `stat -f %m "$f" || stat -c %Y "$f"` — does the worst +# possible thing on Linux: the first command half-succeeds (dumping filesystem +# trivia to stdout) *and* returns 1, so the fallback runs too, and command +# substitution captures both. The result is six lines of "Block size: ..." +# with the real epoch stuck on the end. Any arithmetic on that is a syntax +# error, the comparison silently evaluates false, and the caller concludes +# there is no active feature. +# +# Probe once, bind the right implementation, and validate the output is +# actually numeric so no future platform can reintroduce this class of bug. +if stat -c %Y . >/dev/null 2>&1; then + _hook_stat_mtime() { stat -c %Y "$1" 2>/dev/null; } +else + _hook_stat_mtime() { stat -f %m "$1" 2>/dev/null; } +fi + +# hook_mtime -> epoch seconds, or 0 if unavailable. +# Guaranteed to print a non-negative integer and nothing else. +hook_mtime() { + local m + m=$(_hook_stat_mtime "$1") || m='' + [[ "$m" =~ ^[0-9]+$ ]] || m=0 + printf '%s' "$m" +} + +# --- Active feature ----------------------------------------------------- + +# hook_is_complete -> 0 if the status means "finished". +# Tolerates whitespace, case, and the synonyms people actually type. +hook_is_complete() { + local status + status=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + case "$status" in + complete|completed|done|shipped|archived) return 0 ;; + *) return 1 ;; + esac +} + +# hook_active_progress [plans_dir] -> path to the most recently modified +# progress.md that is not marked complete. Prints nothing if there is none. +# +# Shipped features get moved to .claude/plans/archive/, so anything still +# under features/ with a completion status is excluded deliberately. +hook_active_progress() { + local plans_dir="${1:-.claude/plans/features}" + local active='' newest=0 progress status mtime + + [[ -d "$plans_dir" ]] || return 0 + + while IFS= read -r -d '' progress; do + status=$(grep -m1 '^status:' "$progress" 2>/dev/null | sed 's/^status: *//') + if hook_is_complete "$status"; then + continue + fi + mtime=$(hook_mtime "$progress") + if (( mtime > newest )); then + newest=$mtime + active="$progress" + fi + done < <(find "$plans_dir" -mindepth 2 -maxdepth 2 -name progress.md -print0 2>/dev/null) + + [[ -n "$active" ]] && printf '%s' "$active" + return 0 +} diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 78d8614..16b6e5f 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -16,20 +16,13 @@ set -uo pipefail plans_dir=".claude/plans/features" [[ ! -d "$plans_dir" ]] && exit 0 -active="" -newest=0 -while IFS= read -r -d '' progress; do - status=$(grep -m1 '^status:' "$progress" 2>/dev/null | sed 's/^status: *//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') - case "$status" in - complete|completed|done|shipped|archived) continue ;; - esac - mtime=$(stat -f %m "$progress" 2>/dev/null || stat -c %Y "$progress" 2>/dev/null || echo 0) - if (( mtime > newest )); then - newest=$mtime - active="$progress" - fi -done < <(find "$plans_dir" -mindepth 2 -maxdepth 2 -name progress.md -print0 2>/dev/null) +# Hooks must never block, so a missing lib is a silent no-op rather than an error. +lib="$(dirname "${BASH_SOURCE[0]}")/lib.sh" +[[ -r "$lib" ]] || exit 0 +# shellcheck source=./lib.sh +source "$lib" +active=$(hook_active_progress "$plans_dir") [[ -z "$active" ]] && exit 0 feature_dir=$(dirname "$active") diff --git a/.claude/hooks/stop.sh b/.claude/hooks/stop.sh index 13c0d6d..3282ffd 100755 --- a/.claude/hooks/stop.sh +++ b/.claude/hooks/stop.sh @@ -14,23 +14,15 @@ set -uo pipefail plans_dir=".claude/plans/features" [[ ! -d "$plans_dir" ]] && exit 0 -# Find the most recently modified non-complete progress.md (same logic as -# user-prompt-submit.sh — keeps the two hooks in lockstep on which feature -# is "active"). -active="" -newest=0 -while IFS= read -r -d '' progress; do - status=$(grep -m1 '^status:' "$progress" 2>/dev/null | sed 's/^status: *//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') - case "$status" in - complete|completed|done|shipped|archived) continue ;; - esac - mtime=$(stat -f %m "$progress" 2>/dev/null || stat -c %Y "$progress" 2>/dev/null || echo 0) - if (( mtime > newest )); then - newest=$mtime - active="$progress" - fi -done < <(find "$plans_dir" -mindepth 2 -maxdepth 2 -name progress.md -print0 2>/dev/null) +# Hooks must never block, so a missing lib is a silent no-op rather than an error. +lib="$(dirname "${BASH_SOURCE[0]}")/lib.sh" +[[ -r "$lib" ]] || exit 0 +# shellcheck source=./lib.sh +source "$lib" +# Same definition of "active" the other two hooks use — one implementation, +# in lib.sh, rather than three copies kept in lockstep by hand. +active=$(hook_active_progress "$plans_dir") [[ -z "$active" ]] && exit 0 now=$(date "+%Y-%m-%d %H:%M") diff --git a/.claude/hooks/user-prompt-submit.sh b/.claude/hooks/user-prompt-submit.sh index 5c74ad9..29f836c 100755 --- a/.claude/hooks/user-prompt-submit.sh +++ b/.claude/hooks/user-prompt-submit.sh @@ -14,23 +14,13 @@ set -uo pipefail plans_dir=".claude/plans/features" [[ ! -d "$plans_dir" ]] && exit 0 -# Find the most recently modified progress.md under features/ that is not -# marked status: complete. Plan dirs that have shipped get moved to -# .claude/plans/archive/ — those are intentionally excluded. -active="" -newest=0 -while IFS= read -r -d '' progress; do - status=$(grep -m1 '^status:' "$progress" 2>/dev/null | sed 's/^status: *//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') - case "$status" in - complete|completed|done|shipped|archived) continue ;; - esac - mtime=$(stat -f %m "$progress" 2>/dev/null || stat -c %Y "$progress" 2>/dev/null || echo 0) - if (( mtime > newest )); then - newest=$mtime - active="$progress" - fi -done < <(find "$plans_dir" -mindepth 2 -maxdepth 2 -name progress.md -print0 2>/dev/null) +# Hooks must never block, so a missing lib is a silent no-op rather than an error. +lib="$(dirname "${BASH_SOURCE[0]}")/lib.sh" +[[ -r "$lib" ]] || exit 0 +# shellcheck source=./lib.sh +source "$lib" +active=$(hook_active_progress "$plans_dir") [[ -z "$active" ]] && exit 0 feature_dir=$(dirname "$active") diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c9dec..016516b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ The kit's evolution, kept for humans. Per-feature progress lives in `.claude/plans/`. +## v1.0.4 — 2026-09-16 + +### Fixed + +- **The planning layer was dead on Linux.** `session-start.sh`, `user-prompt-submit.sh` and `stop.sh` each derived a file's mtime with `stat -f %m "$f" || stat -c %Y "$f"`. That is correct on BSD/macOS. On GNU/Linux `-f` means *file system status*, so `%m` is not a format string — it is parsed as a second FILE argument. GNU prints a six-line filesystem dump for `$f`, **then** exits 1 because no file named `%m` exists, so the `||` fallback runs too and the capture holds both. Arithmetic on that blob is a syntax error, the comparison silently evaluates false, and all three hooks concluded there was no active feature. + + Net effect: no plan injection on every turn, no session-start banner, no `progress.md` timestamping — the entire Descrição mechanism, silently inert, on every Linux machine. Green on the author's laptop the whole time. + + Found by the new CI workflow on its first run: 17 failures on Linux, 0 on macOS. + +- **Hook logic is no longer triplicated.** The active-feature selection was copy-pasted into all three hooks, with a comment in `stop.sh` asking the reader to keep them "in lockstep" by hand — which is how they came to share an identical bug. `.claude/hooks/lib.sh` now owns `hook_mtime`, `hook_is_complete` and `hook_active_progress`. `hook_mtime` probes once for which `stat` the machine has and validates its own output is numeric, so no future platform can reintroduce this class of bug. + +- **A flaky test in `test-stop.sh`.** The multi-feature case rewrote both fixtures with `sed -i.bak`, making their mtimes equal to the second, then `touch`ed the newer one to break the tie. Since mtime resolution here is whole seconds, that only won a sub-second race — which macOS happened to win and Linux consistently lost. It now pushes the older fixture into the past with `touch -t`, which is portable and deterministic. + ## v1.0.3 — 2026-06-11 ### Added diff --git a/tests/hooks/test-lib.sh b/tests/hooks/test-lib.sh new file mode 100755 index 0000000..86826a3 --- /dev/null +++ b/tests/hooks/test-lib.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Tests for .claude/hooks/lib.sh +# +# These exist because of a bug that was invisible on macOS and fatal on Linux: +# all three hooks derived a file's mtime with +# +# stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0 +# +# On BSD/macOS that is correct. On GNU/Linux `-f` means "file system status", +# so `%m` is parsed as a second FILE argument: GNU prints a six-line filesystem +# dump for $f, *then* exits 1 because no file named '%m' exists — so the +# fallback runs too and the capture ends up holding both. Arithmetic on that +# blob is a syntax error, the comparison quietly evaluates false, and every +# hook concluded there was no active feature. The planning layer was dead on +# Linux and green on the author's laptop. +# +# The assertions below are therefore about *shape*, not just value: mtime must +# be one line of digits and nothing else. That is what makes them catch this +# class of bug on whichever platform is broken, rather than restating what the +# implementation already does. +# +set -uo pipefail + +# shellcheck source=../lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh" +# shellcheck source=../../.claude/hooks/lib.sh +source "$KIT_ROOT/.claude/hooks/lib.sh" + +echo "hooks/lib.sh" + +# --- hook_mtime: shape --- +setup_sandbox +printf 'x\n' > file.txt + +mtime=$(hook_mtime "file.txt") + +if [[ "$mtime" =~ ^[0-9]+$ ]]; then + _pass "hook_mtime → digits only" +else + _fail "hook_mtime → digits only" "got $(printf %q "$mtime")" +fi + +assert_equals "hook_mtime → exactly one line" "1" "$(printf '%s' "$mtime" | wc -l | tr -d ' ' | awk '{print $1+1}')" + +if [[ "$mtime" -gt 0 ]] 2>/dev/null; then + _pass "hook_mtime → non-zero for a real file" +else + _fail "hook_mtime → non-zero for a real file" "got $(printf %q "$mtime")" +fi + +# The bug's signature: filesystem trivia leaking into the value. +assert_not_contains "hook_mtime → no filesystem dump" "$mtime" "Block size" +assert_not_contains "hook_mtime → no file name echo" "$mtime" "File:" + +# --- hook_mtime: usable in arithmetic --- +# This is the assertion that would have gone red on Linux before the fix. +newest=0 +if (( $(hook_mtime "file.txt") > newest )) 2>/dev/null; then + _pass "hook_mtime → survives arithmetic comparison" +else + _fail "hook_mtime → survives arithmetic comparison" "(( )) rejected the value" +fi + +# --- hook_mtime: missing file --- +assert_equals "hook_mtime → 0 for a missing file" "0" "$(hook_mtime "does-not-exist.txt")" +teardown_sandbox + +# --- hook_is_complete --- +for status in complete completed done shipped archived COMPLETE " done "; do + if hook_is_complete "$status"; then + _pass "hook_is_complete → '$status' is complete" + else + _fail "hook_is_complete → '$status' is complete" "returned non-zero" + fi +done + +for status in in_progress blocked "" "not-done"; do + if hook_is_complete "$status"; then + _fail "hook_is_complete → '$status' is active" "treated as complete" + else + _pass "hook_is_complete → '$status' is active" + fi +done + +# --- hook_active_progress --- +setup_sandbox +assert_empty "hook_active_progress → empty features/ → nothing" "$(hook_active_progress)" + +write_progress "001-only" "in_progress" +assert_contains "hook_active_progress → finds the single active feature" \ + "$(hook_active_progress)" "001-only" + +write_progress "002-shipped" "complete" +assert_not_contains "hook_active_progress → skips complete" \ + "$(hook_active_progress)" "002-shipped" + +sleep 1 +write_progress "003-newer" "in_progress" +assert_contains "hook_active_progress → picks the newest active" \ + "$(hook_active_progress)" "003-newer" + +assert_empty "hook_active_progress → missing dir → nothing" "$(hook_active_progress "no/such/dir")" +teardown_sandbox + +exit $FAIL_COUNT diff --git a/tests/hooks/test-stop.sh b/tests/hooks/test-stop.sh index 370311f..1e94c92 100755 --- a/tests/hooks/test-stop.sh +++ b/tests/hooks/test-stop.sh @@ -77,8 +77,13 @@ sed -i.bak 's/^last_updated:.*/last_updated: 1999-01-01 00:00/' \ .claude/plans/features/001-old/progress.md \ .claude/plans/features/002-newer/progress.md rm -f .claude/plans/features/001-old/progress.md.bak .claude/plans/features/002-newer/progress.md.bak -# Touch 002-newer to make it the newest mtime again (sed changed both). -touch .claude/plans/features/002-newer/progress.md +# sed rewrote both files, so their mtimes are now equal to the second, and +# mtime resolution here is whole seconds. Touching 002-newer did not reliably +# break the tie — it only won a sub-second race, which macOS happened to win +# and Linux consistently lost. Push 001-old firmly into the past instead, so +# the ordering is unambiguous on any platform. `touch -t` is portable across +# BSD and GNU; `touch -d` is not. +touch -t 199901010000 .claude/plans/features/001-old/progress.md run_hook "stop.sh" assert_file_contains "multi-feature → newer touched" \ .claude/plans/features/002-newer/progress.md "last_updated: $(date +%Y)"