Browse Source

feat: peer-writer collision guard (session-start hook + worktree-boundaries doctrine)

- session-start-unicode-scan.sh: fold a peer-writer guard into the SessionStart hook (dirty + freshly-written tree -> advisory before the first write); restructured so both guards run in one spawn and neither short-circuits the other. ~16ms, silent on clean.

- worktree-boundaries.md: add 'Provisioning discipline - one writer per tree' (one-writer/one-worktree, base checkout landing-only, the peer-writer probe) and correct the chips row: spawn_task chips do NOT auto-isolate - they run on the current branch (claude-code#64605).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
0d55981e00
2 changed files with 101 additions and 38 deletions
  1. 58 38
      hooks/session-start-unicode-scan.sh
  2. 43 0
      rules/worktree-boundaries.md

+ 58 - 38
hooks/session-start-unicode-scan.sh

@@ -1,62 +1,83 @@
 #!/bin/bash
 # hooks/session-start-unicode-scan.sh
-# SessionStart hook — one-shot hidden-Unicode scan of the project's instruction files.
-# Matcher: SessionStart (runs once at session boot; ONE process spawn, not per-read).
+# SessionStart guard — two silent, independent boot-time checks in ONE process spawn:
+#   1) Peer-writer guard   — is another session actively writing this same checkout?  (git/bash only)
+#   2) Hidden-Unicode scan  — prompt-injection check of the project's instruction files (needs python)
 #
-# Why SessionStart and not a per-Read hook: a project's CLAUDE.md / AGENTS.md is loaded
-# into the model's context by the harness at boot — it is never read via the Read tool,
-# so no Read hook can ever see it. SessionStart is the one moment to scan those files,
-# and it costs a single spawn (~150 ms) instead of ~150 ms on every file read.
+# (Filename kept for settings.json / prompt-injection.md / README stability; it now does both.)
 #
-# Configuration in .claude/settings.json:
-# {
-#   "hooks": {
-#     "SessionStart": [{
-#       "hooks": [{"type": "command", "command": "bash hooks/session-start-unicode-scan.sh"}]
-#     }]
-#   }
-# }
+# Why SessionStart: a project's CLAUDE.md / AGENTS.md is loaded into the model's context by the
+# harness at boot — never via the Read tool — so SessionStart is the one moment to scan them, and a
+# dirty/contended working tree is exactly what you want to know about *before* the first write. One
+# spawn (~150 ms) covers both.
 #
-# Behaviour (silent guardian):
-#   clean  → no output, exit 0 (you should never notice it)
-#   finding→ prints an advisory to stdout (added to context) naming the files; exit 0
-#            (advisory — never blocks the session)
+# Behaviour (silent guardian): clean → no output; finding → advisory to stdout (added to context);
+# exit 0 ALWAYS (advisory — never blocks the session).
 #
-# Exit codes:
-#   0 = always (advisory hook; a missing scanner / no instruction files is a silent no-op)
+# Configuration in .claude/settings.json:
+#   "SessionStart": [{ "hooks": [
+#     { "type": "command", "command": "bash \"$HOME/.claude/hooks/session-start-unicode-scan.sh\"" } ] }]
 
 set -uo pipefail   # NOT -e: a transient error must never block session start
 
-# ── Locate the scanner (works in repo layout AND installed ~/.claude layout) ──
-# In both, hooks/ and skills/ are siblings, so ../skills/... resolves identically.
+# ── Resolve project dir WITHOUT hard-requiring python (stdin JSON .cwd → env → PWD) ──
 SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
-SCANNER=""
-for cand in \
-  "$SELF_DIR/../skills/prompt-injection-defense/scripts/scan-hidden-unicode.py" \
-  "$HOME/.claude/skills/prompt-injection-defense/scripts/scan-hidden-unicode.py"; do
-  [ -f "$cand" ] && { SCANNER="$cand"; break; }
-done
-[ -n "$SCANNER" ] || exit 0   # scanner not installed → silent no-op
-
-# ── Pick a python that actually runs (Windows Store stub exits 49) ────────────
 PY=""
 for c in python3 python py; do
   command -v "$c" >/dev/null 2>&1 && "$c" -c "import sys" >/dev/null 2>&1 && { PY="$c"; break; }
 done
-[ -n "$PY" ] || exit 0   # no python → silent no-op
-
-# ── Resolve project dir: stdin JSON .cwd → $CLAUDE_PROJECT_DIR → $PWD ──────────
 PROJ=""
 if [ ! -t 0 ]; then
   RAW="$(cat 2>/dev/null)"
-  PROJ="$(printf '%s' "$RAW" | "$PY" -c 'import sys,json
+  if [ -n "$PY" ]; then
+    PROJ="$(printf '%s' "$RAW" | "$PY" -c 'import sys,json
 try: print(json.load(sys.stdin).get("cwd","") or "")
 except Exception: print("")' 2>/dev/null)"
+  fi
 fi
 [ -n "$PROJ" ] || PROJ="${CLAUDE_PROJECT_DIR:-$PWD}"
 [ -d "$PROJ" ] || exit 0
 
-# ── Collect existing instruction files (root-level + .claude/) ────────────────
+# ══ Guard 1: peer-writer detection (git/bash only — runs even without python) ════════
+# Silent unless the tree is dirty AND something was written in the last ~2 min (the signature of
+# another session editing the same checkout). Old WIP with stale mtimes stays silent — an idle
+# non-writer can't collide. The dispositive test (is it STILL changing?) is the model's; this is
+# just the cheap pre-filter. See rules/worktree-boundaries.md.
+if git -C "$PROJ" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  DIRTY="$(git -C "$PROJ" status --porcelain 2>/dev/null)"
+  if [ -n "$DIRTY" ]; then
+    NOW=$(date +%s); NEWEST=0
+    while IFS= read -r l; do
+      p="${l:3}"; p="${p##* -> }"; f="$PROJ/$p"
+      [ -f "$f" ] || continue
+      m=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null)
+      [ -n "${m:-}" ] && [ "$m" -gt "$NEWEST" ] && NEWEST="$m"
+    done <<< "$DIRTY"
+    AGE=$(( NOW - NEWEST )); COUNT=$(printf '%s\n' "$DIRTY" | grep -c .)
+    if [ "$NEWEST" -gt 0 ] && [ "$AGE" -lt 120 ]; then
+      echo "PEER-SESSION ADVISORY: $COUNT uncommitted change(s) in this checkout, newest written ${AGE}s ago."
+      echo "If you did not make these, another Claude session may be writing this same working tree now."
+      echo "Before writing: fingerprint 'git diff | sha1sum' twice ~6s apart — if it changes, a peer writer"
+      echo "is live; move your work to its own worktree (git worktree add ../<dir> -b <branch>) rather than"
+      echo "sharing the checkout. See rules/worktree-boundaries.md."
+      echo ""
+    fi
+  fi
+fi
+
+# ══ Guard 2: hidden-Unicode scan of instruction files (needs python + scanner) ══════
+[ -n "$PY" ] || exit 0   # no python → skip the unicode scan (the peer guard above already ran)
+
+# Locate the scanner (works in repo layout AND installed ~/.claude layout — hooks/ & skills/ siblings)
+SCANNER=""
+for cand in \
+  "$SELF_DIR/../skills/prompt-injection-defense/scripts/scan-hidden-unicode.py" \
+  "$HOME/.claude/skills/prompt-injection-defense/scripts/scan-hidden-unicode.py"; do
+  [ -f "$cand" ] && { SCANNER="$cand"; break; }
+done
+[ -n "$SCANNER" ] || exit 0   # scanner not installed → silent no-op
+
+# Collect existing instruction files (root-level + .claude/)
 FILES=()
 for f in CLAUDE.md AGENTS.md GEMINI.md COPILOT.md CURSOR.md WARP.md \
          .cursorrules .windsurfrules .clinerules .claude/CLAUDE.md; do
