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.

GitHub logo 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:




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.

Terminal showing the gate run on an in-scope absolute path and printing exit=2.
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.

Terminal showing the in-scope write passing and the out-of-scope write refused, followed by a commit.
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.

Terminal listing each task id with its file scope, then the plan commit.
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.

Terminal showing the blocked log entry and the git status output.
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.

Terminal showing elapsed time, blocked write count, the commit list and test totals.
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.