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
35 changes: 35 additions & 0 deletions exact_dot_claude/rules/claude-code-auto-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,41 @@ oversight in the non-auto modes. Under auto mode the allow-list's only job is
skipping the classifier on safe, high-frequency commands — which wants narrow
entries.

## Some tools ALWAYS reach the classifier — only a whole-tool allow rule clears them

Auto mode usually short-circuits *before* the classifier: it re-runs the
tool's own `checkPermissions` with the mode forced to `acceptEdits`, and an
`allow` there skips the classifier entirely. A **hardcoded set of tools is
excluded from that fast path** and is classified on every single call:

`Agent` · `CronCreate` · `RemoteTrigger` · `ScheduleWakeup` · `SendFile`

(`CronCreate` and `RemoteTrigger` also return `passthrough` — "requires
classifier review" — in auto mode, where every other mode gets a plain
`allow`.) The symptom is a steady drip of approve/deny prompts for the
check-back-in machinery — arm a cron, arm a claude.ai routine, notify when CI
finishes — which stalls unattended sessions. No amount of `autoMode` prose
fixes it; the classifier is behaving as designed.

**The lever is a whole-tool allow rule**, which resolves ahead of the
classifier. Bare tool name, no scoping:

"allow": ["CronCreate", "CronDelete", "CronList",
"PushNotification", "RemoteTrigger", "ScheduleWakeup"]

- **Bare names only.** These tools declare no `ruleContentField`, so a scoped
rule like `RemoteTrigger(create)` **silently never matches** — it is
all-actions-or-nothing per tool.
- **Survives the strip above**, which targets arbitrary code execution
(`Bash`, `Agent(*)`); non-exec tool grants carry over into auto mode.
- A tool can opt out via `ignoresWholeToolAllowRule`; none of these do.
- **`Monitor` is deliberately excluded** — it runs arbitrary shell, so it
belongs with `Bash`, not with the benign schedulers.
- **Web/remote needs the project file.** `~/.claude` is unreachable there, so
the same entries must also live in each repo's committed
`.claude/settings.json` — `just -g claude-perms-sweep <scope>` sweeps the
fleet (same global-vs-committed split as `claude-plugins-freshness.md`).

## `deny` is a hard backstop

`permissions.deny` resolves before the classifier and cannot be overridden in
Expand Down
129 changes: 19 additions & 110 deletions exact_dot_claude/rules/git-hazards.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,10 @@
# Git Hazards — Verify the Content, Not the Exit Code

Eight traps sharing one law: **a green git command is not proof the result is
correct.** "Merge went well", "PR merged", and exit 0 are claims about
mechanics, not content. Each hazard below: the trap, the 5-second check, the
fix. (Consolidated 2026-07 from six separate incident rules; full narratives
are in git history.)

## 1. `--merged` misses squash-merged branches

A squash-merge collapses a branch into one fresh-SHA commit on `main`, so the
branch's own commits are never ancestors — `git branch --merged` (and any
ancestry check) reports it **unmerged**. "Files identical to main" also fails
once `main` drifts the same files.

- **Check**, in order of authority:
- `gh pr list --state all --head <branch> --json state` → a MERGED PR is
**authoritative**. Reach for this first; the git-side checks below are all
one-way.
- `git cherry main <branch>` → marks a commit `-` when a patch-equivalent
commit is already upstream, `+` when it is not. Survives squash **and**
cherry-pick, and does not care that `main` drifted.
- `git merge-tree --write-tree main <branch>` equals `git rev-parse main^{tree}`
→ contained. **A match proves containment; a non-match proves nothing.**
- **Not immune to drift** (corrected 2026-07): once `main` moves on over the same
files, merging an already-merged branch back would re-introduce its older
versions, so the trees differ and merge-tree reports **not contained** for work
that fully landed. Observed reporting three merged branches as unmerged. Same
trap as "files identical to main". Use the PR state or `git cherry` to decide;
keep merge-tree only as a positive-containment shortcut.
- **Fix**: use the encoded recipe rather than re-deriving: `just -g branch-audit`
(in `private_dot_config/just/git.just`) prints MERGED vs REVIEW + a paste-ready delete.
- A non-match is "review", **not** proof of unmerged — don't force the count to zero.

## 2. Commits added after a squash-merge are orphaned
Five local-git traps, one law: **a green git command is not proof the result is
correct** — exit 0 is a claim about mechanics, not content. Each: the trap, the
5-second check, the fix. Sibling: `pr-merge-hazards.md` (GitHub PR/merge).

## 1. Commits added after a squash-merge are orphaned

Anything committed to a branch **after** its squash-merge is in neither `main`
nor the squash commit. A fresh branch off `origin/main` silently lacks that
Expand All @@ -43,35 +15,7 @@ work; the first symptom is an ImportError far downstream.
- **Fix**: replay only the orphans: `git rebase --onto origin/main <squash-point> <branch>`,
then verify `git log --oneline origin/main..HEAD` shows only the orphaned + new commits.

