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.
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
- Matches
Bash${CLAUDE_PLUGIN_ROOT}/hooks/approve-merge.sh${CLAUDE_PLUGIN_ROOT}/hooks/no-ci-logs-in-orchestrator.sh - Matches
Agent|Task${CLAUDE_PLUGIN_ROOT}/hooks/require-background-workers.sh
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

