feat(776): every hook reports that it fired, and the report is measured #795

Merged
timothy merged 14 commits from feat/776-hook-fire-log into main 2026-08-14 21:11:05 +02:00
Owner

Every hook now records that it fired and what it decided, through one shared sink.

fixes #776

The gap

In this harness version only Stop hooks emit a structured transcript record. PreToolUse and
PostToolUse hooks — which is every guard this repo relies on: merge consent, worktree ownership,
BOM, agent model/RAM, the golden-file ban — leave no durable execution trace. #773 §5.4 could only
count filename mentions in settings dumps, i.e. inference, so a silently broken hook looked
identical to a working one indefinitely.

What landed

  • scripts/hook-fire-log.sh — one script, write side and read side, so the record format cannot
    drift between them. A fire record on entry, an exit record with status and decision.
  • The decision is parsed from the bytes the hook actually emitted, never declared by the author.
    Every Claude hook here exits 0 always and decides by printing JSON; pretooluse-merge-consent.sh
    alone reaches that print from ~40 call sites. Capturing stdout avoided 40 edits in the repo's most
    load-bearing guard and records what the harness sees rather than what the author meant.
  • Two hook classes, deliberately split: Claude hooks capture stdout; git hooks stream it and
    decide by exit code — capturing a slow pre-push hook's output would hold it back and read as a hang.
  • scripts/tests/test_hook_fire_log.py (78 tests), scripts/tests/conftest.py, guard-inventory
    rows, a decision record, and §5.4 of defect-shapes-773.md re-derived from measurement.

Measured, not inferred

11 of 13 hooks confirmed firing with their decisions. The load-bearing row is a real deny
pretooluse-bash-guard actually blocked an ETV_UPDATE_GOLDENS=1 probe, which is what separates
"ran" from "present". The two zeroes need a live browser and a real merge; §5.4 states what is
established (the scripts work) separately from what is not (that the harness dispatches to them),
because unit tests bypass registration.

Review

Six rounds, two independent reviewers per round (cross-family Codex + cold Claude). Findings and
fixes are in the commit messages; the durable ones are in the decision record. Highlights:

  • The differential test was 97% vacuous on arrival — 160/165 comparisons "" == "". It now
    constructs a positive case per hook (bare remote, stub Gitea, foreign worktree marker, BOM .cs,
    stubbed memory_pressure) and test_the_AB_is_not_VACUOUS_for_any_hook refuses to let any hook
    ride on empty comparisons.
  • exec 0<"$sin" 2>/dev/null was silencing every hook's stderrexec with no command
    redirects the shell permanently. Every husky guard's user-facing output is stderr.
  • Two mechanisms were withdrawn rather than patched: the signal trap (three defects in three
    rounds for a sub-millisecond rescue) and a generalised redirect-order guard (four iterations, then
    constructed false positives and negatives). Both withdrawals enumerate what they lose.
  • The decision record's signal measurement was wrong twice — once fabricated, once inverted —
    and now prints the 12-cell matrix plus its reproduction instead of a conclusion. Independently
    re-measured, five repeats per cell, with a positive control.

Verification

  • pytest scripts/tests — 663 passed, 1 skipped
  • scripts/decisions_validate.py — OK; catalog regenerated
  • Live headless runs for the measurement; pre-push guards pass at this head
  • Two mutation proofs, both witnessed red: coverage (…_LOSES_its_instrumentation_is_DETECTED) and
    transparency (…DELETING_the_replay_makes_the_differential_go_RED)

Not applicable: no .cs (BOM gate) and no web/ (SPA gate) files changed.

