Repository navigation
fix: hook output was 10KB into a 2KB delivery window - #266
Merged
Merged
Conversation
Measured, after the kload plugin (github.com/nightlionsec/kload) documented
the same cap from the other side: the host writes hook output past roughly
two kilobytes to a file and inlines only a preview of the first 2KB. Two
observations, both at exactly 2KB — SessionStart stdout at 9.9KB of output,
and this hook's additionalContext at 10.7KB. The threshold at which
persisting starts is NOT established; only what gets inlined when it does.
That made three defects out of one root cause: nothing budgeted what the
hook emits.
- maxInlineBytes was 48KB, twenty-four times the delivery window. A
formatted body over the window arrived cut off UNDER THE INSTRUCTION
"review it and write it to the file". An agent obeying that writes a
file with its end missing, so this was a data-loss path and not only a
delivery gap.
- The parts were joined with no cap at all, so whatever came last was
dropped silently. Secret findings come after the format part.
- askPart was written as one line per question and was not: a question
derived from a decision record carries the whole record as its text, so
five of them ran to 8.6KB in this repository and crowded out everything
else on every single write.
Now: one budget for the whole payload, a bounded share for the formatted
body (the only part that can be arbitrarily large), and questions flattened
to the one line the code always claimed to emit. A part that does not fit is
DROPPED, never truncated — half a finding is a finding whose meaning cannot
be trusted, and half a formatted file is one somebody may write back over
the whole thing. What was dropped is counted in the output, because a hook
that quietly delivered four findings out of seven is the silent green this
tool exists to remove.
Measured on the same probe: 10,957 bytes before, 775 after.
Two of the five mutations were duds at first and are worth recording,
because both looked like passes. Replacing `fit(parts)` with the old
`strings.Join` at the call site left the unit test of fit green — it tests
fit directly — so fit was correct, tested, and unused. And the fixture for
the end-to-end test landed under budget on its own, so it passed whether Run
called fit or not. The fixture is now tuned to overflow deliberately and
fails loudly if it ever stops. Dropping the flattening was the third dud:
the payload still fits, because fit simply discards the whole ask queue
instead — what is lost is the questions, so that test asserts they SURVIVE
rather than asserting a size.
The SessionStart principles hook has the same problem and is not fixed here:
it still emits 10,279 bytes.
docs: none - internal hook behaviour; the user-visible surface is unchanged
except that oversized output now says what it left out
The SessionStart payload is 10,281 bytes and the host inlines the first 2,000 of them, writing the rest to a file that nothing makes anybody read. Measured in a live session: the reminder read "Output too large (9.9KB). Full output saved to... Preview (first 2KB)" and the principles stopped mid-sentence inside the mutation-testing paragraph. So for every session since this document reached that size, the rules governing the work have been arriving as a path. Truncating the document to fit would be the wrong fix. It is the repository's own text, an adopter's .procoder/PRINCIPLES.md is whatever they wrote, and choosing which of somebody's engineering rules to drop is not a delivery concern. The payload stays whole and becomes CHECKABLE instead: procoder guarantees delivery to the boundary, and the reader verifies receipt. A receipt check goes first, inside the window that is always inlined, and an end marker goes last. A reader that cannot see the marker knows it holds a preview, and the host's own notice names the file with the rest. The notice deliberately does NOT quote the marker, and a test enforces that. A notice carrying it verbatim would appear in the preview too, so a reader searching for the marker would find it in a truncated payload and conclude it had everything — a confident receipt for a delivery that did not happen, which is the precise failure this exists to catch. That flaw was in the first version and the test found it. The principles-hook parity golden had to move, and the reason is recorded where it will be read. It was one of three captured from c4bb353 to prove the state seam changed nothing; the output has now changed on purpose, so it joins config as a golden that guards drift from here rather than parity with before. status and handoff are untouched and still assert parity. Regenerating those two is how a parity assertion stops asserting anything, and the comment now says so. The credit belongs to the kload plugin (github.com/nightlionsec/kload), which hit the same cap from the other side and named the shape: silent, order-dependent loss with a confident receipt on top. docs: none - the principles text is unchanged; what is added is a delivery check around it, and its own first paragraph explains itself to the reader
Both fixes were committed describing 2KB as the point where the host starts persisting hook output. It is not — it is what the host inlines once it does, and the two are different numbers. Bracketed by four observations rather than assumed from one: payloads of 5.2KB and 7.3KB arrived whole, 9.9KB and 10.7KB were persisted with a 2KB preview. So the host tolerates several times what the code was written against, and the comments implied a limit that had not been measured. The budget stays at the preview size anyway, and the reason is now stated rather than left to look like the only option. Guessing high fails silently: a payload over the real threshold loses everything past 2KB with nothing saying so, and the tail is where the findings are. Guessing low costs only that a formatted body over its share arrives as the command that prints it — a documented path an agent already takes. One of those failures is recoverable by the reader and the other is not. Raising it is reasonable once the threshold is pinned; it should not be raised on the strength of two lucky deliveries. The principles hook needed no such caveat and now says why: a receipt check is true whatever the threshold is, and stays true if it changes. That is the argument for a marker over a size limit. docs: docs/architecture.md, docs/lifecycle.md — the write hook's contract said the formatted content is included when a file is unformatted, which is now conditional on fitting the budget, and the session-start list gains the receipt check that every session opens with
Two Critical findings from the pre-PR review, both reproduced, plus four
Important. The first two are the fix failing at the thing it was written
to do.
THE NOTICE WAS NOT INSIDE THE BUDGET. It was appended after the loop with
nothing reserved for it, so fit returned 2,144 bytes against a 2,000-byte
constant — putting the explanation of the truncation in the part that gets
truncated. Reserved now, and the +200 slack in two tests that admitted it
is gone: the budget is the budget.
SECRETS COULD BE STARVED BY A FORMATTED BODY. Parts are measured in build
order, so a 900-byte body plus five doc-drift notes leaves 259 bytes and a
three-secret finding is 270 — the whole part dropped while the drift notes
survived. The file's own comment named that exact risk and then did not act
on it. A secret in a file the agent just wrote is the one finding here that
blocks, so it is now placed first whatever its position in the message, and
the budget is not a reason to withhold it.
The four Important:
- oneLine sliced a byte index. Through this repository's em-dashes it
returned invalid UTF-8, which json.Marshal replaces with U+FFFD — a
question delivered as mojibake. Runes now, with a test that is not
ASCII-only.
- fit's comment said the first part that does not fit ends the message;
the code skipped and carried on. So the agent could see findings 1 and
3 and be told the missing one was "further" — trailing — which is the
wrong conclusion about which finding it lacks. The code keeps skipping,
because a small finding after a large one is worth having, and the
notice now says the omitted ones are not necessarily the last.
- THE END MARKER WAS SPOOFABLE. hookText writes an adopter's
PRINCIPLES.md verbatim, and that file may legitimately quote the marker
— from procoder's own docs, or from a document about procoder. A quoted
marker inside the first 2KB makes the receipt check pass on a truncated
payload, which is the single thing it exists to prevent. Stripped now,
with a test whose fixture assembles the marker at runtime rather than
carrying it as a literal.
- `procoder principles` had no marker of its own, and it is where the
hook's notice sends a reader whose payload was cut. The recovery read
was as unverifiable as the thing it recovered from.
Minors taken: -update now REFUSES to regenerate the two goldens that assert
parity with c4bb353, rather than a comment asking it not to — this branch
had just regenerated a third, which is when the other two are most at risk.
TestFitNeverTruncatesAPart gained its positive half, without which it
passed on an empty return. The ask test no longer skips when gofmt is
absent, so a machine without it still exercises Run against the budget.
One finding was already stale when it arrived: the docs were uncommitted
when the review started and landed in b9ece19 while it ran. Verified rather
than re-fixed. Its other half was real — architecture.md ran to 90 columns
in a file that wraps at 78 — and is fixed.
docs: none - docs/architecture.md and docs/lifecycle.md were updated for
this behaviour in b9ece19; this commit only rewraps a paragraph there
There was a problem hiding this comment.
Pull request overview
This PR addresses host-side truncation of hook output by (1) budgeting the PostToolUse hook’s delivered context to stay within an inlined preview window and (2) making the SessionStart principles payload verifiable via a receipt notice plus an explicit end marker.
Changes:
- Budget and reassemble PostToolUse hook output to fit a fixed delivery window, preferring dropping whole parts over truncation and prioritizing secret findings.
- Add a receipt notice, end marker, and marker-stripping to the SessionStart principles payload (and to
procoder principlesoutput) so truncation becomes detectable. - Add targeted tests and update goldens/docs to lock in the new behavior and explain the delivery constraints.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/hook/hook.go | Introduces a 2KB context budget, part fitting logic, and question flattening for PostToolUse output. |
| internal/hook/budget_test.go | Adds unit tests for budget fitting, non-truncation, rune-safety, and bounded formatted share behavior. |
| internal/hook/budget_run_test.go | Adds an end-to-end test ensuring Run never emits context over the delivery budget. |
| internal/hook/budget_ask_test.go | Adds an end-to-end test ensuring Q&A survives the budget and is flattened to one line per question. |
| internal/principles/principles.go | Adds receipt notice + end marker, strips marker from repo-supplied text, and adds a marker to recovery output. |
| internal/principles/receipt_test.go | Adds tests to ensure the receipt mechanism is present, unspoofable, and the marker is terminal. |
| internal/store/testdata/golden/principles-hook.txt | Updates golden output to include the receipt notice and end marker. |
| internal/store/golden_test.go | Clarifies which goldens are parity vs drift-guarding and prevents accidental regeneration of parity goldens. |
| docs/architecture.md | Documents the write-hook budgeting and “drop, don’t truncate” rule. |
| docs/lifecycle.md | Documents the new SessionStart receipt check behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… test checks its offset The review found two gaps in the delivered work. A keep part was placed against the full budget instead of the reserved one, so a single oversized keep part (a blocking finding larger than the whole window) was dropped silently — defeating the must-keep intent, since the omission notice never counted it. Keep parts now compete under the same reserved limit as everything else, and the one that cannot fit is named in the omission notice: a secret that cannot be delivered whole is a finding the reader is told about, not one that vanishes. The receipt test asserted the check EXISTED in the payload but not that it lands inside the inlined window — the exact regression the window was budgeted to prevent. It now fails if the receipt check starts past byte 2000. docs: none — the budget's meaning lives in the fit() comment, which now says what the keep case actually does
piwi3910
added a commit
that referenced
this pull request
Sep 1, 2026
* records: the 2KB decision is settled by what #266 shipped The decision it was asking about — what to do about a 10KB principles document arriving as a 2KB preview — was answered by merging the fix: the delivery is now measured and budgeted, with the receipt check pinned inside the inlined window. decisions.md carries the Decided paragraph naming the option and the PR, and the answer is recorded in answers.md under its stable key, which closes the last question on the queue: procoder ask now reports all 28 answered. docs: none — decision and answer records in .procoder/ask/ * records: a settled decision does not keep the open-question marker The review caught that the last option line still ended with (ask) after the Decided paragraph — the marker is the tooling's signal for an open question, and other decided sections in the file do not keep it. Removing it; the Decided paragraph and the recorded answer carry the settlement. docs: none — it is a decision record in .procoder/ask/ * records: the Decided paragraph belongs in the settled record The previous commit moved the file from the main tree, where the Decided paragraph had not yet been committed, and dropped it with the marker. Restoring it: a settlement without the settlement's text is the same silence the record exists to end. docs: none — it is a decision record in .procoder/ask/ * records: the answer is recorded under the key the queue now uses The (ask) marker removal changed the question text, and the stable key goes with its text: the answer recorded before the change keyed against the old spelling, so the queue kept asking the question it believed was new. Re-answered under the current key, and procoder ask now reports all 28 answered — the queue is closed at zero. docs: none — it is an answer record in .procoder/ask/
piwi3910
added a commit
that referenced
this pull request
Sep 3, 2026
The reflection #266 shipped without. Copilot found two real defects on that PR, and both were in code written to satisfy an earlier finding. The pre-PR review found that a formatted body could starve the secret scan; the fix was a must-keep path, and that path shipped with two defects of its own. Keep parts were placed against the full budget rather than the reserved one, so the omission notice could push the payload past the very constant it exists to enforce. And a keep part larger than the budget was dropped without being counted, so the one finding declared un-droppable vanished silently — the exact shape the notice was added to prevent. Neither is subtle. They are eleven lines written in direct response to a review finding, on the path that finding said was the important one, and nothing read them, because the reviewer had already run. A review is a snapshot of the diff as it was; everything written to satisfy it is newer than the review. The same session had already demonstrated the fix and then not applied it: during #249 a verification pass over the fix diff caught a race reintroduced one commit after the original was fixed. That pass was not run for #266. Two rubric lines in REVIEW.md, because the general shape and the concrete one are both worth looking for: the fixes made for an earlier finding read as new code, and a budget that excludes its own overhead — where a tolerance in the test is how the overflow passes, which is how it passed here. Plus the ledger entry. procoder lessons: 42 lessons, 0 unlearned.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Both of procoder's context-delivering hooks emitted about 10KB into a
window the host inlines only 2KB of. The PostToolUse payload is now
budgeted; the SessionStart payload is left whole and made checkable.
Answers the decision recorded in #265.
Why
The host does not deliver unbounded hook output. Past roughly two
kilobytes Claude Code writes the output to a file and inlines only a
preview of the first 2KB — the remainder arrives as a path that nothing
makes anybody read.
Measured, not assumed, and bracketed rather than pinned:
So 2KB is the preview size, not the threshold — the host tolerates
several times that. My first pass at this got it wrong in both source
comments and
b9ece19corrects them.The first was observed live: a session's own reminder read "Output too
large (9.9KB). Full output saved to… Preview (first 2KB)" and the
principles stopped mid-sentence inside the mutation-testing paragraph. So
for every session since that document reached its current size, four
fifths of the rules governing the work have not been arriving.
The budget is set to the preview size anyway, and the asymmetry is the
reason. Guessing high fails silently: a payload over the real threshold
loses everything past 2KB with nothing saying so, and the tail is where the
findings are. Guessing low costs only that a large formatted body arrives as
the command that prints it — a documented path an agent already takes. One
of those failures is recoverable by the reader and the other is not.
Credit for the behaviour goes to the kload plugin
(github.com/nightlionsec/kload), which hit the same cap from the other
side and named the failure shape: silent, order-dependent loss with a
confident receipt on top.
How
The write hook — three defects, one root cause
Nothing budgeted what the hook emitted.
maxInlineByteswas 48KB, twenty-four times the window. A formattedbody over it arrived cut off under the instruction "review it and
write it to the file". An agent obeying that writes a file with its
end missing, so this was a data-loss path and not merely a delivery gap.
silence. Secret findings come after the format part.
askPartclaimed one line per question and delivered whole records.A question derived from a decision carries the entire record as its
text, so five of them ran to 8.6 KB in this repository and crowded
everything else out of every single write.
Now: one budget for the payload, a bounded share for the formatted body
(the only part that can be arbitrarily large), and questions flattened to
the one line the code always claimed. A part that does not fit is
dropped, never truncated — half a finding is a finding whose meaning
cannot be trusted — and the count of what was dropped is in the output,
naming
procoder checkas the way to see all of it.Measured on the same probe: 10,957 bytes before, 775 after.
The principles hook — checkable, not shortened
Truncating the document to fit would be the wrong fix. It is the
repository's own text, an adopter's
.procoder/PRINCIPLES.mdis whateverthey wrote, and choosing which of somebody's engineering rules to drop is
not a delivery concern.
So the payload stays whole and becomes verifiable: a receipt check first,
inside the window that is always inlined, and an end marker last. A reader
that cannot see the marker knows it holds a preview, and the host's own
notice names the file with the rest. procoder guarantees delivery to the
boundary; the reader verifies receipt.
The notice deliberately does not quote the marker, and a test enforces
it. A notice carrying it verbatim would appear in the preview too, so a
reader searching for the marker would find it in a truncated payload and
conclude it had everything — a confident receipt for a delivery that did
not happen, which is the exact failure this exists to catch. That flaw was
in the first version; the test found it.
Testing
go test ./...green.procoder checkclean, 0 blocking.procoder gitexits 0.
Five mutations, each with a snapshot taken immediately before and restored
immediately after. Three of them were duds on the first attempt, and all
three looked like passes — worth recording, because a mutation that
silently changes nothing is indistinguishable from a test doing its job:
fit(parts)with the oldstrings.Joinat the call site leftthe unit test of
fitgreen, because it testsfitdirectly.fitwascorrect, tested, and unused. Fixed by an end-to-end test on
Run.passed whether
Runcalledfitor not. It is now tuned to overflowdeliberately and fails loudly if it ever stops.
either —
fitsimply discards the whole ask queue instead. What is lostis the questions, so that test asserts they survive, not that the
payload is small.
What the pre-PR review found
A fresh-context reviewer returned 2 Critical, 4 Important, 5 Minor, and
both Criticals were the fix failing at the thing it was written to do.
The notice was not inside the budget. It was appended after the loop
with nothing reserved, so
fitreturned 2,144 bytes against a 2,000-byteconstant — putting the explanation of the truncation in the part that gets
truncated. Reserved now, and the
+200slack in two tests that admitted itis gone.
A formatted body could starve the secret scan — see the ordering note
below.
The four Important:
oneLinesliced a byte index, so this repository'sem-dashes produced invalid UTF-8 and
json.Marshalreplaced it with U+FFFD,delivering a question as mojibake.
fit's comment said the first part thatdoes not fit ends the message while the code skipped on, so the notice's
word "further" told an agent the wrong thing about which finding it was
missing. The end marker was spoofable —
hookTextwrites an adopter'sPRINCIPLES.mdverbatim and that file may legitimately quote the marker,which would make the receipt check pass on a truncated payload. And
procoder principles, where the notice sends a reader whose payload wascut, had no marker of its own, so the recovery read was as unverifiable as
what it recovered from.
Minors taken:
-updatenow refuses to regenerate the two paritygoldens rather than a comment asking it not to;
TestFitNeverTruncatesAPartgained the positive half without which it passed on an empty return; and the
ask test no longer skips when gofmt is absent, so a machine without it still
exercises
Runagainst the budget.One finding was stale on arrival — the docs were uncommitted when the review
started and landed in
b9ece19while it ran. Verified rather than re-fixed.Its other half was real and is fixed.
The golden that had to move
principles-hook.txtwas one of three goldens captured fromc4bb353toassert that the state seam changed nothing. This change alters that output
on purpose, so it was regenerated and now guards drift from here rather
than parity with before — the same status
config.txtalready had. Thedoc comment records which two still assert parity, and that regenerating
those is how a parity assertion stops asserting anything.
Notes for the reviewer
Start at
fit()ininternal/hook/hook.go. It is the whole mechanismand the arithmetic is the part to distrust.
Then the receipt check in
internal/principles/principles.go— thequestion worth asking is whether it can be spoofed by a payload truncated
at exactly the wrong place.
Deliberate, and open to being overruled
built format → workflow → drift → secrets → lint → ask, but the secret
scan is now must-keep and is placed before the budget is spent on
anything else. The review computed the case that forced it: a 900-byte
formatted body plus five doc-drift notes leaves 259 bytes, and a
three-secret finding is 270. Everything else still competes in build
order.
Deliveries of 5.2KB and 7.3KB arrived whole; 9.9KB and 10.7KB did not.
Raising it is reasonable once that is pinned rather than bracketed — it
should not be raised on two lucky deliveries, because guessing high
fails silently and the tail is where the findings are.
.procoder/ask/decisions.mdis not in this branch. records: the principles hook's 2KB truncation is filed for decision #265 owns thatrecord. This branch owns the fix, so the two cannot conflict.
Docs impact
procoder configoutput is unchanged, but two things a reader is toldwere now false.
docs/architecture.mdsaid the write hook includes thefixed content when a file is unformatted — now conditional on fitting the
budget — and its write-hook section gains the budget and the
drop-never-truncate rule.
docs/lifecycle.mdgains the receipt check atsession start, since every session now opens with it.