Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/agents/implementer.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ You own ONE sub-task end-to-end, on your own branch, in your own worktree.
## GitHub identity (hard rule)
Never call bare `gh`. EVERY `gh` invocation (PR create/update, comments, `gh api`, any query) MUST go through `.claude/scripts/bot-gh.sh` so it runs as the bot. Only `git` commits/pushes use the owner's auth. If `GH_BOT_TOKEN` is missing, stop and report it — do not fall back to owner `gh`.

## Git state (hard rule) — issue #106
ALL git operations — commits, branch switches, resets, `git add`/`rm`/`mv`, anything that mutates repo
state — happen INSIDE this worktree, never in the main checkout. The main checkout is the owner's and
every sibling worker's; touching it directly can leave it dirty or, worse, in a DETACHED HEAD for hours
(the 2026-07-16 incident: a driver's `git checkout` failed mid-operation against a read-only-mounted
agent file and left main detached on an unmerged commit for ~12h). If a task seems to need a shared
branch, or the branch you want is "already checked out elsewhere," that is a RE-SCOPE signal — stop and
report it to the orchestrator. Never `cd`/`git -C` back into the main checkout to work around it.

A `PreToolUse` guard hook (`.claude/scripts/guard-git-add.py`) enforces this for worker sessions. It
recognizes you as a worker via your cwd already sitting under `.claude/worktrees/<name>/...` (always true
for you) and, additionally, via the `RECODE_WORKER=1` marker — set it in your own worktree's
`.claude/settings.local.json` → `env` (e.g. `{"env": {"RECODE_WORKER": "1"}}`) at bootstrap if it isn't
already present there. If the hook blocks a command, that's the guard working as intended — don't try to
route around it; re-scope instead.

## Read first
- `.claude/gates.json` — for the exact gate commands (`build`, `lint`, `typecheck`, `test_affected`, `coverage`) and your module boundary.
- `CLAUDE.md` — conventions, style, definition of done.
Expand Down
15 changes: 15 additions & 0 deletions .claude/agents/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ If `.claude/gates.json` has empty `gates`, STOP and tell the user the project ha
## GitHub identity (hard rule)
EVERY `gh` invocation — by you and by every agent you spawn — MUST go through the bot account via `.claude/scripts/bot-gh.sh`; never call bare `gh`. This covers reads and writes alike: issue creation, issue/PR comments, PR creation, PR merging, and all queries (`gh pr list`, `gh issue view`, `gh api`, …). Only `git` commits and pushes stay on the owner's auth, so the owner can formally review and approve (GitHub blocks a PR's author from approving it). If `GH_BOT_TOKEN` is missing, STOP and point the user at the setup notes in `.claude/scripts/bot-gh.sh` rather than falling back to owner `gh`. When you delegate, tell each worker this same rule.

## Git state (hard rule) — issue #106
ALL git operations — yours and every worker's you spawn — happen inside a worktree, never against the
shared MAIN checkout. This is not optional even for you: the 2026-07-16 incident that motivated this rule
was a driver's own `git checkout` in the main checkout failing mid-operation against a read-only-mounted
agent file, leaving main in a DETACHED HEAD on an unmerged commit for ~12h. If you need to mutate git
state and don't already have your own worktree, create one (`git worktree add`) rather than touching the
main checkout directly. A branch already checked out elsewhere (e.g. by a sibling worker) is a RE-SCOPE
signal, not a license to reach into the main checkout to grab it.

Set the `RECODE_WORKER=1` marker for your own session (`.claude/settings.local.json` → `env`, e.g.
`{"env": {"RECODE_WORKER": "1"}}`) so the `PreToolUse` guard hook (`.claude/scripts/guard-git-add.py`)
recognizes you and blocks any command targeting the main checkout's git state — this matters especially
for you, since (unlike an implementer) you often run directly in the main checkout by cwd, so the hook's
cwd-based corroboration alone won't catch you. Tell every implementer you spawn this same rule.

## Your loop
1. **Scope.** Decompose the task into sub-tasks that are *independent* and *non-overlapping at the file level*. Use the `modules` map in `gates.json` to assign each sub-task to exactly one module/path. If two sub-tasks would touch the same files, either merge them into one sub-task or sequence them (declare the dependency). Scale effort to complexity: a trivial task gets ONE worker and no parallelism — do not fan out for its own sake.
2. **Present the plan and WAIT.** Output the plan: each sub-task's title, target module/path, owner boundary, dependencies, and which reviewers will gate it. Enter plan mode and wait for human approval before any code is written. This is the planning checkpoint.
Expand Down
220 changes: 184 additions & 36 deletions .claude/scripts/guard-git-add.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
#!/usr/bin/env python3
"""PreToolUse guard: stop blanket `git add`/`git commit -a` when the worktree
contains sandbox `/dev/null` device-node masks.
"""PreToolUse guard, two checks in one hook:

