Deliver an oversized dispatch brief through a brief CLI when the SubagentStart hook exceeds the 10,000-character cap #627

Closed
opened 2026-09-22 20:42:27 +00:00 by jared · 2 comments
Owner

Context

Claude Code caps each hook additionalContext string at 10,000 characters. Above the cap it writes the string to a file in the session directory and gives the agent the file path plus the first 2,000 characters. No setting raises the cap. Source: https://code.claude.com/docs/en/hooks.md, section "Hook input and output". The os-sdlc SubagentStart hook sends the whole enriched brief inline as one string. After #618 (ADR-0204) code_changes is a full staged diff with no limit. Any diff past about 10,000 characters pushes the string over the cap. Supersedes #623, which routed the symptom (verifier verdict: error) to a retry that resends the same payload.

Observed

Dispatch 140 (behavior-verifier) of implementation 11 on #621 settled in 9 s with no model call.

os-sdlc-runner subagent-stop  # dispatch 140, behavior-verifier
summary: 'Cannot verify: ticket_brief and code_changes were not delivered in the dispatch prompt (SubagentStart context was truncated to a persisted file; preview shows brief: null).'
verdict: error
{"type":"implementation_failed","node":"implementation-failed","reason":"verdict:error","implementation_id":11}

Reproduce

n/a: needs a live dispatch whose serialized brief exceeds 10,000 UTF-16 code units; any ticket with a staged diff over ~10 KB reproduces it through /os-sdlc:implement

Expected

def test_hook_payload_over_cap_emits_brief_command
  # arrange: envelope + brief string sized to 10_001 UTF-16 units total
  # act: HookPayload#to_json
  # assert: output has session_id, implementation_id, dispatch_id, agent, and "os-sdlc-runner brief --dispatch-id 140"; no ticket_brief, no code_changes
end

Illustration

# illustration, not spec
class OsSdlc::Pipeline::HookPayload
  HOOK_CONTEXT_CAP = 10_000
  def to_json
    text = JSON.generate(@payload)
    return text if utf16_length(text) <= HOOK_CONTEXT_CAP
    JSON.generate(@payload.except(*@enrich_keys).merge(brief: fetch_instruction))
  end
  def utf16_length(text) = text.encode("UTF-16LE").bytesize / 2
  def fetch_instruction = "Brief exceeds the hook cap. Run: os-sdlc-runner brief --dispatch-id #{@payload[:dispatch_id]}"
end

