The digest — the payload the whole plugin exists to carry — had never been improved on its own
merits since v0.1.0. All 460 checks pinned the transport (markers, handshake, occupancy scan,
config readers); none pinned the payload. This release pins the format, closes the two ways a
digest silently lost information, gives it repo facts read from git instead of recalled, and
lets it report its own staleness. 460 → 607 checks.
Added
- Digest section PARITY test (
tests/test-hooks.sh, invariant 18) —templates/session.mdis
now the declared source of truth for the four section headings, and the three copies that repeat
them by hand (the pass-1 REINJECT heredoc instop-hook.sh, the mechanical stub in
precompact-hook.sh, the_first_bullet/_first_linereader insessionstart-hook.sh) are
pinned to it in both directions: every template heading must appear in the two writers, and every
heading the banner READS must be one the template defines. Nothing checked this before — a rename
on either side degraded silently, dropping a line from the banner forever or making it read a
section no digest would ever have. Same defect class invariant 16 closed for the config readers,
left open on the payload. Red-verified with four separate mutations. missionfrontmatter field — the original ask, written once and copied verbatim on every
later snapshot, whileintenttracks where the work stands now. Rewriting the ask on every
snapshot made it a summary of a summary of a summary; after three resets the north star no longer
said what was asked for. Free at the parser layer (digest_fieldreads frontmatter generically,
claim_digestalready preserves unknown keys) — now pinned by a test that it survives the
rehydrate claim byte-identical.scripts/context-block.sh— the free-formgitcaller (one of three git call sites — see
invariant 20): branch, short HEAD, uncommitted
paths (capped, with a count),diff --shortstat, recent commit subjects. Prints nothing and
exits 0 outside a repo, with nogiton PATH, in an empty repo (no HEAD to resolve), or on a
broken.git— the same fail-open-silent shape asproxy_window(). Half a block, or one with
git's stderr in it, would land in a digest that gets injected into a fresh context as fact.- PreCompact's mechanical fallback is now usable. The worst path in the plugin —
auto-compaction firing before any agent-authored digest existed — produced three literal
(unknown)lines. It still cannot author prose (no model runs inside a hook), but it now states
where the repo stood and stampshead:, so the one digest that exists because nobody wrote one
is not also the one with no staleness signal. - Measured staleness in the rehydrate banner, two independent axes, both advisory (they never
gate, never block — invariant 11):head_drift()— the digest stamps the short HEAD it was written at; SessionStart counts the
commits since. A measured signal, unlike the mtime-ntheuristic.digest_age_days()— how long ago the digest was written, from filesystem mtime (never the
model-writtenupdated_at, which is routinely copied forward). This axis has nothing to do
with occupancy: a session that never crosses the budget is never asked to refresh. Measured in
this repo: its digest was stamped 2026-07-10 describing v0.1.9 while the repo was on v0.4.1 —
two months stale, and nothing ever said so.
/snapshot --check— an audit path that tests the digest's content. Every other gate tests
the transport: that the digest arrives. Whether it is any good cannot be judged from inside
the session that wrote it, because the author knows what it omits.--checkdispatches one
subagent given the digest alone — no conversation summary, nothing recalled — and asks what
it would do next, which files it would open, and what the original ask was. Divergence is the
defect, surfaced while it can still be fixed. Writes nothing, arms nothing, applies nothing
silently. The command contract is pinned (16 checks), not the verdict: a live subagent is not
deterministic, so there is no CI fixture suite.tests/test-context-block.sh— 89 checks over real repos built per case (clean, dirty,
detached HEAD, empty, broken.git, non-repo, empty PATH).
Fixed
- Every snapshot was a blind overwrite (invariant 19).
commands/snapshot.mdsaid "overwrite"
and never "read the existing digest first", and neither did the REINJECT the model actually sees.
An Open question raised in one session and untouched in the next died at that next snapshot —
silently, with nothing left to recover from. All four guidance surfaces (template comment,
REINJECT,commands/snapshot.md,SKILL.md) now say: read first, carry unresolved items forward
verbatim, strike only what is demonstrably resolved. Deliberately instruction rather than
mechanism — a hook cannot author a digest, and splicing an untrusted model-written body section
is exactly the corruptionfrontmatter_closedexists to prevent. New e2e cycle 10 pins that the
instruction is delivered at both authoring moments; three of its eight cases were red before. - The pass-1 REINJECT gave weaker guidance than the template's own HTML comment. The text the
model is actually handed listed four heading names and nothing else, while the template — which
it may never open — carried the real instructions. The REINJECT now states the quality bar
directly: In flight names files with line numbers, Next concrete step is an executable action
(a command or an edit with a path) and never "continue with X", and the test baseline goes under
Open questions & risks when there is one. - The staleness signals refuse to guess. A confident wrong number is worse than silence: an
authoritative "0 commits behind" over a badly stale digest is believed by a session that just
lost its context. Four gates, each found unpinned by mutation testing and each then measured:
without the anchored hex gate, ahead:ofHEAD~2— untrusted digest text — resolves and
counts as 2; without^{commit}, a hex-valid blob sha yieldsrev-list --count= 3 with
exit 0; without--is-inside-work-tree, a bare repo answers normally and reports drift for a
directory with no working tree; and withoutmerge-base --is-ancestor, a diverged stamp
answers happily. (An unknown sha already failsrev-listoutright with exit 128, which is why
pinning the^{commit}gate needs a blob and not a made-up sha.) - Drift was reported across diverged branches.
.reload/is per-project and shared across
branches by design, so the ordinary sequence — snapshot on a feature branch, switch to main,
rehydrate — askedrev-list --count <feature-tip>..HEAD, which answers happily. Measured: "2
commits since this digest" for a history the digest's own work is not in at all. The count is
well-formed and means something else entirely ("commits on HEAD absent from the stamp"), so it
now requires the stamp to be an ancestor of HEAD. Found in review; the first cut's other
gates all passed this input. context-block.shreported a failedgit statusas a clean tree.git status --porcelain
prints nothing when it fails (corrupt or locked index, I/O error, permissions), and testing
emptiness alone cannot distinguish that from a genuinely clean tree. Measured with a deliberately
corrupted.git/index: the block asserted "working tree clean" over a tree with uncommitted
content — a false claim landing in a digest a fresh session is told to trust. Now tests the exit
status and says the state is unavailable instead. Found in review.- Dead code removed: an explicit clock-skew guard in
digest_age_days(). A future mtime already
yields a negativedaysthat fails the threshold test (measured:days=-1157), so the check
could not change any outcome — deleting it left every test green, which is the definition of a
guard that is decorative rather than defensive. - The mtime trap, both halves (invariant 20).
claim_digestrewrites the digest through a temp
file +mv, and themvstamps a brand-new mtime — measured: a file backdated to 2020 reads as
nowimmediately after. So the age must be captured before the claim (or the signal dies
within a session), and the claim must restore the original mtime afterwards (or it decays
across sessions: every rehydrate rejuvenates the file, so a digest claimed on each/clearreads
as fresh forever however stale its content). The second half was invisible to the suite and found
by running the real hook against this repo's own digest — content from 2026-07-10, mtime from
that morning, because a SessionStart had claimed it. The single case the signal exists for is the
one it would have missed. Restored withtouch -rfrom a reference file, neverdate -r(an
epoch on BSD, a FILE on GNU — silently wrong on one of the two platforms this runs on).
Changed
context_owner_windownow measures what it documents. The collision guard
(scripts/claim-digest.sh) and the new age signal read the SAME number — the digest's mtime — so
preserving it across a claim moved both. Before 0.4.2 the claim'smvrestamped it, silently
renewing the 4h window on every rehydrate: it measured "time since last claim". It now measures
how recently another session wrote the digest, which is what the key has always been documented
to mean. Measured consequence: a digest whose content is older than the window but was rehydrated
moments ago is no longer side-filed on collision, where before it was. Accepted rather than
reverted — what goes unprotected there is content nobody has touched in over a window, held in
the rehydrating session's own context, and its next/snapshotrestores full protection; the
case that matters, a session that wrote recently, is unchanged. Rejected: widening the default
(tuning a constant to restore an accident) and a separate "last claimed" marker (drags a
best-effort guard into the marker discipline of invariant 15). Pinned so the coupling cannot move
silently again. Found in review.CLAUDE.md— invariants 18, 19 and 20; an expanded digest-format coupling row naming all four
guidance surfaces; coupling rows for the git-touching functions and for/snapshot --check; and
decision notes on why the guidance is duplicated across four files (each reaches the model at a
different moment and none can read the others) and whymissionis a frontmatter field rather
than a fifth section (the ~30-line budget is zero-sum, and a section would add a fifth copy to
everything invariant 18 pins). The same reasoning rejected a standing## Baselinesection in
favour of an instruction line.