Make hooks dumb pipes: runner parses the raw hook event from stdin via Hook::Event #550

Open
opened 2026-09-10 15:48:46 +00:00 by jared · 1 comment
Owner

#550 Make hooks dumb pipes: runner parses the raw hook event from stdin

via Hook::Event (open)

@jared created 2026-09-10 11:48

Context

The user decided this design on 2026-09-10 while investigating #528. The
four hooks in plugins/os-sdlc/hooks/ (subagent_start.rb, subagent_stop.rb,
pre_tool_use.rb, post_tool_use.rb) each parse the Claude Code event JSON
from stdin, pick fields, and rebuild positional argv for bin/os-sdlc-runner.
Each hook redefines the same top-level helpers (read_event, normalize_agent,
runner_bin, main plus rescue guard). Nothing is shared. ADR-0128 already
says hooks are webhook-thin couriers and the runner owns all state. This
ticket moves parsing into the runner.

Observed

subagent_start.rb sends [runner_bin, "subagent-start", session_id,
agent_type, agent_id].compact as argv via Open3.capture3("ruby", *args,
chdir: root). subagent_stop.rb already sends a hand-built JSON payload on
stdin (session_id, agent_type, content, agent_transcript_path) to subagent-
stop. This is the precedent, but it is a custom payload, not the raw event.
post_tool_use.rb sends ["next", session_id] positionally and also loads
OsSdlc::Runner::Db/Models in-process to check pending_dispatch first.
pre_tool_use.rb never calls the runner. It runs
WriteGuard/BashGuard/ScopePolicy in-process because it fires on every tool
call. Keep that shape; it only adopts Hook::Event. Claude Code event fields
(from the hooks documentation) include common fields (session_id,
transcript_path, cwd, hook_event_name, permission_mode, prompt_id,
scratchpad_dir) plus per-event fields. The plugin root is NOT in the
payload; it is only the CLAUDE_PLUGIN_ROOT env var. Claude Code adds fields
over versions. The event class must not declare a closed field list.
Architecture rules require never ActiveModel/ActiveSupport; do not index raw
hashes outside the owning model; agent types are value objects. A plain Ruby
class is justified here because the payload is external and open-ended.

Reproduce

n/a: design/refactor ticket

Expected

[x] Run /os-adr:find on plugins/os-sdlc/hooks and lib/os_sdlc/runner/cli.rb;
create an ADR: runner hook subcommands take the raw event JSON on stdin.
[x] Add OsSdlc::Hook::Event with Minitest coverage: parse, typed readers,
fetch raises KeyError with the key name, key?, transforms, to_h merges
transforms, unknown fields pass through, agent_type strips the os-sdlc:
prefix via the existing agent-type value object.
[x] cli.rb: subagent_start, subagent_stop, next each become
emit(DispatchCommands.(Hook::Event.parse($stdin.read))).
DispatchCommands takes the event, reads event.cwd instead of Dir.pwd.
[x] Each hook becomes a pipe: read stdin, Open3.capture3("ruby", runner_bin,
"", stdin_data: raw, chdir: cwd), print the hook payload, exit
with the runner's status. Extract the shared pipe into one helper file under
hooks/ or lib/os_sdlc/hook/.
[x] pre_tool_use.rb builds Hook::Event from stdin and passes it to the
guards; stays in-process.
[x] Remove the duplicated read_event, normalize_agent, runner_bin copies.
normalize_agent logic lives only in the agent-type value object.
[x] Keep chdir: cwd in the hook for now; drop it in a later slice once
nothing below the CLI calls Dir.pwd.
[ ] bin/refresh-plugins; live-verify with one subagent dispatch; existing
suite green.
[x] Out of scope: the #528 timing probe, the dispatch duration report (#549).

Illustration