Under the hardened sandbox, Claude Code bind-mounts /dev/null over sensitive
config paths (.mcp.json, .gitconfig, .claude/{launch.json,routines,...}, editor
dirs). Git cannot index a character-device node, so `git add -A` / `git add .` /
`git commit -a` abort the *whole* commit with:
1. Blanket `git add`/`git commit -a` when the worktree contains sandbox
`/dev/null` device-node masks.

error: <path>: can only add regular files, symbolic links or git-directories
fatal: adding files failed
Under the hardened sandbox, Claude Code bind-mounts /dev/null over sensitive
config paths (.mcp.json, .gitconfig, .claude/{launch.json,routines,...}, editor
dirs). Git cannot index a character-device node, so `git add -A` / `git add .` /
`git commit -a` abort the *whole* commit with:

Explicit path staging (`git add <path> ...`) skips the masks and works fine, so
this guard blocks only the blanket forms — and only when masks are actually
present (i.e. inside the sandbox). Outside the sandbox it is a no-op, so it never
obstructs consumers who don't run hardened.
error: <path>: can only add regular files, symbolic links or git-directories
fatal: adding files failed

Explicit path staging (`git add <path> ...`) skips the masks and works fine, so
this guard blocks only the blanket forms — and only when masks are actually
present (i.e. inside the sandbox). Outside the sandbox it is a no-op, so it never
obstructs consumers who don't run hardened.