Every hook now records that it fired and what it decided, through one shared sink. fixes #776 ## The gap In this harness version only `Stop` hooks emit a structured transcript record. `PreToolUse` and `PostToolUse` hooks — which is *every* guard this repo relies on: merge consent, worktree ownership, BOM, agent model/RAM, the golden-file ban — leave no durable execution trace. #773 §5.4 could only count filename mentions in settings dumps, i.e. inference, so a silently broken hook looked identical to a working one indefinitely. ## What landed - `scripts/hook-fire-log.sh` — one script, write side and read side, so the record format cannot drift between them. A `fire` record on entry, an `exit` record with status and decision. - **The decision is parsed from the bytes the hook actually emitted**, never declared by the author. Every Claude hook here exits 0 always and decides by *printing* JSON; `pretooluse-merge-consent.sh` alone reaches that print from ~40 call sites. Capturing stdout avoided 40 edits in the repo's most load-bearing guard and records what the harness sees rather than what the author meant. - Two hook classes, deliberately split: Claude hooks `capture` stdout; git hooks `stream` it and decide by exit code — capturing a slow pre-push hook's output would hold it back and read as a hang. - `scripts/tests/test_hook_fire_log.py` (78 tests), `scripts/tests/conftest.py`, guard-inventory rows, a decision record, and §5.4 of `defect-shapes-773.md` re-derived from measurement. ## Measured, not inferred **11 of 13 hooks confirmed firing** with their decisions. The load-bearing row is a real `deny` — `pretooluse-bash-guard` actually blocked an `ETV_UPDATE_GOLDENS=1` probe, which is what separates "ran" from "present". The two zeroes need a live browser and a real merge; §5.4 states what is established (the scripts work) separately from what is not (that the harness dispatches to them), because unit tests bypass registration. ## Review **Six rounds, two independent reviewers per round** (cross-family Codex + cold Claude). Findings and fixes are in the commit messages; the durable ones are in the decision record. Highlights: - The differential test was **97% vacuous** on arrival — 160/165 comparisons `"" == ""`. It now constructs a positive case per hook (bare remote, stub Gitea, foreign worktree marker, BOM `.cs`, stubbed `memory_pressure`) and `test_the_AB_is_not_VACUOUS_for_any_hook` refuses to let any hook ride on empty comparisons. - `exec 0<"$sin" 2>/dev/null` was **silencing every hook's stderr** — `exec` with no command redirects the shell permanently. Every husky guard's user-facing output is stderr. - Two mechanisms were **withdrawn rather than patched**: the signal trap (three defects in three rounds for a sub-millisecond rescue) and a generalised redirect-order guard (four iterations, then constructed false positives and negatives). Both withdrawals enumerate what they lose. - The decision record's signal measurement was **wrong twice** — once fabricated, once inverted — and now prints the 12-cell matrix plus its reproduction instead of a conclusion. Independently re-measured, five repeats per cell, with a positive control. ## Verification - `pytest scripts/tests` — 663 passed, 1 skipped - `scripts/decisions_validate.py` — OK; catalog regenerated - Live headless runs for the measurement; pre-push guards pass at this head - Two mutation proofs, both witnessed red: coverage (`…_LOSES_its_instrumentation_is_DETECTED`) and transparency (`…DELETING_the_replay_makes_the_differential_go_RED`) Not applicable: no `.cs` (BOM gate) and no `web/` (SPA gate) files changed.
timothy added 14 commits 2026-08-14 18:56:55 +02:00
In this harness version only `Stop` hooks emit a structured transcript record.
`PreToolUse` and `PostToolUse` hooks leave no durable execution trace — which is
every guard this repo relies on. #773 §5.4 could therefore only count filename
mentions in settings dumps, i.e. inference, so a silently broken hook looked
identical to a working one indefinitely.

Each hook now sources one shared sink, `scripts/hook-fire-log.sh`, and records a
`fire` record on entry and an `exit` record carrying its status and decision. The
decision is parsed from the bytes the hook actually emitted rather than declared
by the author: Claude hooks always exit 0 and decide by printing JSON, so stdout
is diverted and replayed; git hooks decide by exit code and stream their output,
so theirs is left alone. That avoided ~40 edits inside pretooluse-merge-consent
and records what the harness sees rather than what the author meant.

The instrumentation sits in the stdin/stdout path of the most authoritative
guards in the repo, so transparency is the load-bearing claim, not coverage:
test_instrumentation_changes_NOTHING_the_harness_can_see drives every hook with
and without it over a payload matrix reaching each hook's deciding branch, and
demands byte-equal stdout and equal exit status. It shipped with two defects that
only that shape catches — `out=$(cat f)` stripping a trailing newline, and an
unconditional stdin slurp that hangs on a TTY and would have frozen every
interactive `git commit`. Both are now pinned, the second through a real pty.

fixes #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies ersatztv#784's who-benefits test to this change's own prose: if only
the author's timeline explains why a sentence is there, it is narrative. Three
comments said "the first version of this did X" where the reader only needs the
hazard and the reason nothing downstream would catch it.

One of the three turned into a real correction rather than a reword. The
byte-exactness test was justified as "the A/B does not cover this", which is
sharper and also true: the A/B compares the two halves against each other, so a
wrapper that truncated BOTH would pass it. Only comparing against the literal
expected bytes catches that.

