[Sync] Lua Hyprland chain, licence cleanup

This commit is contained in:
Coja committed 2026-11-09 18:25:35 +01:00
1 parent 93b4f695ce
commit 47166d877f
183 files changed
+5618 -5839

No files matched your search

+49 -19
View File
@@ -7,15 +7,20 @@ back into this repo.
## What's tracked here
- `settings.json` — global settings: permissions (deny git commit/push, `gh pr` write ops, and
reads of secret files), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`,
- `settings.json` — global settings: permissions (deny git push, `gh pr merge` / `close`,
and reads of secret files; ask before `gh pr create` and `git rebase`; allow `gh` reads, `gh pr edit`, `gh workflow run`
and `gh run rerun`; `git commit` is left to `hooks/git-guard.py`, which exempts listed repos), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`,
fullscreen TUI, `statusLine`, `hooks`, `worktree` defaults, a generic `autoMode.environment`, …
Nothing in here may name a specific project, path or remote: this file is published by the
public mirror, and the auto-mode classifier only reads `autoMode` from user/managed settings
anyway (never from a project's `.claude/settings*.json`).
- `statusline.py` — rich status line (dir, git, model, context %, cost, disk — system metrics live in the tmux bar).
- `statusline.py` — rich status line (dir, git, unresolved PR review threads, model, context %, cost, disk — system
metrics live in the tmux bar).
See [StatusBar.md](StatusBar.md) for what each segment and colour means.
- `keybindings.json` — custom keybindings: vim-style scroll/navigation, plus `ctrl+x v` for the
- `keybindings.json` — custom keybindings: vim-style scroll/navigation (`alt+j/k` and
`ctrl+shift+j/k` scroll by line — kitty's own `kitty_mod+j/k` scroll only its main-screen
scrollback and pass the key through to the fullscreen TUI's alternate screen, so they do not
shadow these; dropped once by mistake on 2026-09-15), plus `ctrl+x v` for the
verbose transcript view (`app:toggleTranscript`, same keystroke as opencode's `<leader>v`; the
default `ctrl+o` still works, and `ctrl+e` inside the view expands everything).
- `CLAUDE.md` — global user preferences applied to every project.
@@ -29,17 +34,32 @@ back into this repo.
installed, and always exits 0.
- `hooks/git-guard.py` — PreToolUse hook on `Bash`: structurally blocks anything that commits,
rewrites history or discards working-tree changes — `git commit` / `push` / `revert` /
`cherry-pick` / `rebase` / `merge` (not `--abort`), `clean` / `reset` in any form that moves HEAD
`cherry-pick` / `rebase -i|--autostash` / `merge` (not `--abort`), `clean` / `reset` in any form that moves HEAD
or unstages everything (`reset -- <paths>`, `reset HEAD <path>` stay allowed) / `restore <paths>`
(`--staged <path>` is fine, `--staged .` is not) / `checkout --` / `checkout .` / `stash drop|pop|clear` /
`branch -D|-f|-M`, and `gh pr merge` / `create` / `close` / `edit` — wherever the flags sit
`branch -D|-f|-M`, and `gh pr merge` / `close`, `gh pr create` without `--head` (gh would push
the branch itself) and `gh pr edit` beyond `--title` / `--body` — wherever the flags sit
(`git -C . push`, `/usr/bin/git push`, `sh -c 'git push'`), which the prefix-matched deny rules
can't. The discard group is what protects parallel sessions sharing one checkout. Exit 2 + a
can't. A plain `rebase` and its `--continue` / `--abort` pass: Claude rebases and fixes
conflicts when asked (`CLAUDE.md`), and settings.json asks before each `git rebase`. The
discard group is what protects parallel sessions sharing one checkout. Exit 2 + a
one-line reason. Heredoc bodies are skipped on purpose (file contents). Regression matrix:
`bash ~/.claude/hooks/git-guard-test.sh` (a file on purpose — typed inline, the commands under
test would trip the live hook).
test would trip the live hook). Repos listed in `~/.claude/git-guard-exempt` (host-local, not in
this repo — it names projects; one main checkout per line, worktrees included): `commit` and
`merge` pass there — judged from the hook input's
`cwd`, every `cd` before the call and `git -C` — except `commit --amend` / `-a`. A user-scope deny
cannot be lifted for one repo, so `git commit` is off the deny list: the hook is its only
structural block.
- `hooks/notify.py` — Notification hook (`idle_prompt|permission_prompt`): `notify-send` desktop
notification when Claude is waiting on you; no-op on a headless host.
- `../tmux/scripts/agent-state.sh` — UserPromptSubmit (busy) / Notification (input) / Stop (done) /
StopFailure (error) / SessionEnd (off) hooks, all async: marks the tmux pane this session runs in
(`@agent` pane option, drawn as ✓ ? ! after the window name by tmux.conf; visiting the pane
greys it out, the next prompt removes it) and buzzes the phone for a new mark on a pane not in
view (termux-notification, else a bell). Claude sessions run in daemon-spawned workers with no
tmux env, so the script finds the pane by the session name Claude puts in the pane title. Shared with codex
(`~/.codex/hooks.json`, untracked), pi and opencode. No-op outside tmux.
- `hooks/drift-check.sh` — SessionStart + UserPromptSubmit hook, read-only: fails loudly when a
`~/.config/claude/*` file has broken out of the repo into a plain file, or `~/.claude/*` no longer
resolves into the dots repo. That is what Claude Code's atomic settings rewrite does through the
@@ -55,14 +75,16 @@ back into this repo.
- `skills/` — custom [Agent Skills](https://code.claude.com/docs/en/skills). See **Skills** below.
- `link.sh` — idempotent bootstrap that creates the `~/.claude` symlinks below.
- `../git/claude-commit-msg` + `../git/hooks/prepare-commit-msg` — the commit-message handoff.
A session pipes its suggestion into `claude-commit-msg save`, which stores it as its own file under
`$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **with the paths it staged**; on the next
`git commit` / lazygit `C` the hook prefills the editor with the message(s) whose paths are part of
that commit and consumes them (`used/`). Parallel sessions therefore never overwrite each other,
and committing one session's files gets that session's message. `claude-commit-msg list` shows
what is pending. The old single `CLAUDE_COMMIT_MSG` file is still honoured. Both tracked here
(deployed to `~/.config/git/`); the hook is installed **per repo** — see setup step 4 — and
deliberately not via a global `core.hooksPath`, which would override a project's own hooks.
A session pipes its suggestion into `claude-commit-msg save` (run inside the target repo), which
stores it as its own file under `$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **with the paths
it staged**; on the next `git commit` / lazygit `C` the hook prefills the editor with the message(s)
whose paths are part of that commit and consumes them (`used/`). Parallel sessions therefore never
overwrite each other, and committing one session's files gets that session's message.
`claude-commit-msg list` shows what is pending. The old single `CLAUDE_COMMIT_MSG` file is still
honoured. Both tracked here (deployed to `~/.config/git/`); the hook is installed **per repo** — see
setup step 4 — and deliberately not via a global `core.hooksPath`, which would override a project's
own hooks. The prefill only runs for an editor commit: lazygit's `c` panel passes the message to git
directly, so the hook skips and the suggestion stays pending.
The `settings.json` hook commands for the newer scripts are wrapped in `[ -f … ]`, so a host that
has pulled the repo but not yet re-stowed just skips them instead of blocking every Bash call.
@@ -93,7 +115,8 @@ claude
```
```fish
# 4. In every repo you commit in: install the one-shot commit-message prefill hook
# 4. In every repo you commit in: install the commit-message prefill hook. New clones/inits get it
# from ~/.config/git/template (init.templateDir) once that template hook is a symlink to it too.
ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg
```
@@ -101,6 +124,12 @@ ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/p
need to run `link.sh` by hand (`bash ~/.config/claude/link.sh`) if a link later gets clobbered
(e.g. Claude's `/config` replaces one with a plain file) — it's idempotent and self-healing.
Tracked *files* in `~/.claude` link straight at the repo copy, not through `~/.config/claude`:
Claude Code follows one symlink hop and then replaces the file atomically (temp + rename), so the
old two-hop chain lost its middle link to a plain file on every settings write (`/model`, effort,
plugins, `/config`) and the repo stopped seeing changes. With one hop the rename lands in the
checkout. Dirs (`hooks`, `skills`) still go through `~/.config/claude`.
A **new skill/hook** added to the repo shows up after a re-stow — `./install.sh` (or `dotsync`) links
the new file into `~/.config/claude`, which `link.sh` then surfaces into `~/.claude`.
@@ -122,8 +151,9 @@ so Claude never auto-triggers them. Run them inside the repo whose branch you're
copy-paste markdown block, and copies it to the clipboard with `wl-copy`. Leaves verification
checkboxes for you; never creates/pushes the PR.
- **`/commit-msg [hint]`** — reads the **staged** diff, infers the repo's commit style from recent
`git log` (e.g. this repo's `[Scope] summary`), drafts a matching message, prints it, and copies
it to the clipboard with `wl-copy`. Never stages or commits — draft only.
`git log` (e.g. this repo's `[Scope] summary`), drafts a matching message, prints it, and saves
it for the `prepare-commit-msg` hook (lazygit `C` prefills it). Never stages or commits — draft
only.
- **`/verbose [on|off|status]`** — flips the *persisted* verbose view for the current project:
`viewMode` (`verbose` / `default` — the key that actually decides the view and beats a sticky
`/focus` choice for new sessions) plus `verbose` and `showThinkingSummaries`, written to the
+10 -5
View File
@@ -8,13 +8,13 @@ resize), trailing (lowest-priority) segments are dropped from the right until it
Wide — everything:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 🔢 ↑63M ↓595k │ ♻️ 91% 1h │ 💾 81%
📁 ~/.dots │ 🌿 .dots main* │ 💬 #42 3 · codex 2 │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 🔢 ↑63M ↓595k │ ⚡ 63 t/s │ ♻️ 91% 1h │ 💾 81%
```
Narrow — trailing segments trimmed:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h
📁 ~/.dots │ 🌿 .dots main* │ 💬 #42 3 · codex 2 │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m
```
Every segment is defensive: if its data is missing or a command fails, the segment is
@@ -28,11 +28,13 @@ is supplementary to the main colored value.
| | **Vim mode** | `[I]`/`[N]`/`[V]` editor mode | only with `editorMode: vim`; pair with `hideVimModeIndicator: true` so the built-in one doesn't duplicate it |
| 📁 | **Directory** | Current working dir, with `~` for home | cyan |
| 🌿 | **Git** | `repo branch` + `*` if dirty + `↑N`/`↓N` ahead/behind upstream | repo = magenta; branch = green when clean, yellow + red `*` when dirty; `↑` cyan, `↓` yellow. Omitted outside a repo |
| 💬 | **PR review threads** | `#N` of the current branch's open PR + its **unresolved review threads** (any author, outdated ones included; `+` past 100), Codex's share broken out as `· codex N`; green `✓` when none are left | A thread counts for whoever opened it (its first comment's author; Codex = login matching `codex`, as in `/pr-loop`). PR-conversation comments are not counted — they have no resolved state, so a summary or an LGTM would never let it reach zero. A GitHub round trip is ~0.7 s, so a render only reads the per-branch cache `~/.claude/cache/statusline.pr.<hash>.json` and, once it is older than 90 s, spawns a detached `statusline.py --pr-refresh` that updates it (one at a time, via a `.lock` claim) — new counts show on the next render. A failed fetch (no GitHub remote — e.g. this repo on gitea —, gh not authed, offline) backs off 10 min and keeps the last counts with a gray `?`. Omitted with no `gh`, no open PR or a detached HEAD |
| 🤖 | **Model** | Active model display name + `· effort` level (`low`/`medium`/`high`/`xhigh`) | name = blue, effort = light gray; effort omitted for models without the param |
| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | usage comes from the payload's `context_window` (`used_percentage` + `current_usage`, Claude Code 2.1.x); the transcript's last `message.usage` is only a fallback. Window from `context_window.context_window_size`, then `(1M …)` in the display name, else 200k — **not** from `model.id`, which arrives with any `[1m]` suffix already stripped. Adds a red **⚠compact** at ≥80% |
| 📊 | **5h usage** | `% of the 5-hour rolling limit used` + time until it resets | from `rate_limits.five_hour`; Pro/Max only, and absent until the first API response of a session |
| 💰 | **Cost** | `$` session cost so far + `$/h` burn rate | burn rate shown once the session exceeds ~30s |
| 🔢 | **Session tokens** | `↑` input + `↓` output tokens used so far this session (cumulative, `k`/`M`) | summed over the transcript's assistant `message.usage` blocks, de-duplicated by message id (one line per content block shares the same usage). Input = `input + cache_creation + cache_read` — everything billed as input, so on a 1M session it dwarfs output (every call re-reads the cached prefix). Main transcript only: subagent usage lives in separate sidechain files. Incremental: a per-session state file `~/.claude/cache/statusline.tokens.<session>.json` remembers the byte offset already summed, so each refresh parses only the new tail (cold scan of a 6.5 MB transcript ≈ 70 ms, warm ≈ 0.1 ms); stale state files are pruned after 7 days once 50+ pile up. Omitted when there is no transcript; `↑` cyan, `↓` green, no thresholds |
| 🔢 | **Session tokens** | `↑` input + `↓` output tokens used so far this session (cumulative, `k`/`M`) | summed over the transcript's assistant `message.usage` blocks, de-duplicated by message id (one line per content block shares the same usage). Input = `input + cache_creation + cache_read` — everything billed as input, so on a 1M session it dwarfs output (every call re-reads the cached prefix). Main transcript only: subagent usage lives in separate sidechain files. Incremental: a per-session state file `~/.claude/cache/statusline.tokens.<session>.json` remembers the byte offset already summed, so each refresh parses only the new tail (warm ≈ 0.2 ms), at most 8 MiB per refresh (≈ 150 ms) — Claude Code kills a status script when the next refresh fires, so a cold scan of a huge resumed transcript is spread over a few refreshes (totals climb, then settle) instead of restarting from zero each time. Stale state files are pruned after 7 days once 50+ pile up. Omitted when there is no transcript; `↑` cyan, `↓` green, no thresholds |
| ⚡ | **Decode speed** | `N t/s` — tokens per second the model is generating | duration-weighted mean over the last 8 thinking blocks of the main conversation: `thinking_tokens / thinkingDurationMs` from the same transcript scan (blocks under 1 s or 50 tokens skipped). The only clean per-call timing Claude Code records: the payload's `cost.total_api_duration_ms` also absorbs subagent and advisor calls (it grew 28 s in a 12 s wall interval with no main-conversation completion) and its `output_tokens` is sometimes a mid-stream partial, so neither can give a rate. Includes first-token latency, so short blocks read a little low and long ones converge on the real decode speed (Fable 5.1 measured 60–85 t/s). Needs thinking enabled — omitted until a thinking block has completed, never shown with thinking off |
| ♻️ | **Prompt cache** | hit `%` (higher is better) + cache TTL (`5m` / `1h`), yellow `·cold` once it has expired | from the payload's `prompt_cache` (2.1.260+: `hit_ratio` 0–1, `ttl`, `warm`); computed from usage on older payloads. A `1h` TTL is what keeps a 10-min `/pr-loop` cadence warm |
| 💾 | **Disk** | `% used` of the filesystem at the cwd | from `statvfs` |
@@ -44,7 +46,9 @@ attention. Light gray = secondary detail. Thresholds (`green < … < yellow <
| Segment | green | yellow | red |
|---|---|---|---|
| Context | `< 50%` | `50–80%` | `≥ 80%` (⚠compact) |
| PR review threads | `✓` none unresolved | thread count | `codex N` |
| 5h usage | `< 50%` | `50–80%` | `≥ 80%` |
| Decode speed (inverted) | `≥ 40 t/s` | `20–40 t/s` | `< 20 t/s` |
| Prompt cache (inverted) | `≥ 80%` | `50–80%` | `< 50%` |
| Disk | `< 75%` | `75–90%` | `≥ 90%` |
@@ -74,5 +78,6 @@ events, so system metrics sat visibly stale between turns — the tmux status ba
## Where it lives
`statusline.py` is tracked in this repo and symlinked to `~/.claude/statusline.py`;
`settings.json` runs it via `statusLine` (`/usr/bin/env python3 ~/.claude/statusline.py`).
Edits take effect on the next status refresh.
`settings.json` runs it via `statusLine` (`/usr/bin/env python3 ~/.claude/statusline.py`) on every
message event and, through `refreshInterval: 60`, once a minute while the session idles — so the PR
count moves while you wait on a review. Edits take effect on the next status refresh.
+112 -10
View File
@@ -1,12 +1,24 @@
#!/usr/bin/env bash
# Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch).
# Kept as a file on purpose: typed inline, the very commands under test would trip the live hook.
G="$(dirname "$(readlink -f "$0")")/git-guard.py"; fail=0
G="$(dirname "$(readlink -f "$0")")/git-guard.py"
fail=0
# No exempt repository for the rows that do not ask for one, whatever this host's ~/.claude/git-guard-exempt says.
export GIT_GUARD_EXEMPT=""
# `git reset <one arg>` is allowed only for an existing path: give the matrix one to point at.
tmp=$(mktemp -d) && cd "$tmp" && touch existing.py
trap 'rm -rf "$tmp"' EXIT
run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null; rc=$?
got=allow; [ $rc -eq 2 ] && got=block; [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH; fail=1; }; printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"; }
run() {
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"
}
echo "--- must block: commits / history ---"
run block 'git push'
run block 'git -C /home/coja/.dots push origin main'
@@ -28,7 +40,10 @@ run block 'git commit -m "fix: grep pattern; not a command"'
run block 'git revert HEAD'
run block 'git cherry-pick abc123'
run block 'git rebase -i HEAD~3'
run block 'git rebase main'
run block 'git rebase --interactive main'
run block 'git -C . rebase -i --autosquash main'
run block 'git rebase --autostash origin/main'
run block 'git -c rebase.autoStash=true rebase main'
run block 'git merge feature'
run block 'git merge --no-ff feature'
echo "--- must block: discards working-tree changes ---"
@@ -63,11 +78,16 @@ run block 'git stash clear'
run block 'git branch -D feature'
run block 'git branch -f main HEAD~1'
run block 'git branch -M main trunk'
echo "--- must block: gh pr write ops ---"
echo "--- must block: gh pr write ops (create only with --head, edit only title/body) ---"
run block 'gh pr merge 12 --squash'
run block 'gh -R o/r pr create --fill'
run block 'gh pr edit 3 --title x'
run block 'gh pr close 4'
run block 'gh -R o/r pr create --fill'
run block 'gh pr create --title x --body y'
run block 'gh pr create --dry-run --fill'
run block 'gh pr edit 3 --add-reviewer bob'
run block 'gh pr edit 3 --title x --base main'
run block 'gh pr edit --add-label=bug 3'
run block 'gh pr edit 3 -b y -m v2'
echo "--- must allow ---"
run allow 'git status'
run allow 'git log -1 --oneline'
@@ -88,6 +108,13 @@ run allow 'git checkout main'
run allow 'git checkout -b feature'
run allow 'git switch main'
run allow 'git merge --abort'
run allow 'git rebase main'
run allow 'git rebase origin/main'
run allow 'git rebase -Xtheirs main'
run allow 'git rebase --continue'
run allow 'git rebase --abort'
run allow 'git -c core.editor=true rebase --continue'
run allow 'GIT_EDITOR=true git rebase --continue'
run allow 'git stash'
run allow 'git stash list'
run allow 'git stash push -m wip'
@@ -110,12 +137,87 @@ run allow 'python3 - <<EOF
print("git commit is manual")
EOF'
run allow 'gh pr view 12 --json updatedAt'
run allow 'gh pr create --head feat --title x --body y'
run allow 'gh pr create -H feat --fill'
run allow 'gh -R o/r pr create --head=feat --fill'
run allow 'gh pr create --help'
run allow 'gh pr edit 3 --title x'
run allow 'gh pr edit 3 -t x -b "- item --base"'
run allow 'gh pr edit --body-file - 12'
run allow 'gh pr edit 12 -F body.md --repo o/r'
run allow 'gh pr edit 12 --title=x -bBody'
run allow 'gh workflow run ci.yml --ref main'
run allow 'gh run rerun 123 --failed'
run allow 'gh api graphql -f query="mutation{...}"'
run allow 'gh auth status'
run allow 'ls -la HEAD#x; git status'
run allow 'git log --grep="git push" -5'
run allow '~/.config/git/claude-commit-msg list'
echo "--- exempt repo (GIT_GUARD_EXEMPT = a scratch repo; commit and merge pass there, nothing else) ---"
# runin <expect> <cwd> <command>: the hook input carries the session's cwd, as Claude Code sends it.
R=$tmp/repo
WT=$tmp/repo-wt
git init -q "$R" && git -C "$R" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init \
&& git -C "$R" worktree add -q "$WT" 2>/dev/null && mkdir -p "$R/sub" && git init -q "$tmp/other"
runin() {
printf '%s' "$(python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]},"cwd":sys.argv[2]}))' "$3" "$2")" | GIT_GUARD_EXEMPT=$R python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s [%s] %s\n' "$mark" "$1" "$got" "${2#"$tmp"/}" "$(printf '%s' "$3" | head -1)"
}
runin allow "$R" 'git commit -m "docs: x"'
runin allow "$R" 'git commit -m x -- docs/usage.md'
runin allow "$R" 'git commit -m"fix a bug"'
runin allow "$R" 'git commit -m "-a is not the flag here"'
runin allow "$R" 'git commit --allow-empty -m x'
runin allow "$R/sub" 'git commit -m x'
runin allow "$R" 'git merge --ff-only feature'
runin allow "$R" 'git merge --no-ff feature'
runin allow "$WT" 'git commit -m x'
runin allow "$tmp" "git -C $WT commit -m x"
runin allow "$tmp" "cd $R && git commit -m x"
runin allow "$tmp" 'cd repo && git commit -m x'
runin allow "$tmp" "cd $WT; git status; git merge main"
runin allow "$R" "sh -c 'git commit -m x'"
runin block "$R" 'git push'
runin block "$R" 'git commit --amend --no-edit'
runin block "$R" 'git commit -am wip'
runin block "$R" 'git commit -qa -m wip'
runin block "$R" 'git commit --all -m wip'
runin block "$R" 'git rebase -i main'
runin block "$R" 'git cherry-pick abc123'
runin block "$R" 'git revert HEAD'
runin block "$R" 'git reset --hard HEAD~1'
runin block "$R" 'git checkout -- file.py'
runin block "$R" 'GIT_DIR=/x/.git git commit -m x'
runin block "$R" 'env GIT_WORK_TREE=/x git commit -m x'
runin block "$R" 'git --git-dir=/x/.git commit -m x'
runin block "$R" 'git -C /tmp commit -m x'
runin block "$R" 'git -C $(mktemp -d) commit -m x'
runin block "$tmp" "cd $R && cd /tmp && git commit -m x"
runin block "$R" 'cd - && git commit -m x'
runin block "$R" 'cd $HOME && git commit -m x'
runin block "$R" 'pushd /tmp && git commit -m x'
runin block "$tmp" 'git commit -m x'
runin block "$tmp/other" 'git commit -m x'
runin block "$tmp" "git -C $tmp/other merge x"
echo "--- fail-open ---"
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"; [ $? -eq 0 ] && echo "ok non-Bash tool ignored" || { echo "MISMATCH non-Bash"; fail=1; }
printf 'not json' | python3 "$G"; [ $? -eq 0 ] && echo "ok garbage stdin passes" || { echo "MISMATCH garbage"; fail=1; }
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || { echo "GIT-GUARD MATRIX: mismatches above"; exit 1; }
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"
[ $? -eq 0 ] && echo "ok non-Bash tool ignored" || {
echo "MISMATCH non-Bash"
fail=1
}
printf 'not json' | python3 "$G"
[ $? -eq 0 ] && echo "ok garbage stdin passes" || {
echo "MISMATCH garbage"
fail=1
}
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || {
echo "GIT-GUARD MATRIX: mismatches above"
exit 1
}
+179 -18
View File
@@ -7,15 +7,24 @@ command text, so `git -C . push`, `git -c k=v commit`, `/usr/bin/git push`,
This hook parses each shell segment instead: finds the git/gh token wherever it sits,
skips git's global options (`-C dir`, `-c k=v`, `--git-dir=...`) and blocks
git commit | push | revert | cherry-pick | rebase | merge (not --abort/--quit/--continue)
git commit | push | revert | cherry-pick | merge (not --abort/--quit/--continue)
git rebase -i / --interactive / --autostash
git clean | reset (any form that moves HEAD or unstages everything; `reset -- <paths>`,
`reset HEAD <path>` and `reset <existing path>` are fine) | restore <paths> (--staged
alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f
git stash drop | pop | clear git branch -D / -f / -M
gh pr merge | create | close | edit
gh pr merge | close | create without --head | edit beyond --title/--body
i.e. anything that commits, rewrites history, or discards working-tree changes -- the last
group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate.
On GitHub Claude may open a PR (settings.json asks first) and retitle / redescribe one, nothing
more: `gh pr create` must name its branch with --head, without which gh pushes it itself (even
under --dry-run), and `gh pr edit` must not touch reviewers, labels, base or the like.
A plain `git rebase` (and its --continue / --abort) passes: Claude rebases and fixes the
conflicts when the user asks (global CLAUDE.md), and settings.json asks before each one, as
pi's gate does. `-i` stays blocked (the squash/reword tool, and it needs an editor), and so
does --autostash: it stashes and re-pops other sessions' uncommitted edits, where a plain
rebase refuses to start on a dirty tree.
Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and
stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts.
@@ -31,15 +40,27 @@ README that documents pushing must stay writable -- so a script piped in through
is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode
classifier and the global CLAUDE.md still cover those; this is the belt for the prefix
rules' braces. Fails open: any parse error lets the command through (exit 0).
Exempt repositories (one main checkout per line in ~/.claude/git-guard-exempt, host-local and
outside the dots repo on purpose: it names projects): there `git commit` and `git merge` pass
-- the user's decision for repos where Claude commits and merges itself. The repo is the one the call
actually targets: the hook input's `cwd`, moved by every `cd` before it in the command and by
`git -C`, resolved to its main checkout (so its worktrees count). Anything that hides the
target (`cd -`, `cd $VAR`, a substitution, `--git-dir`, `GIT_DIR=`) is judged not exempt.
`commit --amend` and `commit -a`/`--all` stay blocked there (they rewrite the last commit of
a shared checkout, or sweep other sessions' edits in), as do push, `rebase -i` and the discard
group. Known gap: a `cd` inside `( ... )` leaks into the commands after the parenthesis.
"""
import json
import os
import re
import shlex
import subprocess
import sys
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"}
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick"}
MERGE_OK = {"--abort", "--quit", "--continue"}
REBASE_BLOCK = {"-i", "--interactive", "--autostash"}
STASH_BLOCK = {"drop", "pop", "clear"}
BRANCH_FORCE = {"-D", "-f", "--force", "-M"}
RESET_MODES = {"--hard", "--soft", "--merge", "--keep"}
@@ -50,7 +71,20 @@ _HEX = re.compile(r"^[0-9a-f]{7,40}$")
def looks_like_ref(a):
return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/")
or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a)))
GH_PR_BLOCK = {"merge", "create", "close", "edit"}
GH_PR_BLOCK = {"merge", "close"}
# `gh pr edit` may only retitle and redescribe; these take their value as the next word (or as
# `--title=x` / `-tx`).
GH_EDIT_OK = {"-t", "--title", "-b", "--body", "-F", "--body-file", "-R", "--repo"}
# Repositories (main checkouts; their worktrees count) where Claude commits and merges itself:
# one path per line, `#` comments; a missing file exempts nothing. GIT_GUARD_EXEMPT
# (colon-separated) replaces the file for the test matrix; it is read from the hook's own
# environment (Claude Code's), which a prefix on the checked command cannot reach.
EXEMPT_FILE = "~/.claude/git-guard-exempt"
EXEMPT_VERBS = {"commit", "merge"}
# `git commit` options whose value is the next word (or, for the short ones, the rest of the word).
COMMIT_ARG_SHORT = set("mFCct")
COMMIT_ARG_LONG = {"--message", "--file", "--author", "--date", "--reuse-message", "--reedit-message",
"--fixup", "--squash", "--template", "--trailer", "--cleanup", "--pathspec-from-file"}
# global options that take a *separate* argument; the `--opt=value` spelling is one token.
GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace",
"--super-prefix", "--config-env"}
@@ -72,13 +106,102 @@ def _skip_opts(args, with_arg):
return j
def git_reason(args):
def exempt_repos():
env = os.environ.get("GIT_GUARD_EXEMPT")
if env is not None:
paths = env.split(":")
else:
try:
with open(os.path.expanduser(EXEMPT_FILE)) as f:
paths = [ln.split("#", 1)[0].strip() for ln in f]
except OSError:
return set()
return {os.path.realpath(os.path.expanduser(p)) for p in paths if p}
def main_checkout(d):
"""The main checkout of the repository `d` lies in (a worktree resolves to it), or None."""
try:
out = subprocess.run(["git", "-C", d, "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, timeout=3)
except (OSError, subprocess.SubprocessError):
return None
common = out.stdout.strip().rstrip("/")
if out.returncode != 0 or not common:
return None
return os.path.realpath(os.path.dirname(common))
def in_exempt_repo(opts, cwd, pre):
"""Whether a git call with global options `opts`, run from `cwd` after the words `pre` of
its own command (`env X=1`, ...), targets an exempt repository."""
exempt = exempt_repos()
if not exempt or cwd is None or any(t.startswith(("GIT_DIR=", "GIT_WORK_TREE=", "GIT_COMMON_DIR="))
for t in pre):
return False
d, j = cwd, 0
while j < len(opts):
a = opts[j]
if a in ("--git-dir", "--work-tree") or a.startswith(("--git-dir=", "--work-tree=")):
return False
if a == "-C":
if j + 1 >= len(opts) or "SUBST" in opts[j + 1] or "$" in opts[j + 1]:
return False
d = os.path.join(d, os.path.expanduser(opts[j + 1]))
j += 2 if a in GIT_OPT_ARG else 1
return main_checkout(d) in exempt
def commit_rewrites_or_sweeps(rest):
"""`commit --amend` (rewrites the last commit) or `-a`/`--all` (takes in every modified
tracked file, other sessions' edits included)."""
skip = False
for a in rest:
if skip:
skip = False
continue
if a == "--":
break
if a in ("--amend", "--all"):
return True
if a in COMMIT_ARG_LONG:
skip = True
elif a.startswith("-") and not a.startswith("--"):
for k, ch in enumerate(a[1:], 1):
if ch == "a":
return True
if ch in COMMIT_ARG_SHORT:
skip = k == len(a) - 1 # the value is the next word
break
return False
def change_dir(cwd, args):
"""Where `cd args` leaves the shell started in `cwd`; None when that cannot be told."""
args = [a for a in args if a not in ("-L", "-P", "--")]
if not args:
return os.path.expanduser("~")
t = args[0]
if t == "-" or "SUBST" in t or "$" in t or (cwd is None and not t.startswith(("/", "~"))):
return None
return os.path.normpath(os.path.join(cwd or "/", os.path.expanduser(t)))
def git_reason(args, cwd=None, pre=()):
j = _skip_opts(args, GIT_OPT_ARG)
if j >= len(args):
return None
sub, rest = args[j], args[j + 1:]
if (sub in EXEMPT_VERBS and not (sub == "commit" and commit_rewrites_or_sweeps(rest))
and in_exempt_repo(args[:j], cwd, pre)):
return None
if sub in GIT_BLOCK:
return f"git {sub}"
if sub == "rebase":
flag = next((a for a in rest if a in REBASE_BLOCK), None)
if flag == "--autostash" or any(o.lower().startswith("rebase.autostash") for o in args[:j]):
return "git rebase --autostash (stashes other sessions' uncommitted edits)"
return f"git rebase {flag} (interactive history rewrite)" if flag else None
if sub == "reset":
opts = [a for a in rest if a.startswith("-") and a != "--"]
if RESET_MODES & set(opts):
@@ -109,14 +232,41 @@ def git_reason(args):
return None
def gh_edit_extra(rest):
"""The first `gh pr edit` option that is neither the title nor the body, else None."""
k = 0
while k < len(rest):
a = rest[k]
if a == "--":
break
name = a.split("=", 1)[0]
if name in GH_EDIT_OK:
k += 2 if name == a else 1 # skip the value, which may well start with `-`
continue
if a.startswith("-") and not (a[:2] in GH_EDIT_OK and not a.startswith("--")):
return a
k += 1
return None
def gh_reason(args):
j = _skip_opts(args, GH_OPT_ARG)
if j >= len(args) or args[j] != "pr":
return None
rest = args[j + 1:]
k = _skip_opts(rest, GH_OPT_ARG)
if k < len(rest) and rest[k] in GH_PR_BLOCK:
return f"gh pr {rest[k]}"
if k >= len(rest) or "--help" in rest:
return None
sub, rest = rest[k], rest[k + 1:]
if sub in GH_PR_BLOCK:
return f"gh pr {sub}"
if sub == "create" and not any(a in ("-H", "--head") or a.startswith("--head=")
or (a.startswith("-H") and not a.startswith("--")) for a in rest):
return "gh pr create without --head: gh would push the branch itself"
if sub == "edit":
extra = gh_edit_extra(rest)
if extra:
return f"gh pr edit {extra.split('=', 1)[0]}: only --title / --body edits are allowed"
return None
@@ -146,8 +296,8 @@ def segments(toks):
yield seg
def scan(text, depth=0):
"""Reason string for the first blocked invocation in `text`, else None."""
def scan(text, depth=0, cwd=None):
"""Reason string for the first blocked invocation in `text` (run from `cwd`), else None."""
if depth > 4:
return None
text = _HEREDOC.sub(" ", text)
@@ -163,23 +313,29 @@ def scan(text, depth=0):
while prev != text:
prev, text = text, _SUBST.sub(_grab, text)
for body in bodies:
r = scan(body, depth + 1)
r = scan(body, depth + 1, cwd)
if r:
return r
for toks in segments(tokenize(text)):
if toks[0] == "cd":
cwd = change_dir(cwd, toks[1:])
continue
if toks[0] in ("pushd", "popd"):
cwd = None
continue
for i, tok in enumerate(toks):
base = os.path.basename(tok)
r = None
if base == "git":
r = git_reason(toks[i + 1:])
r = git_reason(toks[i + 1:], cwd, toks[:i])
elif base == "gh":
r = gh_reason(toks[i + 1:])
elif base == "eval":
r = scan(" ".join(toks[i + 1:]), depth + 1)
r = scan(" ".join(toks[i + 1:]), depth + 1, cwd)
elif base in SHELLS and "-c" in toks[i + 1:]:
k = toks.index("-c", i + 1) # `sh -c '<cmd>'`: the real command is one word
if k + 1 < len(toks):
r = scan(toks[k + 1], depth + 1)
r = scan(toks[k + 1], depth + 1, cwd)
if r:
return r
return None
@@ -191,16 +347,21 @@ def main():
if data.get("tool_name", "Bash") != "Bash":
return
cmd = (data.get("tool_input") or {}).get("command") or ""
reason = scan(cmd) if cmd else None
reason = scan(cmd, 0, data.get("cwd") or os.getcwd()) if cmd else None
except Exception:
return # fail open: the deny list still applies
if reason:
short = cmd.strip().splitlines()[0]
short = short if len(short) <= 80 else short[:77] + "..."
sys.stderr.write(
f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges "
"manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n"
)
hint = ("On a PR Claude reads, opens one when asked (with --head <pushed branch>) and edits its "
"--title / --body; pushing, merging, closing and every other edit are the user's."
if reason.startswith("gh ") else
"Rebase with a plain `git rebase` on a clean tree, and only when the user asked; if the "
"tree holds other sessions' edits, tell the user instead of stashing them."
if reason.startswith("git rebase") else
"The user commits, pushes and merges manually (global CLAUDE.md): stage and stop, "
"and draft the message with /commit-msg.")
sys.stderr.write(f"git-guard: blocked `{short}` ({reason}). {hint}\n")
sys.exit(2)
+3 -1
View File
@@ -19,7 +19,9 @@
"context": "Global",
"bindings": {
"alt+j": "scroll:lineDown",
"alt+k": "scroll:lineUp"
"alt+k": "scroll:lineUp",
"ctrl+shift+j": "scroll:lineDown",
"ctrl+shift+k": "scroll:lineUp"
}
},
{
+14 -4
View File
@@ -41,6 +41,16 @@ items="settings.json statusline.py keybindings.json CLAUDE.md hooks skills"
# Expose each item in ~/.claude (where Claude actually reads).
for item in $items; do
[ -e "$SRC/$item" ] || continue
# Files link straight at the repo copy, not through ~/.config/claude: Claude Code
# resolves ONE symlink hop, then writes atomically (temp + rename) at that path —
# verified on 2.1.296 with `claude plugin disable` against a scratch CLAUDE_CONFIG_DIR.
# Through a ~/.claude -> ~/.config/claude -> repo chain, every settings write (/model,
# effort, plugins, /config) swapped the middle link for a plain file and severed the
# repo. One hop puts the rename inside the checkout, so both links survive. A SRC file
# that already drifted into a plain file keeps being the target (it may hold the newest
# edits) until the drift guard below is reconciled. Dirs keep the stow path.
target="$SRC/$item"
[ -f "$repo/$item" ] && [ -L "$SRC/$item" ] && target="$repo/$item"
if [ -d "$DST/$item" ] && [ ! -L "$DST/$item" ]; then
echo "skip ~/.claude/$item (real directory in the way — move it aside first)" >&2
continue
@@ -62,14 +72,14 @@ for item in $items; do
if [ "$same" != 1 ]; then
echo "DRIFT ~/.claude/$item is a real file that differs from the repo copy —" >&2
echo " it may hold newer edits. Review, then re-link:" >&2
echo " diff '$DST/$item' '$SRC/$item' # compare; keep whichever is right" >&2
echo " ln -sfn '$SRC/$item' '$DST/$item' # re-link" >&2
echo " diff '$DST/$item' '$target' # compare; keep whichever is right" >&2
echo " ln -sfn '$target' '$DST/$item' # re-link" >&2
continue
fi
echo "healed ~/.claude/$item (plain copy identical to repo — re-linking)"
fi
ln -sfn "$SRC/$item" "$DST/$item"
echo "linked ~/.claude/$item -> ~/.config/claude/$item"
ln -sfn "$target" "$DST/$item"
echo "linked ~/.claude/$item -> ${target/#$HOME/\~}"
done
# Stow-layer drift guard: each SRC *file* should be a symlink into the repo, so edits
+116 -8
View File
@@ -4,23 +4,60 @@
"pr": ""
},
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(sed -n:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(ls:*)",
"Bash(wc:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git rev-parse:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(pnpm run:*)",
"Bash(pnpm exec:*)",
"Bash(node -p:*)",
"Bash(gh pr view:*)",
"Bash(gh pr list:*)",
"Bash(gh pr diff:*)",
"Bash(gh pr checks:*)",
"Bash(gh pr status:*)",
"Bash(gh issue view:*)",
"Bash(gh issue list:*)",
"Bash(gh issue status:*)",
"Bash(gh run view:*)",
"Bash(gh run list:*)",
"Bash(gh run watch:*)",
"Bash(gh workflow view:*)",
"Bash(gh workflow list:*)",
"Bash(gh repo view:*)",
"Bash(gh release view:*)",
"Bash(gh release list:*)",
"Bash(gh search:*)",
"Bash(gh label list:*)",
"Bash(gh status:*)",
"Bash(gh auth status:*)",
"Bash(gh pr edit:*)",
"Bash(gh workflow run:*)",
"Bash(gh run rerun:*)"
],
"deny": [
"Bash(git push)",
"Bash(git push:*)",
"Bash(git commit)",
"Bash(git commit:*)",
"Bash(git reset --hard)",
"Bash(git reset --hard:*)",
"Bash(git clean)",
"Bash(git clean:*)",
"Bash(gh pr merge)",
"Bash(gh pr merge:*)",
"Bash(gh pr create)",
"Bash(gh pr create:*)",
"Bash(gh pr close)",
"Bash(gh pr close:*)",
"Bash(gh pr edit)",
"Bash(gh pr edit:*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/*.env)",
@@ -42,9 +79,15 @@
"Read(**/.gnupg/**)",
"Read(**/fish_variables)"
],
"ask": [
"Bash(gh pr create)",
"Bash(gh pr create:*)",
"Bash(git rebase)",
"Bash(git rebase:*)"
],
"defaultMode": "auto"
},
"model": "claude-fable-5-1[1m]",
"model": "opus",
"hooks": {
"SessionStart": [
{
@@ -67,6 +110,16 @@
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc busy; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"UserPromptExpansion": [
@@ -125,6 +178,51 @@
"timeout": 5
}
]
},
{
"matcher": "idle_prompt|permission_prompt",
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc input; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc done; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc error; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc off; exit 0"
}
]
}
]
},
@@ -136,13 +234,23 @@
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/statusline.py",
"padding": 0,
"refreshInterval": 60,
"hideVimModeIndicator": true
},
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
"frontend-design@claude-plugins-official": true,
"rust-analyzer-lsp@claude-plugins-official": true
},
"effortLevel": "xhigh",
"modelSettings": {
"claude-fable-5-1": {
"effortLevel": "xhigh"
},
"claude-opus-5-5": {
"effortLevel": "xhigh"
}
},
"advisorModel": "fable",
"tui": "fullscreen",
"skipWorkflowUsageWarning": true,
@@ -1,8 +1,8 @@
---
description: Draft a commit message for the staged changes, matching this repo's commit style, then print it in the reply, save it to the repo's CLAUDE_COMMIT_MSG file, and copy it to the clipboard. Never stages or commits. Use once you've staged what you want to commit.
description: Draft a commit message for the staged changes, matching this repo's commit style, then print it in the reply and save it to the repo's CLAUDE_COMMIT_MSG file. Never stages or commits. Use once you've staged what you want to commit.
argument-hint: [optional focus/scope hint]
disable-model-invocation: true
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Glob
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(~/.config/git/claude-commit-msg:*), Read, Glob
---
# Draft a commit message for the staged changes
@@ -26,9 +26,8 @@ Context for the current commit — what's staged, plus this repo's recent style:
- Add a short body (wrapped ~72 cols) only when the change needs the *why*; otherwise
subject-only. Don't invent motivation that isn't evident from the diff.
4. **Print the message in your reply first** — as a single fenced ` ```text ` block, copy-paste
ready. This in-reply copy is the durable one: it lives in the transcript, so it survives even if
the clipboard gets overwritten later (by me copying something else, or another Claude session
running `wl-copy`). Always print it; don't rely on the clipboard alone.
ready. This in-reply copy is the durable one: it lives in the transcript, so it survives even
after the hook has consumed the handoff. Always print it.
5. **Hand it off** so I can recover it any time — feed the raw message to the CLI as a quoted
heredoc (so quotes, backticks and `$` in the body survive untouched):
```sh
@@ -41,8 +40,6 @@ Context for the current commit — what's staged, plus this repo's recent style:
`prepare-commit-msg` hook prefills the next `git commit` / lazygit `C` with the message(s) whose
paths are part of that commit, then consumes them. Never write `CLAUDE_COMMIT_MSG` or
`COMMIT_EDITMSG` directly. The `save` output names the saved id and file path.
6. **Also copy it to the clipboard** as a convenience: `wl-copy < <file path printed by save>`.
Then tell me all three places it is: **printed above**, **saved for the hook** (with its id),
and **on the clipboard** — so I never lose it to a stray copy.
7. **Never** run `git add`, `git commit`, or `git push`, and never offer to — I stage and commit
Then tell me both places it is: **printed above** and **saved for the hook** (with its id).
6. **Never** run `git add`, `git commit`, or `git push`, and never offer to — I stage and commit
manually. Draft only.
@@ -2,7 +2,7 @@
description: Babysit the current branch's PR — address review comments that don't need my input, re-check every ~10 min, and stop when there are no open comments left or the automated reviewers (Codex, Claude) hit their limit. Prep-only — stages fixes and drafts the commit message; never commits, pushes, or merges.
argument-hint: [pr number or url — optional; defaults to the current branch's PR]
disable-model-invocation: true
allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup
allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(~/.config/git/claude-commit-msg:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup
---
# Watch this PR and resolve review comments (prep-only)
@@ -238,8 +238,7 @@ Current state (auth + the current branch's PR):
quoted heredoc, so quotes and backticks survive) — it is stored with the paths you staged this cycle, so the `prepare-commit-msg` hook prefills it only
for the commit that contains them (other sessions' handoffs are not overwritten). Never write
`CLAUDE_COMMIT_MSG` / `COMMIT_EDITMSG` directly.
- **(c) Copy it:** `wl-copy < <file path printed by save>`.
- Tell me all three locations. **Never** run `git commit`.
- Tell me both locations. **Never** run `git commit`.
(Skip this step when nothing new was staged this cycle.)
10. **Ping + schedule.** Give me a tight summary — deltas only, don't restate unchanged threads:
@@ -271,9 +270,10 @@ Current state (auth + the current branch's PR):
- **Never** `git commit`, `git push`, `gh pr merge`, `gh pr create`, `gh pr close`, or `gh pr edit`
— I do all of those. You stage, react, resolve, and draft; nothing more. The only comment you may
post is a needs-input decision question. This is enforced, not just asked: `settings.json` denies
those commands and the `git-guard.py` PreToolUse hook blocks any spelling of them, so a slip is
refused with a reason rather than executed.
post is a needs-input decision question. Commit, push, merge and close are enforced, not just
asked: `settings.json` denies them and the `git-guard.py` PreToolUse hook blocks any spelling of
them. `gh pr create` / `edit` are allowed globally (create asks first), so for this loop the rule
above is the only thing holding them back.
- Act only on the target PR. Don't touch unrelated files or other PRs.
- **Keep context lean:** no raw JSON dumps in replies, no re-listing threads that haven't changed,
idle cycles ≤2 lines. Every line you emit is re-read (uncached) on every later cycle.
+218 -31
View File
@@ -2,13 +2,15 @@
"""Claude Code status line.
One line: vim mode | dir (repo-relative, hidden at the root) |
git(repo/branch +dirty +ahead/behind) | model (+effort) |
git(repo/branch +dirty +ahead/behind) | PR review threads (open count, Codex's share) | model (+effort) |
context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) |
session tokens (cumulative in/out) | prompt-cache hit% (+ttl, cold marker) | disk. If the
session tokens (cumulative in/out) | decode speed t/s (thinking blocks) |
prompt-cache hit% (+ttl, cold marker) | disk. If the
rendered line would overflow the terminal, trailing (lowest-priority) segments are
dropped until it fits.
(Dormant, re-addable in main(): ram, cpu%, temp -- retired 2026-07-26 because the
status line only refreshes on message events, so system metrics sat visibly stale;
status line then only refreshed on message events, so system metrics sat visibly stale
(settings.json's 60 s refreshInterval, added for the PR count, is still too coarse for them);
tmux's status bar owns those now -- plus velocity, cache hit %, api ratio, version,
style.)
@@ -16,6 +18,7 @@ Reads the status JSON from stdin (Claude Code statusLine command). Every segment
is wrapped defensively so the status line can never crash the UI -- on any error
a segment is simply omitted.
"""
import hashlib
import json
import os
import re
@@ -159,6 +162,9 @@ def last_usage(data):
# --- session token totals (transcript, incremental) ---------------------------
TOKENS_STATE_DIR = os.path.expanduser("~/.claude/cache")
TOKENS_RECENT_IDS = 20 # message ids remembered across runs for de-duplication
TPS_SAMPLES = 8 # thinking blocks the t/s readout is averaged over
TPS_MIN_MS, TPS_MIN_TOKENS = 1000, 50 # shorter blocks are first-token latency, not decode speed
TOKENS_SCAN_BUDGET = 8 << 20 # bytes parsed per refresh (~0.1 s); see session_tokens
TOKENS_STATE_MAX_AGE = 7 * 86400 # prune per-session state files older than this
@@ -183,9 +189,10 @@ def _tokens_state_path(data, path):
def _prune_tokens_state(keep):
"""Drop stale per-session state files (one scandir, only when the dir has piled up)."""
"""Drop stale per-session token and per-branch PR state files (one scandir, only when
the dir has piled up)."""
try:
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith("statusline.tokens.")]
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith(("statusline.tokens.", "statusline.pr."))]
if len(entries) < 50:
return
cutoff = time.time() - TOKENS_STATE_MAX_AGE
@@ -197,12 +204,21 @@ def _prune_tokens_state(keep):
def session_tokens(data):
"""Cumulative (input, output) tokens this session, summed over the transcript's
assistant `message.usage` blocks -- or None when there is no transcript.
"""Cumulative session token totals plus decode-speed samples, from the transcript --
`{"in", "out", "tps"}` or None when there is no transcript.
Input = input + cache_creation + cache_read (everything the API billed as input), so
on a 1M session it dwarfs output: every call re-reads the whole cached prefix.
Only the main transcript is counted; subagent usage lives in separate sidechain files.
in/out: summed over assistant `message.usage` blocks. Input = input + cache_creation +
cache_read (everything the API billed as input), so on a 1M session it dwarfs output:
every call re-reads the whole cached prefix. Only the main transcript is counted;
subagent usage lives in separate sidechain files.
tps: the last TPS_SAMPLES `[thinking_tokens, thinkingDurationMs]` pairs -- the only
clean per-call timing Claude Code records. The status payload's cost.total_api_duration_ms
is useless for a rate: subagent and advisor calls land in the same counter (it grew 28s
in a 12s wall interval with no main-conversation completion), and the payload's
output_tokens is sometimes a mid-stream partial. thinkingDurationMs is main-conversation
only; it includes first-token latency, so short blocks read low and long ones converge
on the real decode speed -- hence the duration-weighted mean and the minimum-size filter.
Incremental: the status line runs on every refresh and transcripts grow to tens of
MB, so a per-session state file (~/.claude/cache/statusline.tokens.<session>.json)
@@ -210,12 +226,18 @@ def session_tokens(data):
de-duplicated by message id: the transcript writes one line per content block of a
turn, all carrying the same id and the same usage block. Only complete lines are
consumed (the writer may be mid-line); a shrunken file (truncate/rotate) resets.
A cold scan is spread over refreshes, TOKENS_SCAN_BUDGET bytes each: Claude Code kills
a status script when the next refresh fires, and a multi-second scan of a 100 MB
transcript that never gets to write its state would start from zero every time.
Totals climb for a few refreshes on a huge resumed transcript, then settle.
"""
path = data.get("transcript_path")
if not path or not os.path.exists(path):
return None
state_path = _tokens_state_path(data, path)
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
fresh = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": [], "tps": [], "tps_ids": []}
state = dict(fresh)
try:
with open(state_path) as fh:
s = json.load(fh)
@@ -225,27 +247,36 @@ def session_tokens(data):
and all(isinstance(s.get(k), int) and s[k] >= 0 for k in ("offset", "in", "out"))
and isinstance(s.get("ids"), list)
):
state = s
state = s # "tps"/"tps_ids" are optional: older state files predate them
except Exception:
pass
def result(st):
return {"in": st["in"], "out": st["out"], "tps": st.get("tps") or []}
size = os.path.getsize(path)
if size < state["offset"]: # truncated or rotated under us: start over
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
state = dict(fresh)
if size == state["offset"]:
return state["in"], state["out"]
return result(state)
with open(path, "rb") as fh:
fh.seek(state["offset"])
chunk = fh.read()
end = chunk.rfind(b"\n")
chunk = fh.read(TOKENS_SCAN_BUDGET)
end = chunk.rfind(b"\n")
if end < 0 and len(chunk) == TOKENS_SCAN_BUDGET: # one line bigger than the budget: finish it
chunk += fh.readline()
end = chunk.rfind(b"\n")
if end < 0: # no complete new line yet
return state["in"], state["out"]
return result(state)
text = chunk[: end + 1].decode("utf-8", "ignore")
tin, tout = state["in"], state["out"]
ids = [i for i in state["ids"] if isinstance(i, str)]
seen = set(ids)
tps = [s for s in (state.get("tps") or []) if isinstance(s, list) and len(s) == 2]
tps_ids = [i for i in (state.get("tps_ids") or []) if isinstance(i, str)]
tps_seen = set(tps_ids)
for line in text.splitlines():
if '"usage"' not in line:
continue
@@ -258,19 +289,38 @@ def session_tokens(data):
if not isinstance(usage, dict):
continue
mid = msg.get("id") or obj.get("requestId") or obj.get("uuid")
if not isinstance(mid, str) or mid in seen:
if not isinstance(mid, str):
continue
def num(d, k):
v = d.get(k, 0) if isinstance(d, dict) else 0
return int(v) if isinstance(v, (int, float)) else 0
# Decode-speed sample: checked on every line (not just the first-wins one below) so
# it does not depend on the thinking block being the message's first line.
ms = num(obj, "thinkingDurationMs")
toks = num(usage.get("output_tokens_details"), "thinking_tokens")
if ms >= TPS_MIN_MS and toks >= TPS_MIN_TOKENS and mid not in tps_seen:
tps_seen.add(mid)
tps_ids.append(mid)
tps.append([toks, ms])
if mid in seen:
continue
seen.add(mid)
ids.append(mid)
tin += num(usage, "input_tokens") + num(usage, "cache_creation_input_tokens") + num(usage, "cache_read_input_tokens")
tout += num(usage, "output_tokens")
def num(k):
v = usage.get(k, 0)
return int(v) if isinstance(v, (int, float)) else 0
tin += num("input_tokens") + num("cache_creation_input_tokens") + num("cache_read_input_tokens")
tout += num("output_tokens")
state = {"path": path, "offset": state["offset"] + end + 1, "in": tin, "out": tout, "ids": ids[-TOKENS_RECENT_IDS:]}
state = {
"path": path,
"offset": state["offset"] + end + 1,
"in": tin,
"out": tout,
"ids": ids[-TOKENS_RECENT_IDS:],
"tps": tps[-TPS_SAMPLES:],
"tps_ids": tps_ids[-TOKENS_RECENT_IDS:],
}
try:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
tmp = f"{state_path}.{os.getpid()}.tmp"
@@ -280,7 +330,7 @@ def session_tokens(data):
_prune_tokens_state(state_path)
except Exception:
pass
return tin, tout
return result(state)
# --- row 1: work ------------------------------------------------------------
@@ -395,6 +445,122 @@ def git_segment(cwd, top):
return ""
# --- PR review threads (gh, cached, fetched in the background) ------------------
PR_TTL = 90 # seconds a fetched count stays fresh
PR_ERR_TTL = 600 # back-off after a failed fetch (no GitHub remote, gh not authed, offline)
PR_CLAIM = 30 # a refresher spawned this recently is still running: don't start another
PR_CODEX = re.compile(r"codex", re.I) # chatgpt-codex-connector; same match as /pr-loop
PR_QUERY = """query($owner:String!,$repo:String!,$branch:String!){
repository(owner:$owner,name:$repo){
pullRequests(headRefName:$branch,states:OPEN,first:1,orderBy:{field:UPDATED_AT,direction:DESC}){
nodes{ number reviewThreads(first:100){ pageInfo{hasNextPage}
nodes{ isResolved comments(first:1){ nodes{ author{login} } } } } } } } }"""
def _pr_cache_path(top, branch):
key = hashlib.sha1(f"{top}\0{branch}".encode()).hexdigest()[:16]
return os.path.join(TOKENS_STATE_DIR, f"statusline.pr.{key}.json")
def _read_json(path):
try:
with open(path) as fh:
d = json.load(fh)
return d if isinstance(d, dict) else {}
except Exception:
return {}
def pr_refresh(cwd, branch, path):
"""Background half of pr_segment (`statusline.py --pr-refresh CWD BRANCH CACHE`): fetch the
branch's open PR and write its unresolved review-thread counts to the cache. `{owner}` /
`{repo}` are gh placeholders resolved from the repo at `cwd`. A failed fetch keeps the last
counts and flags them stale; a branch without an open PR clears them."""
state = _read_json(path)
state["ts"] = time.time()
try:
r = subprocess.run(
["gh", "api", "graphql", "-F", "owner={owner}", "-F", "repo={repo}",
"-f", f"branch={branch}", "-f", f"query={PR_QUERY}"],
cwd=cwd, capture_output=True, text=True, timeout=20,
)
if r.returncode != 0:
raise RuntimeError(r.stderr)
nodes = json.loads(r.stdout)["data"]["repository"]["pullRequests"]["nodes"]
state = {"ts": state["ts"]}
if nodes:
threads = nodes[0]["reviewThreads"]
# a thread belongs to whoever opened it: its first comment's author
authors = [((t["comments"]["nodes"] or [{}])[0].get("author") or {}).get("login") or ""
for t in threads["nodes"] if not t["isResolved"]]
state.update(number=nodes[0]["number"], open=len(authors),
codex=sum(1 for a in authors if PR_CODEX.search(a)),
more=threads["pageInfo"]["hasNextPage"])
except Exception:
state["error"] = True
try:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w") as fh:
json.dump(state, fh)
os.replace(tmp, path)
except Exception:
pass
def pr_segment(cwd, top):
"""Unresolved review threads (any author, outdated ones included) on the current branch's
open PR: `💬 #N ✓` when none are left, else the count with Codex's share broken out.
A GitHub round trip takes ~0.7 s, too slow for a render, so this only reads a per-branch
cache (~/.claude/cache/statusline.pr.<hash>.json) and, once it is older than PR_TTL,
spawns a detached `--pr-refresh` child to update it -- the new count shows on the next
render. PR-conversation comments are not counted: they have no resolved state, so a
summary or an LGTM would keep the count from ever reaching zero.
"""
try:
if not top or not shutil.which("gh"):
return ""
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
if not branch or branch == "HEAD":
return ""
path = _pr_cache_path(top, branch)
state = _read_json(path)
if time.time() - (state.get("ts") or 0) > (PR_ERR_TTL if state.get("error") else PR_TTL):
claim = path + ".lock"
try:
claimed = time.time() - os.path.getmtime(claim) < PR_CLAIM
except OSError:
claimed = False
if not claimed:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
with open(claim, "w"):
pass
# own session + no inherited stdout: Claude Code reads ours to EOF, and
# cancels an in-flight render when the next one triggers
subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "--pr-refresh", cwd, branch, path],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
num = state.get("number")
if not num:
return ""
n, codex = state.get("open") or 0, state.get("codex") or 0
out = "💬 " + color(f"#{num} ", "gray")
if not n:
out += color("✓", "green")
else:
out += color(f"{n}{'+' if state.get('more') else ''}", "yellow", bold=True)
if codex:
out += color(" · ", "gray") + color(f"codex {codex}", "red")
if state.get("error"):
out += color(" ?", "gray") # last fetch failed: these counts are stale
return out
except Exception:
return ""
def model_segment(data):
name = (data.get("model") or {}).get("display_name")
if not name:
@@ -503,13 +669,12 @@ def velocity_segment(data):
return ""
def tokens_segment(data):
def tokens_segment(totals):
"""Cumulative session tokens: ↑ input (incl. cache reads/writes) and ↓ output."""
try:
totals = session_tokens(data)
if not totals:
return ""
tin, tout = totals
tin, tout = totals["in"], totals["out"]
if not (tin or tout):
return ""
return "🔢 " + color(f"↑{fmt_tokens(tin)}", "cyan") + " " + color(f"↓{fmt_tokens(tout)}", "green")
@@ -517,6 +682,22 @@ def tokens_segment(data):
return ""
def tps_segment(totals):
"""Decode speed in tokens/s: duration-weighted mean over the last few thinking blocks
(see session_tokens). Omitted until a thinking block ≥1s has completed -- so never
shown with thinking disabled."""
try:
samples = (totals or {}).get("tps") or []
toks = sum(s[0] for s in samples)
ms = sum(s[1] for s in samples)
if toks <= 0 or ms <= 0:
return ""
rate = toks / ms * 1000
return "⚡ " + color(f"{rate:.0f} t/s", bucket(rate, 20, 40, invert=True))
except Exception:
return ""
def cache_segment(usage, data=None):
"""Prompt-cache hit % (higher is better) + the cache TTL, `·cold` when the cache has
expired. Hit % from the 2.1.260+ payload `prompt_cache.hit_ratio` (a 0..1 fraction, null
@@ -697,6 +878,9 @@ def style_segment(data):
def main():
if sys.argv[1:2] == ["--pr-refresh"] and len(sys.argv) == 5:
pr_refresh(*sys.argv[2:])
return
try:
data = json.load(sys.stdin)
except Exception:
@@ -708,6 +892,7 @@ def main():
or os.getcwd()
)
usage = payload_usage(data) or last_usage(data)
totals = session_tokens(data)
top = git_top(cwd)
# One row: work (left) then system (right), joined. If it would overflow the
@@ -720,11 +905,13 @@ def main():
vim_segment(data),
dir_segment(cwd, top),
git_segment(cwd, top),
pr_segment(cwd, top),
model_segment(data),
context_segment(data, usage),
usage_segment(data),
cost_segment(data),
tokens_segment(data),
tokens_segment(totals),
tps_segment(totals),
cache_segment(usage, data),
) if s]
system = [s for s in (