2. git STATE mutations (add/rm/mv/reset/switch/checkout <branch>/restore
--staged) run by a WORKER session against the MAIN checkout (issue #106).

Workers (implementer/orchestrator, isolation: worktree) must do all git
mutation inside their OWN `.claude/worktrees/<name>` worktree — the main
checkout is the owner's. A worker that `cd`s or `-C`s back into the main
checkout and mutates it there races/clobbers the owner's and every sibling
worker's git state; this is exactly the bug this deliverable closes.

Worker detection: PRIMARY signal is the `RECODE_WORKER=1` env var, set for
worker sessions via the `implementer`/`orchestrator` agent defs (see
`.claude/agents/{implementer,orchestrator}.md`). CORROBORATING signal: the
event's `cwd` already sitting under a `<main>/.claude/worktrees/<name>/...`
path also implies a worker session even if the marker didn't propagate.
Either signal alone is enough (`_is_worker`) — this stays robust without
weakening the default-allow guarantee for owner sessions, which normally
carry neither. "Main checkout" = the mutating command's EFFECTIVE git
toplevel (honoring `cd`/`git -C` overrides in the command string) equals the
toplevel you get by stripping a `.claude/worktrees/<name>/...` suffix off the
session cwd (or, if the cwd carries no such suffix, the cwd's own toplevel —
i.e. the session is already sitting in the main checkout).

Contract: reads the PreToolUse event JSON on stdin. Exit 0 = allow. Exit 2 =
block (stderr is shown to the agent).
Expand Down Expand Up @@ -76,7 +100,20 @@ def _is_blanket(cmd):
return False


def _has_device_masks():
def _git_toplevel(cwd="."):
"""`git -C <cwd> rev-parse --show-toplevel`, or "" if that fails (not a repo,
git missing, etc). Shared by the device-mask scan and the worker guard."""
try:
r = subprocess.run(
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""


def _has_device_masks(cwd="."):
"""True if the worktree contains a /dev/null character-device mask.

The sandbox bind-mounts /dev/null over sensitive config paths. Crucially, a
Expand All @@ -87,13 +124,7 @@ def _has_device_masks():
shell rc, editor dirs) and .claude/* items, so a shallow scan of those two
dirs is enough and stays fast on every Bash call.
"""
try:
root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
).stdout.strip() or "."
except Exception:
root = "."
root = _git_toplevel(cwd) or "."
for d in (root, os.path.join(root, ".claude")):
try:
with os.scandir(d) as it:
Expand Down Expand Up @@ -136,6 +167,97 @@ def _sandbox_enabled():
return False


# Marker env var (issue #106): set by the implementer/orchestrator agent defs
# for every worker session. See the module docstring, check 2, for the full
# primary/corroborating detection rationale.
_WORKER_ENV = "RECODE_WORKER"
_WORKTREE_SEGMENT = "/.claude/worktrees/"

# Subcommands that always mutate git state, regardless of arguments.
_MUTATING_SIMPLE = {"add", "rm", "mv", "reset", "switch"}


def _cwd_in_worktree(cwd):
return _WORKTREE_SEGMENT in (cwd or "")


def _is_worker(cwd):
"""True if this session looks like a worker (implementer/orchestrator),
not the owner. PRIMARY: the RECODE_WORKER=1 marker env var. CORROBORATING:
an event cwd already under <main>/.claude/worktrees/<name>/... — enough on
its own even if the marker didn't propagate. Either signal suffices;
absent both, an owner session is unaffected (default allow)."""
return os.environ.get(_WORKER_ENV) == "1" or _cwd_in_worktree(cwd)


def _main_root(cwd):
"""Resolve the MAIN checkout root implied by a session cwd. If cwd sits
under <root>/.claude/worktrees/<name>/..., root is the prefix before that
marker. Otherwise cwd is already (presumably) the main checkout, so its own
git toplevel IS the root."""
idx = (cwd or "").find(_WORKTREE_SEGMENT)
if idx != -1:
return cwd[:idx]
return _git_toplevel(cwd or ".")


def _is_mutating_git(subcmd, args):
"""True if `git <subcmd> <args>` mutates repo state in a way workers must
never do against the main checkout."""
if subcmd in _MUTATING_SIMPLE:
return True
if subcmd == "restore":
return "--staged" in args
if subcmd == "checkout":
# `git checkout -- <path>` (or no args) restores/lists paths, not a
# branch switch; anything else (a branch name, -b/-B, ...) switches.
if not args or "--" in args:
return False
return True
return False


def _mutating_targets(cmd, base_cwd):
"""Yield (subcmd, effective_cwd) for every git invocation in `cmd` that is
a state mutation, tracking `cd <dir> &&` and `git -C <dir>` overrides
sequentially so the effective target directory is resolved correctly."""
cwd = base_cwd
out = []
for seg in _segments(cmd):
toks = _tokens(seg)
if not toks:
continue
if toks[0] == "cd" and len(toks) >= 2:
target = toks[1]
cwd = target if target.startswith("/") else os.path.normpath(os.path.join(cwd, target))
continue
if "git" not in toks:
continue
gi = toks.index("git")
rest = toks[gi + 1 :]
local_cwd = cwd
i = 0
while i < len(rest) and rest[i].startswith("-"):
if rest[i] == "-C" and i + 1 < len(rest):
target = rest[i + 1]
local_cwd = (
target if target.startswith("/") else os.path.normpath(os.path.join(local_cwd, target))
)
i += 2
continue
if rest[i] in ("-c", "--exec-path", "--namespace") and i + 1 < len(rest):
i += 2
continue
i += 1
subrest = rest[i:]
if not subrest:
continue
subcmd, args = subrest[0], subrest[1:]
if _is_mutating_git(subcmd, args):
out.append((subcmd, local_cwd))
return out


def main():
try:
data = json.load(sys.stdin)
Expand All @@ -146,24 +268,50 @@ def main():
cmd = (data.get("tool_input") or {}).get("command", "")
if not cmd or "git" not in cmd:
return 0
if not _is_blanket(cmd):
return 0
# Fire when the sandbox is active (its masks will abort the blanket add) or,
# should a future Claude Code run hooks sandboxed, when masks are visible.
if not (_sandbox_enabled() or _has_device_masks()):
cwd = data.get("cwd") or os.getcwd()

# Best-effort, same spirit as check 1: only engage when the sandbox is
# hardened (its masks/marker are what make either failure mode real) —
# never obstruct non-hardened downstream consumers.
hardened = _sandbox_enabled() or _has_device_masks(cwd)
if not hardened:
return 0

sys.stderr.write(
"Blocked: a blanket `git add -A/./--all` or `git commit -a` will try to "
"index this sandbox's /dev/null device-node masks (the `crw-` entries in "
"`git status`) and abort the whole commit "
"(`can only add regular files, symbolic links or git-directories`).\n"
"Stage the files you actually changed, by name:\n"
' git add <path1> <path2> && git commit -m "..."\n'
"The `crw-` entries are sandbox masks, not your work — ignore them. "
"See docs/HARDENING.md -> Caveats.\n"
)
return 2
# Check 1: blanket `git add -A/./--all` or `git commit -a`.
if _is_blanket(cmd):
sys.stderr.write(
"Blocked: a blanket `git add -A/./--all` or `git commit -a` will try to "
"index this sandbox's /dev/null device-node masks (the `crw-` entries in "
"`git status`) and abort the whole commit "
"(`can only add regular files, symbolic links or git-directories`).\n"
"Stage the files you actually changed, by name:\n"
' git add <path1> <path2> && git commit -m "..."\n'
"The `crw-` entries are sandbox masks, not your work — ignore them. "
"See docs/HARDENING.md -> Caveats.\n"
)
return 2

# Check 2: worker git STATE mutations against the MAIN checkout (#106).
if _is_worker(cwd):
main_root = _main_root(cwd)
if main_root:
for subcmd, local_cwd in _mutating_targets(cmd, cwd):
if _git_toplevel(local_cwd) == main_root:
sys.stderr.write(
f"Blocked: `git {subcmd}` targets the MAIN checkout ({main_root}), "
"but this is a worker session. Workers must do ALL git state "
"mutation inside their OWN worktree, never the main checkout — "
"the owner and every sibling worker share it.\n"
"Run this from your own worktree instead. Need a branch that's "
"already checked out elsewhere (e.g. shared with another "
"worker)? `git worktree add` it into YOUR worktree — do not "
"touch the main checkout to get it. A \"branch already checked "
"out elsewhere\" error is a RE-SCOPE signal: stop and report it "
"to the orchestrator, don't work around it via the main checkout.\n"
)
return 2

return 0


if __name__ == "__main__":
Expand Down
Loading
Loading