Refs: #776
Refs: #784

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5.4 said we cannot tell whether our own hooks fire, and could only count
filename mentions in settings dumps. It now carries a per-hook table with the
decision each hook reached: 11 of 13 confirmed firing, including a real deny
from pretooluse-bash-guard blocking an ETV_UPDATE_GOLDENS probe, which is what
distinguishes "this guard ran" from "this guard is present".

The two zeroes are named as the not-exercised kind rather than presented as a
verdict — nav-guard needs a live browser and merge-consent needs a real merge,
and manufacturing a merge to watch the merge guard is worse than the gap.

Refs: #776

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2, from two independent cold reviews. The headline defect was found by
the test the reviews prompted rather than by the reviews themselves:

  exec 0<"$sin" 2>/dev/null

reads as "suppress errors from this redirection". It is not. `exec` with
redirections and no command applies them to the shell PERMANENTLY, so this sent
each hook's ENTIRE stderr to /dev/null. Every husky guard's user-facing output
is stderr — the H6 "push to main BLOCKED" message, the BOM guard's remediation
text, the missing-trailer message — so the guards went on blocking correctly
while telling the human nothing about why. Invisible to the original A/B, which
compared only stdout and exit status.

The finding that exposed it was ranked Low ("the A/B ignores stderr").

Also fixed, each with an executed proof:

- The fd restore sat inside the same conditional as the replay, and the replay
  read the PATH, so an unlinked-but-open scratch file meant a swallowed `deny`
  at exit 0. Restore is now unconditional and the replay reads through a held
  fd, which survives unlink.
- A SIGTERMed hook lost output it had already printed (52 bytes -> 0). Bash runs
  no EXIT trap for an untrapped fatal signal; INT/TERM/HUP are trapped now.
- The classifier misreported an uppercase decision as prose, read the bare word
  additionalContext in free text as a decision, and let a non-zero exit ERASE a
  decision the hook really printed.
- The session id reached the log FILENAME unscrubbed. The first fix reused the
  record scrubber, which deliberately keeps `/` and `.`, so `../../escaped` went
  through untouched: passing a value through a scrub is not the same as passing
  it through the right scrub.
- Preamble line 1 could abort a `set -e` hook, turning a fail-open guard
  fail-closed; and a missing sink printed `command not found` to stderr.

The big one is test quality. `test_instrumentation_changes_NOTHING_the_harness_
can_see` was 160/165 empty-vs-empty and 165/165 exit-0-vs-exit-0, so deleting
the whole replay left it green for four hooks, two of which issue `deny`. Three
places claimed the matrix reached every deciding branch, including the decision
record. It now constructs the missing cases — a foreign worktree marker, a BOM
`.cs` in an *ersatztv*-named repo, stubbed memory_pressure, a real bare remote
for the three pre-push guards, a stub Gitea for prepush-donewhen — and
test_the_AB_is_not_VACUOUS_for_any_hook fails unless each hook has a case where
the control side actually says something.

Running pytest also wrote 115 synthetic fires into the production log, so the
report described the test suite while looking like a record of real sessions.
scripts/tests/conftest.py isolates every test; verified live, a full suite run
now leaves the real log untouched.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3, from two independent re-reviews of the round-2 fix commit. The fix for
the lost-output-on-SIGTERM defect introduced a worse one than the defect.

The signal handler ended in `exit "$?"`, and `$?` at signal time is the status of
whatever command was running — normally 0 for a guard still gathering evidence.
Measured on the real guard, `prepush-donewhen.sh` SIGTERMed mid-curl with an
unticked Done-when box:

  uninstrumented   rc=-15  git ABORTS the push
  instrumented     rc=0    git PUSHES to main

That converts a killed guard into consent, in the permissive direction, in the
hook that protects `main`. The handler now flushes, restores the default
disposition and re-raises the signal, so the process dies the way it would have
died. test_output_survives_a_SIGTERM could not see it: it asserts stdout content,
never reads the return code, and drives a capture-mode hook so no exit-code guard
is ever signalled.

Also from this round:

- The flush ran TWICE on the signal path (signal handler, then EXIT trap),
  writing two contradictory exit records for one invocation — a real decision
  then a phantom no-op — so the report mis-histogrammed the very case the trap
  was added for. It is idempotent now.
- `exec 4<&- 2>/dev/null`: the round-1 blocker pattern again, eight lines below
  the comment forbidding it. Rather than fix the third instance and wait for a
  fourth, test_no_exec_in_the_sink_carries_a_STDERR_REDIRECT now enforces it.
