Skip to content

fix(cli): the commit trailer survives a hook another tool regenerates, and check says whether it did - #749

Merged
blafourcade merged 10 commits into
nextfrom
fix/trailer-survives-a-regenerated-hook
Sep 3, 2026
Merged

blafourcade merged 10 commits into
nextfrom
fix/trailer-survives-a-regenerated-hook

Conversation

@blafourcade

Copy link
Copy Markdown
Contributor

🎯 What & why

The commit trailer installs by appending one line to prepare-commit-msg. When another tool
owns 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 check report whether commits actually carry their session.

🛠️ How it works

The repair does not defend the call site, it restores it. On session-start, where
journal.cjs already pays a once-per-session cost, trailer-repair.cjs
appends 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.hooksPath move, and a hook that never existed. No third-party tool is named, so
nothing rots when their formats change. aidd telemetry off deletes the delegate and the
repair 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:

  • Containment is compared on fs.realpathSync, not path.resolve. ln -s ../.githooks .git/hooks
    defeats a lexical comparison and modifies a tracked file.
  • git rev-parse prints --git-path relative 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-parse fails atomically, so adding a fourth option to the existing call made an older
    git 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 and chmod does not, so a 0770 hook came back 0750;
    and rename(2) needs write permission on the directory, so a 0444 hook was silently
    rewritten. Both are checked explicitly.

check gains the claim that proves the chain rather than its parts: how many of the last
commits actually carry AIDD-Session-Id, as a count, never a bare pass. A zero count on an
empty 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

node --test 'scripts/__tests__/aidd-telemetry-trailer-repair.test.js'   # 16 cases
cd cli && pnpm test                                                     # 3,061 + 292 e2e
pnpm exec lefthook run pre-push

Reproduce the failure by its shape, not by installing lefthook:

git init /tmp/t && cd /tmp/t
aidd telemetry on
printf '#!/bin/sh\n' > .git/hooks/prepare-commit-msg   # a tool regenerates it
aidd telemetry check                                    # call site: missing
# start a session, then:
aidd telemetry check                                    # call site: present

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 can
reach — it exists for Windows EPERM, and the code says so.
One line was deleted rather than tested: git log exits 128 on an empty repository, so the
branch above it always answers first.

⚠️ Heads-up

  • This branch has never run on Windows. trailer-repair.cjs does file-mode, rename and
    symlink work — where POSIX and Windows diverge — and the one accepted mutation survivor is
    untestable here by construction. The cli / Windows job is the check that matters. Refs test(framework): a real session per tool, on Windows and on Linux #707.
  • aidd_docs/memory/coding-assertions.md gains a "Before the code" section: write the test
    first, 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

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

reference-week and others added 8 commits September 3, 2026 06:38
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
@blafourcade
blafourcade requested a review from a team as a code owner September 3, 2026 08:04
reference-week and others added 2 commits September 3, 2026 10:10
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
@blafourcade
blafourcade merged commit 5b222a3 into next Sep 3, 2026
17 checks passed
@blafourcade
blafourcade deleted the fix/trailer-survives-a-regenerated-hook branch September 3, 2026 08:31
@aidd-bot aidd-bot Bot mentioned this pull request Sep 9, 2026
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