Skip to content
Open
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
10 changes: 10 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
}
]
},
{
"matcher": "Task|Agent",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-worktree-isolation-guard.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
Expand Down
121 changes: 121 additions & 0 deletions hooks/pre-task-worktree-isolation-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# =============================================================================
# pre-task-worktree-isolation-guard.sh — PreToolUse guard for the subagent
# spawn tool (Task / Agent).
#
# WHY THIS EXISTS
# Git worktrees share ONE object store and ONE set of branch refs; a branch
# can be checked out in only one worktree at a time. The harness's
# `isolation: worktree` is supposed to provision a fresh worktree per agent,
# but two failure conditions defeat that:
# (a) Provisioning RACE — spawning >=2 isolation:worktree agents in the
# SAME batch races provisioning; one agent fails to get a dedicated
# directory and reuses the parent (or another agent's) worktree.
# (b) cwd-reset + bare git — the agent's shell cwd resets to the shared
# checkout between commands, so a bare `git checkout -b ...` runs
# against whatever worktree the cwd resolves to, switching THAT
# worktree's branch and dragging its uncommitted files along.
# Together they commingle two contexts' work in one working directory. Once
# commits entangle (branch A's commit captures branch B's files), a `git
# revert` cannot separate them — the only recovery is a manual forward-fix.
# (Real incident: the sibling-recheckout damage that needed a forward-fix
# instead of a revert. Memory: reference_worktree_rechecked_out_by_sibling /
# reference_parallel_agent_worktree_collision.)
#
# WHAT IT ENFORCES (the user's Rule 1)
# At most ONE in-flight isolation:worktree spawn per session within a short
# provisioning window. The 2nd+ batched spawn is BLOCKED (exit 2) with
# guidance to either (Rule 1) spawn one-per-message, or (Rule 2 — preferred)
# pre-create a dedicated worktree and pin `git -C <abs-path>` for ALL git.
# Single / sufficiently-staggered worktree spawns pass. Non-worktree
# subagents (no `isolation` field, or isolation != "worktree") always pass.
#
# FAIL-OPEN
# Missing jq, unparseable input, unwritable lock dir, or
# CLAUDE_DISABLE_PLUGIN_HOOKS=1 all exit 0 — an installation/runtime issue
# with the guard must never block legitimate work.
# =============================================================================
set -u

HOOK_NAME="pre-task-worktree-isolation-guard.sh"
# Worktree provisioning window. Two isolation:worktree spawns inside this
# window are treated as a racing batch. The SAFE multi-worktree pattern
# (pre-create + git -C, Rule 2) does NOT use isolation:worktree, so it never
# trips this guard.
WINDOW_SECONDS="${MNM_WORKTREE_GUARD_WINDOW:-90}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Validate that WINDOW_SECONDS is a positive integer to prevent runtime failures in bash arithmetic under set -u if a user provides an invalid custom MNM_WORKTREE_GUARD_WINDOW environment variable.

[pass 1]

# A malformed MNM_WORKTREE_GUARD_WINDOW would break the `(( … ))` arithmetic
# below under `set -u`; fall back to the default when it is not a positive int.
[[ "$WINDOW_SECONDS" =~ ^[1-9][0-9]*$ ]] || WINDOW_SECONDS=90

[[ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-}" == "1" ]] && exit 0
command -v jq >/dev/null 2>&1 || exit 0 # fail-open: no jq

INPUT_RAW="$(cat)"

# Only act on isolation:worktree spawns; everything else passes untouched.
ISOLATION="$(printf '%s' "$INPUT_RAW" | jq -r '.tool_input.isolation // empty' 2>/dev/null || true)"
[[ "$ISOLATION" != "worktree" ]] && exit 0

SESSION_ID="$(printf '%s' "$INPUT_RAW" | jq -r '.session_id // "default"' 2>/dev/null || echo default)"
SESSION_ID="${SESSION_ID//[^A-Za-z0-9_.-]/_}" # sanitize for use in a path
LABEL="$(printf '%s' "$INPUT_RAW" | jq -r '.tool_input.name // .tool_input.description // "agent"' 2>/dev/null || echo agent)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Make the lock root user-specific to prevent potential permission collisions on multi-user development machines. If User A runs the hook first, they will create the shared /tmp/mnm-worktree-spawn-guard directory. Depending on their system umask, this directory might be created with restrictive permissions (like 700). If User B then runs a task on the same machine, they will not have write access to that shared directory, causing their spawns to fall back to the fail-closed path and get blocked. Appending the username (using ${USER:-shared}) ensures isolated lock directories for different users on the same host.

[pass 1]

LOCK_ROOT="${TMPDIR:-/tmp}/mnm-worktree-spawn-guard"
mkdir -p "$LOCK_ROOT" 2>/dev/null || exit 0 # fail-open: unwritable tmp
LOCK_DIR="$LOCK_ROOT/${SESSION_ID}.lockd"

now="$(date +%s 2>/dev/null || echo 0)"
[[ "$now" =~ ^[0-9]+$ ]] || exit 0 # fail-open: no clock

# Atomic acquire. `mkdir` is atomic across simultaneous processes, so when two
# hooks fire at once (parallel tool calls in one assistant message) exactly one
# wins — a plain `[[ -f lock ]]` test would let BOTH through. The lock dir is
# NEVER deleted here: staleness is decided by the timestamp below, not by
# existence, so there is no check-then-rm (TOCTOU) window in which a racing
# process's freshly-created lock could be removed.
if mkdir "$LOCK_DIR" 2>/dev/null; then
printf '%s' "$now" > "$LOCK_DIR/ts" 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Writing directly to ts via redirection truncate (>) can cause a racing process (reading ts concurrently on line 88) to read an empty file if the scheduler interleaves between the truncation and the write. Using a temp file and an atomic mv guarantees a safe, race-free, non-blocking read.

[pass 1]

exit 0 # first / uncontended spawn
fi

# Lock already exists — decide by AGE, never by deleting it:
# - STALE (>= window): the prior spawn already finished provisioning, so this
# is not a racing batch → allow, rolling the window forward by refreshing
# the timestamp. (Accepted edge: if a brand-new batch fires simultaneously
# while the lock is already stale, both may refresh-and-allow — an
# over-allow, never corruption; the load-bearing safety is the pre-create +
# git -C pattern this guard nudges toward.)
# - FRESH (< window): a batched 2nd+ spawn inside the window → block.
held="$(cat "$LOCK_DIR/ts" 2>/dev/null || echo "$now")"
[[ "$held" =~ ^[0-9]+$ ]] || held="$now"
age=$(( now - held ))
if (( age >= WINDOW_SECONDS )); then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Writing directly to ts via redirection truncate (>) can cause a racing process (reading ts concurrently on line 88) to read an empty file if the scheduler interleaves between the truncation and the write. Using a temp file and an atomic mv guarantees a safe, race-free, non-blocking read.

[pass 1]

printf '%s' "$now" > "$LOCK_DIR/ts" 2>/dev/null || true # roll the window
exit 0 # stale lock → prior spawn done
fi

cat >&2 <<EOF
BLOCKED [${HOOK_NAME}]: a second isolation:worktree subagent ("${LABEL}") is
being spawned ${age}s after another — inside the ${WINDOW_SECONDS}s worktree
provisioning window. Batching >=2 isolation:worktree spawns RACES provisioning:
one agent fails to get a dedicated directory, reuses the parent/another
worktree, and its bare git (cwd resets to the shared checkout) switches that
worktree's branch and commingles uncommitted work. Once commits entangle, a
git revert cannot separate them — the only recovery is a manual forward-fix.

How to fix:
Rule 2 (preferred — eliminates the race entirely):
1. Pre-create a DEDICATED worktree yourself, off the target branch:
git worktree add .claude/worktrees/wt-<slug> origin/develop
2. Spawn the agent WITHOUT isolation:worktree, and brief it to capture its
toplevel once: MYWT="\$(git rev-parse --show-toplevel)"
and run ALL git as git -C "\$MYWT" ... — never bare git, never
'git checkout' of another branch, never 'git worktree'.
Rule 1 (also accepted):
Spawn isolation:worktree agents ONE PER MESSAGE and wait for each to finish
provisioning (>${WINDOW_SECONDS}s) before spawning the next.

Override (intentional, this turn only):
Set CLAUDE_DISABLE_PLUGIN_HOOKS=1, or widen MNM_WORKTREE_GUARD_WINDOW.
EOF
exit 2
75 changes: 72 additions & 3 deletions hooks/rules/rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -2326,7 +2326,7 @@
},
{
"id": "warn-localhost-in-pr-body",
"description": "Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.dojocoding.io, not localhost)",
"description": "Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.example.com, not localhost)",
"applies_to": [
"Bash"
],
Expand All @@ -2343,7 +2343,7 @@
"action": "warn",
"bypass_marker": "localhost-in-pr-body-allowed",
"memory_ref": "feedback_test_on_staging.md",
"message": "WARNING: localhost reference in PR body.\n\nPer feedback_test_on_staging.md the QA testing target is\ndev.dojocoding.io (staging), not localhost. Any reviewer who tries\nto verify against localhost will hit env mismatches:\n - Local DB differs from staging\n - Edge Functions missing local secrets\n - Different feature flags\n\nReplace localhost links with dev.dojocoding.io equivalents.\n\nBypass marker (localhost-in-pr-body-allowed) ONLY for steps that\nlegitimately require an explicit local build (e.g. asking the\nreviewer to run `bun run dev` before pushing).\n",
"message": "WARNING: localhost reference in PR body.\n\nPer feedback_test_on_staging.md the QA testing target is\ndev.example.com (staging), not localhost. Any reviewer who tries\nto verify against localhost will hit env mismatches:\n - Local DB differs from staging\n - Edge Functions missing local secrets\n - Different feature flags\n\nReplace localhost links with dev.example.com equivalents.\n\nBypass marker (localhost-in-pr-body-allowed) ONLY for steps that\nlegitimately require an explicit local build (e.g. asking the\nreviewer to run `bun run dev` before pushing).\n",
"tests": [
{
"name": "warns-pr-create-with-localhost",
Expand All @@ -2369,7 +2369,7 @@
"name": "allows-pr-create-with-staging-url",
"input": {
"tool_input": {
"command": "gh pr create --base develop --title \"feat: foo\" --body \"Test on https://dev.dojocoding.io/foo\""
"command": "gh pr create --base develop --title \"feat: foo\" --body \"Test on https://dev.example.com/foo\""
}
},
"expected_exit": 0
Expand Down Expand Up @@ -3706,5 +3706,74 @@
"expected_exit": 0
}
]
},
{
"id": "git-worktree-add-discipline",
"description": "Warn on `git worktree add` — give each parallel agent a dedicated worktree and pin git -C",
"applies_to": [
"Bash"
],
"match": [
{
"field": "command",
"pattern": "git[[:space:]]+worktree[[:space:]]+add\\b"
}
],
"action": "warn",
"bypass_marker": null,
"memory_ref": "reference_parallel_agent_worktree_collision.md",
"references": [
"Sibling re-checkout incident — collision needed a forward-fix (revert cannot separate entangled commits)"
],
"message": "WORKTREE DISCIPLINE: `git worktree add` detected.\n\nParallel subagents share one repo: a branch can be checked out in only ONE\nworktree at a time, and an agent's shell cwd resets to the shared checkout\nbetween commands — so a bare `git checkout` / `git worktree add` can switch\nANOTHER worktree's branch and commingle uncommitted work. Once commits\nentangle, only a forward-fix recovers them (a revert cannot separate them).\n\nWhen orchestrating agents that touch git:\n - Give EACH agent its OWN dedicated worktree off the target branch:\n git worktree add .claude/worktrees/wt-<slug> origin/develop\n - Brief each agent to capture its toplevel once:\n MYWT=\"$(git rev-parse --show-toplevel)\"\n and run ALL git as `git -C \"$MYWT\" ...` — never bare git, never\n `git checkout` of another branch, never `git worktree` from an agent.\n - Never spawn >=2 isolation:worktree subagents in one message (the\n pre-task-worktree-isolation-guard hook blocks the 2nd; this is the\n upstream nudge).\n",
"tests": [
{
"name": "warns-on-worktree-add",
"input": {
"tool_input": {
"command": "git worktree add .claude/worktrees/wt-x origin/develop"
}
},
"expected_exit": 0,
"expected_stderr_contains": "git-worktree-add-discipline"
},
{
"name": "warns-on-worktree-add-with-b-flag",
"input": {
"tool_input": {
"command": "git worktree add -b feat/x .claude/worktrees/wt-x origin/develop"
}
},
"expected_exit": 0,
"expected_stderr_contains": "git-worktree-add-discipline"
},
{
"name": "allows-worktree-list",
"input": {
"tool_input": {
"command": "git worktree list"
}
},
"expected_exit": 0
},
{
"name": "allows-worktree-remove",
"input": {
"tool_input": {
"command": "git worktree remove .claude/worktrees/wt-x"
}
},
"expected_exit": 0
},
{
"name": "allows-non-worktree-git",
"input": {
"tool_input": {
"command": "git status"
}
},
"expected_exit": 0
}
]
}
]
81 changes: 77 additions & 4 deletions hooks/rules/rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1856,7 +1856,7 @@
expected_exit: 0

