You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: .claude/rules/hooks.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -82,6 +82,8 @@ The two hooks address the two INDEPENDENT layers of that failure. Either one alo
82
82
83
83
- **`.claude/hooks/restore-backup.sh`** — PreToolUse (matcher `Bash`), **non-blocking**. Before `git checkout -- <path>` / `git checkout .`, `git restore`, `git reset --hard`, `git clean -f*`, or `git stash`, snapshots the working tree into `<resolved git dir>/wipe-backups/<UTC ts>-<verb>/` (`tracked.patch` from `git diff HEAD --binary`, `COMMAND`, plus `untracked.tar` for `clean`, whose targets a diff cannot capture). Always exits 0 and never prompts: blocking a legitimate restore would be constant friction, and the operator does not know the changes are precious at the moment they run it. Skips entirely when `git status --porcelain` is empty, so a pristine tree writes nothing. Cwd-aware with the same `git -C <path>` / leading `cd <path> &&` / payload-`cwd` resolution as `branch-gate.sh`; snapshots land in the **per-worktree** git dir (`.git/worktrees/<name>/`), matching how markgate resolves its marker store. Deliberately does NOT match `git checkout <branch>` / `-b` (a branch switch is not a restore, and is governed by `main-tree-branch-gate.sh`). Recovery is `git apply --include=<path> <snap>/tracked.patch` for one file or `git apply --3way <snap>/tracked.patch` for the tree — the plain `git apply` form fails with "patch does not apply" once any other change in the whole-tree patch is still present, so the hook prints the two forms that were verified against a real wipe-and-recover replay rather than the one that reads best. Smoke test at `.claude/hooks/restore-backup.test.sh` (14 cases against a real throwaway repo, including the end-to-end wipe-then-recover proof and the cdkd#563 quoted-body false-positive cases).
84
84
85
-
- **`.claude/hooks/worktree-owner-gate.sh`** — PreToolUse (matcher `Edit|Write|NotebookEdit`), **blocking**. Gives each LINKED worktree one owning session: the first file write claims it by recording `<session_id> <UTC time>` in `<worktree git dir>/session-owner`, and a write from a different `session_id` exits 2 naming the owner, the worktree, and the release command. Closes the enforcement gap under the existing "one lane, one worktree" convention — nothing checked ownership at the SESSION level, and `git worktree list` shows which worktrees exist, not which another agent is actively driving. Scope decisions: only linked worktrees (the main tree is already covered by `main-tree-edit-gate.sh`); only file-writing tools, because a Bash command's write targets cannot be resolved statically and the read-only commands that dominate Bash usage must never be blocked (`main-tree-dirty-detector.sh` is the reactive analogue for that gap); repo opt-in via `.markgate.yml` at the TARGET's own toplevel, matching `branch-gate.sh` (issue #1259) — note a linked worktree has its own checkout of that file, so the marker must be absent from the WORKTREE, not just the main tree. Fails OPEN on anything unresolvable (no `session_id` in the payload, path outside a repo, unreadable sentinel): this catches an honest mistake and is not a security boundary, so a false block costs more than a rare miss. An owner idle longer than `CDKD_WORKTREE_OWNER_TTL_HOURS` (default 12) is treated as abandoned and taken over silently — sessions end without cleanup routinely, and a stale lock needing manual clearing is a worse failure than the one being prevented. `CDKD_SKIP_WORKTREE_OWNER_GATE=1` is the deliberate hand-off bypass. Smoke test at `.claude/hooks/worktree-owner-gate.test.sh` (15 cases against a real `git worktree add`, including the foreign-session block, TTL takeover, fresh-owner-not-stolen, and every fail-open case).
85
+
- **`.claude/hooks/worktree-owner-gate.sh`** — PreToolUse (matcher `Edit|Write|NotebookEdit`), **blocking**. Gives each LINKED worktree one owning session: the first file write claims it by recording `<session_id> <UTC time>` in `<worktree git dir>/session-owner`, and a write from a different `session_id` exits 2 naming the owner, the worktree, and the release command. Closes the enforcement gap under the existing "one lane, one worktree" convention — nothing checked ownership at the SESSION level, and `git worktree list` shows which worktrees exist, not which another agent is actively driving. Scope decisions: only linked worktrees (the main tree is already covered by `main-tree-edit-gate.sh`); only file-writing tools, because a Bash command's write targets cannot be resolved statically and the read-only commands that dominate Bash usage must never be blocked (`main-tree-dirty-detector.sh` is the reactive analogue for that gap); repo opt-in via `.markgate.yml` at the TARGET's own toplevel, matching `branch-gate.sh` (issue #1259) — note a linked worktree has its own checkout of that file, so the marker must be absent from the WORKTREE, not just the main tree. Fails OPEN on anything unresolvable (no `session_id` in the payload, path outside a repo, unreadable sentinel): this catches an honest mistake and is not a security boundary, so a false block costs more than a rare miss. An owner idle longer than `CDKD_WORKTREE_OWNER_TTL_HOURS` (default 12) is treated as abandoned and taken over silently — sessions end without cleanup routinely, and a stale lock needing manual clearing is a worse failure than the one being prevented. `CDKD_SKIP_WORKTREE_OWNER_GATE=1` is the deliberate hand-off bypass. Smoke test at `.claude/hooks/worktree-owner-gate.test.sh` (24 cases against a real `git worktree add`, including the foreign-session block, TTL takeover, fresh-owner-not-stolen, the sentinel-write block below, and every fail-open case).
86
+
87
+
**The sentinel is itself gated (2026-08-10).** The gate shipped with one hole, and it was the lock itself: `session-owner` lives INSIDE the git dir, which has no work tree, so `git rev-parse --show-toplevel` fails on it, the repo opt-in check `[ -n "$top" ] && [ -f "$top/.markgate.yml" ] || exit 0` fell through, and a Write targeting the sentinel passed unguarded. Taking another session's worktree was therefore a single `Write` — no `rm`, no env var, no prompt. That is exactly how it went wrong: a session found a sentinel owned by another session, judged from a recent claim plus a stale-looking diff that the owner had been `/clear`-ed, wrote its own id over the file, and drove a lane another LIVE agent was working; the collision only surfaced when the real owner's next edit was blocked and it asked the maintainer. (The victim's uncommitted work survived by luck — the trespassing session had committed it rather than reverting it.) The fix recovers the worktree root from `<git dir>/gitdir` (which points at the linked worktree's `.git` file) so the opt-in still consults the WORKTREE's own `.markgate.yml`, and the ordinary ownership branch below then applies to the sentinel like any other file. Claiming an absent or TTL-stale sentinel by writing it still works, and the owner may still refresh its own — the guard adds no new rule, it removes an exemption. The refusal message is tailored for this path: it states that writing the file IS taking the worktree, that a claim younger than the TTL means the owner is **presumed LIVE**, that a live session and a dead one produce identical evidence (a recent claim, an unfamiliar diff, and a `/clear` you did not observe), and that the operator must ASK THE MAINTAINER before handing off — especially when `git -C <worktree> status --short` is non-empty. **Never infer that an owning session is dead.** The judgemental half lives in memory rule `feedback_never_infer_dead_worktree_owner.md`.
86
88
87
89
**Related correction.** The same incident produced a second, purely informational failure: both sessions believed markgate markers were repo-global and serialized every gate/commit/integ step across four lanes for no reason. They are per-worktree — `git rev-parse --git-dir` resolves to `.git/worktrees/<name>` in a linked worktree — which the "Markgate gate hooks (cwd-aware)" section above already stated. The wrong belief lived in an agent memory rule that contradicted this checked-in documentation and was never re-derived. `ls .git/worktrees/*/markgate` settles it in one command.
0 commit comments