Repository navigation
fix(hooks): the planning layer was dead on Linux - #6
Merged
Merged
Conversation
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by #5 (the CI workflow) on its very first run: 17 failures on Linux, 0 on macOS. This is not a test bug.
What was broken
All three plan hooks derived a file's mtime like this:
mtime=$(stat -f %m "$progress" 2>/dev/null || stat -c %Y "$progress" 2>/dev/null || echo 0)Correct on BSD/macOS. On GNU/Linux,
-fmeans file system status, so%mis not a format string — it is parsed as a second FILE argument. GNU prints a six-line filesystem dump for$progress, then exits 1 because no file named%mexists.Because it exits 1, the
||fallback runs too. Command substitution captures both:(( mtime > newest ))on that is a syntax error. Bash prints to stderr, the condition evaluates false,activeis never set, and every hook exits silently.What that means
On any Linux machine: no plan injection on every turn, no session-start banner, no
progress.mdtimestamping.The entire Descrição layer — the thing the README calls the kit's durable cross-session memory, the thing
docs/03-planning.mdcalls "the load-bearing defense against plan drift" — has never worked on Linux. And it was green on the author's laptop the whole time, which is precisely the failure mode a CI gate exists to catch. Worth noting the kit had a pre-commit hook, a quality script and a 56-case test suite, and none of it caught this, because none of it ran anywhere but macOS.The fix
.claude/hooks/lib.shnow ownshook_mtime,hook_is_completeandhook_active_progress.The selection logic was copy-pasted into all three hooks, with a comment in
stop.shasking the reader to keep them "in lockstep" by hand — which is exactly how they came to share one bug. One definition now.hook_mtimeprobes once for whichstatthis machine has, then validates its own output is numeric before returning it. That second part is the real guard: it makes this class of bug impossible to reintroduce on a platform nobody has tested yet, rather than just fixing the two we know about.Hooks still fail open — a missing
lib.shis a silent no-op, never a block.Also: a flaky test
test-stop.sh's multi-feature case rewrote both fixtures withsed -i.bak, making their mtimes equal to the second, thentouched the newer one to break the tie. Since mtime resolution here is whole seconds, that only ever won a sub-second race — macOS happened to win it, Linux consistently lost. Now usestouch -tto push the older fixture into the past. Portable across BSD and GNU, and deterministic.Verification
Reproduced and fixed against
debian:bookworm-slimin Docker, not by pushing and hoping:ubuntu-latest)New
tests/hooks/test-lib.sh(23 assertions) asserts mtime shape, not just value — one line, digits only, no filesystem dump, survives(( )). Those are what catch this class of bug on whichever platform is broken, rather than restating what the implementation does.One thing I found and did not fix
On a Linux box without
python3, 3 more tests fail:pre-commit.shparses the hook payload withpython3 -c, and when the interpreter is absent the command comes back empty, doesn't matchgit commit*, and the hook exits 0. The quality gate silently fails open.Not a CI problem —
ubuntu-latestships python3, so this is green there. But a gate that disables itself when a dependency is missing is its own bug, and a different one from this PR. Flagging rather than folding it in. Happy to do it as a follow-up.Merge note
Adds a
## v1.0.4heading toCHANGELOG.md, as do #3 and #4. Whoever merges second gets a trivial conflict at the top of the file.🤖 Generated with Claude Code