Skip to content
Merged
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
89 changes: 89 additions & 0 deletions .claude/hooks/lib.sh
Original file line number Diff line number Diff line change
@@ -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 <file> -> 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 <status> -> 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
}
19 changes: 6 additions & 13 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 8 additions & 16 deletions .claude/hooks/stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 6 additions & 16 deletions .claude/hooks/user-prompt-submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions tests/hooks/test-lib.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions tests/hooks/test-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down