Skip to content

fix(hooks): move the compaction reprime to SessionStart, where its output reaches context - #1185

Merged
wshallwshall merged 2 commits into
mainfrom
precompact-hook
Sep 16, 2026
Merged

wshallwshall merged 2 commits into
mainfrom
precompact-hook

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The hook ran on the wrong event and put nothing back, for its whole life

scripts/hooks/precompact-reprime.ps1 was registered on PreCompact and emitted:

{"hookSpecificOutput":{"hookEventName":"PreCompact","additionalContext":"..."}}

The harness rejects that value. Observed live 2026-09-15:

Hook JSON output validation failed - hookSpecificOutput.hookEventName:
expected one of "PreToolUse" | "UserPromptSubmit" | "UserPromptExpansion" |
"SessionStart" | "Setup" | "PreModelSwitch" | ...

PreCompact is a real event. It is not an accepted hookSpecificOutput.hookEventName, so the
reprime 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.
PreCompact fires before the summary is written, which makes anything it adds to context exactly
what the compaction then summarises away. The vendor documentation agrees from the other side: exit-0
stdout is added to context for UserPromptSubmit, UserPromptExpansion, SessionStart and
PostModelSwitch, PreCompact is in none of those lists, and no context-injection schema is
documented for it at all. That documentation names this exact repair -- a SessionStart hook on the
compact source.

What changed

  1. Plain text on stdout, exit 0 -- the form seat-declare-prompt.ps1 has always used at
    SessionStart, and the form measured landing in context the same day.
  2. Wired on SessionStart, added to the existing handler group. The PreCompact row is left in
    place; see the collision section.
  3. Scoping lives in the script, which reads source off the stdin payload and stays quiet unless
    it is compact. The guard fails open: it goes silent only when it can positively read a payload
    saying this is not a compaction restart.
  4. A negative age is fixed. ConvertFrom-Json hands back declaredAt as a [datetime] with
    Kind=Utc; the [string] cast rendered the UTC wall clock with no zone marker, and re-parsing
    that gave Kind=Unspecified, which subtracted as though it were local. A six-minute-old
    declaration reported -0.2 days old. The hook now calls the shared ConvertTo-UtcDateTime in
    scripts/coord/config-roots.ps1 rather than carrying a third copy of that rule.
  5. tests/test_precompact_reprime_hook.py, 21 tests. Before this, grep -ri precompact over the
    repository returned two files and no test.

Why the scoping is not a "matcher": "compact" row

That matcher is documented and would be the tidier spelling. No SessionStart matcher has ever been
used 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 UserPromptSubmit block, whose hunk ends immediately above "PreCompact": [.
This branch therefore only adds to the SessionStart block and leaves PreCompact alone.

Merge Result Exit
Control 1 -- self-merge of this head clean 0
This head vs PR 1183 (6d3e0eb) clean 0
This head vs current origin/main (94b8c2a) clean 0
Control 2 -- an artificial head that removes the PreCompact block CONFLICT (content) in .claude/settings.json 1

Control 2 is the one that makes the clean result mean something: the same merge-tree invocation
can report a conflict, so exit 0 above is attributable to the merge rather than to a broken
instrument. It was built with git commit-tree plumbing, 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.json conflict 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). Beyond
those, the script was deliberately broken five ways and the suite re-run each time:

Mutation Tests that fired
A -- rejected JSON envelope restored 2
B -- scope guard deleted 2
C -- age degradation catch removed 1
D -- half the age fix reverted 1
E -- UTC instant compared against a local clock (the exact original defect) 2

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_reprime drives a copy of the script from a
directory where the library is unreachable.

Checks

Run, all green:

  • ruff check . and ruff format --check .
  • mypy messagefoundry (strict) -- 274 source files
  • pytest tests/test_precompact_reprime_hook.py -- 21 passed
  • pytest 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 subtests
  • every pre-commit hook, on both commits