@@ -64,12 +85,11 @@ for f in CLAUDE.md AGENTS.md GEMINI.md COPILOT.md CURSOR.md WARP.md \
 done
 [ "${#FILES[@]}" -eq 0 ] && exit 0   # nothing to scan → silent
 
-# ── Scan once. --quiet = silent on clean; findings still print (data on stdout)
+# Scan once. --quiet = silent on clean; findings still print (data on stdout).
 OUT="$("$PY" "$SCANNER" --quiet "${FILES[@]}" 2>/dev/null)"
 RC=$?
 [ "$RC" -eq 0 ] && exit 0   # clean → say nothing
 
-# ── Finding (RC=10): surface an advisory into context ─────────────────────────
 echo "PROMPT-INJECTION ADVISORY: hidden-Unicode indicator(s) in this project's"
 echo "instruction files — these are loaded as agent instructions, so review before trusting:"
 echo ""

+ 43 - 0
rules/worktree-boundaries.md

@@ -32,6 +32,49 @@ During a private-project ecosystem-wide "commit + push all tool repos" pass, `gi
 - Never include worktrees in commit messages, scripts, or cleanup routines
 - If a repo's `.claude/` state looks "dirty" during cross-project work, that's the repo's problem, not yours
 
+## Provisioning discipline — one writer per tree (parallel isolation)
+
+The rule above is *defensive* (don't touch others' trees). This is the *provisioning* half: how to
+isolate your own parallel work so commits never clash. A git working tree has exactly one index and
+one HEAD — **two sessions committing to the same tree is the write-time clash** (index locks,
+interleaved commits, a working tree that mutates under you).
+
+**One writer, one worktree, one branch. Always. No exception for "small" work.** "Feature branches
+vs worktrees" is a false choice at parallel scale — a branch is the ref that lands, a worktree is
+where it's written; you use both. Branches are unlimited and free; the scarce resource is the
+*checkout* — one directory has exactly one branch checked out at a time, so two sessions in the same
+folder are forced onto the same branch. Plain feature branches (one checkout, `git switch`) only
+work for *sequential* work.
+
+| Spawn type | Isolation |
+|---|---|
+| Background agents (`claude --bg`) | ✅ auto-worktree under `.claude/worktrees/` — safe by default |
+| `Agent`-tool subagents / `/workflows` agents that **write** | set `isolation: 'worktree'` (read-only agents don't need it) |
+| **`spawn_task` chips** | ❌ **do NOT isolate** — the spawned session runs on the *current branch* of the primary checkout ([claude-code#64605](https://github.com/anthropics/claude-code/issues/64605)); seed the chip prompt to `git switch -c <slug>` first |
+| Manual parallel sessions | give each its own worktree; **never two writing sessions in one checkout** |
+| Agent teams | share one tree — only safe with file-partitioned, non-overlapping scopes |
+
+**Detect a live peer writer before you write.** The "worktree contract" is not enforced (chips
+violate it; an agent can escape a worktree via an absolute path), so don't *assume* isolation —
+verify it. When you start in a checkout whose tree is **already dirty with changes you didn't make**,
+probe before writing: fingerprint `git diff | sha1sum` twice ~6s apart and check the newest
+modified-file mtime. If the fingerprint changes (or a file was written seconds ago and you didn't do
+it), **a peer session is live** — warn and move your work to its own worktree rather than sharing the
+checkout. Old WIP with stale mtimes is fine. The `session-start-unicode-scan.sh` SessionStart hook
+raises this flag automatically at boot.
+
+Corollaries:
+
+- **The main/base checkout is sacred — landing-only.** Never run a *writing* session there; it's the
+  integration tree. Land parallel branches through [fleet-ops](../skills/fleet-ops/SKILL.md) (the
+  manual, test-gated landing queue).
+- **Land early, land often.** Bound divergence by integrating green lanes continuously, not as one
+  big-bang merge at the end — at 10+ lanes that's what bites.
+- **Lane naming:** `lane/<slug>` (and the native `claude/<slug>`) so the backlog is legible.
+- **A branch name does not reveal isolation** — `claude/eager-wozniak` looks identical whether it's a
+  worktree-isolated background agent or a session in your main checkout. Isolation is a structural
+  property you enforce, not something you read off the label.
+
 ## Scope this rule covers
 
 All projects. Never make exceptions "just for this session". If a worktree ever looks like it needs cleanup, ask the user explicitly before touching it.