A PM agent planned three tasks with disjoint file scopes and wrote no code, and a PreToolUse hook blocked the one write I forced outside its scope, after my first version of the hook blocked a legal write.
agentshipped / agent-starter
Agent Shipped demo repo (agent-starter): the public playground used in the channel's videos.
agent-starter
A minimal Claude Code project skeleton: CLAUDE.md with working rules plus a reviewer subagent. Public demo repo for the Agent Shipped channel; clone it and try the prompts from the videos.
CLAUDE.md project rules the agent reads on every session .claude/agents/reviewer.md read-only reviewer subagent .claude/settings.json hook that blocks commits containing obvious secrets Shown in
Videos on the Agent Shipped channel that use this repo:
- My subagent stopped halfway. One missing path was why #Shorts · E007 · 2026-11-07
- My subagent hook read an empty field. Now a bad report exits 2 #Shorts · E004 · 2026-11-14
Multi-agent work rarely fails because the agents are dumb. It fails because they collide: two tasks touch the same module and one overwrites the other. This build tries one fix. A PM agent writes only a plan, every task gets a disjoint file scope, and a hook reads the plan on every write.
The skeleton
The starting point is agent-starter, a bare Claude Code skeleton: working rules in CLAUDE.md, one reviewer subagent, and one hook that blocks commits with obvious secrets. Nothing in it stops two agents from editing the same file.
Write the first gate
I asked Claude for the simplest gate on purpose: compare the path to the scope entries exactly as written. Three decisions went into the prompt:
- The active task id comes from the env var
ACTIVE_TASK, or else from a file the agent cannot rewrite from inside a scope. - The hook exits with code 2, which blocks the tool call and sends the reason back to the agent.
- The gate stays off when no task is active, so setup work is not locked out.
The PM is itself a task in the plan, with exactly one writable file: the plan.
First failure
I fed the hook a write inside task T0's scope, docs/a.md, as an absolute path, the way Claude Code sends it:
jq -n --arg p "$PWD/docs/a.md" '{tool_input:{file_path:$p}}' | ACTIVE_TASK=T0 node .claude/hooks/scope-gate.js; echo "exit=$?" The gate blocked it with exit 2. The scope says docs/, the payload carries the full path from the filesystem root, and a string comparison never matches. Live, this would have blocked every write by every subagent. I would rather find that with a fake payload in a terminal.

Look at the exit code: a legal write to docs/ was blocked by the first gate. · watch this step
Fix the paths
The fix is one idea: normalize before comparing. The prompt asked the hook to resolve tool_input.file_path against the project dir (CLAUDE_PROJECT_DIR, falling back to process.cwd()), make it relative with forward slashes, and treat any result starting with .. as outside scope. Without that last rule, src/../secrets would slip past a prefix check.
Five tests pin the behaviour: absolute and relative inputs agree, out-of-scope is blocked, traversal is blocked, and no active task means the gate is off. They write to a temporary log, so they do not pollute the count taken at the end.
Prove the gate
Same fake payload, twice. The legal write to docs/ passes with exit 0. A write to src/evil.js is refused with exit 2, and the message names the task, the path and what is allowed, so the agent can report instead of looping. Then I committed the hook and its tests. When the gate was wrong, I fixed the gate rather than switching it off.

Two runs of the same payload: one exit code for the legal path, one for src/evil.js. · watch this step
Start the clock
: > .claude/blocked.log; date +%s > .claude/run-start; echo PM > .claude/active-task Emptying the log means every line in it from now on is a real block. The active task is set from the terminal, outside the agent. If the agent could change that file, it could promote itself to another scope.
PM writes the plan
The prompt starts: "You are the PM. You write only .claude/plan.json and no code." It asks for three small tasks: T1 a slug helper, T2 a word counter, T3 usage docs. The PM prints an overlap check, so disjointness is shown, not assumed.
Review the plan
This is the human checkpoint. The plan is small enough to read in five lines.
jq -r '.tasks[] | .id + ": " + (.scope | join(", "))' .claude/plan.json; git add .claude/plan.json; git commit -m 'PM plan: three disjoint tasks'; echo T1 > .claude/active-task Committing it before any code exists makes the plan the contract.

The whole division of labour in a few lines: check that no file appears twice. · watch this step
Task T1
The main agent hands a self-contained brief to a subagent through the Task tool: what to build, which commands to run, which files it may commit. The brief fixes the hand-back at four lines: result, commit, tests, open items. Both files are inside scope, so the gate stays silent.
Switch to T2
The hash in the hand-back should match the top of git log --oneline -3. Prose can claim anything; a hash exists or it does not. Then echo T2 > .claude/active-task.
Forced violation
The T2 agent builds the word counter, its tests and a commit. Then it is told to also edit the slug file, which belongs to T1. The hook reads the plan, sees task T2 and a path outside its scope, and returns exit 2 with the reason. The edit never happens. The agent is told to report the block under open items rather than look for a back door.
Switch to T3
cat .claude/blocked.log; git status --short; echo T3 > .claude/active-task Two signals should agree: the blocked log shows the time, task and path, and git status shows nothing changed.

The log names the blocked path; git status is the second, independent signal. · watch this step
Task T3
The third subagent reads both modules and writes the usage doc. Examples must come from real node -e output, not from the model's memory. It commits one file and reports tests.
Measure the run
echo "elapsed: $(( $(date +%s) - $(cat .claude/run-start) ))s"; echo "blocked writes: $(wc -l < .claude/blocked.log)"; git log --oneline -6; node --test test/*.test.js 2>&1 | grep -E '^# (tests|pass|fail)' The result: three subagent tasks completed in one run, with one deliberate out-of-scope write blocked. The plan is the first commit, then one commit per task. The limit: the gate watches Edit, Write and MultiEdit, not Bash, so a shell redirect could still write anywhere.

The measured numbers: elapsed time, blocked writes, commits, test pass and fail. · watch this step
What to copy
- A plan file with one task per agent and a scope list per task, committed before any code.
- Disjoint scopes, with an overlap check the PM must print.
- An exit-2 PreToolUse hook that prints the task, the path and what is allowed.
- A task id that lives outside the agent's reach, set from the terminal.
- A four-line hand-back (result, commit, tests, open items) checked against git.
Not just JavaScript
Write the gate in Python and test it with pytest instead of node --test, or enforce the same scope at commit time with a pre-commit hook instead of a PreToolUse hook.
Video with the full run: https://youtu.be/-_t_ZsufwvU
Techniques from my real projects, shown on a public demo repo.
Narration: AI voice. The sessions, code and errors are real. Not affiliated with Anthropic or any tool vendor.