Origin

  • Trigger: /os-sdlc:implement 621, implementation 11, dispatch 140; design discussion of #623 on 2026-09-22
  • Improvised this session: none
  • Chain: verifier verdict: error ← agent preview showed brief: null ← Claude Code persisted the hook string over 10,000 characters (UPSTREAM-TOOL, documented cap at https://code.claude.com/docs/en/hooks.md, no override) ← os-sdlc sends the full brief inline through one additionalContext string (DESIGN, hook/pipe.rb, pipeline/start_subagent.rb) ← #618 removed the code_changes truncation (DESIGN, ADR-0204)
  • Root candidate: this ticket
  • Where: plugins/os-sdlc/lib/os_sdlc/pipeline/start_subagent.rb, pipeline/brief_composer.rb, pipeline/cli.rb, hook/pipe.rb, agents/wiring.md
  • Session: bcd41422-a277-439c-b169-4fbfa8560ee3
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/bcd41422-a277-439c-b169-4fbfa8560ee3.jsonl

Owner sketch

class OsSdlc::Pipeline::HookPayload
  HOOK_CONTEXT_CAP = 10_000
  def initialize(payload, enrich_keys:)
  def to_json
end

class OsSdlc::Pipeline::StartSubagent
  def call
end

class OsSdlc::Pipeline::Cli
  def brief(dispatch_id:)
end

class OsSdlc::Pipeline::DispatchCommands
  def self.brief(dispatch_id)
end

Cases

  1. Given a serialized hook payload of exactly 10,000 UTF-16 code units, when the SubagentStart hook emits it, then the payload is emitted unchanged with the enriched facts inline.
  2. Given a serialized hook payload of 10,001 UTF-16 code units, when the SubagentStart hook emits it, then the output holds session_id, implementation_id, dispatch_id, agent, and the exact command os-sdlc-runner brief --dispatch-id <id>, and holds no enriched fact.
  3. Given a payload whose brief contains a character outside the Basic Multilingual Plane, when measured, then the length counts that character as two units.
  4. Given a dispatch id, when os-sdlc-runner brief --dispatch-id <id> runs, then it prints the full brief that BriefComposer builds for that dispatch on stdout and exits 0.
  5. Given an unknown dispatch id, when os-sdlc-runner brief --dispatch-id <id> runs, then it prints one error naming the id and exits nonzero.
  6. Given the wiring charter, when its tools: line is read, then it includes Bash.
  7. Given the project settings, when read, then bashOutputMaxChars is 128000.

Approach

Keep the zero-tool-call path for every brief that fits. Measure the final serialized additionalContext string, not the brief object, in UTF-16 code units, because Claude Code counts JavaScript string length. Over the cap, emit the routing envelope plus the command; the CLI rebuilds the full brief from the same BriefComposer at call time rather than caching it. The CLI must exit 0, because a failing Bash command gets only a 10,000-character excerpt with no file path.

Rejected: always pass a file path (costs a tool call on every dispatch). Rejected: one SubagentStart hook per fact (a single diff over 10,000 still fails). Rejected: retry the verifier on error (#623; resends the same payload). Rejected: trim brief contents near 30 KB (YAGNI; a ticket that large should be split).

Depends on: Bash tool default ceiling 30,000 characters, raised by bashOutputMaxChars (max 128,000, Claude Code v2.1.261+). Run /os-adr:find on hook/pipe.rb, pipeline/start_subagent.rb, pipeline/brief_composer.rb before the first edit; record the delivery decision as an ADR alongside ADR-0204.

Structural follow-through

  • Live check in a real session that the cap is exactly 10,000 and the unit is UTF-16 code units; move the constant if it disagrees.
  • ADR for brief delivery. Removing the #618 behavior-verifier self-retry edge (ADR-0204) is a separate user decision, out of scope here.
  • Leaf History row in docs/implementation-status for os-sdlc.
## Context Claude Code caps each hook `additionalContext` string at 10,000 characters. Above the cap it writes the string to a file in the session directory and gives the agent the file path plus the first 2,000 characters. No setting raises the cap. Source: https://code.claude.com/docs/en/hooks.md, section "Hook input and output". The os-sdlc SubagentStart hook sends the whole enriched brief inline as one string. After #618 (ADR-0204) `code_changes` is a full staged diff with no limit. Any diff past about 10,000 characters pushes the string over the cap. Supersedes #623, which routed the symptom (verifier `verdict: error`) to a retry that resends the same payload. ## Observed Dispatch 140 (behavior-verifier) of implementation 11 on #621 settled in 9 s with no model call. ``` os-sdlc-runner subagent-stop # dispatch 140, behavior-verifier ``` ``` summary: 'Cannot verify: ticket_brief and code_changes were not delivered in the dispatch prompt (SubagentStart context was truncated to a persisted file; preview shows brief: null).' verdict: error {"type":"implementation_failed","node":"implementation-failed","reason":"verdict:error","implementation_id":11} ``` ## Reproduce ``` n/a: needs a live dispatch whose serialized brief exceeds 10,000 UTF-16 code units; any ticket with a staged diff over ~10 KB reproduces it through /os-sdlc:implement ``` ## Expected ```ruby def test_hook_payload_over_cap_emits_brief_command # arrange: envelope + brief string sized to 10_001 UTF-16 units total # act: HookPayload#to_json # assert: output has session_id, implementation_id, dispatch_id, agent, and "os-sdlc-runner brief --dispatch-id 140"; no ticket_brief, no code_changes end ``` ## Illustration ```ruby # illustration, not spec class OsSdlc::Pipeline::HookPayload HOOK_CONTEXT_CAP = 10_000 def to_json text = JSON.generate(@payload) return text if utf16_length(text) <= HOOK_CONTEXT_CAP JSON.generate(@payload.except(*@enrich_keys).merge(brief: fetch_instruction)) end def utf16_length(text) = text.encode("UTF-16LE").bytesize / 2 def fetch_instruction = "Brief exceeds the hook cap. Run: os-sdlc-runner brief --dispatch-id #{@payload[:dispatch_id]}" end ``` ## Origin - Trigger: /os-sdlc:implement 621, implementation 11, dispatch 140; design discussion of #623 on 2026-09-22 - Improvised this session: none - Chain: verifier `verdict: error` ← agent preview showed `brief: null` ← Claude Code persisted the hook string over 10,000 characters (UPSTREAM-TOOL, documented cap at https://code.claude.com/docs/en/hooks.md, no override) ← os-sdlc sends the full brief inline through one `additionalContext` string (DESIGN, hook/pipe.rb, pipeline/start_subagent.rb) ← #618 removed the `code_changes` truncation (DESIGN, ADR-0204) - Root candidate: this ticket - Where: plugins/os-sdlc/lib/os_sdlc/pipeline/start_subagent.rb, pipeline/brief_composer.rb, pipeline/cli.rb, hook/pipe.rb, agents/wiring.md - Session: bcd41422-a277-439c-b169-4fbfa8560ee3 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/bcd41422-a277-439c-b169-4fbfa8560ee3.jsonl ## Owner sketch ```ruby class OsSdlc::Pipeline::HookPayload HOOK_CONTEXT_CAP = 10_000 def initialize(payload, enrich_keys:) def to_json end class OsSdlc::Pipeline::StartSubagent def call end class OsSdlc::Pipeline::Cli def brief(dispatch_id:) end class OsSdlc::Pipeline::DispatchCommands def self.brief(dispatch_id) end ``` ## Cases 1. Given a serialized hook payload of exactly 10,000 UTF-16 code units, when the SubagentStart hook emits it, then the payload is emitted unchanged with the enriched facts inline. 2. Given a serialized hook payload of 10,001 UTF-16 code units, when the SubagentStart hook emits it, then the output holds session_id, implementation_id, dispatch_id, agent, and the exact command `os-sdlc-runner brief --dispatch-id <id>`, and holds no enriched fact. 3. Given a payload whose brief contains a character outside the Basic Multilingual Plane, when measured, then the length counts that character as two units. 4. Given a dispatch id, when `os-sdlc-runner brief --dispatch-id <id>` runs, then it prints the full brief that BriefComposer builds for that dispatch on stdout and exits 0. 5. Given an unknown dispatch id, when `os-sdlc-runner brief --dispatch-id <id>` runs, then it prints one error naming the id and exits nonzero. 6. Given the wiring charter, when its `tools:` line is read, then it includes Bash. 7. Given the project settings, when read, then `bashOutputMaxChars` is 128000. ## Approach Keep the zero-tool-call path for every brief that fits. Measure the final serialized `additionalContext` string, not the brief object, in UTF-16 code units, because Claude Code counts JavaScript string length. Over the cap, emit the routing envelope plus the command; the CLI rebuilds the full brief from the same BriefComposer at call time rather than caching it. The CLI must exit 0, because a failing Bash command gets only a 10,000-character excerpt with no file path. Rejected: always pass a file path (costs a tool call on every dispatch). Rejected: one SubagentStart hook per fact (a single diff over 10,000 still fails). Rejected: retry the verifier on error (#623; resends the same payload). Rejected: trim brief contents near 30 KB (YAGNI; a ticket that large should be split). Depends on: Bash tool default ceiling 30,000 characters, raised by `bashOutputMaxChars` (max 128,000, Claude Code v2.1.261+). Run /os-adr:find on hook/pipe.rb, pipeline/start_subagent.rb, pipeline/brief_composer.rb before the first edit; record the delivery decision as an ADR alongside ADR-0204. ## Structural follow-through - Live check in a real session that the cap is exactly 10,000 and the unit is UTF-16 code units; move the constant if it disagrees. - ADR for brief delivery. Removing the #618 behavior-verifier self-retry edge (ADR-0204) is a separate user decision, out of scope here. - Leaf History row in docs/implementation-status for os-sdlc.
Author
Owner

Work started via /os-sdlc:implement on branch ticket-627 (worktree).

Work started via /os-sdlc:implement on branch ticket-627 (worktree).
Author
Owner

Resolution

Done: HookPayload caps the SubagentStart additionalContext at 10,000 UTF-16 units; over the cap the routing envelope carries os-sdlc-runner brief --dispatch-id N and BriefLookup recomposes the full brief while the dispatch is open; wiring charter gains Bash for that command; bashOutputMaxChars 128000. All 7 cases have tests. ADR-0206 amends ADR-0109/0141/0204.

Evidence: Branch ticket-627 commit 023183a6. Suite 1395 runs, 0 failures; changed files lint-clean. Live check: brief --dispatch-id 99999 exits 3 naming the id; a settled dispatch exits 3 with 'already settled'. Pipeline implementation 12 escalated at behavior-verifier (evidence scope, see #629); hand-finished from the green increment.

Follow-ups: #629 behavior-verifier misses changes outside code_path (workflow defect that caused the escalation); #630 HookPayload fallback can still exceed the cap on a large handoff. Live check that the cap is exactly 10,000 UTF-16 units in a real session is still owed (ADR-0206 consequences).

## Resolution **Done:** HookPayload caps the SubagentStart additionalContext at 10,000 UTF-16 units; over the cap the routing envelope carries os-sdlc-runner brief --dispatch-id N and BriefLookup recomposes the full brief while the dispatch is open; wiring charter gains Bash for that command; bashOutputMaxChars 128000. All 7 cases have tests. ADR-0206 amends ADR-0109/0141/0204. **Evidence:** Branch ticket-627 commit 023183a6. Suite 1395 runs, 0 failures; changed files lint-clean. Live check: brief --dispatch-id 99999 exits 3 naming the id; a settled dispatch exits 3 with 'already settled'. Pipeline implementation 12 escalated at behavior-verifier (evidence scope, see #629); hand-finished from the green increment. **Follow-ups:** #629 behavior-verifier misses changes outside code_path (workflow defect that caused the escalation); #630 HookPayload fallback can still exceed the cap on a large handoff. Live check that the cap is exactly 10,000 UTF-16 units in a real session is still owed (ADR-0206 consequences).
jared closed this issue 2026-09-22 21:47:51 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#627
No description provided.