Skip to content
Development
Hook

Hooks

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

From plugin
app-dev-team
432 skills30 agents27 commands2 hooks
Install
> /plugin marketplace add vmobifystudio/app-dev-team
> /plugin install app-dev-team@mobify-studio

Ships with app-dev-team. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBashsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-shared-tree-destructive-git.sh"
  • MatchesWritesh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh"
  • MatchesEditsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh"
  • MatchesMultiEditsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh"

SubagentStop

  • Matchescode-reviewersh "${CLAUDE_PLUGIN_ROOT}/hooks/require-review-verdict.sh"
Read hooks/hooks.json

Where it lives

  • hooks/block-cross-worktree-write.shRunsGitHub
    Read the script
    #!/bin/sh
    # block-cross-worktree-write — a PreToolUse hook stopping a Write/Edit reaching into ANOTHER
    # agent's worktree.
    #
    # WHY THIS EXISTS
    #
    # DR4-027 (2026-07-29) was two writing agents sharing ONE checkout: one ran `git stash` + `git
    # reset`, 22 files of the other's uncommitted work vanished. `block-shared-tree-destructive-git.sh`
    # closed that at the git layer — a repo-wide destructive command on a dirty tree. But nothing closes
    # the same class of harm at the FILE layer: nothing stops an agent standing in its own worktree from
    # using `Write`/`Edit`/`MultiEdit` to reach straight into a *sibling* worktree
    # (`.agent-wt/<other-owner>/...`) and overwrite that owner's in-progress file directly — no git
    # command involved at all, so the git-layer hook never sees it. `agent-isolation` and `ic-workflow`
    # both say "you are given that path and never leave it" and "another IC's code... is somebody else's
    # — if your change needs one, say so and stop" — prose, and this repo's own measured lesson (H6,
    # 2026-08-07: a qa-engineer agent ran `git merge` despite that exact sentence in its own role file)
    # is that prose does not stop an agent. This makes the boundary a command instead of a convention.
    #
    # SCOPE — narrow, and specifically the SIBLING-worktree collision, not a blanket path restriction
    #
    # It refuses ONLY when the write target resolves inside a worktree directory
    # (`.agent-wt/<name>/` or `.claude/worktrees/<name>/`) that is NOT the one the caller's own current
    # directory is already inside. A shared-tree agent (no worktree at all) reaching into ANY agent
    # worktree is refused the same way — it has no more business there than a sibling agent does.
    #
    # It does NOT restrict writes elsewhere: scratch files, files outside every worktree, and ordinary
    # writes inside the caller's own tree are all untouched. A hook that blocked every write outside one
    # directory would be broader than the harm it exists to stop, and — this repo's own recurring
    # lesson — a gate that refuses constantly gets switched off, which protects nothing.
    #
    # CONTRACT
    #   stdin  : the PreToolUse payload (JSON) — the target path is read from `tool_input.file_path`
    #   exit 0 : allow
    #   exit 2 : BLOCK, with the reason and the safe alternative on stderr
    #
    # Anything unparseable is ALLOWED, for the same reason the git-layer hook allows it: a hook that
    # blocks on its own confusion is a hook that gets removed the first time it misfires.
    
    set -u
    
    PAYLOAD=$(cat 2>/dev/null || true)
    [ -n "$PAYLOAD" ] || exit 0
    
    # Pull `file_path` out without a JSON parser — same technique and same portability constraint as
    # block-shared-tree-destructive-git.sh: GNU sed alternation fails SILENTLY on BSD sed (i.e. macOS),
    # so this stays awk, POSIX character classes only.
    FILE_PATH=$(printf '%s' "$PAYLOAD" | tr '\n' ' ' | awk '
      {
        i = index($0, "\"file_path\"");
        if (i == 0) exit;
        rest = substr($0, i + 12);
        j = index(rest, "\"");
        if (j == 0) exit;
        rest = substr(rest, j + 1);
        out = "";
        for (k = 1; k <= length(rest); k++) {
          c = substr(rest, k, 1);
          if (c == "\\") { k++; out = out substr(rest, k, 1); continue }
          if (c == "\"") break;
          out = out c;
        }
        print out;
      }')
    [ -n "$FILE_PATH" ] || exit 0
    
    # Resolve to an absolute path without requiring the file to exist yet (Write creates new files).
    # `cd` the parent directory and re-append the basename — the standard trick for resolving a path
    # that may not exist, since `realpath`/`readlink -f` are not universally present (notably not on
    # stock macOS `/usr/bin`).
    case "$FILE_PATH" in
      /*) ABS_TARGET="$FILE_PATH" ;;
      *)  ABS_TARGET="$(pwd)/$FILE_PATH" ;;
    esac
    
    # Resolve symlinks WITHOUT requiring the file (or even its parent directory) to exist: `Write`
    # creates new files, sometimes in a new subdirectory. `cd`+`pwd -P` only works on a directory that
    # already exists, so walk up from the target until an existing ancestor is found, resolve that one
    # (the earliest point a symlink could be hiding), then re-append every path segment that did not
    # exist yet, unresolved — they cannot contain a symlink if nothing has created them.
    #
    # Skipping this walk once bit exactly this class of bug: on macOS `/tmp` is itself a symlink to
    # `/private/tmp` (spawn-gate.sh's own header names the identical trap), so a target under a
    # not-yet-created subdirectory of `/tmp/...` resolved to a different absolute string than
    # `$(pwd -P)` for a caller standing in the SAME worktree — read as two different worktrees, and a
    # same-worktree write was refused. Caught by testing a brand-new subdirectory, not by reading it.
    tail=""
    walk="$ABS_TARGET"
    while [ ! -e "$walk" ] && [ "$walk" != "/" ] && [ -n "$walk" ]; do
      tail="$(basename "$walk")/$tail"
      walk=$(dirname "$walk")
    done
    RESOLVED_BASE=$(cd "$walk" 2>/dev/null && pwd -P) || RESOLVED_BASE="$walk"
    case "$tail" in
      */) tail="${tail%/}" ;;
    esac
    if [ -n "$tail" ]; then ABS_TARGET="$RESOLVED_BASE/$tail"; else ABS_TARGET="$RESOLVED_BASE"; fi
    
    # Which worktree, if any, is the CALLER standing in right now?
    CWD=$(pwd -P)
    worktree_of() {
      # Prints the worktree root (…/.agent-wt/<name> or …/.claude/worktrees/<name>) that $1 sits
      # inside, or nothing if $1 is not inside one. Pure string matching on the path — deliberately not
      # `git worktree list`, which needs a git repo and a live process; this only needs a path.
      case "$1" in
        */.agent-wt/*)
          printf '%s' "$1" | awk -F'/.agent-wt/' '{ n = split($2, parts, "/"); print $1 "/.agent-wt/" parts[1] }'
          ;;
        */.claude/worktrees/*)
          printf '%s' "$1" | awk -F'/.claude/worktrees/' '{ n = split($2, parts, "/"); print $1 "/.claude/worktrees/" parts[1] }'
          ;;
      esac
    }
    
    MY_WT=$(worktree_of "$CWD")
    TARGET_WT=$(worktree_of "$ABS_TARGET")
    
    # The refusal: the target is inside SOME worktree, and it is not the one I am standing in —
    # including the case where I am standing in none at all (the shared tree reaching into an isolated
    # worktree has exactly as lit
  • hooks/block-shared-tree-destructive-git.shRunsGitHub
    Read the script
    #!/bin/sh
    # block-shared-tree-destructive-git — a PreToolUse hook that makes the destructive-command ban
    # executable instead of merely written down.
    #
    # WHY THIS EXISTS
    #
    # `agent-isolation` has banned repo-wide destructive git commands since v1.4.0. On 2026-07-29 the
    # orchestrator that had spent the day hardening that very rule spawned two writing agents into one
    # shared checkout; one ran `git stash` + `git reset` to get a clean tree for a check, and 22 files
    # of the other agent's uncommitted work vanished. Recovery was luck — the work happened to be in a
    # stash. Had the command been `git checkout -- .`, it was gone.
    #
    # The security review then found the obvious thing: the ban was prose in four files, and the only
    # test asserted THE BAN TEXT WAS PRESENT IN THE MARKDOWN. A documentation-presence check, not a
    # behavioural one — a rule that cannot fail, guarding the incident that had just happened.
    #
    # `spawn-gate.sh` enforces the PRECONDITION (worktrees exist before spawning). This enforces the
    # ACTION. They are different halves and neither substitutes for the other — and spawn-gate is
    # invoked by markdown an orchestrator can skip, which is precisely what happened on 2026-07-29.
    #
    # SCOPE — narrow, because a blanket ban on `git reset` would be user-hostile
    #
    # It refuses ONLY when both are true:
    #   1. the command is repo-wide destructive — it sweeps or discards work the caller did not write
    #      individually, and
    #   2. **this tree has uncommitted work.** That is the thing such a command destroys.
    #
    # A clean tree keeps every git command. A path-scoped command keeps working in any tree. So this
    # cannot fire on everything, which matters: a gate that refuses constantly gets switched off, and a
    # switched-off gate protects nothing.
    #
    # The first version keyed on agent worktrees existing. The security review probed both states and
    # found the hole: DR4-027 was two writers in ONE checkout with NO worktrees, so the hook stood down
    # in exactly the configuration it was written for. Keying on the harm rather than a proxy for it
    # also covers the case the proxy had backwards — a solo developer with dirty state has MORE to lose
    # from `git reset --hard`, not less.
    #
    # CONTRACT
    #   stdin  : the PreToolUse payload (JSON) — the Bash command is read from it
    #   exit 0 : allow
    #   exit 2 : BLOCK, with the reason and the safe alternative on stderr
    #
    # Anything unparseable is ALLOWED. A hook that blocks on its own confusion is a hook that gets
    # removed the first time it misfires on a legitimate command.
    
    set -u
    
    PAYLOAD=$(cat 2>/dev/null || true)
    [ -n "$PAYLOAD" ] || exit 0
    
    # Pull the command out without a JSON parser (there is none, and this must stay dependency-free).
    #
    # The first version of this used `\(...\|...\)` alternation, which is a GNU sed extension. BSD sed
    # — i.e. macOS, i.e. the machine this plugin is mostly used on — does not support it and fails the
    # substitution SILENTLY, leaving CMD empty, so the hook exited 0 and allowed everything. A gate that
    # cannot fire, written on the day this repo spent hunting gates that cannot fire, and caught only
    # because it was tested rather than read. Keep this POSIX; there is a portability assertion for it.
    CMD=$(printf '%s' "$PAYLOAD" | tr '\n' ' ' | awk '
      {
        i = index($0, "\"command\"");
        if (i == 0) exit;
        rest = substr($0, i + 9);
        j = index(rest, "\"");
        if (j == 0) exit;
        rest = substr(rest, j + 1);
        out = "";
        for (k = 1; k <= length(rest); k++) {
          c = substr(rest, k, 1);
          if (c == "\\") { k++; out = out substr(rest, k, 1); continue }
          if (c == "\"") break;
          out = out c;
        }
        print out;
      }')
    [ -n "$CMD" ] || exit 0
    
    case "$CMD" in *git*) ;; *) exit 0 ;; esac
    
    # Resolved once, used by both checks below. Not a git repository -> nothing here to guard.
    ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
    
    # --- a raw `git merge` INTO the integration branch, by anyone at all ------------------------------
    #
    # FOUND BY RUNNING A REAL SPRINT (H6, 2026-08-07). A `qa-engineer` agent, told in its own role file
    # "you never merge your own work — tech-manager merges", ran `git merge --no-ff` directly onto
    # `main` anyway. No board event backed it — no `review_requested`, no `approved`, no `merged` — so
    # the work landed on the branch every other ticket builds from with zero provenance and zero review.
    # `board.mjs` could not have stopped this: the rule it enforces is which EVENTS may be appended to
    # the log, and this command never touched the log at all. It is DR4-027's shape at the git layer —
    # a rule that was prose, defeated by an agent with a shell — caught by the same kind of hook that
    # closed that one.
    #
    # THIS IS NOT A ROLE CHECK, because a hook has no reliable signal for which subagent is running —
    # the payload carries a command, not an identity. It is a PATTERN check, and the pattern it refuses
    # is one this repository has independently decided is always wrong now, for anyone: merging
    # directly into the checked-out integration branch via `git merge`. `wave-integrate.mjs` used to do
    # exactly this — `git merge --ff-only` on the live checkout — and B1 (the review that shipped
    # alongside this hook) replaced it with a ref update precisely because merging into a checkout you
    # might not even be standing on is unsafe. If the studio's OWN merge tool no longer does this, an
    # agent should not be doing it by hand either — regardless of role.
    #
    # So this fires whenever: (a) the command contains `git merge`, (b) it is NOT `--ff-only`, and
    # (c) the branch currently checked out in this tree is the project's DECLARED integration branch.
    #
    # `--ff-only` IS SPECIFICALLY EXEMPT, and that took a second pass to get right. `wave-integrate.mjs`
    # prints exactly one documented manual fallback — `git checkout $BASE && git merge --ff-only
    # $WAVE_BRANCH && git push origin $BASE` — for landing an already-vetted, fully-tested wave onto
    # the integration branch by hand. Running the first version 
  • hooks/require-review-verdict.shRunsGitHub
    Read the script
    #!/bin/sh
    # require-review-verdict — a SubagentStop hook refusing to let a `code-reviewer` subagent finish
    # with no recorded verdict at all.
    #
    # WHY THIS EXISTS
    #
    # Measured live, this session: a `code-reviewer` spawned as a named interactive teammate to review
    # this plugin's own PR #32 sat idle three separate times, producing nothing — no verdict message, no
    # `docs/53-reviews/*.md` file, no `board.mjs move ... approved|changes` call. Two direct nudges asking
    # it to report its findings changed nothing. The review only happened because a human stepped in and
    # did it by hand. `agents/code-reviewer.md` already says, in prose, "your verdict is only checkable
    # if it is recorded" and "before you return, write your full verdict to docs/53-reviews/..." — and
    # this repo's own recurring lesson (H6, DR4-027, and now this) is that prose does not stop an agent
    # from simply not doing it. This makes "did SOMETHING get recorded" a command instead of a hope.
    #
    # VERSION 2 — THE FIRST VERSION SHIPPED THE EXACT BUG IT EXISTED TO CATCH
    #
    # v1 grepped the SUBAGENT'S OWN TRANSCRIPT for the words "docs/53-reviews/" and
    # "board.mjs move ... approved|changes". A real independent review of this hook (2026-08-10) ran it
    # against a realistic transcript and reproduced, live: a nudge that merely QUOTES the required command
    # (exactly what the incident above's own "two direct nudges" would contain) satisfies the check with
    # nothing ever executed — exit 0, allowed to stop, verdict recorded is nothing. Worse, the hook's OWN
    # refusal message (line ~90 below) contains both trigger strings verbatim, so if that stderr text ever
    # reaches a later turn of the same transcript, the hook can fire at most ONCE per subagent, ever, then
    # permanently disarms itself. Both are the "rule scanning text finds its own documentation" failure
    # `defect-hunting` §3 names, and this hook fell into a version of exactly the pattern it exists to make
    # other roles stop doing.
    #
    # THE FIX: stop trusting the agent's own prose for the CLAIM OF SUCCESS. The transcript is only ever
    # used to find a CANDIDATE ticket ID to check — never to decide whether that ticket's review actually
    # happened. What actually happened is answered by the board's own append-only event log
    # (`docs/31-board-events.jsonl`), which can only gain a real `approved`/`changes` event authored by
    # `code-reviewer` if `board.mjs move` was actually invoked AND actually succeeded — `board.mjs`'s own
    # `readVerdict()` independently re-reads the verdict file and checks it contains a matching verdict
    # word before that event is ever appended (`scripts/lib/verdict.mjs`). A line in that log naming this
    # ticket, this role, and a `verdict_path` is not a claim — it is the same class of ground truth
    # `verify-done.sh` already trusts over self-report, applied to review instead of implementation.
    #
    # RESIDUAL, STATED LIMITATION: correlating "this stop event" to "this review" still uses a ticket ID
    # extracted from the transcript, because the SubagentStop payload carries no ticket ID at all. A real
    # event for that ticket ID, from ANY point in the ticket's history (not provably from THIS session),
    # satisfies the check. This closes the demonstrated exploit (prose can never forge a real log entry)
    # without claiming to prove which session wrote it — narrower and honest, over a wider claim this
    # script cannot actually back.
    #
    # WHAT IT DOES NOT DO
    #
    # It does not grade the verdict, check it against the diff, or verify the reviewer's reasoning was
    # sound — that is `defect-hunting` and the human/downstream process's job. It answers exactly one
    # question: does the board's own log show a real trace of one of this role's three legitimate outcomes
    # (APPROVE, REQUEST CHANGES, or a documented BLOCKED refusal) for a ticket this subagent named — or
    # did it just stop, the way the incident above did.
    #
    # CONTRACT
    #   stdin  : the SubagentStop payload (JSON) — reads `agent_type`, `transcript_path`, `cwd`,
    #            `last_assistant_message`
    #   exit 0 : allow the stop
    #   exit 2 : BLOCK, with the reason and what is missing on stderr
    #
    # Anything unparseable, or state this hook cannot read, is ALLOWED — same rule as every other hook in
    # this plugin: a hook that blocks on its own confusion is a hook that gets removed the first time it
    # misfires.
    
    set -u
    
    PAYLOAD=$(cat 2>/dev/null || true)
    [ -n "$PAYLOAD" ] || exit 0
    
    # Same character-walk JSON string extractor as block-cross-worktree-write.sh — no `jq` dependency,
    # GNU sed alternation fails silently on BSD sed (macOS), so this stays awk with POSIX character
    # classes only. Takes the field name as $1, reads $PAYLOAD (flattened to one line) from stdin.
    extract_field() {
      printf '%s' "$PAYLOAD" | tr '\n' ' ' | awk -v key="\"$1\"" '
        {
          i = index($0, key);
          if (i == 0) exit;
          rest = substr($0, i + length(key));
          j = index(rest, "\"");
          if (j == 0) exit;
          rest = substr(rest, j + 1);
          out = "";
          for (k = 1; k <= length(rest); k++) {
            c = substr(rest, k, 1);
            if (c == "\\") { k++; nc = substr(rest, k, 1); if (nc == "n") out = out "\n"; else out = out nc; continue }
            if (c == "\"") break;
            out = out c;
          }
          print out;
        }'
    }
    
    AGENT_TYPE=$(extract_field "agent_type")
    TRANSCRIPT=$(extract_field "transcript_path")
    CWD=$(extract_field "cwd")
    LAST_MSG=$(extract_field "last_assistant_message")
    
    # Belt-and-braces beyond hooks.json's own "code-reviewer" matcher: a fail-CLOSED hook misfiring on
    # the wrong role (developer, tech-lead, qa-engineer — none of which ever write docs/53-reviews/) is
    # the worst possible blast radius for a matcher assumption this script never itself verified. If
    # agent_type is present and is not code-reviewer, this hook has nothing to do with this subagent.
    if [ -n "$AGENT_TYPE" ] && [ "$AGENT_TYPE" != "code-reviewer" ]; then
      exit 0
    fi
    
    # A documented BLOCKED refusal (e.g. self-review) is a legitimate third outcome — code-reviewer.md's
    # own form

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 withapp-dev-team

Describe your app idea in one line. Get a shipped iOS & Android app. AI App Studio is a team of 30 AI specialists — a CEO, product manager, designers, iOS/Android engineers, a code reviewer, QA, and a release manager — that works like a real software studio.

Get the whole plugin