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
85 skills2 agents3 hooks

Claude Code

> /plugin marketplace add sgomez/developer-skills
> /plugin install developer-skills@sgomez

What fires, and when

PreToolUse

  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/approve-merge.sh${CLAUDE_PLUGIN_ROOT}/hooks/approve-push.sh
  • MatchesAgent|Task${CLAUDE_PLUGIN_ROOT}/hooks/require-background-workers.sh

SubagentStart

  • ${CLAUDE_PLUGIN_ROOT}/hooks/hc-agent-terminals.sh

SubagentStop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/hc-agent-terminals.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.
    #      Or the PR merges into a spec's integration branch (`agent/developer/spec-<N>`):
    #      /developer integrates every sub-issue there unattended whatever the
    #      merge policy, which governs only the spec PR into main. Nothing reaches
    #      main that way, so `merge: manual` is not being bypassed — and guard 5
    #      does not apply either: sub-issues are not gated on CI, the spec PR's
    #      gate runs it once on all of them before anything reaches main.
    #   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
    base="$(cd "$root" && gh pr view "$pr" --json baseRefName --jq .baseRefName 2>/dev/null)" || base=""
    if [[ "$base" =~ ^agent/developer/spec-[0-9]+$ ]]; then
      integration=1
    else
      integration=0
      grep -qE '^merge:[[:space:]]*auto[[:space:]]*$' "$root/docs/agents/developer-defaults.md" 2>/dev/null || exit 0
    fi
    
    # 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.
    if [[ "$integration" -eq 0 ]]; then
      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
    
  • hooks/approve-push.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # PreToolUse (Bash) hook for the /developer pipeline.
    #
    # Auto-approves a fix or merge-fix worker's push of its commits onto the PR it
    # was sent to fix — and ONLY that — so it is never handed to the auto-mode
    # permission classifier. On lab-05 the classifier denied the spec PR's fixer
    # `git push origin HEAD:agent/developer/spec-1` as "Modify Shared Resources"
    # (the integration branch is the head of a PR into main), after letting the
    # very same push through on lab-04: the spec escalated with its fix committed
    # locally and never published. A PreToolUse `allow` runs before that
    # classifier, so it is the only deterministic way to let the push 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 `git push origin HEAD:agent/developer/<spec|issue>-<N>`
    #      — fully anchored: no force, no flags, no refspec but HEAD, no chaining.
    #      Without a force flag the remote refuses anything but a fast-forward,
    #      so the push can only add commits on top of the PR branch.
    #   3. The call comes from a linked worktree — where workers run — never the
    #      primary checkout.
    #   4. That worktree is on a fix branch the workflow names:
    #      `agent/developer/fix-pr-<PR>`, or a variant of it (`-r2`, `-merge`…).
    #   5. PR <PR> exists and its head branch is the push's target: a worker sent
    #      to fix PR 16 may push onto PR 16's branch, and onto nothing else.
    
    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 fix prompts issue.
    re='^git push origin HEAD:(agent/developer/(spec|issue)-[0-9]+)$'
    [[ "$cmd" =~ $re ]] || exit 0
    target="${BASH_REMATCH[1]}"
    
    cwd="$(printf '%s' "$payload" | jq -r '.cwd // ""' 2>/dev/null)" || exit 0
    [[ -n "$cwd" ]] || cwd="$PWD"
    
    # 3. Only from a linked worktree: there git-dir and git-common-dir differ.
    #    Outside a repo both are empty, which must not read as a worktree.
    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" && -n "$commondir" && "$gitdir" != "$commondir" ]] || exit 0
    
    # 4. On a fix branch, which names the PR it fixes.
    branch="$(git -C "$cwd" symbolic-ref --short -q HEAD 2>/dev/null)" || exit 0
    bre='^agent/developer/fix-pr-([0-9]+)(-[a-z0-9]+)?$'
    [[ "$branch" =~ $bre ]] || exit 0
    pr="${BASH_REMATCH[1]}"
    
    # 5. The push lands on that PR's own head branch.
    head="$(cd "$cwd" && gh pr view "$pr" --json headRefName --jq .headRefName 2>/dev/null)" || exit 0
    [[ "$head" == "$target" ]] || exit 0
    
    jq -nc '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "allow",
        permissionDecisionReason: "Sanctioned /developer fix push: a fix worker publishing its commits onto the head of the PR it fixes, fast-forward only — kept out of the auto-mode classifier by design."
      }
    }'
    
  • hooks/hc-agent-terminals.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # SubagentStart / SubagentStop hook: in herdr-core, each /developer worker
    # (build, fix, merge-fix, review) gets a child terminal of the Claude terminal,
    # named after the workflow label (build-387), that follows the worker's
    # transcript and status (hc-agent-view.sh). SubagentStop tells it the worker
    # ended; it shows the result for a while, then exits.
    #
    # The herdr app lists terminals, not OSC 7501 sub-records, so a terminal per
    # worker is what makes the workers visible there. Short mechanical agents
    # (cleanup, gate…) get none: their agent_type is not a worker's. A no-op
    # outside herdr-core, outside a workflow, and without jq or hc.
    #
    # The payload has no label: it comes from the workflow journal, which holds the
    # agent's `started` line when SubagentStart fires. --no-notify keeps the core
    # from typing notes about the child into Claude's prompt.
    
    [[ -n ${HC_TERMINAL_ID:-} ]] || exit 0
    command -v jq >/dev/null 2>&1 && command -v hc >/dev/null 2>&1 || exit 0
    
    IFS=$'\t' read -r type id event transcript < <(jq -r '[.agent_type // "", .agent_id // "", .hook_event_name // "", .transcript_path // ""] | @tsv' 2>/dev/null) || exit 0
    [[ $id =~ ^[A-Za-z0-9_-]+$ ]] || exit 0
    case "${type##*:}" in
      code-author | diff-reviewer) ;;
      *) exit 0 ;;
    esac
    journal="$(grep -l "\"agentId\":\"$id\"" "${transcript%.jsonl}"/subagents/workflows/*/journal.jsonl 2>/dev/null | head -1)"
    [[ -n $journal ]] || exit 0
    
    if [[ $event == SubagentStop ]]; then
      hc ls --json 2>/dev/null | jq -r --arg id "$id" '.terminals[] | select(.labels["claude.agent"] == $id) | .address' |
        while read -r t; do hc send "$t" stop >/dev/null 2>&1; done
      exit 0
    fi
    [[ $event == SubagentStart ]] || exit 0
    
    label="$(grep "\"agentId\":\"$id\"" "$journal" | jq -r 'select(.type == "started") | .label // empty' 2>/dev/null | head -1)"
    label=${label:-agent}
    
    # hc names: 1-32 of a-z 0-9 - _ ., not shaped like an id (t7), unique among live terminals.
    name="$(printf %s "$label" | tr 'A-Z' 'a-z' | tr -cs 'a-z0-9._' '-' | sed 's/^[-._]*//; s/-*$//' | cut -c1-25)"
    [[ -n $name && ! $name =~ ^t[0-9] ]] || name="agent-$name"
    name=${name%-}
    for n in "$name" "$name-${id:0:6}"; do
      hc run --bg --no-notify --rm --name "$n" --label "claude.agent=$id" \
        "$(dirname "$0")/hc-agent-view.sh" "$label" "$(dirname "$journal")/agent-$id.jsonl" "$journal" "$id" >/dev/null 2>&1 && break
    done
    exit 0
    
  • hooks/hc-agent-view.shGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # hc-agent-view.sh <label> <agent transcript> <workflow journal> <agent id> —
    # runs in the terminal that hc-agent-terminals.sh opens for a worker: prints
    # the worker's messages and tool calls as its transcript grows, reports each
    # step to herdr-core as `working`, and the journal's result as `done` or `error`.
    #
    # A finished terminal loses its status, so the final one lingers
    # HC_AGENT_LINGER seconds. A line on stdin (`hc send <t> stop`, from
    # SubagentStop) means the worker ended: no result within 5 s is an error.
    
    label=$1 file=$2 journal=$3 id=$4
    stty -echo 2>/dev/null
    status() { printf '\033]7501;state=%s:app=claude-workflow:msg=%s\033\134' "$1" "$(printf %s "$2" | base64 -w0)"; }
    status working "$label"
    printf '\033]2;%s\007' "$label"
    echo "── $label ──"
    for _ in $(seq 50); do [[ -f $file ]] && break; sleep 0.2; done
    tail -n +1 -F "$file" 2>/dev/null | jq --unbuffered -j '
      def osc($m): "\u001b]7501;state=working:app=claude-workflow:msg=\($m | @base64)\u001b\\";
      select(.type == "assistant") | .message.content[]? |
      if .type == "text" then "· " + .text + "\n"
      elif .type == "tool_use" then
        (.name + " " + ((.input.command // .input.description // .input.file_path // .input.pattern // "") | tostring) | .[0:200]) as $s
        | "→ \($s)\n" + osc($s | gsub("\\s+"; " ") | .[0:80])
      else empty end' &
    
    deadline=
    until result="$(jq -c --arg id "$id" 'select(.type == "result" and .agentId == $id) | .result' "$journal" 2>/dev/null | head -1)"; [[ -n $result ]]; do
      [[ -n $deadline ]] && ((SECONDS >= deadline)) && break
      if read -r -t 1 _; then deadline=$((SECONDS + 5)); elif (($? < 128)); then sleep 1; fi
    done
    sleep 0.5
    pkill -P $$ 2>/dev/null
    
    IFS=$'\t' read -r state msg < <(jq -r '
      if . == null then ["error", "stopped without a result"]
      elif type != "object" then ["done", tostring]
      else ([.status, .verdict, .outcome] | map(select(. != null))[0] // "done") as $v
        | if ($v | ascii_downcase) | IN("blocked", "escalate") then ["error", .reason // $v]
          elif .pr then ["done", "PR #\(.pr)"]
          elif $v == "pr" then ["done", .url // "pushed"]
          elif (.fixes // 0) > 0 then ["done", "\($v) (\(.fixes) fixes)"]
          else ["done", $v] end
      end | .[1] |= (tostring | gsub("\\s+"; " ") | .[0:80]) | @tsv' <<<"${result:-null}")
    status "$state" "$msg"
    [[ $state == done ]] && echo "✓ $msg" || echo "✗ $msg"
    sleep "${HC_AGENT_LINGER:-60}"
    
  • 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) ;;
      *) 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.

@sgomez
Ships withdeveloper-skills

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

85 skills2 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
8
Star history of sgomez/developer-skills on GitHub, a chart by star-history.com

More plugins that ship hooks.

n8n-mcp-skills

n8n-mcp-skills

Expert Claude Code skills for building flawless n8n workflows using the n8n-mcp MCP server.

15 skills3 hooks6.6k
@czlonkowski@czlonkowski
Install Plugin
claude-scholar

claude-scholar

A research assistant plugin for Claude Code that supports literature review, paper writing, citation verification, Zotero and Obsidian integration, and code review and debugging for academic and software work.

45 skills+35.9k
@galaxy-dawn@galaxy-dawn
Install Plugin
octo

octo

Runs multi-AI workflows in Claude Code that split research, requirements scoping, implementation and review across several external AI providers, such as Codex, Gemini-style CLIs, Copilot, Ollama and OpenRouter, to cover each model's blind spots.

70 skills+34.3k
@nyldn@nyldn
Install Plugin
ai-maestro

ai-maestro

Orchestrates multiple Claude Code agents across terminals and machines, giving them persistent memory and agent-to-agent messaging so you no longer have to relay messages between them by hand.

33 hooks1 MCP841
@23blocks-os@23blocks-os
Install Plugin
godotmaker

godotmaker

An autonomous pipeline that turns a text description into a playable Godot game, generating design docs, game assets such as backgrounds, characters, props, cards and effects, and running visual checks and playtests.

41 skills+2559
@randallliuxin@randallliuxin
Install Plugin
sutando

sutando

A personal AI assistant with voice, screen and meeting support that learns your patterns and can update its own code. It runs across several Macs and can talk with other people's assistants.

79 skills18 hooks401
@sonichi@sonichi
Install Plugin
nextboard-hardware-solution

nextboard-hardware-sol…

Helps hardware teams turn product requirements into a reviewable, buildable PCB design plan. Covers embedded architecture, MCU or SoC selection, power tree, interfaces, sensors, connectivity, and BOM risk.

1 skill+2395
@leokemp223@leokemp223
Install Plugin
claude-prompts

claude-prompts

MCP server for reusable prompt templates, multi-step workflow chains, and quality gates. Compose agentic workflows with an operator syntax; export as native skills to Claude Code, Cursor, OpenCode, and Gemini CLI.

5 hooks1 MCP191
@minipuft@minipuft
Install Plugin

Questions about the hooks in developer-skills.

Which coding agents can use the hooks in developer-skills?
The hooks in developer-skills install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.