[Sync] Lua Hyprland chain, licence cleanup
This commit is contained in:
1 parent
93b4f695ce
commit
47166d877f
183 files changed
+5618
-5839
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user