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.
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
- Matches
Bashsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-shared-tree-destructive-git.sh" - Matches
Writesh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh" - Matches
Editsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh" - Matches
MultiEditsh "${CLAUDE_PLUGIN_ROOT}/hooks/block-cross-worktree-write.sh"
SubagentStop
- Matches
code-reviewersh "${CLAUDE_PLUGIN_ROOT}/hooks/require-review-verdict.sh"
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
Stats
4
Stars
0
Forks
Active
Maintenance
JavaScript
Language
MIT
License
8d ago
Last commit
3mo ago
Created
Repo: vmobifystudio/app-dev-team