module OsSdlc                                                             
  module Hook                                                             
    class Event                                                           
      def self.parse(json) = new(JSON.parse(json, symbolize_names: true)) 
                                                                          
      def initialize(attributes)                                          
        @attributes = attributes.freeze                                   
      end                                                                 
                                                                          
      def agent_type = AgentType.parse(fetch(:agent_type))                
      def session_id = fetch(:session_id)                                 
      def cwd        = fetch(:cwd)                                        
      def name       = fetch(:hook_event_name)                            
                                                                          
      def fetch(key) = @attributes.fetch(key) { raise KeyError, "hook     

event has no #{key.inspect}" }
def key?(key) = @attributes.key?(key)

      def transforms = { agent_type: agent_type }                         
      def to_h       = @attributes.merge(transforms)                      
    end                                                                   
  end                                                                     
end                                                                       
                                                                          
# cli.rb                                                                  
def subagent_start = emit(DispatchCommands.subagent_start(Hook::Event.    

parse($stdin.read)))

Caveat: to_h[:agent_type] is an AgentType object; JSON serialization needs
to_s/to_json on it. One test.

Origin

• Trigger: 2026-09-10 user investigation of #528
• Improvised this session: none
• Chain: DESIGN (ADR-0128)
• Root candidate: none (this is the root)
• Where: plugins/os-sdlc/hooks/subagent_start.rb, plugins/os-
sdlc/lib/os_sdlc/runner/cli.rb
• Session: 8d211345-da76-4772-b616-ed676eb86ad1
• Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-
worktrees-ticket-528/8d211345-da76-4772-b616-ed676eb86ad1.jsonl

# #550 Make hooks dumb pipes: runner parses the raw hook event from stdin via Hook::Event (open) @jared created 2026-09-10 11:48 ## Context The user decided this design on 2026-09-10 while investigating #528. The four hooks in plugins/os-sdlc/hooks/ (subagent_start.rb, subagent_stop.rb, pre_tool_use.rb, post_tool_use.rb) each parse the Claude Code event JSON from stdin, pick fields, and rebuild positional argv for bin/os-sdlc-runner. Each hook redefines the same top-level helpers (read_event, normalize_agent, runner_bin, main plus rescue guard). Nothing is shared. ADR-0128 already says hooks are webhook-thin couriers and the runner owns all state. This ticket moves parsing into the runner. ## Observed subagent_start.rb sends [runner_bin, "subagent-start", session_id, agent_type, agent_id].compact as argv via Open3.capture3("ruby", *args, chdir: root). subagent_stop.rb already sends a hand-built JSON payload on stdin (session_id, agent_type, content, agent_transcript_path) to subagent- stop. This is the precedent, but it is a custom payload, not the raw event. post_tool_use.rb sends ["next", session_id] positionally and also loads OsSdlc::Runner::Db/Models in-process to check pending_dispatch first. pre_tool_use.rb never calls the runner. It runs WriteGuard/BashGuard/ScopePolicy in-process because it fires on every tool call. Keep that shape; it only adopts Hook::Event. Claude Code event fields (from the hooks documentation) include common fields (session_id, transcript_path, cwd, hook_event_name, permission_mode, prompt_id, scratchpad_dir) plus per-event fields. The plugin root is NOT in the payload; it is only the CLAUDE_PLUGIN_ROOT env var. Claude Code adds fields over versions. The event class must not declare a closed field list. Architecture rules require never ActiveModel/ActiveSupport; do not index raw hashes outside the owning model; agent types are value objects. A plain Ruby class is justified here because the payload is external and open-ended. ## Reproduce n/a: design/refactor ticket ## Expected [x] Run /os-adr:find on plugins/os-sdlc/hooks and lib/os_sdlc/runner/cli.rb; create an ADR: runner hook subcommands take the raw event JSON on stdin. [x] Add OsSdlc::Hook::Event with Minitest coverage: parse, typed readers, fetch raises KeyError with the key name, key?, transforms, to_h merges transforms, unknown fields pass through, agent_type strips the os-sdlc: prefix via the existing agent-type value object. [x] cli.rb: subagent_start, subagent_stop, next each become emit(DispatchCommands.<cmd>(Hook::Event.parse($stdin.read))). DispatchCommands takes the event, reads event.cwd instead of Dir.pwd. [x] Each hook becomes a pipe: read stdin, Open3.capture3("ruby", runner_bin, "<subcommand>", stdin_data: raw, chdir: cwd), print the hook payload, exit with the runner's status. Extract the shared pipe into one helper file under hooks/ or lib/os_sdlc/hook/. [x] pre_tool_use.rb builds Hook::Event from stdin and passes it to the guards; stays in-process. [x] Remove the duplicated read_event, normalize_agent, runner_bin copies. normalize_agent logic lives only in the agent-type value object. [x] Keep chdir: cwd in the hook for now; drop it in a later slice once nothing below the CLI calls Dir.pwd. [ ] bin/refresh-plugins; live-verify with one subagent dispatch; existing suite green. [x] Out of scope: the #528 timing probe, the dispatch duration report (#549). ## Illustration module OsSdlc module Hook class Event def self.parse(json) = new(JSON.parse(json, symbolize_names: true)) def initialize(attributes) @attributes = attributes.freeze end def agent_type = AgentType.parse(fetch(:agent_type)) def session_id = fetch(:session_id) def cwd = fetch(:cwd) def name = fetch(:hook_event_name) def fetch(key) = @attributes.fetch(key) { raise KeyError, "hook event has no #{key.inspect}" } def key?(key) = @attributes.key?(key) def transforms = { agent_type: agent_type } def to_h = @attributes.merge(transforms) end end end # cli.rb def subagent_start = emit(DispatchCommands.subagent_start(Hook::Event. parse($stdin.read))) Caveat: to_h[:agent_type] is an AgentType object; JSON serialization needs to_s/to_json on it. One test. ## Origin • Trigger: 2026-09-10 user investigation of #528 • Improvised this session: none • Chain: DESIGN (ADR-0128) • Root candidate: none (this is the root) • Where: plugins/os-sdlc/hooks/subagent_start.rb, plugins/os- sdlc/lib/os_sdlc/runner/cli.rb • Session: 8d211345-da76-4772-b616-ed676eb86ad1 • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude- worktrees-ticket-528/8d211345-da76-4772-b616-ed676eb86ad1.jsonl
Author
Owner

Built and merged to main 93f1595 on 2026-09-10 (ADR-0172, commits 1e1d1a9..86f36f2). Suite 1149 green. Remaining: live verification via the fresh /os-sdlc:implement #528 run; a real seam-designer dispatch already passed through the new subagent-start hook and the single-pipe subagent-stop without error.

Built and merged to main 93f1595 on 2026-09-10 (ADR-0172, commits 1e1d1a9..86f36f2). Suite 1149 green. Remaining: live verification via the fresh /os-sdlc:implement #528 run; a real seam-designer dispatch already passed through the new subagent-start hook and the single-pipe subagent-stop without error.
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#550
No description provided.