## 3. Merging a stacked base auto-CLOSES the child PR

When PR B is based on PR A's branch, merging A and deleting its branch
auto-closes B (GitHub does **not** retarget it), and a closed PR whose base
branch is gone **cannot be reopened**.

- **Fix — order matters**: retarget the child **first**, while the base PR is open:
1. `gh pr edit <child> --base main`
2. `gh pr merge <base> --squash --delete-branch`
3. `git rebase --onto origin/main <old-base-tip> <child-branch>` (drops the
already-squashed base commits) + `git push --force-with-lease`
4. merge the child.
- **If already auto-closed**: the head branch survives — rebase as above,
`gh pr create` fresh, comment "Superseded by #new" on the closed one.
- **Nothing tells you this happened.** The auto-close is silent: no failed
check, no notification, and the PR list just looks one shorter. claude-plugins
#2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying
work that had **never had a PR opened at all** (so no event ever fired for
them either). A scheduled sweep is the only thing that finds this class —
an event handler on `pull_request: closed` is too late by construction (the
base ref is already deleted, so the reopen window is gone) and is blind to
never-PR'd branches. `claude-plugins scripts/check-stranded-work.sh` is the
encoded audit; it takes `--repo`, so one run sweeps the portfolio.
- **Telling an accident from a decision**: a closed-unmerged PR whose base ref
**404s** was auto-closed; one whose base ref is still **alive** was closed by a
human (duplicate/superseded). That single check is the discriminator — 11 of
those 26 branches were deliberate closes and must not be resurrected.

## 4. Unpushed commits on local `main` ride into new branches
## 2. Unpushed commits on local `main` ride into new branches

Branching off local `main` inherits whatever it is ahead of `origin/main` by;
the PR then bundles stray commits under an unrelated title (squash hides it —
Expand All @@ -81,7 +25,7 @@ visible only in the file list).
`git fetch origin && git switch -c <branch> origin/main`.
- **Check** when unsure: `git log --oneline origin/main..main` — empty means clean.

## 5. A clean textual merge can duplicate identical additions
## 3. A clean textual merge can duplicate identical additions

When two branches each add the **same** helper/import/enum arm in non-adjacent
spots, `git merge` sees no overlapping hunk, reports success, and keeps **both
Expand All @@ -93,7 +37,7 @@ lax languages).
- **Fix**: hand-resolve to a single combined definition; never trust
"Automatic merge went well" as a verdict on content.

## 6. `git add` aborts atomically on a bad pathspec
## 4. `git add` aborts atomically on a bad pathspec

`git add fileA nonexistent` stages **nothing** — not "fileA plus a warning".
Classic trip: `git mv old new`, edit `new`, then `git add new old` → the stale
Expand All @@ -106,51 +50,16 @@ Classic trip: `git mv old new`, edit `new`, then `git add new old` → the stale
- **Recovery**: the edit is still unstaged in the working tree; add and
commit/amend — don't redo the work.

## 7. Stacked-chain merges: push by SHA, never `HEAD:` — and expect auto-close races