Not run: the full suite, and every hosted-runner-only leg (windows-service-smoke and the rest).
Those must be read on the PR after this process exits. A local red in
tests/test_connscale_smoke.py is disk contention on this box, not this change.

Open items for whoever picks this up

  1. The dead PreCompact row should be deleted once PR 1183 lands. It is measured to conflict
    today (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 a
    reviewed record whose statusMessage claims a live control that does nothing, which is a family
    this repo already names.
  2. seat-declare-prompt.ps1 has no source guard, so it also fires on a compaction restart. Two
    consequences, 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 -Prompt re-stamps goalPromptedAt on every compaction, so "asked at T, still no goal N
    hours 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.
  3. Stop and the context-reaching event set disagree. _STDOUT_REACHES_CONTEXT in the new test
    quotes the documented four, which exclude Stop; scripts/hooks/seat-record.ps1 states in its
    header 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.
  4. A generic check may belong in tests/test_claude_settings_contract.py: every hook emitting a
    hookSpecificOutput envelope must name an event the harness accepts. Note that the obvious
    version -- "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.ps1 echoes the payload's event name dynamically and would defeat a
    regex-based version, so it must be classified as unreadable rather than skipped.
  5. Efficiency findings I did not take, all pre-existing lines this diff only touches as context:
    three git rev-parse calls that collapse to one; the seats walk, measured at 1451 files and up to
    1110 ms, which could narrow to one box directory via ConvertTo-BoxKey but changes behaviour; and
    the alloc walk at 611 ms over 852 records, 816 of them backlog, which CLAUDE.md says is no
    longer allocated here. Each is a judgement call rather than a cleanup.
  6. One review finding was rejected after measuring it. It reported the hook's worktree-ownership
    test as case-sensitive against a case-insensitive filesystem. PowerShell's -ne is
    case-insensitive by default (-cne is the case-sensitive form), measured directly, so the claim
    does not hold and no change was made.

wshallwshall added 2 commits September 15, 2026 11:05
…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.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander review

The deeper half is the event, and getting that right is what makes this a fix rather than a second dud. PreCompact is a real event but not an accepted hookSpecificOutput.hookEventName, so the payload was rejected and the reprime never reached context once. Repairing only the payload would have shipped a hook that still fires before the summary is written — adding exactly what the compaction then summarises away.

Verified rather than read

Fail-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 tests/test_precompact_reprime_hook.py against .claude/settings.json. That is the guard against this PR's own defect class: a hook that reads as wired and never runs. 19 new tests against a grep -ri precompact that previously returned two files and no test.

The matcher decision is the right kind of conservative. "matcher": "compact" is the tidier spelling, but no SessionStart matcher has ever been used 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 would risk re-shipping precisely the defect being fixed. The in-script guard holds either way, so adding the matcher later stays safe.

The negative-age fix uses the shared helper, ConvertTo-UtcDateTime in scripts/coord/config-roots.ps1, rather than carrying a third copy of the rule. ConvertFrom-Json hands back declaredAt as [datetime] with Kind=Utc; the [string] cast dropped the zone and re-parsing gave Kind=Unspecified, which subtracted as local — hence a six-minute-old declaration reporting -0.2 days old.

On leaving the PreCompact row in place

I checked this against PR 1183 before reviewing either, because the handoff flagged them as an ordering hazard. They do not collide: you add to SessionStart at line 57, 1183 removes UserPromptSubmit at line 125. The merge preserves both sides — 4266 bytes on main, minus 1183's 450, plus your 386, equals the 4202 the merge actually produces, and the result still parses.

So leaving PreCompact alone to avoid touching 1183's hunk was a real decision that worked, not a loose end. The row is inert anyway: its output is the rejected payload this PR exists to fix.

Verdict: merge.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 2578161 Sep 16, 2026
41 checks passed
@wshallwshall
wshallwshall deleted the precompact-hook branch September 16, 2026 18:09
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