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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ Settings this repo's own tooling reads from the environment.
| `AI_CONFIG_PR_REVIEWERS` | Comma-separated GitHub logins to request as reviewers on a PR the orchestrator opens. **Unset means no reviewer is requested**, which is deliberate: this repo is used by people other than its author, so there is no login that could be a correct default. Before this existed the value was hardcoded, and every request named a login that exists for nobody (ai-config#2627). |
| `AI_CONFIG_DOTFILES_FORCE` | Install dotfiles on a machine that fails the environment gate --- see [`dotfiles/shiva/README.md`](dotfiles/shiva/README.md). |
| `ALLOW_FORCE_PUSH` | Escape valve for `hooks/no-clobbering-push.py`, for a case the guard did not foresee. Using it means stating why. |
| `ALLOW_UNCLAIMED_PR_WORK` | Escape valve for `hooks/no-pr-work-without-claim.py`, which otherwise denies a `git commit` or `git push` on a branch with an open Morrison-Lab PR that has no claim comment naming this session. Using it means saying why. |
| `ALLOW_COMMIT_AND_PUSH` | Escape valve for `hooks/no-commit-chained-to-push.py`, which otherwise refuses a `git commit` and a `git push` in one Bash call. Using it means saying why the call could not be split. |
| `ALLOW_BREAKING_SLIDE` | Escape valve for `hooks/guard-slide-major-tag.py`, recording a deliberate override when a reusable workflow permission addition has already been prepared in consumers. |

Expand Down Expand Up @@ -501,6 +502,7 @@ The payload gaps that remain and the per-guard status are in
| `flag-stale-adjacent-comment.py` | `PreToolUse` (Bash) | warns, never blocks, when a `git commit` changes a literal value while an unchanged comment within ten lines still asserts the old one |
| `warn-unparseable-staged-config.py` | `PreToolUse` (Bash) | warns when `git commit` would commit staged `.toml`, `.json`, or `.yaml`/`.yml` config files that fail parser validation (`tomllib`, `json`, `yaml.safe_load`) |
| `no-delete-branch-under-stacked-pr.py` | `PreToolUse` (Bash) | warns when `gh pr merge --delete-branch` or `gh pr close --delete-branch` would delete a branch that is an open PR's base. GitHub's documented behaviour is to retarget such a PR, but a measured case closed it instead, and a closed PR can be neither retargeted nor reopened while its base is gone. Silent when nothing is stacked, when the query fails or returns an unexpected shape, when `gh` is absent, when the command carries no `-R` or PR target, and on `--delete-branch=false` |
| `no-pr-work-without-claim.py` | `PreToolUse` (Bash) | denies a `git commit` or `git push` on a branch whose open Morrison-Lab PR has no claim comment (agent marker plus hold-off wording) naming this session by session id or worktree path (a claim naming no session only warns, so existing claim flows keep working until #4160), and denies when a claim naming another session is newer than yours. Before a push it warns, never denies, about pushes since your claim that local history lacks (including any this clone has not fetched), read from the forge activity endpoint. Fails open and says so on any `gh` or network failure. An own claim's age and most release wordings are not evaluated (a peer claim on a PR idle over 2 hours only warns), and a fork PR is not seen. Local half of ai-config#4155: hooks are inert in remote and web sessions. |
| `no-clobbering-push.py` | `PreToolUse` (Bash) | refuses a bare `git push --force`/`-f`, whose remedy (`--force-with-lease --force-if-includes`) costs one word. Warns on every other push whose remote tip a live, read-only `git ls-remote` shows is not an ancestor of the ref being pushed (which is `HEAD` only when the refspec says so, resolved in the directory the push runs in rather than the session's -- a `cd`, scoped to its subshell but not to a brace group, and declined where a compound statement's body, a short-circuited alternative, or a fork into a background job or a pipeline means the pushing shell never takes its effect, then the push's own `-C`, declined in turn when the shell would have had to expand it), names that directory and qualifies its remediation commands with `git -C` when it is not the call's own, declines the reading when the directory is indeterminate or `--git-dir`/`--work-tree`/`GIT_DIR=` redirected the repository, and stays silent on a fast-forward |
| `no-commit-chained-to-push.py` | `PreToolUse` (Bash) | denies a Bash call that chains a `git commit` into a later `git push`. A PreToolUse deny rejects the whole invocation, so a guard refusing the push discards the commit too while its message speaks only about the push (ai-config#2992). Denies rather than warns because the refusal stops the chain reaching the sibling guards at all, and its remedy -- two Bash calls -- is always available. Clearable with `ALLOW_COMMIT_AND_PUSH=1`, either prefixing the commit or push or as the call's own leading assignment (a subshell or short-circuited one sets nothing and does not count). Matches over an argv split (`scripts/lib/shellcmd.py`), so a quoted commit message, a heredoc body and `git commit-tree` cannot trip it, while `timeout 60 git push`, `/usr/bin/git push` and `{ git commit; } && git push` all resolve -- the guard has to fire wherever its siblings would. There is no exemption for a `--dry-run` or `--delete` command: one was written and removed after a review measured `git commit ... && git push --force --delete` and `... --dry-run --no-dry-run --force` both going silent while `no-clobbering-push.py` denied them |
| `flag-chained-push.py` | `PreToolUse` (Bash) | warns, never blocks, when a `git push` is chained after another command with `&&`, `;`, or `\|\|`, piped onward with `\|`, or suffixed by a redirection (`>`, `>>`, or an fd form like `2>&1`). `no-clobbering-push.py` and the plugin's own `no-push-without-self-review.py` push policy both parse the WHOLE command text for a push rather than the isolated segment, so a trailing `2>&1` hands either parser a bare `2` sitting where a commit-ish token would sit in other shapes, and a chained prefix reads as part of the same invocation; a refused chain runs NOTHING, which the refusal naming only the push invites the author to misread as the prefix having succeeded. Measured on Lacaedemon/sparta, 2026-09-05: three refusals in one session |
Expand Down
7 changes: 7 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,13 @@
"script": "no-commit-chained-to-push.py",
"why": "shared/workflow/check-before-pushing.md, plus ai-config#2992, which is a REPORT rather than a measurement -- its author states they verified the mechanism from the hook registration and did not reproduce the lost commit. A PreToolUse deny rejects the WHOLE Bash invocation, so a call that chains `git commit` into `git push` loses the commit when any guard refuses the push -- and no-push-without-self-review.py and no-clobbering-push.py are both registered PreToolUse on Bash. Their refusal names only the push, so it reads as \"the push was blocked\" while the change is still an uncommitted working-tree edit. Reported 2026-09-02, caught only because an adversarial reviewer checked whether HEAD had moved. Note that on a HEREDOC commit neither sibling currently sees a push at all (they carry the #2993 splitter defect this hook's library fixes), so for that shape today nothing would be lost -- a reason to fix #2993, not to exempt the shape, and the deny message is worded so it never asserts a sibling would have refused a particular call. DENIES rather than warns, unlike its warn-dupe-check-chained-to-create.py sibling of the same shape: an advisory additionalContext is attached while the call proceeds, so a sibling's deny in the same pass still discards the commit, and whether the author is even TOLD depends on unverified harness behaviour (does a non-denying hook's context survive a sibling's deny -- see the hook docstring for the ten-minute experiment that settles it). The refusal does not depend on that answer: it stops the chain reaching the siblings at all. The refusal is always satisfiable (issue the same two commands as two calls) and clearable with an `ALLOW_COMMIT_AND_PUSH=1` env-assignment prefix. Matches over an argv split (scripts/lib/shellcmd.py) rather than the raw string, so a quoted commit message, a heredoc body, and `git commit-tree`/`git commit-graph` cannot trip it. Requires commit BEFORE push: push-then-commit loses nothing. No exemption for a --dry-run or --delete command: one was written on review advice and removed when a second review measured `git push --force --delete` and `--dry-run --no-dry-run --force` going silent here while no-clobbering-push.py denied both -- a refused dry-run costs one tool call, a missed force-delete costs the commit. Fails open. Tracked as #2992; the shared splitter's heredoc defect and the eight unmigrated copies are #2993 (eight, not seven: derive the set from `grep -rlF '_SHELL_OPS = set(\"();|&\")' hooks/`, since remind-ci-crosscheck-sim-verdict.py spells its copy without the leading underscore)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-pr-work-without-claim.py\"",
"timeout": 30,
"script": "no-pr-work-without-claim.py",
"why": "ai-config#4155, measured 2026-09-30: two agent sessions worked the same three PR branches at once and neither posted a claim, so a cloud session's restore commit on PR #4134 left memories/preferences.md at 706 lines instead of 1177. shared/workflow/claim-pr.md says to claim first and nothing enforced it; ListAgents cannot see a cloud session. DENIES a `git commit` or `git push` on a branch with an open Morrison-Lab PR unless a claim comment (agent marker plus hold-off wording) names THIS session by session id or worktree path, and denies when a claim naming another session is newer than yours. Before a push it WARNS (never denies) about pushes since your claim that are not in local history, read from the forge activity endpoint. Fails OPEN and says so on any network or gh failure. Local half only: hooks are inert in remote/web sessions (#2004). Clear with ALLOW_UNCLAIMED_PR_WORK=1. Every subprocess shares one 20s budget (PR_CLAIM_TOTAL_BUDGET), under this 30s timeout, so a slow forge is a visible fail-open rather than a harness kill. The skills/claim-pr template now carries the Session worktree line the hook matches on; the other claim emitters (ardi, handoff, gi, st, gip, pr-on-claim, tracked in #4160) do not yet, so a claim that names no session only WARNS (it may be the session's own) instead of denying. A commit nested in `bash -c` is a visible fail-open, and two sessions sharing one checkout cannot be told apart (known limits in the hook docstring)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-chained-push.py\"",
Expand Down
Loading
Loading