Working down a stacked-PR chain (retarget child → merge base → rebase child →
force-push → merge, per #3) has three traps of its own (observed 2026-07,
claude-plugins #1979→#1987):

- **`HEAD:` in a push refspec is a race in a shared checkout.** HEAD is
process-global repo state; a coworker session can move it *between two of
your Bash calls*. Observed: rebase left HEAD at the child's new tip; by the
next call HEAD was `main`'s tip, so `git push --force-with-lease origin
HEAD:<child-branch>` overwrote the branch with main. Resolve the tip to an
**explicit SHA in the same command that creates it** and push
`git push --force-with-lease origin <sha>:<branch>`.
- **An empty-diff force-push auto-closes the PR — and a closed PR whose *head*
moved after closing cannot be reopened.** Sibling of #3's
base-branch-deleted variant. GitHub saw the branch == main, closed the PR,
and refused `gh pr reopen` because the head ref had moved since closing.
- **A single mergeability read after a force-push is a race.** GitHub
recomputes `mergeable` asynchronously; `gh pr merge` right after a push
fails with "not mergeable" on a perfectly clean PR. Poll
`gh pr view <n> --json mergeable` until it leaves `UNKNOWN`.
- **Waiting for CI races check *registration*, not just completion.** A loop on
"zero pending checks" can exit **immediately** after a push/`update-branch`:
zero pending is trivially true before the jobs are registered. Observed
2026-07: `state=CLEAN` on a **single** check while three CI jobs had not yet
appeared — merging there merges untested. Gate on **both** nothing-pending
**and** `--jq 'length'` ≥ the expected check count. Same root cause as the
mergeability race above: an async field read once, too early.

- **Check** before every force-push: `git log --oneline origin/main..<sha>` —
expect *exactly* the child's commits, nothing more, never empty.
- **Recovery** when auto-closed: the rebased commits survive in local objects
(`git reflog`) — `git push --force-with-lease origin <sha>:<branch>`, open a
fresh PR from the branch, comment "Superseded by #new" on the closed one.

## 8. A "vanished" staged file in a shared checkout was probably committed by a coworker

Sibling of #7's HEAD race: the **index and HEAD are process-global**, so a
coworker session's commit lands between two of your Bash calls with no
warning. Observed 2026-07 (dotfiles): a file another session had staged (`A `)
disappeared from `git status`, then `ls` said it didn't exist, then status
flapped `A ` → `M ` across consecutive calls. The wrong theory ("pre-commit's
stash dance ate it") was nearly acted on; the truth was the coworker had
committed the file to `main` mid-flight — every observation was a stale read
of state the coworker kept moving.
## 5. A "vanished" staged file in a shared checkout was probably committed by a coworker

Sibling of the HEAD race in `pr-merge-hazards.md` #3: the **index and HEAD are
process-global**, so a coworker session's commit lands between two of your Bash
calls with no warning. Observed 2026-07 (dotfiles): a file another session had
staged (`A `) disappeared from `git status`, then `ls` said it didn't exist,
then status flapped `A ` → `M ` across consecutive calls. The wrong theory
("pre-commit's stash dance ate it") was nearly acted on; the truth was the
coworker had committed the file to `main` mid-flight — every observation was a
stale read of state the coworker kept moving.

- **Check first, before any recovery**: `git log --oneline -3` — did HEAD
move? — and `git log -1 -- <path>`; a fresh commit touching the path is the
Expand All @@ -163,4 +72,4 @@ of state the coworker kept moving.
- **Status flapping between consecutive calls is itself the tell** that a
coworker is active — stop mutating shared state (index, HEAD, branch
switches) until the flapping stops; re-read state fresh in the same command
that acts on it (same instinct as #7's push-by-SHA).
that acts on it (same instinct as push-by-SHA).
105 changes: 105 additions & 0 deletions exact_dot_claude/rules/pr-merge-hazards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# PR & Merge Hazards — Exit 0 Is a Claim About Mechanics

Four traps in GitHub's merge machinery, one law: "PR merged" says nothing about
*content* — and a red check is not proof of failure. Each: the trap, the
5-second check, the fix. Sibling: `git-hazards.md` (local git).

## 1. `--merged` misses squash-merged branches

A squash-merge collapses a branch into one fresh-SHA commit on `main`, so the
branch's own commits are never ancestors — `git branch --merged` (and any
ancestry check) reports it **unmerged**. "Files identical to main" also fails
once `main` drifts the same files.

- **Check**, in order of authority:
- `gh pr list --state all --head <branch> --json state` → a MERGED PR is
**authoritative**. Reach for this first; the git-side checks below are all
one-way.
- `git cherry main <branch>` → marks a commit `-` when a patch-equivalent
commit is already upstream, `+` when it is not. Survives squash **and**
cherry-pick, and does not care that `main` drifted.
- `git merge-tree --write-tree main <branch>` equals `git rev-parse main^{tree}`
→ contained. **A match proves containment; a non-match proves nothing.**
- **Not immune to drift** (corrected 2026-07): once `main` moves on over the same
files, merging an already-merged branch back would re-introduce its older
versions, so the trees differ and merge-tree reports **not contained** for work
that fully landed. Observed reporting three merged branches as unmerged. Same
trap as "files identical to main". Use the PR state or `git cherry` to decide;
keep merge-tree only as a positive-containment shortcut.
- **Fix**: use the encoded recipe rather than re-deriving: `just -g branch-audit`
(in `private_dot_config/just/git.just`) prints MERGED vs REVIEW + a paste-ready delete.
- A non-match is "review", **not** proof of unmerged — don't force the count to zero.

## 2. Merging a stacked base auto-CLOSES the child PR

When PR B is based on PR A's branch, merging A and deleting its branch
auto-closes B (GitHub does **not** retarget it), and a closed PR whose base
branch is gone **cannot be reopened**.

- **Fix — order matters**: retarget the child **first**, while the base PR is open:
1. `gh pr edit <child> --base main`
2. `gh pr merge <base> --squash --delete-branch`
3. `git rebase --onto origin/main <old-base-tip> <child-branch>` (drops the
already-squashed base commits) + `git push --force-with-lease`
4. merge the child.
- **If already auto-closed**: the head branch survives — rebase as above,
`gh pr create` fresh, comment "Superseded by #new" on the closed one.
- **Nothing tells you this happened.** The auto-close is silent: no failed
check, no notification, and the PR list just looks one shorter. claude-plugins
#2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying
work that had **never had a PR opened at all** (so no event ever fired for
them either). A scheduled sweep is the only thing that finds this class —
an event handler on `pull_request: closed` is too late by construction (the
base ref is already deleted, so the reopen window is gone) and is blind to
never-PR'd branches. `claude-plugins scripts/check-stranded-work.sh` is the
encoded audit; it takes `--repo`, so one run sweeps the portfolio.
- **Telling an accident from a decision**: a closed-unmerged PR whose base ref
**404s** was auto-closed; one whose base ref is still **alive** was closed by a
human (duplicate/superseded). That single check is the discriminator — 11 of
those 26 branches were deliberate closes and must not be resurrected.

## 3. Stacked-chain merges: push by SHA, never `HEAD:` — and expect auto-close races

Working down a stacked-PR chain (retarget child → merge base → rebase child →
force-push → merge, per #2) has three traps of its own (observed 2026-07,
claude-plugins #1979→#1987):

- **`HEAD:` in a push refspec is a race in a shared checkout.** HEAD is
process-global repo state; a coworker session can move it *between two of
your Bash calls*. Observed: rebase left HEAD at the child's new tip; by the
next call HEAD was `main`'s tip, so `git push --force-with-lease origin
HEAD:<child-branch>` overwrote the branch with main. Resolve the tip to an
**explicit SHA in the same command that creates it** and push
`git push --force-with-lease origin <sha>:<branch>`.
- **An empty-diff force-push auto-closes the PR — and a closed PR whose *head*
moved after closing cannot be reopened.** Sibling of #2's
base-branch-deleted variant. GitHub saw the branch == main, closed the PR,
and refused `gh pr reopen` because the head ref had moved since closing.
- **A single mergeability read after a force-push is a race.** GitHub
recomputes `mergeable` asynchronously; `gh pr merge` right after a push
fails with "not mergeable" on a perfectly clean PR. Poll
`gh pr view <n> --json mergeable` until it leaves `UNKNOWN`.
- **Waiting for CI races check *registration*, not just completion.** A loop on
"zero pending checks" can exit **immediately** after a push/`update-branch`:
zero pending is trivially true before the jobs are registered. Observed
2026-07: `state=CLEAN` on a **single** check while three CI jobs had not yet
appeared — merging there merges untested. Gate on **both** nothing-pending
**and** `--jq 'length'` ≥ the expected check count. Same root cause as the
mergeability race above: an async field read once, too early.

- **Check** before every force-push: `git log --oneline origin/main..<sha>` —
expect *exactly* the child's commits, nothing more, never empty.
- **Recovery** when auto-closed: the rebased commits survive in local objects
(`git reflog`) — `git push --force-with-lease origin <sha>:<branch>`, open a
fresh PR from the branch, comment "Superseded by #new" on the closed one.

## 4. A red PR may still be mergeable — `UNSTABLE` is not `BLOCKED`

`mergeStateStatus` separates **required** failing checks (`BLOCKED` — merge
refused) from merely-present ones (`UNSTABLE` — plain `gh pr merge` works), so
`--admin` on an `UNSTABLE` PR takes a privilege you didn't need. Read it first.

Merging over red needs **two** checks: `--json files` (config/docs can't break
a compile) **and** the same check already failing on `main`. Either alone is a
guess — and a stale-green `main` lies, so check `createdAt` (2026-07: a "green"
run was 21 days old; main hadn't compiled for three weeks).
19 changes: 19 additions & 0 deletions private_dot_config/just/claude.just
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,22 @@ settings-audit:
if [[ $((critical + high)) -gt 0 ]]; then
exit 1
fi

# Sweep permission allow-rules into every repo's committed .claude/settings.json
# (dry-run by default; pass --apply to write, --pr to also open PRs).
#
# Committed project settings are the ONLY place permission rules reach Claude
# Code web/remote — ~/.claude is unreachable there. A rule added to the chezmoi
# overlay alone therefore covers local sessions and nothing else, which is the
# same global-vs-committed split plugins-audit handles for enabledPlugins.
#
# Goes through the GitHub API, not local checkouts: a portfolio sweep meets
# dirty trees and feature-branch checkouts, and the API leaves both alone while
# reading true remote state. Repos with no committed settings.json are skipped,
# never created — an absent project config is a deliberate state.
#
# just -g claude-perms-sweep laurigates "CronCreate,RemoteTrigger"
# just -g claude-perms-sweep fvh "CronCreate,CronDelete" --pr
[group: "claude"]
claude-perms-sweep scope rules *flags:
@python3 "$HOME/.local/share/chezmoi/scripts/claude-perms-sweep.py" "{{scope}}" --rules "{{rules}}" {{flags}}
Loading
Loading