fix(hooks): move the compaction reprime to SessionStart, where its output reaches context - #1185
Conversation
…tput reaches context
precompact-reprime.ps1 was registered on PreCompact and emitted
{"hookSpecificOutput":{"hookEventName":"PreCompact",...}}. The harness rejects that
value, so the hook ran, exited 0 and put nothing back for its whole life. Measured
live 2026-09-15.
The event was the deeper half, so repairing the payload alone would have shipped a
second dud. PreCompact fires before the summary is written, which makes anything it
adds to context exactly what the compaction then summarises away. Exit-0 stdout is
added to context for UserPromptSubmit, UserPromptExpansion, SessionStart and
PostModelSwitch; PreCompact is in none of those and carries no documented
context-injection schema at all.
So the hook now emits plain text and is wired on SessionStart. The "only after a
compaction" scoping reads the source field off the stdin payload inside the script
rather than through a "matcher": "compact" row, because no SessionStart matcher has
ever been used here and the no-matcher form is the one measured to fire. The guard
fails open: it goes quiet only when it can positively read a payload saying this is
not a compaction restart.
Also repairs a negative age. ConvertFrom-Json hands back declaredAt as a datetime
with Kind=Utc, and the [string] cast dropped that kind, so a six-minute-old record
reported "-0.2 days old" -- wrong by exactly the UTC offset.
Adds tests/test_precompact_reprime_hook.py, which drives the real script. Before it,
grep -ri precompact over the repository returned two files and no test at all.
…path Review follow-up on the reprime fix, plus one defect the review surfaced. REUSE. The age arithmetic was a third copy of ConvertTo-UtcDateTime, which already lives in scripts/coord/config-roots.ps1 and whose own docstring forbids restating it. The copy was the weakest of the three: it dropped the [datetimeoffset] arm and the DateTimeOffset parse. The hook now dot-sources that library, AFTER the scope guard so a cold start never pays for it. THE DEFECT THAT FOUND ITSELF. Deleting the local copy left the call site pointing at a function that no longer existed. The call threw, the script's outer handler swallowed it, and the hook exited 0 in perfect silence -- the exact failure shape this change exists to repair, reproduced by the repair. The age computation is now scoped in its own catch so a missing library costs the age line and not the seat, the goal and the held ledger numbers, and a test drives a copy of the script from a directory where the library is unreachable. SCOPE COMMENT. The guard's property is "not a compaction", so the comment no longer enumerates the starts that are not one; the harness ships at least five sources and a list of the others is one short the day it grows. TESTS. A contract arm now drives the real seat.ps1 -Declare rather than hand-building the record, so the reader and the writer cannot drift apart silently. The fixture dropped from five git spawns to three, and the two paths the hook derives are cached once per worktree instead of per call. Rejected one review finding after measuring it: PowerShell's -ne is case-insensitive by default, so the worktree-ownership test is not case-sensitive as reported.
Lander reviewThe deeper half is the event, and getting that right is what makes this a fix rather than a second dud. Verified rather than readFail-open, in the right direction. The scope guard goes silent only when it can positively read a payload saying this is not a compaction restart; an absent or unparseable payload speaks anyway. For a reprime that is correct — a spurious re-inject costs a few lines, a silent one costs the facts. The wiring itself is asserted, in The matcher decision is the right kind of conservative. The negative-age fix uses the shared helper, On leaving the
|
The hook ran on the wrong event and put nothing back, for its whole life
scripts/hooks/precompact-reprime.ps1was registered onPreCompactand emitted:The harness rejects that value. Observed live 2026-09-15:
PreCompactis a real event. It is not an acceptedhookSpecificOutput.hookEventName, so thereprime text never reached context once. It surfaced only because the rejection notice happened to
echo the payload into a transcript somebody was reading.
The event was the deeper half, so repairing the payload alone would have shipped a second dud.
PreCompactfires before the summary is written, which makes anything it adds to context exactlywhat the compaction then summarises away. The vendor documentation agrees from the other side: exit-0
stdout is added to context for
UserPromptSubmit,UserPromptExpansion,SessionStartandPostModelSwitch,PreCompactis in none of those lists, and no context-injection schema isdocumented for it at all. That documentation names this exact repair -- a
SessionStarthook on thecompactsource.What changed
seat-declare-prompt.ps1has always used atSessionStart, and the form measured landing in context the same day.SessionStart, added to the existing handler group. ThePreCompactrow is left inplace; see the collision section.
sourceoff the stdin payload and stays quiet unlessit is
compact. The guard fails open: it goes silent only when it can positively read a payloadsaying this is not a compaction restart.
ConvertFrom-Jsonhands backdeclaredAtas a[datetime]withKind=Utc; the[string]cast rendered the UTC wall clock with no zone marker, and re-parsingthat gave
Kind=Unspecified, which subtracted as though it were local. A six-minute-olddeclaration reported
-0.2 days old. The hook now calls the sharedConvertTo-UtcDateTimeinscripts/coord/config-roots.ps1rather than carrying a third copy of that rule.tests/test_precompact_reprime_hook.py, 21 tests. Before this,grep -ri precompactover therepository returned two files and no test.
Why the scoping is not a
"matcher": "compact"rowThat matcher is documented and would be the tidier spelling. No
SessionStartmatcher has ever beenused in this repository and whether this harness honours one is unmeasured, while the no-matcher
row is measured to fire on a compaction. Choosing the unmeasured spelling risks re-shipping the exact
defect this PR fixes: a hook that reads as wired and never runs. The guard holds either way, so
adding the matcher later stays safe.
The collision with PR 1183, measured
PR 1183 deletes the
UserPromptSubmitblock, whose hunk ends immediately above"PreCompact": [.This branch therefore only adds to the
SessionStartblock and leavesPreCompactalone.6d3e0eb)origin/main(94b8c2a)PreCompactblock.claude/settings.jsonControl 2 is the one that makes the clean result mean something: the same
merge-treeinvocationcan report a conflict, so exit 0 above is attributable to the merge rather than to a broken
instrument. It was built with
git commit-treeplumbing, so no working tree or ref was touched.Control 2 is also the answer to "why not just delete the dead row". Deleting it conflicts, and a
settings.jsonconflict is a content conflict the Lander may not resolve. See the open items.Positive controls
Every absence assertion is paired with a planted defect in the file itself
(
test_the_envelope_detector_can_actually_fail,test_the_source_grep_can_actually_fail). Beyondthose, the script was deliberately broken five ways and the suite re-run each time:
The tree was restored and re-verified green (21 passed) after each.
Mutation D is recorded because it found a gap in my own reasoning: reverting only the
[string]cast still passed the age tests, because the
.ToUniversalTime()on the other side compensated.Mutation E is the one that reproduces the shipped defect, and it fires.
A defect the review surfaced, caused by the repair
Deleting the local UTC helper left the call site pointing at a function that no longer existed. The
call threw, the script's outer handler swallowed it, and the hook exited 0 in perfect silence --
the exact failure shape this PR exists to repair, reproduced by the repair. Two things came out of
it: the age computation is now scoped in its own catch so a missing library costs the age line and
not the seat, the goal and the held ledger numbers; and
test_a_missing_shared_library_costs_the_age_and_not_the_reprimedrives a copy of the script from adirectory where the library is unreachable.
Checks
Run, all green:
ruff check .andruff format --check .mypy messagefoundry(strict) -- 274 source filespytest tests/test_precompact_reprime_hook.py-- 21 passedpytest tests/test_tooling_partition.py tests/test_claude_settings_contract.py tests/test_announce_wiring.py tests/test_coord_seat_prompt.py tests/test_role_cards.py-- 106 passed, 5 subtestspre-commithook, on both commitsNot run: the full suite, and every hosted-runner-only leg (
windows-service-smokeand the rest).Those must be read on the PR after this process exits. A local red in
tests/test_connscale_smoke.pyis disk contention on this box, not this change.Open items for whoever picks this up
PreCompactrow should be deleted once PR 1183 lands. It is measured to conflicttoday (control 2 above). Leaving it is safe at runtime -- the script's event guard keeps it silent
there, pinned by
test_a_leftover_registration_on_another_event_stays_quiet-- but it is areviewed record whose
statusMessageclaims a live control that does nothing, which is a familythis repo already names.
seat-declare-prompt.ps1has no source guard, so it also fires on a compaction restart. Twoconsequences, both pre-existing and neither fixed here: the session now receives the declare
prompt and the restored seat as adjacent lines, which is redundant rather than false; and
seat.ps1 -Promptre-stampsgoalPromptedAton every compaction, so "asked at T, still no goal Nhours later" stops being measurable. That discriminator is what that file's own header calls its
whole contribution. I judged this out of scope for a one-turn change -- it is a second hook
with its own suite, and the re-stamping predates this PR. It is the natural follow-up.
Stopand the context-reaching event set disagree._STDOUT_REACHES_CONTEXTin the new testquotes the documented four, which exclude
Stop;scripts/hooks/seat-record.ps1states in itsheader that Stop output is shown to the model and relies on plain stdout for a diagnostic. One is
wrong. Nothing here turns on it, so it is recorded rather than guessed at.
tests/test_claude_settings_contract.py: every hook emitting ahookSpecificOutputenvelope must name an event the harness accepts. Note that the obviousversion -- "the envelope must name the event it is registered on" -- would have passed over
this defect, because the two agreed and were both wrong. The check needs the accepted union, which
is a vendor fact that decays on client upgrade. A review agent reported reading that union out of
the installed binary; I did not witness that read, so I have not encoded it anywhere.
scripts/hooks/mail-drain.ps1echoes the payload's event name dynamically and would defeat aregex-based version, so it must be classified as unreadable rather than skipped.
three
git rev-parsecalls that collapse to one; the seats walk, measured at 1451 files and up to1110 ms, which could narrow to one box directory via
ConvertTo-BoxKeybut changes behaviour; andthe alloc walk at 611 ms over 852 records, 816 of them
backlog, which CLAUDE.md says is nolonger allocated here. Each is a judgement call rather than a cleanup.
test as case-sensitive against a case-insensitive filesystem. PowerShell's
-neiscase-insensitive by default (
-cneis the case-sensitive form), measured directly, so the claimdoes not hold and no change was made.