Make hooks dumb pipes: runner parses the raw hook event from stdin via Hook::Event #550
Labels
No labels
P0
P1
P2
P3
bug
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#550
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
#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
event has no #{key.inspect}" }
def key?(key) = @attributes.key?(key)
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
Built and merged to main
93f1595on 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.