- id: warn-localhost-in-pr-body
description: Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.dojocoding.io, not localhost)
description: Warn when `gh pr create` / `gh pr edit` body contains a localhost reference (memory feedback_test_on_staging.md — QA target is dev.example.com, not localhost)
applies_to: [Bash]
match:
# Two AND-chained conditions on `command`: the gh subcommand uses
Expand All @@ -1875,13 +1875,13 @@
WARNING: localhost reference in PR body.

Per feedback_test_on_staging.md the QA testing target is
dev.dojocoding.io (staging), not localhost. Any reviewer who tries
dev.example.com (staging), not localhost. Any reviewer who tries
to verify against localhost will hit env mismatches:
- Local DB differs from staging
- Edge Functions missing local secrets
- Different feature flags

Replace localhost links with dev.dojocoding.io equivalents.
Replace localhost links with dev.example.com equivalents.

Bypass marker (localhost-in-pr-body-allowed) ONLY for steps that
legitimately require an explicit local build (e.g. asking the
Expand All @@ -1902,7 +1902,7 @@
- name: allows-pr-create-with-staging-url
input:
tool_input:
command: 'gh pr create --base develop --title "feat: foo" --body "Test on https://dev.dojocoding.io/foo"'
command: 'gh pr create --base develop --title "feat: foo" --body "Test on https://dev.example.com/foo"'
expected_exit: 0
- name: allows-non-pr-localhost
input:
Expand Down Expand Up @@ -3043,3 +3043,76 @@
tool_input:
command: 'gcloud sql import sql my-instance gs://backups/dump.sql --database=mydb # hook-bypass: db-mutation-rule'
expected_exit: 0

# -----------------------------------------------------------------------------
# Agent orchestration (warn) — parallel-subagent worktree discipline.
#
# Complements the blocking hook hooks/pre-task-worktree-isolation-guard.sh
# (which stops >=2 isolation:worktree spawns racing in one message). This rule
# is the upstream nudge at the `git worktree add` call site: it reminds the
# orchestrator (or any agent) to give each parallel agent its OWN dedicated
# worktree and pin `git -C <abs-path>` for all git, so the cwd-reset between
# commands cannot switch another worktree's branch and commingle work.
# Memory: reference_parallel_agent_worktree_collision /
# reference_worktree_rechecked_out_by_sibling (the incident that needed a
# forward-fix because a revert cannot separate entangled commits).
# -----------------------------------------------------------------------------

- id: git-worktree-add-discipline
description: Warn on `git worktree add` — give each parallel agent a dedicated worktree and pin git -C
applies_to: [Bash]
match:
- field: command
pattern: 'git[[:space:]]+worktree[[:space:]]+add\b'
action: warn
bypass_marker: null
memory_ref: reference_parallel_agent_worktree_collision.md
references:
- "Sibling re-checkout incident — collision needed a forward-fix (revert cannot separate entangled commits)"
message: |
WORKTREE DISCIPLINE: `git worktree add` detected.

Parallel subagents share one repo: a branch can be checked out in only ONE
worktree at a time, and an agent's shell cwd resets to the shared checkout
between commands — so a bare `git checkout` / `git worktree add` can switch
ANOTHER worktree's branch and commingle uncommitted work. Once commits
entangle, only a forward-fix recovers them (a revert cannot separate them).

When orchestrating agents that touch git:
- Give EACH agent its OWN dedicated worktree off the target branch:
git worktree add .claude/worktrees/wt-<slug> origin/develop
- Brief each agent to capture its toplevel once:
MYWT="$(git rev-parse --show-toplevel)"
and run ALL git as `git -C "$MYWT" ...` — never bare git, never
`git checkout` of another branch, never `git worktree` from an agent.
- Never spawn >=2 isolation:worktree subagents in one message (the
pre-task-worktree-isolation-guard hook blocks the 2nd; this is the
upstream nudge).
tests:
- name: warns-on-worktree-add
input:
tool_input:
command: 'git worktree add .claude/worktrees/wt-x origin/develop'
expected_exit: 0
expected_stderr_contains: 'git-worktree-add-discipline'
- name: warns-on-worktree-add-with-b-flag
input:
tool_input:
command: 'git worktree add -b feat/x .claude/worktrees/wt-x origin/develop'
expected_exit: 0
expected_stderr_contains: 'git-worktree-add-discipline'
- name: allows-worktree-list
input:
tool_input:
command: 'git worktree list'
expected_exit: 0
- name: allows-worktree-remove
input:
tool_input:
command: 'git worktree remove .claude/worktrees/wt-x'
expected_exit: 0
- name: allows-non-worktree-git
input:
tool_input:
command: 'git status'
expected_exit: 0
Loading
Loading