- The log write leaked `Operation not permitted` to stderr on an unwritable log,
  because `>> "$file" 2>/dev/null` opens the file BEFORE redirecting stderr. The
  fail-open test used a path that dies at mkdir and never reached this.
- Classification: an unrecognised value is no longer lowercased into a valid
  decision, exit 2 now dominates a printed `allow` (it is the harness's block
  channel, and reporting a permit for a refused call is the one direction this
  log must never be wrong in), and a non-zero exit no longer annotates a real
  decision with an invented `+error`.
- Classification and replay read the same bytes; they disagreed when a hook used
  fd 4, so the harness saw `deny` while the log recorded `no-op`.
- The stderr normaliser rewrote `line N:` globally, collapsing genuinely
  different diagnostics; it is anchored to the script-path prefix now.
- docs 5.4 claimed the two zero rows were "trigger did not fire", justified by
  unit tests that invoke the scripts directly and therefore prove nothing about
  harness dispatch. It now separates what is established from what is not, and
  stops contradicting its own decision record.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review rounds, and every round found the previous round's FIX had
introduced something beside its target. That is the transferable finding, not any
individual bug, so it goes in the record rather than only in a commit message.

Also names the pattern that cost two of those rounds: a fix that reads as applied
while changing nothing (a scrubber that permits the very characters it was added
to remove), and a rule restated in a comment three times instead of enforced once
by a test.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 4, two independent reviews. No Blocker, but the signal trap produced its
third defect in three rounds and the fourth finding settles it: bash does not run
a trap handler until the current foreground command completes. Measured, same
hook, SIGTERM to the pid: uninstrumented dies in 1s, instrumented in 30s. A
TERM-then-KILL supervisor therefore gets no flush AT ALL plus a 29s stall —
strictly worse than untrapped, on the exact path the trap existed for.

So it is withdrawn, not patched again. What that loses is enumerated in the
decision record (a killed hook's exit record, its already-printed stdout, its
temp files) rather than waved past. The invariant that replaces it is stronger
and is asserted as an A/B: under a signal, instrumented and uninstrumented are
indistinguishable in BOTH exit status and time to die. The timing half is what
the trap broke, and no assertion about output could have caught it.

Also fixed this round:

- A signalled hook was RECORDED as code=0 decision=pass, because `local sig=...`
  clobbers `$?` before the flush can read it. git blocked correctly while the log
  said the killed guard passed. Moot now the trap is gone, but it is why the
  signal tests assert the record and not only the process status.
- The classifier's sed/tr were locale-aware, so one 0xE9 byte in a deny reason
  printed `illegal byte sequence` at the harness AND filed a real deny as
  `output`. LC_ALL=C.
- Payload fields were read from a 64KB prefix, so a large tool_response pushed
  session_id past the cap and those fires filed under `unknown-session` — reading
  as never-fired in the report. Unbounded now.
- `cat <&4 2>/dev/null` and `cat > "$sin" 2>/dev/null`: the SAME left-to-right
  redirect hazard, at two more commands. The previous guard grepped only `exec`,
  so it walked past both — the class was named, not removed.
  test_no_redirection_in_the_sink_PRECEDES_its_stderr_redirect now covers any
  command, exempting `2>&1` (which is correct precisely because it comes after).
- `deny2`/`deny_now` extracted as `output` rather than `unrecognized`; an
  inherited ETV_HOOK_FIRE_FLUSHED=1 disabled reporting entirely; unclassified
  output plus a failing exit hid the failure as `output`; NUL bytes were dropped
  and warned about on stderr.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 5, two independent reviews. Two findings, one technical and one about
honesty, and the second is the more important.

THE RATIONALE. The round-4 commit and the decision record state as measured fact
that trapping signals made a hook take 30s to die where it took 1s. That came
from a review finding I accepted without re-measuring. It is wrong. Measured A/B:

  signal the pid    control 4.01s   trapped 4.12s
  signal the group  control 0.002s  trapped 0.054s

The stall is bash deferring a fatal signal until its foreground command finishes,
and it happens identically with NO trap — the control stalls too. The trap cost
~0.1s, not 29s. The withdrawal still stands, but on its real grounds: three
silent permissive-direction defects in three rounds for a sub-millisecond rescue
window, and the trapped sink STILL returned rc 0 on SIGINT where the control
returns -2. The record now carries the correction rather than the fabrication,
because a decision record with an invented measurement reads as checked and talks
the next reader out of checking.

