Skip to content

fix: hook output was 10KB into a 2KB delivery window - #266

Merged
piwi3910 merged 6 commits into
mainfrom
fix/hook-output-2kb-cap
Sep 1, 2026
Merged

piwi3910 merged 6 commits into
mainfrom
fix/hook-output-2kb-cap

Conversation

@piwi3910

@piwi3910 piwi3910 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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:

Payload Outcome
5.2 KB (PostToolUse) delivered whole
7.3 KB (PostToolUse) delivered whole
9.9 KB (SessionStart, 10,281 B emitted) persisted, first 2 KB inlined
10.7 KB (PostToolUse, 10,957 B emitted) persisted, first 2 KB inlined

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 b9ece19 corrects 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.

  • maxInlineBytes was 48KB, twenty-four times the window. A formatted
    body 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.
  • Parts were joined with no cap, so whatever came last was dropped in
    silence. Secret findings come after the format part.
  • askPart claimed 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 check as 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.md is whatever
they 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 check clean, 0 blocking. procoder git
exits 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:

  • Replacing fit(parts) with the old strings.Join at the call site left
    the unit test of fit green, because it tests fit directly. fit was
    correct, tested, and unused. Fixed by an end-to-end test on Run.
  • That end-to-end fixture then landed under budget on its own, so it
    passed whether Run called fit or not. It is now tuned to overflow
    deliberately and fails loudly if it ever stops.
  • Dropping the question flattening did not make the payload oversized
    either — fit simply discards the whole ask queue instead. What is lost
    is 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 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.

A formatted body could starve the secret scan — see the ordering note
below.

The four Important: oneLine sliced a byte index, so this repository's
em-dashes produced invalid UTF-8 and json.Marshal replaced it with U+FFFD,
delivering a question as mojibake. fit's comment said the first part that
does 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 — hookText writes an adopter's
PRINCIPLES.md verbatim 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 was
cut, had no marker of its own, so the recovery read was as unverifiable as
what it recovered from.

Minors taken: -update now refuses to regenerate the two parity
goldens rather than a comment asking it not to; TestFitNeverTruncatesAPart
gained 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 Run against the budget.

One finding was stale on arrival — 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 and is fixed.

The golden that had to move

principles-hook.txt was one of three goldens captured from c4bb353 to
assert 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.txt already had. The
doc 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() in internal/hook/hook.go. It is the whole mechanism
and the arithmetic is the part to distrust.

Then the receipt check in internal/principles/principles.go — the
question worth asking is whether it can be spoofed by a payload truncated
at exactly the wrong place.

Deliberate, and open to being overruled

  • Display order is unchanged; delivery order is not. Parts are still
    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.
  • The budget is set to the preview size, not the measured threshold.
    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.md is not in this branch. records: the principles hook's 2KB truncation is filed for decision #265 owns that
    record. This branch owns the fix, so the two cannot conflict.

Docs impact

procoder config output is unchanged, but two things a reader is told
were now false. docs/architecture.md said the write hook includes the
fixed 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.md gains the receipt check at
session start, since every session now opens with it.

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
Copilot AI lite review requested due to automatic review settings September 1, 2026 19:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 principles output) 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.

Comment thread internal/hook/hook.go
Comment thread internal/principles/receipt_test.go
… 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
piwi3910 merged commit dd323fb into main Sep 1, 2026
11 checks passed
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
piwi3910 deleted the fix/hook-output-2kb-cap branch September 1, 2026 22:24
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.
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.

2 participants