Skip to content
Automation
Hook

Hooks

What developer-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
developer-skills
75 skills3 agents1 hook
Install
> /plugin marketplace add sgomez/developer-skills
> /plugin install developer-skills@sgomez

Ships with developer-skills. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/approve-merge.sh${CLAUDE_PLUGIN_ROOT}/hooks/no-ci-logs-in-orchestrator.sh
  • MatchesAgent|Task${CLAUDE_PLUGIN_ROOT}/hooks/require-background-workers.sh
Read hooks/hooks.json

Where it lives

  • hooks/approve-merge.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse (Bash) hook for the /developer pipeline.
    #
    # Auto-approves the pipeline's one sanctioned unattended merge — and ONLY that —
    # so it is never handed to the auto-mode permission classifier. In `auto` mode
    # the classifier re-evaluates "the orchestrator is merging a PR that only got a
    # subagent COMMENT, with no human approval" as an adversarial pattern and denies
    # it, even when `Bash(gh pr merge:*)` is on the allow-list (a wildcard allow rule
    # does not exempt it). A PreToolUse `allow` decision runs before that classifier,
    # so it is the only deterministic way to let the sanctioned merge through.
    #
    # Every guard below must hold; otherwise the hook stays silent (exit 0 → defer to
    # the normal permission flow), so it can never widen anything unexpectedly:
    #   1. jq is available (the hook payload is JSON on stdin).
    #   2. The command is EXACTLY `gh pr merge <PR> --(merge|squash|rebase)` — fully
    #      anchored: no shell chaining, no --admin, no extra flags.
    #   3. The call comes from the primary checkout, where the orchestrator runs.
    #      developer-defaults.md is committed, so guard 4 is equally true inside
    #      every worker's linked worktree — a worker that merged on its own would
    #      be auto-approved by guards 1, 2 and 4 alone. Merging is the
    #      orchestrator's alone, and only it runs where git-dir == git-common-dir.
    #   4. The repo opted into unattended merges: docs/agents/developer-defaults.md
    #      carries a `merge: auto` line. Interactive `--auto-merge` overrides on a
    #      `merge: manual` repo are deliberately NOT covered — a prompt there is fine.
    #   5. The change's CI checks are green — or the repo has none, which is the
    #      same thing here: nothing to gate on. The Merge step tells the
    #      orchestrator to gate on this, but that gate is a prompt; this hook is
    #      what makes it real. Without guard 5 the hook would wave a red build
    #      straight past the classifier — the one reader that would otherwise have
    #      stopped it.
    #
    # Note the asymmetry in guard 5: green approves, and anything else (red,
    # pending, no CI, gh failure, no network) merely stays silent. The hook never
    # emits a `deny`. Staying silent hands the merge back to the normal permission
    # flow, which is free to ask or refuse; that keeps this hook incapable of
    # blocking a merge a human would have allowed, while still never approving one
    # on red.
    
    command -v jq >/dev/null 2>&1 || exit 0
    
    payload="$(cat)"
    cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // ""' 2>/dev/null)" || exit 0
    
    # 2. Strict, fully-anchored match — the exact form the orchestrator issues.
    re='^gh pr merge ([0-9]+) --(merge|squash|rebase)$'
    [[ "$cmd" =~ $re ]] || exit 0
    pr="${BASH_REMATCH[1]}"
    
    cwd="$(printf '%s' "$payload" | jq -r '.cwd // ""' 2>/dev/null)" || exit 0
    [[ -n "$cwd" ]] || cwd="$PWD"
    
    # 3. Only from the primary checkout. In a linked worktree these two paths
    #    differ; outside a repo both are empty, which must not read as a match.
    #    --path-format=absolute is required: without it git prints whichever form
    #    is shortest from cwd, so a subdirectory of the primary checkout yields
    #    "/abs/.git" and "../.git" — unequal, and the merge would never be approved.
    gitdir="$(git -C "$cwd" rev-parse --path-format=absolute --git-dir 2>/dev/null)" || exit 0
    commondir="$(git -C "$cwd" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0
    [[ -n "$gitdir" && "$gitdir" == "$commondir" ]] || exit 0
    
    # 4. Only where the user pre-authorized unattended merges. The defaults file is
    #    repo-relative, so resolve it from the top level — cwd is wherever the
    #    orchestrator happens to stand, which is often a subdirectory.
    root="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)" || exit 0
    [[ -n "$root" ]] || exit 0
    grep -qE '^merge:[[:space:]]*auto[[:space:]]*$' "$root/docs/agents/developer-defaults.md" 2>/dev/null || exit 0
    
    # 5. Only on green checks.
    #
    #    `gh pr checks` is the wrong probe here: it exits non-zero both when a
    #    check failed and when the PR has no checks at all, and those two cases
    #    must not be conflated — the second is a repo without CI, which this hook
    #    has always approved and must keep approving. `gh pr view
    #    --json statusCheckRollup` separates them cleanly: it exits 0 either way
    #    and returns an empty array when nothing reports on the head commit.
    #
    #    Deliberately no --watch/polling: a PreToolUse hook must answer promptly,
    #    and waiting for CI is the orchestrator's job in the Merge step. A check
    #    still running has a null conclusion, counts as not-green, and the hook
    #    simply stays silent.
    rollup="$(cd "$root" && gh pr view "$pr" --json statusCheckRollup \
      --jq '.statusCheckRollup // []' 2>/dev/null)" || exit 0
    
    # A check run reports `conclusion` (null while running); a commit status
    # reports `state`. Anything that is not a settled success counts against the
    # merge — including the empty string a running check yields.
    notgreen="$(printf '%s' "$rollup" | jq '
      [ .[]
        | ((.conclusion // .state // "") | ascii_upcase)
        | select(. != "SUCCESS" and . != "NEUTRAL" and . != "SKIPPED")
      ] | length
    ' 2>/dev/null)" || notgreen=""
    
    # Non-numeric means gh failed, is unauthenticated, or printed something
    # unparseable: an unknown CI state is not a green one, so defer, never approve.
    [[ "$notgreen" =~ ^[0-9]+$ ]] || exit 0
    [[ "$notgreen" -eq 0 ]] || exit 0
    
    jq -nc '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "allow",
        permissionDecisionReason: "Sanctioned /developer merge:auto merge (gh pr merge <PR>) — pre-authorized in docs/agents/developer-defaults.md, kept out of the auto-mode classifier by design."
      }
    }'
    
  • hooks/no-ci-logs-in-orchestrator.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse (Bash) hook for the /developer pipeline.
    #
    # Keeps raw CI job output out of the orchestrator's context. `gh run view
    # --log-failed` and friends dump whole job logs into the one context the whole
    # design protects — in a field run, six such calls diagnosing a single flaky
    # suite are what compacted it. The Merge step's `gh run view --json
    # conclusion,jobs` classification is the entire diagnosis the orchestrator is
    # meant to make; past that the answer is the one allowed retry, then a fixer,
    # which reads the logs in its own disposable context and already receives the
    # failing job's URL. SKILL.md says this; this hook is what makes it real.
    #
    # Every guard must hold, otherwise the hook stays silent (exit 0 → defer to the
    # normal permission flow):
    #   1. jq is available (the hook payload is JSON on stdin).
    #   2. The command reads a run's logs. Matched as substrings, not anchored, on
    #      purpose: `gh run view 123 --log-failed | grep -i error` is precisely the
    #      shape to catch, and anchoring would miss every pipe.
    #   3. The call comes from the primary checkout. Session hooks fire inside
    #      subagents too (see hooks/approve-merge.sh, guard 3), and a worker is
    #      exactly who *should* be reading these logs — in a linked worktree
    #      git-dir and git-common-dir differ, so workers fall through untouched.
    #   4. A /developer run is actually in flight, evidenced by its run log. This
    #      hook has no business in an ordinary session where a human is debugging
    #      their own CI from the primary checkout.
    #
    # Limitation of guard 4: the wrap-up archives the run log, so between that
    # archive and the next spawn row there is a window where the hook stays silent.
    # The run is effectively over there, and a stale log at worst leaves the guard
    # armed for one session too long — both preferable to blocking a human.
    
    command -v jq >/dev/null 2>&1 || exit 0
    
    payload="$(cat)"
    cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // ""' 2>/dev/null)" || exit 0
    
    # 2. Log reads only. The --json classification the Merge step prescribes has no
    #    --log flag and is never matched here.
    [[ "$cmd" == *"gh run view"* && "$cmd" == *"--log"* ]] || exit 0
    
    cwd="$(printf '%s' "$payload" | jq -r '.cwd // ""' 2>/dev/null)" || exit 0
    [[ -n "$cwd" ]] || cwd="$PWD"
    
    root="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)" || exit 0
    [[ -n "$root" ]] || exit 0
    
    # 3. Primary checkout only. --path-format=absolute is required: without it git
    #    prints whichever form is shortest from cwd, so a subdirectory of the
    #    primary checkout yields "/abs/.git" and "../.git" — unequal, and the hook
    #    would never fire. Outside a repo both are empty, which must not match.
    gitdir="$(git -C "$cwd" rev-parse --path-format=absolute --git-dir 2>/dev/null)" || exit 0
    commondir="$(git -C "$cwd" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0
    [[ -n "$gitdir" && "$gitdir" == "$commondir" ]] || exit 0
    
    # 4. Only while a run is in flight.
    shopt -s nullglob
    logs=("$root"/.scratch/developer-run-*.log)
    (( ${#logs[@]} )) || exit 0
    
    jq -nc '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "Raw CI logs must not enter the orchestrator context during a /developer run. Classify the red with the Merge step gh run view --json conclusion,jobs query instead. If that is not enough to decide, take the one allowed rerun, and if it comes back red hand the failing job URL to a fixer — it reads the logs in its own context, which is what keeps this one small."
      }
    }'
    
  • hooks/require-background-workers.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse (Agent) hook for the /developer pipeline.
    #
    # Refuses a foreground spawn of a /developer worker. A foreground spawn holds
    # the orchestrator's turn open for the worker's entire run, so anything that
    # interrupts that turn — a Ctrl-C, a dropped connection — takes the worker down
    # with it: its context, its worktree and its commits are gone, unrecoverably.
    # The same interruption leaves a background worker running and reachable with
    # SendMessage. SKILL.md says this ("Every spawn is run_in_background: true");
    # this hook is what makes it real.
    #
    # Deliberately narrow, because unlike hooks/approve-merge.sh this one *denies*,
    # and a misfire blocks real work:
    #   1. jq is available (the hook payload is JSON on stdin).
    #   2. The spawn targets one of the three /developer worker types. Any other
    #      agent — Explore, general-purpose, another plugin's — passes untouched.
    #   3. run_in_background is *explicitly* false. The harness default is already
    #      background, so an omitted field is correct and must not be denied;
    #      denying a call that would have behaved properly is the false positive
    #      this hook must never produce. (If that default ever flips, the absent
    #      case stops being covered here — the SKILL.md rule is what covers it.)
    #
    # The denial reason is half the point: it has to be actionable enough that the
    # model re-issues the same spawn correctly in the same turn instead of giving
    # up on the sub-issue.
    
    command -v jq >/dev/null 2>&1 || exit 0
    
    payload="$(cat)"
    
    # 2. Only the pipeline's own workers. The type arrives namespaced when the
    #    plugin is installed ("developer-skills:code-author") and bare when the
    #    agents are loaded from a checkout, so match on the part after the colon.
    type="$(printf '%s' "$payload" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null)" || exit 0
    case "${type##*:}" in
      code-author | diff-reviewer | dispatcher) ;;
      *) exit 0 ;;
    esac
    
    # 3. Only an explicit opt-out of the background default.
    bg="$(printf '%s' "$payload" | jq -r '.tool_input.run_in_background' 2>/dev/null)" || exit 0
    [[ "$bg" == "false" ]] || exit 0
    
    jq -nc --arg t "$type" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: ("Foreground spawn of \($t) refused. A /developer worker spawned with run_in_background: false holds the orchestrator turn open for its whole run, so an interruption destroys the worker context, worktree and commits with no way to recover them. Re-issue this identical call with run_in_background: true, then wait for its result before spawning the next worker.")
      }
    }'
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withdeveloper-skills

Unattended spec delivery for Claude Code: you write specs, a pipeline of isolated agents implements every sub-issue — triage → build → review → fix → merge — and pings you when it's done.

Get the whole plugin
Stats
7
Stars
0
Forks
Active
Maintenance
Shell
Language
EUPL-1.2
License
8d ago
Last commit
2mo ago
Created

Repo: sgomez/developer-skills