diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index d99f0bb..b65df3f 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -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//...` (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. diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index dba63ac..19c710d 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -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. diff --git a/.claude/scripts/guard-git-add.py b/.claude/scripts/guard-git-add.py index 05e804a..92c21b0 100755 --- a/.claude/scripts/guard-git-add.py +++ b/.claude/scripts/guard-git-add.py @@ -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: : 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 ...`) 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: : can only add regular files, symbolic links or git-directories + fatal: adding files failed + + Explicit path staging (`git add ...`) 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 /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/` 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 `
/.claude/worktrees//...` + 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//...` 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). @@ -76,7 +100,20 @@ def _is_blanket(cmd): return False -def _has_device_masks(): +def _git_toplevel(cwd="."): + """`git -C 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 @@ -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: @@ -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
/.claude/worktrees//... — 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 /.claude/worktrees//..., 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 ` 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 -- ` (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 &&` and `git -C ` 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) @@ -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 && 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 && 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__": diff --git a/.claude/scripts/guard-git-add.test.sh b/.claude/scripts/guard-git-add.test.sh new file mode 100644 index 0000000..8f9cbb7 --- /dev/null +++ b/.claude/scripts/guard-git-add.test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# guard-git-add.test.sh — offline smoke test for guard-git-add.py's PreToolUse +# hook (issue #106): both the pre-existing blanket-`git add -A`/`commit -a` +# guard and the new worker-vs-main-checkout git-state-mutation guard. +# +# Hermetic: builds a real throwaway git repo ($main) plus a real `git worktree +# add`-created worktree under $main/.claude/worktrees/w1 (mirroring how the +# implementer/orchestrator agents actually run), and pipes crafted PreToolUse +# event JSON (tool_name/cwd/tool_input.command) straight into +# `python3 guard-git-add.py`, asserting its exit code (0 = allow, 2 = block). +# No `git add`/`checkout`/etc in the crafted commands is ever actually run — +# the hook only inspects the command text and shells out to +# `git -C rev-parse --show-toplevel` to resolve effective targets, so +# nonexistent branch/file names in the crafted commands are fine. +# +# _sandbox_enabled() is forced true via a $main/.claude/settings.json fixture +# + CLAUDE_PROJECT_DIR=$main (see guard-git-add.py) so the guard deterministically +# engages, matching the hook's own hardened-only detection logic. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/guard-git-add.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +guard="$script_dir/guard-git-add.py" + +work="$(mktemp -d "${TMPDIR:-/tmp}/guard-git-add-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# --------------------------------------------------------------------------- +# Fixture: a real git repo ($main) — the "main checkout" — with a real +# `git worktree add` worktree at $main/.claude/worktrees/w1, exactly the +# layout a worker's isolation:worktree session runs under. A sandbox-hardened +# settings.json makes _sandbox_enabled() true so the guard engages. +# --------------------------------------------------------------------------- +main="$work/main" +mkdir -p "$main/.claude" +git -C "$main" init -q -b main +git -C "$main" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init + +cat > "$main/.claude/settings.json" <<'EOF' +{ "sandbox": { "enabled": true } } +EOF + +git -C "$main" worktree add -q -b wbranch "$main/.claude/worktrees/w1" main +w1="$main/.claude/worktrees/w1" + +# --------------------------------------------------------------------------- +# Helpers: build a PreToolUse event JSON and invoke the guard against it. +# worker=1 sets the RECODE_WORKER=1 marker env var; worker=0 (or omitted) +# leaves it unset, i.e. an owner session (or, for the corroboration-only +# case, a worker whose marker somehow didn't propagate). +# --------------------------------------------------------------------------- +build_event() { + node -e ' + const [cwd, command] = process.argv.slice(1); + process.stdout.write(JSON.stringify({ tool_name: "Bash", cwd, tool_input: { command } })); + ' "$1" "$2" +} + +assert_guard() { + local desc="$1" cwd="$2" command="$3" worker="$4" expect="$5" + local json rc out + json="$(build_event "$cwd" "$command")" + if [ "$worker" = "1" ]; then + out="$(printf '%s' "$json" | CLAUDE_PROJECT_DIR="$main" RECODE_WORKER=1 python3 "$guard" 2>&1)" + else + out="$(printf '%s' "$json" | CLAUDE_PROJECT_DIR="$main" env -u RECODE_WORKER python3 "$guard" 2>&1)" + fi + rc=$? + check "$desc (exit $expect)" bash -c '[ "$1" -eq "$2" ]' _ "$rc" "$expect" + if [ "$rc" -ne "$expect" ]; then + echo " cwd=$cwd command=[$command] worker=$worker got=$rc output=$out" + fi +} + +# --------------------------------------------------------------------------- +# (c) Owner session (no marker) in the main checkout: NEVER blocked, whether +# the command is a plain explicit-path add or a blanket one — owner +# sessions must be unaffected (default allow when the marker is absent). +# --------------------------------------------------------------------------- +assert_guard "owner: explicit-path git add in main is allowed" \ + "$main" "git add foo.txt" 0 0 + +# --------------------------------------------------------------------------- +# Pre-existing behavior: blanket `git add -A`/`git commit -a` blocked once +# hardened, regardless of worker/owner (check 1 is independent of check 2). +# --------------------------------------------------------------------------- +assert_guard "owner: blanket 'git add -A' in main is blocked" \ + "$main" "git add -A" 0 2 +assert_guard "owner: blanket 'git commit -a' in main is blocked" \ + "$main" 'git commit -a -m wip' 0 2 + +# --------------------------------------------------------------------------- +# (a) Worker session (marker=1) + mutating git subcommands, toplevel==main +# checkout -> BLOCKED for every mutating subcommand named in the issue. +# --------------------------------------------------------------------------- +assert_guard "worker: git add in main checkout is blocked" \ + "$main" "git add foo.txt" 1 2 +assert_guard "worker: git rm --cached in main checkout is blocked" \ + "$main" "git rm --cached foo.txt" 1 2 +assert_guard "worker: git mv in main checkout is blocked" \ + "$main" "git mv a b" 1 2 +assert_guard "worker: git reset --hard in main checkout is blocked" \ + "$main" "git reset --hard" 1 2 +assert_guard "worker: git switch in main checkout is blocked" \ + "$main" "git switch some-branch" 1 2 +assert_guard "worker: git checkout in main checkout is blocked" \ + "$main" "git checkout some-branch" 1 2 +assert_guard "worker: git restore --staged in main checkout is blocked" \ + "$main" "git restore --staged foo.txt" 1 2 + +# Non-mutating / narrower forms must NOT be blocked even for a worker in main +# — this proves the guard isn't just blanket-blocking every git call there. +assert_guard "worker: git status in main checkout is allowed" \ + "$main" "git status" 1 0 +assert_guard "worker: git restore (no --staged) in main checkout is allowed" \ + "$main" "git restore foo.txt" 1 0 +assert_guard "worker: 'git checkout -- ' (pathspec restore) is allowed" \ + "$main" "git checkout -- foo.txt" 1 0 + +# --------------------------------------------------------------------------- +# (b) Worker session + the SAME commands run against its OWN worktree -> +# ALLOWED (this is exactly where a worker is supposed to work). +# --------------------------------------------------------------------------- +assert_guard "worker: git add in its own worktree is allowed" \ + "$w1" "git add foo.txt" 1 0 +assert_guard "worker: git checkout in its own worktree is allowed" \ + "$w1" "git checkout some-branch" 1 0 +assert_guard "worker: git reset --hard in its own worktree is allowed" \ + "$w1" "git reset --hard" 1 0 + +# --------------------------------------------------------------------------- +# Escape hatches: a worker whose cwd IS its own worktree but whose command +# retargets the MAIN checkout via `git -C
` or a `cd
&&` prefix +# must still be BLOCKED — this is the exact bug #106 closes. +# --------------------------------------------------------------------------- +assert_guard "worker: 'git -C
add' from own worktree is blocked" \ + "$w1" "git -C $main add foo.txt" 1 2 +assert_guard "worker: 'cd
&& git add' from own worktree is blocked" \ + "$w1" "cd $main && git add foo.txt" 1 2 + +# Corroboration-only: marker absent, but cwd is already under +# .claude/worktrees/* — the cwd signal alone must still catch the -C escape. +assert_guard "corroboration-only (no marker): 'git -C
add' from a worktree cwd is blocked" \ + "$w1" "git -C $main add foo.txt" 0 2 + +echo "" +if [ "$fail" -eq 0 ]; then + echo "guard-git-add.test.sh: PASS ($ok checks)" + exit 0 +else + echo "guard-git-add.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/scripts/loop-census.sh b/.claude/scripts/loop-census.sh index 7e769e6..47f4262 100644 --- a/.claude/scripts/loop-census.sh +++ b/.claude/scripts/loop-census.sh @@ -39,6 +39,24 @@ # non-"none" advance_ready when plan.gate != # "off" (issue #100) — tells the tick which # driver prompt variant to build. +# main_dirty=yes|no `git -C $root status --porcelain` is non-empty +# AFTER excluding (a) sandbox-mask phantom paths +# (device-node masks, see below) and (b) the +# read-only-mounted `.claude/agents/` and +# `.claude/skills/setup/templates/` paths, which +# can legitimately lag behind HEAD in sandboxed +# sessions — i.e. the MAIN checkout has real +# uncommitted state. Surfaces issue #106's failure +# mode: a worker mutated the shared main checkout +# instead of its own worktree. +# main_head=|detached the MAIN checkout's current HEAD: the branch +# name, or literally `detached` when HEAD isn't +# on any branch. Surfaces the 2026-07-16 incident +# where a driver's `git checkout` failed mid-op +# on a read-only-mounted agent file and left main +# in a DETACHED HEAD on an unmerged commit for +# ~12h — a state a clean working tree alone +# (main_dirty=no) would NOT reveal. # cadence=FAST|WATCH|IDLE cron= desired cadence per the loop policy # # --- PLAN GATE (issue #100) ------------------------------------------------- @@ -124,6 +142,53 @@ repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" gates_rel="${GATES_FILE:-.claude/gates.json}" case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac +# main_dirty (issue #106): is the MAIN checkout ($root) dirty? A `git status +# --porcelain` line is only real dirt if its path is NOT one of: +# (a) one of the sandbox's `/dev/null` character-device masks (`.mcp.json`, +# `.claude/routines`, `.idea`, `.vscode`, `.gitmodules`, +# `.claude/launch.json` — see docs/HARDENING.md -> Caveats), or +# (b) under the read-only-mounted `.claude/agents/` or +# `.claude/skills/setup/templates/` trees, which can legitimately lag +# behind HEAD in sandboxed sessions (the bind-mount, not a real edit). +# Those are expected artifacts, not a worker's or owner's real uncommitted +# work, so they must never flip main_dirty to "yes". +main_dirty="no" +status_lines=$(git -C "$root" status --porcelain 2>/dev/null || true) +if [ -n "$status_lines" ]; then + while IFS= read -r line; do + [ -z "$line" ] && continue + path="${line:3}" + case "$path" in + *" -> "*) path="${path##* -> }" ;; # rename: "old -> new" -> take new + esac + # git quotes paths containing unusual characters in double quotes; strip + # a matched leading/trailing quote pair if present. + case "$path" in + \"*\") path="${path#\"}"; path="${path%\"}" ;; + esac + if [ -c "$root/$path" ]; then + continue # sandbox-mask phantom path — not real dirt, skip + fi + case "$path" in + .claude/agents/*|.claude/skills/setup/templates/*) + continue # read-only-mount path that can legitimately lag HEAD, skip + ;; + esac + main_dirty="yes" + break + done <<< "$status_lines" +fi +echo "main_dirty=$main_dirty" + +# main_head (issue #106): the MAIN checkout's current HEAD — the branch name, +# or literally "detached" when HEAD isn't on any branch. Companion signal to +# main_dirty: the 2026-07-16 incident left main on a DETACHED HEAD with a +# CLEAN working tree (main_dirty=no would have missed it entirely), so this +# must be reported independently rather than folded into main_dirty. +main_head=$(git -C "$root" symbolic-ref --quiet --short HEAD 2>/dev/null || true) +[ -n "$main_head" ] || main_head="detached" +echo "main_head=$main_head" + # Adapter-derived facts: base branch + the module:* label set. base=$(node -e 'const g=require(process.argv[1]); console.log((g.merge&&g.merge.baseBranch)||"main")' "$gates") module_labels=$(node -e 'const g=require(process.argv[1]); console.log(g.modules.map(m=>"module:"+m.name).join("\n"))' "$gates") diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh index dc7f9ed..df2d048 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -143,6 +143,26 @@ chmod +x "$scripts_dir"/*.sh git -C "$fixture" init -q -b main git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init +# Commit the fixture scaffolding written above (.claude/scripts/*, gates.json, +# ...) plus a real tracked file — used further below to exercise +# main_dirty=yes (issue #106) via an actual uncommitted modification. Without +# this commit the scaffolding itself would sit untracked and main_dirty would +# always read "yes", breaking the "clean fixture" checks that run first. +# +# Also seed TRACKED baseline files under the read-only-mounted +# .claude/agents/ and .claude/skills/setup/templates/ trees, mirroring a real +# repo where those paths are checked in. Used further below to exercise the +# "can legitimately lag behind HEAD" exclusion (issue #106, acceptance +# criterion 3) via an in-place modification to an EXISTING tracked file — +# the real shape of a stale bind-mount, as opposed to a brand-new untracked +# path (which git would collapse into a single directory-level status line). +echo "tracked" > "$fixture/tracked.txt" +mkdir -p "$fixture/.claude/agents" "$fixture/.claude/skills/setup/templates" +echo "implementer baseline" > "$fixture/.claude/agents/implementer.md" +echo "template baseline" > "$fixture/.claude/skills/setup/templates/CLAUDE.md" +git -C "$fixture" add .claude tracked.txt +git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q -m "add fixture scaffolding + tracked file" + # Bare "remote" so `git branch -a` prints genuine "remotes/origin/..." lines. remote="$work/remote.git" git init -q --bare "$remote" @@ -593,6 +613,55 @@ check "stall: age_min on the stalled line is at least the 2-minute threshold" ba [ -n "$age" ] && [ "$age" -ge 2 ] ' _ "$outStall" +# --------------------------------------------------------------------------- +# main_dirty (issue #106): git-state-mutation guard's companion telemetry. +# --------------------------------------------------------------------------- +check "clean fixture: main_dirty=no" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=no"' _ "$out" + +# A real uncommitted modification to a tracked file -> main_dirty=yes. +echo "modified" >> "$fixture/tracked.txt" +out_dirty="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "fixture with a real tracked-file modification: main_dirty=yes" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=yes"' _ "$out_dirty" +git -C "$fixture" checkout -q -- tracked.txt + +# The ONLY dirt is a sandbox-mask phantom path — a symlink to /dev/null +# satisfies the same `[ -c path ]` test a real bind-mounted device-node mask +# would (unprivileged test code can't mknod a real character device, but a +# symlink to one passes the exact same `test -c`, since `[ -c ]` follows +# symlinks). Must still report main_dirty=no: the exclusion works. +ln -sf /dev/null "$fixture/.mcp.json" +out_mask="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "fixture whose only dirt is a sandbox-mask phantom path: main_dirty=no" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=no"' _ "$out_mask" +rm -f "$fixture/.mcp.json" + +# A modification to the EXISTING tracked .claude/agents/ baseline file can +# legitimately lag behind HEAD in sandboxed sessions — must NOT flip +# main_dirty (issue #106, acceptance criterion 3). +echo "stale mount content" >> "$fixture/.claude/agents/implementer.md" +out_agents="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "fixture with only a .claude/agents/ modification: main_dirty=no" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=no"' _ "$out_agents" +git -C "$fixture" checkout -q -- .claude/agents/implementer.md + +# Same for the read-only-mounted .claude/skills/setup/templates/ tree. +echo "stale mount content" >> "$fixture/.claude/skills/setup/templates/CLAUDE.md" +out_templates="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "fixture with only a .claude/skills/setup/templates/ modification: main_dirty=no" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=no"' _ "$out_templates" +git -C "$fixture" checkout -q -- .claude/skills/setup/templates/CLAUDE.md + +# --------------------------------------------------------------------------- +# main_head (issue #106): detects a DETACHED HEAD in the main checkout — the +# 2026-07-16 incident (a failed mid-op `git checkout` left main detached on +# an unmerged commit for ~12h, with a CLEAN working tree throughout, i.e. +# main_dirty=no the whole time; only main_head would have caught it). +# --------------------------------------------------------------------------- +check "clean fixture on its named branch: main_head=main" bash -c 'printf "%s\n" "$1" | grep -qx "main_head=main"' _ "$out" + +git -C "$fixture" checkout -q --detach HEAD +out_detached="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "fixture with HEAD detached: main_head=detached" bash -c 'printf "%s\n" "$1" | grep -qx "main_head=detached"' _ "$out_detached" +check "fixture with HEAD detached: main_dirty still no (working tree itself is clean)" bash -c 'printf "%s\n" "$1" | grep -qx "main_dirty=no"' _ "$out_detached" +git -C "$fixture" checkout -q main + echo "" if [ "$fail" -eq 0 ]; then echo "loop-census.test.sh: PASS ($ok checks)"