Add action hook points to start, doctor, and phase-merge #331

Closed
opened 2026-04-03 09:03:57 +00:00 by mprihoda · 0 comments
mprihoda commented 2026-04-03 09:03:57 +00:00 (Migrated from github.com)

Context

As part of the bounded context extraction (iterative-works/dev-docs#123), Claude-specific behavior in start, doctor, and phase-merge needs to move into plugins. Before #324 can cleanly remove the Claude code, these commands need hook extension points so plugins can provide the behavior.

Currently, the hook mechanism only supports Check discovery for doctor (reflection-based Check values). We need a parallel mechanism for action hooks — plugin-provided behavior that commands invoke at specific points.

Relationship to #324

This issue is a prerequisite for #324. The sequence:

  1. This issue: Add hook points (commands gain the ability to delegate to plugins)
  2. dev-docs#123 Phase 2: kanon plugin provides Claude-specific hooks
  3. #324: Remove the hardcoded Claude code from core (safe because hooks provide it)

Hook Points Needed

1. start / open — Post-Session Hook

When: After worktree + tmux session is set up, before attaching.

Current behavior: If --prompt is given, sends claude --dangerously-skip-permissions <prompt> to the tmux session. Otherwise attaches normally.

Proposed hook: *.hook-start.scala objects can expose a SessionAction that receives context and optionally sends a command to the tmux session.

// Hook interface (in core/model/)
case class SessionContext(
  sessionName: String,
  worktreePath: os.Path,
  issueId: String,
  prompt: Option[String]  // from --prompt flag
)

trait SessionAction:
  def run(ctx: SessionContext): Option[String]  // Returns tmux command to send, or None

Discovery: Same pattern as doctor hooks — reflection finds objects exposing SessionAction values in *.hook-start.scala files. If multiple hooks exist, first non-None wins (or error on conflict).

Behavior change: --prompt flag stays as a generic "pass a prompt string to hooks." If no hook provides a SessionAction, --prompt is ignored (or warns). Same mechanism for open.

2. doctor — Fix Action Hook

When: After all checks run, if --fix flag is set and there are failures.

Current behavior: Detects build system, generates FixPrompt, spawns interactive Claude session.

Proposed hook: *.hook-doctor.scala objects can expose a FixAction (in addition to existing Check values).

case class DoctorFixContext(
  failedChecks: List[String],
  buildSystem: BuildSystem,
  ciPlatform: String,
  config: ProjectConfiguration
)

trait FixAction:
  def fix(ctx: DoctorFixContext): Int  // Returns exit code

Discovery: Same reflection pass that finds Check values also looks for FixAction values. If --fix is requested and no FixAction is found, warn that no fix provider is installed.

3. phase-merge — Recovery Action Hook

When: CI checks fail during the polling loop.

Current behavior: Builds recovery prompt from failed checks, spawns claude --dangerously-skip-permissions, retries.

Proposed hook: *.hook-phase-merge.scala objects can expose a RecoveryAction.

case class RecoveryContext(
  failedChecks: List[CICheckResult],
  prUrl: String,
  branch: String,
  attempt: Int,
  maxRetries: Int
)

trait RecoveryAction:
  def recover(ctx: RecoveryContext): Unit

Discovery: Reflection finds RecoveryAction in *.hook-phase-merge.scala files. If no hook is found, phase-merge skips recovery and fails immediately on CI failure (current behavior minus Claude).

Behavior change: --max-retries flag stays generic. Recovery loop calls the hook's recover() on each attempt.

Implementation Notes

  • The hook discovery mechanism in iw-run already supports plugin hooks ($plugin_dir/hooks/*.hook-{cmd}.scala). The reflection pass just needs to look for the new trait types alongside Check.
  • The trait definitions go in core/model/ as pure interfaces.
  • Each command's code change is small: extract the hardcoded Claude invocation into "call hook if available, else skip/warn."
  • The --prompt flag on start/open becomes a generic parameter passed through to hooks.

Acceptance Criteria

  • SessionAction, FixAction, RecoveryAction traits defined in core/model/
  • start invokes SessionAction hook instead of hardcoding Claude
  • open invokes SessionAction hook instead of hardcoding Claude
  • doctor --fix invokes FixAction hook instead of hardcoding Claude
  • phase-merge invokes RecoveryAction hook instead of hardcoding Claude
  • Commands warn (not error) when hooks are missing
  • Existing *.hook-doctor.scala check hooks still work unchanged
  • --prompt flag still works on start/open (passed through to hook context)

Estimated Effort

3-4 hours

## Context As part of the bounded context extraction (iterative-works/dev-docs#123), Claude-specific behavior in `start`, `doctor`, and `phase-merge` needs to move into plugins. Before #324 can cleanly remove the Claude code, these commands need hook extension points so plugins can *provide* the behavior. Currently, the hook mechanism only supports `Check` discovery for `doctor` (reflection-based `Check` values). We need a parallel mechanism for **action hooks** — plugin-provided behavior that commands invoke at specific points. ## Relationship to #324 This issue is a **prerequisite** for #324. The sequence: 1. **This issue**: Add hook points (commands gain the ability to delegate to plugins) 2. **dev-docs#123 Phase 2**: kanon plugin provides Claude-specific hooks 3. **#324**: Remove the hardcoded Claude code from core (safe because hooks provide it) ## Hook Points Needed ### 1. `start` / `open` — Post-Session Hook **When:** After worktree + tmux session is set up, before attaching. **Current behavior:** If `--prompt` is given, sends `claude --dangerously-skip-permissions <prompt>` to the tmux session. Otherwise attaches normally. **Proposed hook:** `*.hook-start.scala` objects can expose a `SessionAction` that receives context and optionally sends a command to the tmux session. ```scala // Hook interface (in core/model/) case class SessionContext( sessionName: String, worktreePath: os.Path, issueId: String, prompt: Option[String] // from --prompt flag ) trait SessionAction: def run(ctx: SessionContext): Option[String] // Returns tmux command to send, or None ``` **Discovery:** Same pattern as doctor hooks — reflection finds objects exposing `SessionAction` values in `*.hook-start.scala` files. If multiple hooks exist, first non-None wins (or error on conflict). **Behavior change:** `--prompt` flag stays as a generic "pass a prompt string to hooks." If no hook provides a `SessionAction`, `--prompt` is ignored (or warns). Same mechanism for `open`. ### 2. `doctor` — Fix Action Hook **When:** After all checks run, if `--fix` flag is set and there are failures. **Current behavior:** Detects build system, generates `FixPrompt`, spawns interactive Claude session. **Proposed hook:** `*.hook-doctor.scala` objects can expose a `FixAction` (in addition to existing `Check` values). ```scala case class DoctorFixContext( failedChecks: List[String], buildSystem: BuildSystem, ciPlatform: String, config: ProjectConfiguration ) trait FixAction: def fix(ctx: DoctorFixContext): Int // Returns exit code ``` **Discovery:** Same reflection pass that finds `Check` values also looks for `FixAction` values. If `--fix` is requested and no `FixAction` is found, warn that no fix provider is installed. ### 3. `phase-merge` — Recovery Action Hook **When:** CI checks fail during the polling loop. **Current behavior:** Builds recovery prompt from failed checks, spawns `claude --dangerously-skip-permissions`, retries. **Proposed hook:** `*.hook-phase-merge.scala` objects can expose a `RecoveryAction`. ```scala case class RecoveryContext( failedChecks: List[CICheckResult], prUrl: String, branch: String, attempt: Int, maxRetries: Int ) trait RecoveryAction: def recover(ctx: RecoveryContext): Unit ``` **Discovery:** Reflection finds `RecoveryAction` in `*.hook-phase-merge.scala` files. If no hook is found, `phase-merge` skips recovery and fails immediately on CI failure (current behavior minus Claude). **Behavior change:** `--max-retries` flag stays generic. Recovery loop calls the hook's `recover()` on each attempt. ## Implementation Notes - The hook discovery mechanism in `iw-run` already supports plugin hooks (`$plugin_dir/hooks/*.hook-{cmd}.scala`). The reflection pass just needs to look for the new trait types alongside `Check`. - The trait definitions go in `core/model/` as pure interfaces. - Each command's code change is small: extract the hardcoded Claude invocation into "call hook if available, else skip/warn." - The `--prompt` flag on start/open becomes a generic parameter passed through to hooks. ## Acceptance Criteria - [ ] `SessionAction`, `FixAction`, `RecoveryAction` traits defined in `core/model/` - [ ] `start` invokes `SessionAction` hook instead of hardcoding Claude - [ ] `open` invokes `SessionAction` hook instead of hardcoding Claude - [ ] `doctor --fix` invokes `FixAction` hook instead of hardcoding Claude - [ ] `phase-merge` invokes `RecoveryAction` hook instead of hardcoding Claude - [ ] Commands warn (not error) when hooks are missing - [ ] Existing `*.hook-doctor.scala` check hooks still work unchanged - [ ] `--prompt` flag still works on start/open (passed through to hook context) ## Estimated Effort 3-4 hours ## Related - iterative-works/iw-cli#323 — Plugin command directory support (merged) - iterative-works/iw-cli#324 — Remove AI-specific code from core (depends on this) - iterative-works/dev-docs#123 — Bounded context extraction
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
iterative-works/iw-cli#331
No description provided.