The test that was supposed to enforce this was itself vacuous: it signalled the
pid, so both sides took the full sleep and the timing assertion could distinguish
nothing. It signals the process group now — the supervisor shape, and the only
one where the assertion can see anything.

THE LOCALE FIX WAS INERT. `local LC_ALL=C` does not export, so the child sed/tr
never saw it. It read as fixed only because this author's shell sets LANG alone;
with an inherited LC_CTYPE (macOS Terminal, ssh SendEnv, sudo env_keep) LC_CTYPE
outranks the exported LANG=C and the symptom returns in full — a real deny filed
as `output`, records under `unknown-session` with empty event and tool, and
`illegal byte sequence` at the harness. `local -x` now, verified end-to-end
across four locale environments, and the test is parametrised over them because a
single ambient locale tests one row of the matrix.

Also this round:

- Removing the 64KB cap ARMED a latent bug rather than fixing one: a greedy `.*`
  over a single-line payload takes the LAST match, so a nested session_id inside
  a tool_response outranked the top-level one and filed the whole invocation
  under the wrong session. The cap had been accidentally protecting the right
  answer. First-match extraction now, bounded at 256KB where the bound is no
  longer load-bearing (3 unbounded scans cost 1.5s on a 20MB payload).
- A present-but-empty decision value was laundered to `output`.
- The redirect-order guard excluded operators preceded by a digit, so it was
  blind to `exec 0<` — the exact line it was written for. Mutation-checked both
  ways now: clean on the real file, red when the original bug is reintroduced
  verbatim. Its limits are stated in the docstring rather than implied.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table was measured against an early version of the sink and the classifier
changed materially afterwards, so the numbers were a claim about code that no
longer existed. Repeated against the final implementation: identical decisions
for all five Claude hooks a Bash/Write session reaches.

States what the repeat did NOT cover — the Agent pair and the four git hooks are
carried over from the original run — rather than implying the whole table was
re-measured.

Refs: #776

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 6. One High, and it is round 5's own fix: bounding the payload scan to
256KB to make it fast reintroduced the truncating read that round 5 had just
removed. A payload whose filler pushes session_id past the cap returns nothing,
the record files under `unknown-session`, and that fire reads as NEVER HAVING
HAPPENED. My nested-field test could not see it — its identities sit before only
1000 bytes of filler.

The cap is now a fast path with an unbounded fallback, so it is an optimisation
rather than a correctness dependency. Verified with identity 400KB deep.

Also this round:

- The field helper was defined inside a function and so was globally scoped under
  a short generic name, able to collide with a hook's own function. Namespaced
  and unset after use.
- WITHDRAWN: the generalised redirect-order guard. Four iterations, each fixing a
  false positive or negative the last introduced, and review then constructed
  more of both. Deleted rather than patched a fifth time, per the withdrawal
  precedent in docs/guard-inventory.md — a weak detector is itself the
  symptom-keyed mistake. The narrower exec-only guard is retained: a sound match
  on a fixed keyword, covering every case that has actually occurred.
- The signal A/B compared only status and timing and DISCARDED stdout/stderr. It
  now compares both, which surfaced a real difference: on a group kill with a
  live child, bash prints a job-control notice the control does not. Isolated to
  "the exit handler does work, so the shell lives ~50ms longer" — inherent to the
  sink's purpose. Exempted narrowly, by the signal word only, and documented as a
  measured limit rather than papered over.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 6 found a Blocker, and it is in the decision record rather than the code:
the paragraph written LAST round to correct a fabricated measurement contains an
inverted one. Same mistake, one round later, in the artifact written to fix it —
and by the same route, taking a review summary without re-measuring.

Claimed: the round-3 trapped sink "returned rc 0 on SIGINT where the control
returns -2". Measured myself, 12 cells ({group,pid} x {TERM,INT,HUP} x
{control, round-3, HEAD}):

  pid + INT:   control 0   round-3 -2   HEAD 0

Exactly inverted. The trapped sink returned -2 where the control returns 0 — a
real transparency violation, but in the RESTRICTIVE direction, which also makes
"the consent bug survived" the wrong characterisation. HEAD matches the control
in all twelve cells. The record now prints the matrix instead of asserting a
conclusion about it.

