Repository navigation
fix(cli): the commit trailer survives a hook another tool regenerates, and check says whether it did - #749
Merged
Conversation
Frames the trailer repair. The evidence is this repository's own `.git/hooks`: lefthook rewrote `commit-msg`, `pre-commit` and `pre-push` on 2 September and spared `prepare-commit-msg` only because `lefthook.yml` declares no job for it — a coincidence of configuration, not a guarantee. And `grep -c trailer` over the diagnostic and its display returns 0 on both, so the loss would be silent. The approach does not defend the appended line, it re-establishes it, and never asks why it went. One code path then covers a regenerated hook, an overwrite by hand, a `core.hooksPath` move and a hook that never existed, without naming a third-party tool anywhere. Settles the cost question with a measurement rather than an estimate: `getRepoLocation` already spawns one `git rev-parse` per session start, and the same call answers `--git-path hooks` as a fourth output. So the hooks directory costs no additional process, and it honours `core.hooksPath` instead of assuming `.git/hooks`. Two consequences carried into the acceptance: that call failing must not cost the worktree facts it already produces, and from a linked worktree it answers the common hooks directory, which is right. Records the one decision that is not mine to take. The repair writes into `.git/hooks` on a session start rather than on a command — restoring only what a person asked for, only while the delegate they installed is present, and still a write nobody typed. The alternative repairs only when an `aidd telemetry` command runs and leaves anyone who never runs `check` silently broken. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The hook must write the line the CLI writes, and cannot import it — zero-dependency CommonJS on one side, TypeScript on the other. That literal therefore exists twice, which is the exact shape this branch already shipped once with `unrecognised_payload` and fixed by spawning the real hook and reading what it wrote rather than by typing the string into a second fixture. Same guard here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
`aidd telemetry on` installs two things: a delegate script, ours outright and regenerated by nobody, and one line in `prepare-commit-msg` that calls it. Only the second is fragile — in a repository where lefthook or husky owns that file it is generated, and a generated file is rewritten. Measured on this repository: lefthook rewrote three hooks on 2 September and spared `prepare-commit-msg` only because its config declares no job for that event. So the hook does not defend the call site, it re-establishes it, and never asks why the line is gone. That is what lets one path cover a regenerated hook, an overwrite by hand, a `core.hooksPath` that moved and a hook that never existed — with no third-party tool named anywhere, so nothing rots when one of them changes format. The opt-out is the one people know: `off` deletes the delegate, and the repair only runs when the delegate is present, so nothing is resurrected after it. It costs no process. `getRepoLocation` already spawns one `git rev-parse` per session start; `--git-path hooks` rides along as a fourth output. That also honours `core.hooksPath` rather than joining `.git/hooks` by hand, which is right in exactly the repositories this is about, and from a linked worktree it answers the common hooks directory — what git actually runs. Placed in `handleSessionStart` because that is the only place already holding an enabled repository's hooks directory, and buying a module boundary with a second process on every session start is not a trade this hook makes. Never on `tool-used`, inheriting the cost argument the journal already writes down for its own per-call path. Seven cases, each reproducing the failure by its shape — a hook file replaced — never by its brand: nothing installs lefthook or husky and nothing names them. They strip `GIT_*` from every git they run, because git exports those into the hooks it spawns and without it these pass by hand and fail only inside a commit. And the one literal spelled twice is held to itself. The hook restores the line the CLI installs, in CommonJS that cannot import TypeScript, so both modules are loaded and compared rather than checked against a fixture that would only agree with whoever wrote it last. Proven to bite: making the hook skip the backslash-to-slash rewrite fails the Windows case and nothing else — the divergence no POSIX test would catch. scripts 324, CLI 3,034 unit and integration; typecheck and biome clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The trailer had no claim in `check` at all — `grep -c trailer` over the diagnostic and its display returned 0 on both. So a repository whose call site had been erased kept committing, kept recording, and said nothing. Five facts now, and the row leads with the only one that describes the chain rather than its parts: **how many of the last twenty commits actually carry `AIDD-Session-Id`**. Read through git's own `%(trailers:key=…)`, so it agrees with what `git log` shows a person by construction rather than by a regex of ours agreeing with itself. The four that explain it come after the count, and only when there is something to explain: where git runs hooks from (`--git-path hooks`, never `.git/hooks` assumed — a `core.hooksPath` pointing elsewhere is exactly the configuration under which every other fact would describe the wrong directory), whether the delegate is there **and executable** (git will not run a hook it cannot execute, so present-but-not-executable is its own answer), whether `prepare-commit-msg` calls it, and whether that hook is somebody else's too — said, never named, because which tool owns the file changes nothing a person does and naming one would be a guess read out of its contents. Twenty commits rather than a date: the cost is the same on a repository of ten commits and one of a million. `recentlyCarrying` is absent, never `0`, when there is no history. A repository with no commits and one whose every commit is unstamped are different facts and only the second is something to act on — the same rule that keeps `unanswerable` apart from `not-registered` one row above. Seven cases against a real git repository, because every one of these is a git question and the two that matter are answers only git has. Seven more on the row itself. And the skill relays the count first, with "no commit history to read" explicitly not zero. CLI 3,047 unit and integration, 292 e2e, scripts 324; typecheck, biome, knip, jscpd, layering and links clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…d only where it may An independent check scored this 30 and reproduced two blockers. Both are real, both were invisible to a fully green suite, and both are fixed with the case that would have caught them. **It wrote into a repository the session was not in.** `git rev-parse --git-path hooks` prints relative to the directory it ran in; this resolved it against the repository root. From `sub/deep` that turned `../../.git/hooks` into a path two levels *above* the checkout — so the repair appended to a different repository's hook while the session's own stayed broken. The correct pattern sat three lines higher, with a comment stating the exact rule `worktreeFields` follows for the same stdout. Fourth time on this branch that a false sentence sat beside code that looked right. **And it could have killed the journal outright.** `rev-parse` fails atomically, so adding the fourth option meant a git that does not understand `--git-path` refused all four, `resolveWriteTarget` returned null, and nothing was recorded at all. Measured against a stubbed git: exit 0, no run file. Now the four-option form is asked first and the three-option form is the fallback — every git still pays one call, and only one too old to answer pays a second and goes without the hooks directory. An existing test caught the naive fix, which had added a second call unconditionally and broken the contract's own cost promise. **Version-controlled hooks are not ours to write.** A `core.hooksPath` pointing at a checked-in `.githooks/` would be dirtied on every session start with a machine-absolute path nobody can commit, and restored by `git checkout` only to be dirtied again. A symlinked `prepare-commit-msg` is worse: `writeFileSync` follows it and edits whatever the team put on the other end. Both decline now. `aidd telemetry on` writing that line once was a write a person asked for; this one is not, so it stays inside the git directory. **And it is atomic** — staged beside the target and renamed over it, so a hook cannot be read half-written by a session starting at the same moment and written back short. The count no longer includes commits that can never carry the trailer: merges are skipped by the delegate itself, so counting them put commits in the denominator that can never be in the numerator. And a shortfall now says when it is expected — a commit no session made carries none by design, so a bare "4 of 20" invited reading a healthy install as broken. Smaller: the mode read goes through the port that answered `fileExists`, and looks at any execute bit rather than the owner's; `#!/bin/sh` is one exported constant on the CLI side and compared across the language boundary, so a hook this project wrote can never report as somebody else's; the row says "no repository here" instead of listing pieces that cannot exist; `resolveHooksDir`'s doc comment is back above `resolveHooksDir`; and the plan says where the wiring actually landed and why the promised e2e was replaced. Four new cases, each pinning a defect this had: a session in a subdirectory, a checked-in hooks directory, a symlinked hook, and no staging file left behind. Reverting the resolve base fails three of them. scripts 328, CLI 3,054 unit and integration, 292 e2e; typecheck, biome, knip, jscpd, layering and links clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
… is not a missing repository A second independent check scored 38 and reproduced two more blockers. Both are the same mistake in different clothes — a guard that reasons about strings where the question is about the world — and both are fixed with the case that reproduces them. **A symlinked hooks directory defeated both guards at once.** `ln -s ../.githooks .git/hooks` is a common way to share hooks with a team, and it makes git answer `<repo>/.git/hooks`: inside the git directory by string, inside the working tree on disk. `path.resolve` never resolves a link, so containment passed; and `lstat` on the hook file, reached *through* the linked directory, saw a real file rather than a link. Reproduced: a tracked `.githooks/prepare-commit-msg` was modified — the one thing the previous commit's own acceptance line promised would never happen. Containment now goes through `realpath`, and a path that cannot be resolved declines, because this is the guard that decides whether to write into somebody's repository. **And `check` said "no repository here" inside a repository.** `resolveHooksDir` answers `null` for three causes and the row collapsed them into one sentence, discarding a commit count it had already computed. Measured against a git that rejects `--git-path`, in a repository with four commits one of which carried the trailer: the row printed the false sentence and dropped the true fact. A worse trade than saying nothing. The two causes are told apart now, and the count is printed either way. Three more from the same review, each a promise the code did not keep: `rename` replaces the inode, so a `0700` hook came back `0755` and a non-executable one came back executable — widening a third party's file on a path meant to be conservative. The mode is carried across now. Staging beside the target needs write permission on the *directory*, where a direct write needs it only on the file. A `0555` hooks directory holding a writable hook was repairable before the atomic write existed; the direct write is the fallback rather than that capability being quietly dropped. And a staging file left behind when `rename` throws — one per session, each under a different pid, in the directory a person reads when something is wrong — is unlinked. Three tests were asserting nothing. The subdirectory regression checked one level above the repository where the defect wrote two. "Declines a hooks directory outside the git directory" never reached that guard, returning early on a delegate that did not exist — it passed with the guard deleted. And the in-memory reader answered `null` for a path its own `fileExists` answered `true` for, which is the contradiction `FileReader.fileMode` was added to prevent. scripts 331, CLI 3,056 unit and integration, 292 e2e; typecheck, biome, knip, jscpd, layering and links clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A third independent check scored 58 — the four prior blockers closed and mutation-proven — and found the field added to tell them apart is written by nothing. `hooksDirMissing` existed in the model and in two display fixtures; the adapter branch that should set it returns without it. The previous commit's message says otherwise, which makes it the fifth false sentence on this branch, and the first one I wrote about my own edit rather than about the code. Outside a repository `check` therefore printed "git could not say where it runs hooks from" directly above its own "not a git repository" line. Two adjacent sentences, one false, about a git that was perfectly fine. Set now, and asserted through the real adapter — the three green tests that covered this all hand-built a state production cannot produce. **Four more, each a promise the code did not keep.** A `0444` hook was silently rewritten and left reading `0444`: `rename` needs the directory, not the file, so the staged path is more permissive than the direct one it was chosen over, not less. Writability is asked before anything is written, so the answer is "declined". `open(2)` applies the umask to the mode it is handed, so a `0770` hook came back `0750` — narrowed rather than widened, and just as much somebody else's file to have changed. `chmod` is not filtered, and now follows the write. The guard compared realpaths while the line was built from the raw path, so on any machine whose temp or project directory is reached through a link — every macOS one — a repair added a second call site rather than recognising the first. The delegate ran twice per commit, and `check` then called the file "somebody else's too" about two lines this project wrote. And the count excused zero. The guard read `carrying < examined`, so `0 of 20 with everything installed` — the entire finding this row exists to surface — was softened with "a commit no session made carries none, by design". Its own docstring said the qualifier applies when *some* commits carry it. A hook git will not run is now reported: git prints a hint on every commit and the repair preserves whatever mode it finds, so a bit lost to a regeneration stayed lost with nothing saying so. **And three tests asserted nothing.** "An unwritable hook file costs the session nothing" checked only the exit code, passing whether the file was left alone or replaced. The atomicity criterion had no test that could fail — removing stage-and-rename left every case green — and is now asserted on the one thing only `rename` produces: a different inode. Mutation-proven, as is the fallback. scripts 332, CLI 3,060 unit and integration, 292 e2e; typecheck, biome, knip, jscpd, layering and links clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…hat should have come first Three review rounds found defects a green suite could not see, so this measures the suite instead of arguing about it: seventeen mutations, one per guard the branch adds, each the smallest edit that removes it. Four survived. The worst was `repo.cjs`'s three-option fallback — the fix for a blocker that killed the journal outright on an older git. Nothing failed when it was removed, so that blocker could have walked straight back in. The four are now held, and each was written the way the new rule says: the test first, then the mutation to watch it go red. - A git stubbed to reject `--git-path` still journals its session. - A merge commit is not counted, since the delegate refuses merges by design and counting them puts commits in the denominator that can never be in the numerator. - The empty-commit-list branch is deleted rather than tested: `git log` exits non-zero in a repository with no commits, so the branch above already answers there, and a repository whose every commit is a merge cannot exist. A line nothing can reach is a guard nothing can fail for. - The staging unlink stays, untested, and the comment says so plainly instead of implying otherwise: no POSIX input gets past the write and fails the rename — one directory, so no cross-device case, and a target that is a directory throws on the read long before. It exists for Windows EPERM, and no test on this branch runs there. 16 of 17 held now, and the survivor is the one documented as unreachable here. **And the rule, in `coding-assertions.md`, because the method failed before the code did.** Tests written after the code confirm what was built; only one that failed first says it is checking anything. Two consequences learned by paying for them: a guard ships with the mutation that proves it, and a scripted edit is read back before anything is run — a replacement whose anchor drifted applies to nothing and reports nothing, which is how `hooksDirMissing` came to be declared in a type, believed by a display, and produced by no code. An inconsistent state is worse than a missing feature: nobody designed it, so nobody can reason about it. scripts 333, CLI 3,061 unit and integration, 292 e2e; typecheck, biome, knip, layering and links clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Windows has no POSIX permission bits: `statSync` reports `0o666` for every writable file whatever it was chmod'ed to, so `cli / Windows` failed on `438 !== 504` in two tests that compared against `0o770` and `0o700`. The repair was never wrong. The assertions were: they named a literal where the property is "the mode the repair found is the mode it left", which holds on both platforms and still dies for the right reason — carrying a plausible `0o755` instead of the mode found turns both red, and only those two. The `mode` option on the staging write is documented as having no test that can fail for it, rather than left looking guarded: `chmod` sets the same bits immediately after, so it survives every mutation. It stays for the creation window it narrows, which nothing here can observe. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…says `cli / Windows` failed on `expected 'not-executable' to be 'executable'`. Windows records no execute bit: every readable file reports `0o666`, and git runs whatever hook it finds through `sh`. Reading `mode & 0o111` therefore printed "its script is not executable, so git will not run it" about a hook git runs — an unknown reported as a fault, on every Windows run. `FileReader.fileMode` becomes `FileReader.isExecutable`, backed by `access(X_OK)`. That is the question `check` was always asking: not what bits a file carries, but whether whoever invokes git can execute it. On POSIX it also answers for the invoking user rather than for any owner, which is who git actually runs the hook as. The two assertions that remain POSIX-only say so: removing a bit means something only where the bit exists, and the states that exist everywhere — absent, and runnable — are still asserted on both. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
This was referenced Sep 3, 2026
Merged
5 tasks done
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.
🎯 What & why
The commit trailer installs by appending one line to
prepare-commit-msg. When another toolowns that file — lefthook, husky — the next regeneration erases the line, the trailer stops
being written, and nothing says so. This re-establishes the call site on every session start,
and makes
aidd telemetry checkreport whether commits actually carry their session.🛠️ How it works
The repair does not defend the call site, it restores it. On
session-start, wherejournal.cjsalready pays a once-per-session cost, trailer-repair.cjsappends the line back when the delegate exists and the hook no longer calls it. It never asks
why it is gone, which is what makes one code path cover a regenerated hook, an overwrite by
hand, a
core.hooksPathmove, and a hook that never existed. No third-party tool is named, sonothing rots when their formats change.
aidd telemetry offdeletes the delegate and therepair only runs when the delegate is present, so nothing is resurrected after
off.Four guards decide whether the file is ours to write, each added because a case defeated
the previous one:
fs.realpathSync, notpath.resolve.ln -s ../.githooks .git/hooksdefeats a lexical comparison and modifies a tracked file.
git rev-parseprints--git-pathrelative to the cwd it ran in, not the repository root.Resolving against the root wrote into a different repository from a nested directory.
git rev-parsefails atomically, so adding a fourth option to the existing call made an oldergit refuse all of them and killed the journal. repo.cjs
asks four, then falls back to three.
open(2)applies the umask to a mode andchmoddoes not, so a0770hook came back0750;and
rename(2)needs write permission on the directory, so a0444hook was silentlyrewritten. Both are checked explicitly.
checkgains the claim that proves the chain rather than its parts: how many of the lastcommits actually carry
AIDD-Session-Id, as a count, never a bare pass. A zero count on anempty history and a zero count on a broken hook are different answers and read differently.
A cross-language guard (telemetry-trailer-line-agrees.integration.test.ts)
compares the line the hook writes against the line the CLI looks for, so the two cannot drift.
🧪 How to verify
Reproduce the failure by its shape, not by installing lefthook:
Mutation-measured, not asserted: 17 mutations, one per guard this branch adds. 16 fail a
test named for the thing broken. The 17th is the staging
unlink, which no POSIX input canreach — it exists for Windows
EPERM, and the code says so.One line was deleted rather than tested:
git logexits 128 on an empty repository, so thebranch above it always answers first.
trailer-repair.cjsdoes file-mode,renameandsymlink work — where POSIX and Windows diverge — and the one accepted mutation survivor is
untestable here by construction. The
cli / Windowsjob is the check that matters. Refs test(framework): a real session per tool, on Windows and on Linux #707.aidd_docs/memory/coding-assertions.mdgains a "Before the code" section: write the testfirst, ship a guard with the mutation that proves it, and read a file back after any scripted
edit. Three defects on this branch trace to not doing one of those.
🔗 Linked issue
Closes #746
✅ I certify
🤖 Generated with Claude Code
https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp