Skip to content

fix(hooks): the planning layer was dead on Linux - #6

Merged
pluginslab merged 1 commit into
mainfrom
fix/hook-portability
Sep 21, 2026
Merged

pluginslab merged 1 commit into
mainfrom
fix/hook-portability

Conversation

@pluginslab

Copy link
Copy Markdown
Owner

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, -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 $progress, then exits 1 because no file named %m exists.

Because it exits 1, the || fallback runs too. Command substitution captures both:

mtime=[  File: "/repo/CHANGELOG.md"
    ID: 0        Namelen: 255     Type: fuseblk
Block size: 1048576    Fundamental block size: 4096
Blocks: Total: 242824745  Free: 48957042   Available: 48957042
Inodes: Total: 1965548735 Free: 1958281680
1789592215]

(( mtime > newest )) on that is a syntax error. Bash prints to stderr, the condition evaluates false, active is 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.md timestamping.

The entire Descrição layer — the thing the README calls the kit's durable cross-session memory, the thing docs/03-planning.md calls "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.sh now owns hook_mtime, hook_is_complete and hook_active_progress.

The selection logic 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 exactly how they came to share one bug. One definition now.

hook_mtime probes once for which stat this 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.sh is a silent no-op, never a block.

Also: a flaky test

test-stop.sh's multi-feature case rewrote both fixtures with sed -i.bak, making their mtimes equal to the second, then touched 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 uses touch -t to push the older fixture into the past. Portable across BSD and GNU, and deterministic.

Verification

Reproduced and fixed against debian:bookworm-slim in Docker, not by pushing and hoping:

Platform Before After
macOS 79 passed 79 passed
Linux + python3 (= ubuntu-latest) 17 failed 79 passed

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.sh parses the hook payload with python3 -c, and when the interpreter is absent the command comes back empty, doesn't match git commit*, and the hook exits 0. The quality gate silently fails open.

Not a CI problem — ubuntu-latest ships 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.4 heading to CHANGELOG.md, as do #3 and #4. Whoever merges second gets a trivial conflict at the top of the file.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant