Hooks call DispatchCommands directly; delete Pipe, Script, HookSource (supersedes ADR-0172 item 4) #767

Closed
opened 2026-09-27 13:11:06 +00:00 by jared · 2 comments
Owner

Problem

Each hook today calls DispatchCommands through an in-process Thor CLI invocation, not directly. hooks/subagent_start.rb calls Hook::SubagentStart.call, which calls Pipe.call("subagent-start"), which calls RunnerCLI.start(["subagent-start"], invocation:) in-process. Pipe builds a StringIO stdout/stderr pair, rescues the SystemExit that Thor raises on exit 0, and re-wraps the CLI's JSON output into the hook envelope. The Thor subcommands read only invocation.stdin and cwd; they never read environment variables. This indirection exists to satisfy ADR-0172 item 4, "each hook is a pipe to the runner subcommand," but the CLI layer adds no behavior the hook does not already have to unwrap.

Reproduce

n/a: design and refactor ticket, not a reproducible bug.

Approach

VERDICT: CREATE (user-approved 2026-09-27)

Hooks call DispatchCommands directly. Agreed shape, user-written 2026-09-27:

module OsSdlc
  class SubagentStart
    def self.call(io:)
      event = Event.parse(io.read)
      return unless event.name == "SubagentStart"
      payload = DispatchCommands.subagent_start(event)
      puts JSON.generate(hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: payload.to_json })
    end
  end
end

Entry scripts pass io: $stdin. The SubagentStop hook follows the same shape, calling DispatchCommands.subagent_stop_from_stdin(event.cwd, StringIO.new(raw)) or a new event-taking method. The PostToolUse hook's next call moves to DispatchCommands.next_instruction_from_event. Accepted behavior change: malformed JSON and command errors now raise (exit 1, non-blocking for Claude Code) instead of exiting 0 silently or exiting 3.

Required work:

  1. Write a characterization test that pins the current hook output envelope bytes on success, before any change — check: bin/suite-check (or the project's equivalent) passes against the pre-change hooks.
  2. Rewrite the three hook classes (SubagentStart, SubagentStop, the PostToolUse/next handler) to call DispatchCommands directly per the shape above.
  3. Delete command/pipe.rb, hook/script.rb, model/hook_source.rb and their tests (tests/command/pipe_*.rb, tests/support/hook_stdin_contract.rb).
  4. Confirm hooks.json matchers route exactly one event per script; if so, drop the event.name == check in each hook class.
  5. Write an ADR superseding ADR-0172 item 4 ("each hook is a pipe to the runner subcommand") and amending ADR-0207.

Rejected: keep Pipe as a thin pass-through — rejected because it still wraps SystemExit and re-serializes JSON for no behavioral gain once hooks call DispatchCommands directly.

Acceptance criteria

  1. Given the pre-change and post-change hook classes run against the same event payload, when the output is compared, then it is byte-identical on the success path.
  2. Given the full test suite, when it runs after this change, then it is green.
  3. Given a search of lib/os_sdlc/hook/ for RunnerCLI, when the search runs after this change, then it returns no matches.

Owner sketch

module OsSdlc
  class SubagentStart
    def self.call(io:); end
  end

  class SubagentStop
    def self.call(io:); end
  end
end

Non-goals

Dissolving RunnerCLI itself and its bin scripts — tracked separately, and depends on this ticket.

Origin

  • Trigger: user question about why hooks route through RunnerCLI (Thor) in-process, during session 9 CRC.
  • Improvised this session: none.
  • Chain: unnecessary CLI indirection in every hook call ← ADR-0172 item 4 mandated a pipe-to-subcommand shape before it was known that hooks fire natively ← DESIGN, ADR-0172.
  • Root candidate: this ticket.
  • Where: plugins/os-sdlc/lib/os_sdlc/hook/, plugins/os-sdlc/command/pipe.rb.
  • Session: 8e4aac4f-9878-45da-ac68-c7d55261a73f
  • Transcript: /home/jared/dev/cc-os/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-crc-pass-os-sdlc/8e4aac4f-9878-45da-ac68-c7d55261a73f.jsonl
## Problem Each hook today calls `DispatchCommands` through an in-process Thor CLI invocation, not directly. `hooks/subagent_start.rb` calls `Hook::SubagentStart.call`, which calls `Pipe.call("subagent-start")`, which calls `RunnerCLI.start(["subagent-start"], invocation:)` in-process. `Pipe` builds a `StringIO` stdout/stderr pair, rescues the `SystemExit` that Thor raises on exit 0, and re-wraps the CLI's JSON output into the hook envelope. The Thor subcommands read only `invocation.stdin` and `cwd`; they never read environment variables. This indirection exists to satisfy ADR-0172 item 4, "each hook is a pipe to the runner subcommand," but the CLI layer adds no behavior the hook does not already have to unwrap. ## Reproduce n/a: design and refactor ticket, not a reproducible bug. ## Approach VERDICT: CREATE (user-approved 2026-09-27) Hooks call `DispatchCommands` directly. Agreed shape, user-written 2026-09-27: ```ruby module OsSdlc class SubagentStart def self.call(io:) event = Event.parse(io.read) return unless event.name == "SubagentStart" payload = DispatchCommands.subagent_start(event) puts JSON.generate(hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: payload.to_json }) end end end ``` Entry scripts pass `io: $stdin`. The `SubagentStop` hook follows the same shape, calling `DispatchCommands.subagent_stop_from_stdin(event.cwd, StringIO.new(raw))` or a new event-taking method. The `PostToolUse` hook's `next` call moves to `DispatchCommands.next_instruction_from_event`. Accepted behavior change: malformed JSON and command errors now raise (exit 1, non-blocking for Claude Code) instead of exiting 0 silently or exiting 3. Required work: 1. Write a characterization test that pins the current hook output envelope bytes on success, before any change — check: `bin/suite-check` (or the project's equivalent) passes against the pre-change hooks. 2. Rewrite the three hook classes (`SubagentStart`, `SubagentStop`, the `PostToolUse`/`next` handler) to call `DispatchCommands` directly per the shape above. 3. Delete `command/pipe.rb`, `hook/script.rb`, `model/hook_source.rb` and their tests (`tests/command/pipe_*.rb`, `tests/support/hook_stdin_contract.rb`). 4. Confirm `hooks.json` matchers route exactly one event per script; if so, drop the `event.name ==` check in each hook class. 5. Write an ADR superseding ADR-0172 item 4 ("each hook is a pipe to the runner subcommand") and amending ADR-0207. Rejected: keep `Pipe` as a thin pass-through — rejected because it still wraps `SystemExit` and re-serializes JSON for no behavioral gain once hooks call `DispatchCommands` directly. ## Acceptance criteria 1. Given the pre-change and post-change hook classes run against the same event payload, when the output is compared, then it is byte-identical on the success path. 2. Given the full test suite, when it runs after this change, then it is green. 3. Given a search of `lib/os_sdlc/hook/` for `RunnerCLI`, when the search runs after this change, then it returns no matches. ## Owner sketch ```ruby module OsSdlc class SubagentStart def self.call(io:); end end class SubagentStop def self.call(io:); end end end ``` ## Non-goals Dissolving `RunnerCLI` itself and its bin scripts — tracked separately, and depends on this ticket. ## Origin - Trigger: user question about why hooks route through RunnerCLI (Thor) in-process, during session 9 CRC. - Improvised this session: none. - Chain: unnecessary CLI indirection in every hook call ← ADR-0172 item 4 mandated a pipe-to-subcommand shape before it was known that hooks fire natively ← DESIGN, ADR-0172. - Root candidate: this ticket. - Where: plugins/os-sdlc/lib/os_sdlc/hook/, plugins/os-sdlc/command/pipe.rb. - Session: 8e4aac4f-9878-45da-ac68-c7d55261a73f - Transcript: /home/jared/dev/cc-os/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-crc-pass-os-sdlc/8e4aac4f-9878-45da-ac68-c7d55261a73f.jsonl
Author
Owner

Investigated after #766 (double subagent-stop path).

Investigated after #766 (double subagent-stop path).
Author
Owner

Resolution

Done: Hooks call DispatchCommands directly. lib/os_sdlc/hook/subagent_start.rb, subagent_stop.rb, post_tool_use.rb parse the event from io, call subagent_start / subagent_stop_from_event / next_instruction_from_event, and print the envelope. Deleted command/pipe.rb, hook/script.rb, model/hook_source.rb, tests/support/hook_stdin_contract.rb, tests/command/pipe_*_test.rb, tests/hook/. lint_changed.rb calls new.call without Script. Event-name checks dropped: hooks.json routes one event type per script; post_tool_use keeps its tool_name == Agent filter. Errors raise and exit 1 as accepted.

Evidence: Commit 5e5457ce on branch crc-pass-os-sdlc. tests/hooks/hook_envelope_bytes_test.rb pins byte-identical success envelopes for all three hooks, green before and after. Suite: 1327 runs, 2912 assertions, 0 failures, 0 errors, 10 skips. rubocop: 72 pre-existing offenses, none in touched files. grep -rn RunnerCLI lib/os_sdlc/hook/ returns nothing. ADR-0231 supersedes ADR-0172 item 4 and ADR-0217, amends ADR-0207 and ADR-0194 item 6. plugins/os-sdlc/CLAUDE.md hook-contract section and docs/implementation-status/os-sdlc.md History row updated.

Follow-ups: none; #768 (RunnerCLI dissolution) is the next ticket and was already open.

## Resolution **Done:** Hooks call DispatchCommands directly. lib/os_sdlc/hook/subagent_start.rb, subagent_stop.rb, post_tool_use.rb parse the event from io, call subagent_start / subagent_stop_from_event / next_instruction_from_event, and print the envelope. Deleted command/pipe.rb, hook/script.rb, model/hook_source.rb, tests/support/hook_stdin_contract.rb, tests/command/pipe_*_test.rb, tests/hook/. lint_changed.rb calls new.call without Script. Event-name checks dropped: hooks.json routes one event type per script; post_tool_use keeps its tool_name == Agent filter. Errors raise and exit 1 as accepted. **Evidence:** Commit 5e5457ce on branch crc-pass-os-sdlc. tests/hooks/hook_envelope_bytes_test.rb pins byte-identical success envelopes for all three hooks, green before and after. Suite: 1327 runs, 2912 assertions, 0 failures, 0 errors, 10 skips. rubocop: 72 pre-existing offenses, none in touched files. grep -rn RunnerCLI lib/os_sdlc/hook/ returns nothing. ADR-0231 supersedes ADR-0172 item 4 and ADR-0217, amends ADR-0207 and ADR-0194 item 6. plugins/os-sdlc/CLAUDE.md hook-contract section and docs/implementation-status/os-sdlc.md History row updated. **Follow-ups:** none; #768 (RunnerCLI dissolution) is the next ticket and was already open.
jared closed this issue 2026-09-27 14:08:20 +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#767
No description provided.