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
532 skills30 agents27 commands2 hooks

Claude Code

> /plugin marketplace add vmobifystudio/app-dev-team
> /plugin install app-dev-team@mobify-studio

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.

@vmobifystudio
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.

532 skills30 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
5
Star history of vmobifystudio/app-dev-team on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3112k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in app-dev-team.

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