A second false claim in the same paragraph, also removed: that the test's
exit-status assertion "catches the SIGINT case". It does not. The test signals
the process GROUP, and in every group cell the trapped sink is indistinguishable
— so it would not have caught round 3 at all. The test pins the transparency of
the code that ships; it is not evidence any earlier version was worse, and the
record now says so.

Also this round:

- The job-notice exemption in the signal A/B hides a line that NAMES the sink's
  path on a guard's stderr (bash attributing its own message to the executing
  line). That is a real if small leak and the exemption conceals it. Accepted
  rather than fixed — it cannot be suppressed while the sink does any work at
  exit — but stated plainly instead of left as "bash's notice, harmless".
- docs/guard-inventory.md records the withdrawn redirect-order guard and, more
  importantly, what the surviving exec-only guard does NOT cover.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent re-measurement confirmed all twelve cells (five repeats each,
unanimous), that HEAD matches the control everywhere, and that the group-kill
test is blind to the trapped sink — the last with a positive control, patching
the trapped re-raise back to `exit 0` to watch the same test go red.

The table now names the sha it measured rather than a round number, and carries
the command that produces it. Third version of this paragraph; the first was
fabricated and the second inverted, both because a number was written without the
means to re-derive it.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI red on `Script tests (pytest)`, three failures, and the first is a real guard
defect that six review rounds on macOS could not see.

`pretooluse-bom-guard.sh` detects a BOM with `head -c3 | xxd -p`. `xxd` ships
with vim and is ABSENT on the Linux CI runner (verified on the runner host), so
the substitution yields empty, never equals efbbbf, and the guard passes every
BOM silently. It has been fail-open on any host without vim for its whole life.
`od -A n -t x1 -N 3` is POSIX and byte-identical on both platforms.

It surfaced only because #776's non-vacuity assertion demands that hook actually
reach a decision. The environment was a sampled population too — every review
round ran where xxd exists.

The other two failures were my tests being platform-specific:

- Linux prints a bare `Terminated` job-control notice where macOS prints
  `Terminated: 15   sleep 5`; the exemption regex required a trailing colon or
  space and so did not match.
- The locale matrix used en_US.UTF-8, which is not installed on the runner, so
  bash itself warned and the assertion was measuring the absent locale rather
  than the export fix. Unavailable locales are skipped with that reason stated.

And the stripper test is reframed. It compared the stripped hook against the
merge base, which asserted two unrelated things — that the stripper is exact, and
that no hook was edited for any other reason. The second went red the moment a
hook had a real defect fixed. It now asserts the property that actually protects
the A/B control: every line removed is a preamble line and none is added.
Mutation-checked — widening the stripper to eat `set -` lines turns it red.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(776): restore two inventory sections the rebase resolution dropped
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m3s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Review-verdict: MERGEABLE @ ba2b162 (base: main)
ba2b162238
Rebasing onto #798 conflicted in docs/guard-inventory.md at every commit that
touched it, and resolving those by taking the accumulated side silently dropped
two prose sections: the WITHDRAWN note for the generalised redirect-order guard,
and the paragraph recording that test_hook_fire_log.py asserts two clauses with a
separate executed mutation for each.

Counts, rows and the inventory test were all green without them — which is the
point worth recording. A conflict resolution that satisfies every mechanical
check can still lose the reasoning, because the checks count rows and the prose
carries the WHY. Caught by grepping the merged file for the content the branch
was supposed to add, rather than trusting green.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy force-pushed feat/776-hook-fire-log from 4c9a829bcd to ba2b162238 2026-08-14 18:56:55 +02:00 Compare
Author
Owner

Review-verdict: MERGEABLE @ ba2b162

Six independent cold review rounds, two reviewers each, plus a third-party re-measurement of the disputed signal matrix. Every round found a real defect; all fixed or explicitly withdrawn with lost behaviours enumerated. CI green on this head after rebase onto main. 667 tests pass. Two mutation proofs witnessed red. Known limits stated in the decision record rather than implied.

Review-verdict: MERGEABLE @ ba2b162 Six independent cold review rounds, two reviewers each, plus a third-party re-measurement of the disputed signal matrix. Every round found a real defect; all fixed or explicitly withdrawn with lost behaviours enumerated. CI green on this head after rebase onto main. 667 tests pass. Two mutation proofs witnessed red. Known limits stated in the decision record rather than implied.
timothy merged commit 499dd348ab into main 2026-08-14 21:11:05 +02:00
timothy deleted branch feat/776-hook-fire-log 2026-08-14 21:11:06 +02:00
Sign in to join this conversation.