[Sync] adopt unified stow layout from the private repo

Mirrors the private dots tree at 900bdda: one shared base plus per-host
overlays, replacing the old flat .config/ layout (last synced 2026-06-28).

- packages: common/ gui/ wm/ lw/ fl/ + install.sh and bin/ tooling (dotsync,
  reconcile-hyde.sh)
- new README (layout, deploy order, HyDE dependency), plus ToDo.md and
  HYDE-UPDATE.md
- current HyDE waybar rig (layouts/, cava), pi agent extensions, claude/
  config, tmux, presenterm, aichat roles
- drops stale duplicates and generated cruft that should never have been
  tracked: the second top-level .pi/ copy, btop.log, zellij config.kdl.bak,
  fish_variables, nvim codecompanion.lua
- .pi/agent/auth.json is gitignored now; auth.json.example ships instead
- fl/ and wm/ hypr themes/ stay untracked (HyDE-generated per machine, per
  the root .gitignore)
This commit is contained in:
coja
2026-08-12 22:50:18 +02:00
parent f62cb40499
commit a700e23e0a
318 changed files with 12564 additions and 10772 deletions
+21
View File
@@ -0,0 +1,21 @@
# Global user preferences (applies to every project)
## Environment
- Arch Linux, Wayland, fish shell, kitty terminal. Prefer fish-compatible syntax in shell snippets (not bash-isms) when writing things the user will run interactively. Clipboard is `wl-copy`.
- Package manager: pacman / yay. Common abbrs: `pacs`, `yays`.
## Local-first LLMs
- The user self-hosts models on the LAN at `192.168.0.204`:
- llama.cpp server — `http://192.168.0.204:11343/v1` (OpenAI-compatible).
- Ollama — `192.168.0.204:11434`.
- Recurring models: Qwen3-Coder-30B, gemma-4-26B, DeepSeek-Coder-V2-Lite, GLM-4.7-Flash. When configuring any AI/agent tool, default to these endpoints rather than a cloud API unless asked otherwise.
## Git
- **Do not run `git commit` or `git push`, and never offer or ask to do the committing/pushing yourself** — the user reviews and commits entirely manually (these are also denied in settings.json). Stage/prepare changes and stop there. You may suggest a commit message for the user to use, but nothing more.
- To hand off a suggested commit message, write it to the per-worktree file `$(git rev-parse --git-path CLAUDE_COMMIT_MSG)`**not** `COMMIT_EDITMSG` (git overwrites that on `git commit`). A `prepare-commit-msg` hook prefills the editor from it (one-shot) on the next `git commit` / lazygit `C`. Just the raw message, no `#` comments.
## Formatting
- Lua: format with `stylua` (2-space indent, 120 col, no call parentheses — see any `.stylua.toml`).
## Dotfiles
- Configs live in the dots repo under `.config/` and are surfaced via per-directory symlinks into `~/.config/` (and `~/.claude/`). Editing a file under the repo's `.config/` is editing the live config — no deploy step.
+91
View File
@@ -0,0 +1,91 @@
# Claude Code config
Portable [Claude Code](https://claude.com/claude-code) customizations, synced via these
dots. Claude Code still uses its default `~/.claude/` directory; we surface the tracked
files below into it via per-file/dir symlinks, so edits made through Claude write straight
back into this repo.
## What's tracked here
- `settings.json` — global settings: permissions (deny git commit/push + read of secret
files), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`, fullscreen
TUI, `statusLine`, `hooks`, `worktree` defaults, …
- `statusline.py` — rich status line (dir, git, 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).
- `CLAUDE.md` — global user preferences applied to every project.
- `hooks/format.py` — PostToolUse hook: formats edited files by extension (stylua / ruff /
prettier / gofmt / rustfmt / fish_indent / shfmt / taplo). Opinionated formatters (stylua, ruff,
prettier, taplo) only run where the project opts in via a config file found walking up; gofmt and
rustfmt gate on a project marker (`go.mod` / `Cargo.toml`); fish_indent and shfmt run on sight.
No-ops if the formatter isn't installed, and always exits 0.
- `skills/` — custom [Agent Skills](https://code.claude.com/docs/en/skills). See **Skills** below.
- `link.sh` — idempotent bootstrap that creates the `~/.claude` symlinks below.
Paths inside `settings.json` reference `~/.claude/...` via `$HOME`/`PATH` (`/usr/bin/env
python3 ~/.claude/...`), so they work regardless of the username, python location, or where the
dots repo is cloned (`~/.dots`, …).
## What is **not** tracked (stays local, per device)
Everything else under `~/.claude/` is machine-specific or secret and must never be committed:
credentials (`.credentials.json`), session history, `projects/` (transcripts + memory),
caches, and the `plugins/` cache/binaries.
## Setup on a new device
```sh
# 1. Deploy the dotfiles repo — install.sh stows ~/.config/claude AND runs link.sh for you
cd ~/.dots && ./install.sh
# 2. Launch Claude and log in once (credentials are NOT synced)
claude
# 3. Reinstall the plugins from settings.json -> enabledPlugins
# (typescript-lsp, frontend-design — anthropics/claude-plugins-official) via /plugin.
# Optional: install any formatters you want the hook to use (stylua, ruff, prettier, …).
```
`install.sh` deploys `~/.config/claude` (via stow) and then runs `link.sh` automatically. You only
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.
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`.
## Skills
Custom skills live in `skills/<name>/SKILL.md` (the directory name is the `/command`) and are
linked in as `~/.claude/skills`. All are `disable-model-invocation: true` — explicit `/` only,
so Claude never auto-triggers them. Run them inside the repo whose branch you're working on.
- **`/review-branch [base]`** — reviews the current branch's diff (vs its merge-base with the
base branch) for 🐞 bugs / 🔒 security / ⚡ optimizations / 📖 readability, reports grouped
findings with `file:line`, then applies the readability + safe fixes *after you approve*. Never
commits.
Borrows `/code-review`'s discipline: CLAUDE.md-aware, changed-lines-only, verified bugs with a
false-positive filter (skips what linters/CI catch and pre-existing issues). Ends by pointing
you at the relevant built-in follow-ups (`/security-review`, `/code-review`, `/verify`, …).
- **`/pr-description [base]`** — auto-detects this repo's GitHub PR template
(`.github/pull_request_template.md`, …), fills it from the branch diff + commits, prints a
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.
- **`/pr-loop [pr]`** — babysits the current branch's PR in a self-paced loop: fixes review
comments that don't need your input — from **any** reviewer (Codex, Claude, humans) plus any
`@claude` request — `git add`s them, drafts the commit message (`/commit-msg` style) and marks
each thread with a reaction instead of an "Addressed — …" reply: **👍** fixed, **👀** parked for
your decision, **👎** a bot false positive (bots only, always with a one-line why). Then pings you
to commit & push and re-checks every ~10 min (via `ScheduleWakeup`). Resolves threads once their
fix is pushed — dismissals right away, since there's nothing to land. Stops when no unresolved comments
remain or the automated reviewers (Codex/Claude) hit their limit. Prep-only — never
commits/pushes/merges; needs `gh` authed and the session left open. Each cycle it prints when it
will re-check and that `Esc` (between cycles) stops it; on the last cycle it prints a clear
**"Finished — no more active issues"** and does not reschedule.
## Adding more config later
Drop the file/dir under `.config/claude/` here, add its name to the `items` list in `link.sh`,
and re-run `bash ~/.config/claude/link.sh`. For example `commands/` or `agents/`.
+76
View File
@@ -0,0 +1,76 @@
# Status line (`statusline.py`)
The custom Claude Code status line, ordered **work info (left) → system info (right)**,
segments joined by a light-gray `│`. It's **responsive**: always a single line — if the
rendered width would exceed the terminal (`$COLUMNS`, which Claude Code re-exports on
resize), trailing (lowest-priority) segments are dropped from the right until it fits.
Wide — everything:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Opus 4.8 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 💾 81%
```
Narrow — trailing segments trimmed:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Opus 4.8 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h
```
Every segment is defensive: if its data is missing or a command fails, the segment is
simply omitted (the bar never crashes the UI). Secondary detail (the parts in light gray)
is supplementary to the main colored value.
## Live segments
| | Segment | Shows | Notes |
|---|---|---|---|
| | **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 |
| 🤖 | **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 | window = 1M for `[1m]` models, else 200k. 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 |
| 💾 | **Disk** | `% used` of the filesystem at the cwd | from `statvfs` |
## Colour legend
Most numbers are **green / yellow / red** by threshold — green = healthy, red = needs
attention. Light gray = secondary detail. Thresholds (`green < … < yellow < … ≤ red`):
| Segment | green | yellow | red |
|---|---|---|---|
| Context | `< 50%` | `5080%` | `≥ 80%` (⚠compact) |
| 5h usage | `< 50%` | `5080%` | `≥ 80%` |
| Disk | `< 75%` | `7590%` | `≥ 90%` |
> Light gray is `\033[37m`; dimming (`\033[2m`) is disabled because it blended the gray
> detail text into dark terminal backgrounds. To go brighter, set `"gray"` to `\033[97m`
> (bright white) in the `C` table near the top of `statusline.py`.
## Optional segments (defined but not shown)
These functions exist in `statusline.py` but aren't in the output. Enable one by adding it
to the `work` or `system` list in `main()`:
RAM/CPU/temp were **retired 2026-07-26**: the status line only refreshes on message
events, so system metrics sat visibly stale between turns — the tmux status bar
(`tmux/scripts/`) owns live system metrics now.
| | Function | Shows |
|---|---|---|
| 🧠 | `ram_segment` | `% used` + `used/totalG` from `/proc/meminfo` (green `<70%` / yellow / red `≥85%`) |
| 🖥️ | `cpu_segment` | `% busy` (diffed `/proc/stat` snapshot) + loadavg + cores (green `<60%` / red `≥85%`) |
| 🌡️ | `temp_segment` | hottest CPU thermal zone °C (green `<60` / red `≥80`) |
| ✏️ | `velocity_segment` | `+added/-removed` lines this session + lines/min |
| ♻️ | `cache_segment` | prompt-cache hit % (higher is better) |
| ⚙️ | `api_segment` | share of wall-clock time spent in API/inference |
| | `version_segment` | Claude Code version (`vX.Y.Z`) |
| 🎨 | `style_segment` | active output-style name |
## 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.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""PostToolUse hook: format an edited file with the right formatter.
Reads the hook JSON from stdin, extracts the edited file path, and runs a
language-appropriate formatter -- but, to avoid imposing a style on a project
that never asked for it, opinionated formatters (stylua, ruff, prettier, taplo)
only run when the project opts in via a config file discoverable by walking up
from the edited file. Canonical single-style toolchains run on sight: gofmt and
rustfmt on their go.mod / Cargo.toml marker; fish_indent and shfmt whenever the
tool itself is installed (shell and fish have one de-facto style, no config).
Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not
guaranteed on the host, hence python for the stdin JSON parse.
"""
import json
import os
import shutil
import subprocess
import sys
def find_up(start, names):
"""Nearest ancestor dir (incl. the file's own) containing any of `names`."""
d = os.path.dirname(os.path.abspath(start))
while True:
if any(os.path.exists(os.path.join(d, n)) for n in names):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def has_toml_section(start, filename, section):
"""True if the nearest ancestor `filename` contains `section`."""
d = find_up(start, [filename])
if not d:
return False
try:
with open(os.path.join(d, filename), errors="ignore") as fh:
return section in fh.read()
except Exception:
return False
def stylua_cmd(path):
exe = os.path.expanduser("~/.local/share/nvim/mason/bin/stylua")
if not os.access(exe, os.X_OK):
exe = shutil.which("stylua")
if exe and find_up(path, [".stylua.toml", "stylua.toml"]):
return [exe, path]
return None
def ruff_cmd(path):
exe = shutil.which("ruff")
if exe and (find_up(path, ["ruff.toml", ".ruff.toml"])
or has_toml_section(path, "pyproject.toml", "[tool.ruff")):
return [exe, "format", path]
return None
_PRETTIER_CFGS = [
".prettierrc", ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml",
".prettierrc.json5", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs",
".prettierrc.toml", "prettier.config.js", "prettier.config.cjs",
"prettier.config.mjs",
]
def prettier_cmd(path):
exe = shutil.which("prettier")
if exe and find_up(path, _PRETTIER_CFGS):
return [exe, "--write", path]
return None
def gofmt_cmd(path):
exe = shutil.which("gofmt")
if exe and find_up(path, ["go.mod"]): # one canonical style
return [exe, "-w", path]
return None
def rustfmt_cmd(path):
exe = shutil.which("rustfmt")
if exe and find_up(path, ["Cargo.toml", "rustfmt.toml", ".rustfmt.toml"]):
return [exe, path]
return None
def fish_cmd(path):
exe = shutil.which("fish_indent") # ships with fish, one canonical style
return [exe, "-w", path] if exe else None
def shfmt_cmd(path):
exe = shutil.which("shfmt") # de-facto shell formatter, sane defaults
return [exe, "-w", path] if exe else None
def taplo_cmd(path):
exe = shutil.which("taplo")
# opt-in: TOML reflow/align is intrusive, so only where the project asked.
if exe and find_up(path, ["taplo.toml", ".taplo.toml"]):
return [exe, "format", path]
return None
HANDLERS = {
".lua": stylua_cmd,
".py": ruff_cmd,
".js": prettier_cmd, ".jsx": prettier_cmd, ".mjs": prettier_cmd,
".cjs": prettier_cmd, ".ts": prettier_cmd, ".tsx": prettier_cmd,
".css": prettier_cmd, ".scss": prettier_cmd, ".less": prettier_cmd,
".html": prettier_cmd, ".vue": prettier_cmd, ".json": prettier_cmd,
".md": prettier_cmd, ".yaml": prettier_cmd, ".yml": prettier_cmd,
".go": gofmt_cmd,
".rs": rustfmt_cmd,
".fish": fish_cmd,
".sh": shfmt_cmd, ".bash": shfmt_cmd,
".toml": taplo_cmd,
}
def main():
try:
data = json.load(sys.stdin)
except Exception:
return
ti = data.get("tool_input") or {}
tr = data.get("tool_response") or {}
path = ti.get("file_path") or tr.get("filePath") or ""
if not path or not os.path.isfile(path):
return
handler = HANDLERS.get(os.path.splitext(path)[1].lower())
if not handler:
return
cmd = handler(path)
if not cmd:
return
try:
subprocess.run(cmd, timeout=15, capture_output=True)
except Exception:
pass
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{
"context": "Global",
"bindings": {
"alt+j": "scroll:lineDown",
"alt+k": "scroll:lineUp",
"ctrl+shift+j": "scroll:lineDown",
"ctrl+shift+k": "scroll:lineUp"
}
},
{
"context": "MessageSelector",
"bindings": {
"g": "messageSelector:top",
"shift+g": "messageSelector:bottom"
}
},
{
"context": "Select",
"bindings": {
"g": "select:first",
"shift+g": "select:last"
}
},
{
"context": "Footer",
"bindings": {
"j": "footer:down",
"k": "footer:up"
}
}
]
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Surface this repo's Claude Code config into ~/.claude via symlinks.
#
# `stow` deploys the repo to ~/.config/claude; Claude itself reads from ~/.claude,
# so we link the individual files/dirs across. Idempotent and self-healing: re-run
# any time. It also flags any file that has broken out of stow into a plain file —
# which silently stops Claude's edits from reaching the repo.
# (New skills/hooks added to the repo appear after a re-stow: `./install.sh` or `dotsync`.)
#
# bash ~/.config/claude/link.sh
set -eu
SRC="$HOME/.config/claude"
DST="$HOME/.claude"
if [ ! -e "$SRC" ]; then
echo "error: $SRC not found — run 'cd ~/.dots && ./install.sh' first" >&2
exit 1
fi
mkdir -p "$DST"
# This repo's claude dir, resolved via link.sh's own symlink (location-independent).
repo=$(dirname "$(readlink -f "$SRC/link.sh")")
# If link.sh itself has broken out of stow into a plain file, $repo resolves to $SRC and
# the drift heal below would relink each drifted file onto ITSELF — destroying the only
# copy of its content. Refuse to continue.
if [ "$repo" = "$SRC" ]; then
echo "error: link.sh itself is a plain file (broken out of stow) — re-stow first:" >&2
echo " cd ~/.dots && ./install.sh" >&2
exit 1
fi
# Tracked items to expose in ~/.claude. Add new ones (e.g. commands agents) here.
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
if [ -d "$DST/$item" ] && [ ! -L "$DST/$item" ]; then
echo "skip ~/.claude/$item (real directory in the way — move it aside first)" >&2
continue
fi
# A plain FILE here means an atomic write replaced the link (Claude writes e.g.
# ~/.claude/settings.json) — it may hold newer edits, so never clobber it silently:
# heal only when provably lossless, warn otherwise (mirrors the SRC drift guard below).
if [ -f "$DST/$item" ] && [ ! -L "$DST/$item" ]; then
same=0
case "$item" in
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$DST/$item" "$SRC/$item" 2>/dev/null && same=1 ;;
*)
cmp -s "$DST/$item" "$SRC/$item" && same=1 ;;
esac
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
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"
done
# Stow-layer drift guard: each SRC *file* should be a symlink into the repo, so edits
# Claude writes (e.g. via /config) flow back and stay tracked. If an atomic write
# replaced one with a plain file, it has silently broken out of stow. Auto-heal only
# when provably lossless — the plain file is identical to the repo copy (JSON compared
# ignoring key order, since /config often just reorders keys). Otherwise warn and stop:
# the file may hold newer edits worth diffing in before re-linking.
for item in $items; do
[ -f "$SRC/$item" ] && [ ! -L "$SRC/$item" ] || continue # a real file where a link belongs
same=0
if [ -f "$repo/$item" ]; then
case "$item" in
*.json)
python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))==json.load(open(sys.argv[2])) else 1)' \
"$SRC/$item" "$repo/$item" 2>/dev/null && same=1 ;;
*)
cmp -s "$SRC/$item" "$repo/$item" && same=1 ;;
esac
fi
if [ "$same" = 1 ]; then
ln -sfrn "$repo/$item" "$SRC/$item" # -r: relative link, so stow keeps owning it
echo "healed ~/.config/claude/$item (plain copy identical to repo — re-linked)"
continue
fi
echo "DRIFT ~/.config/claude/$item is a real file that differs from the repo copy —" >&2
echo " it may hold edits not yet tracked. Review, then re-link:" >&2
echo " diff '$SRC/$item' '$repo/$item' # compare; keep whichever is right" >&2
echo " ln -sfrn '$repo/$item' '$SRC/$item' # re-link (relative, stow-owned)" >&2
done
+79
View File
@@ -0,0 +1,79 @@
{
"attribution": {
"commit": "",
"pr": ""
},
"permissions": {
"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:*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/*.env)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/*.p12)",
"Read(**/*.pfx)",
"Read(**/*.keystore)",
"Read(**/id_rsa*)",
"Read(**/.ssh/**)",
"Read(**/.aws/**)",
"Read(**/.npmrc)",
"Read(**/.netrc)",
"Read(**/.git-credentials)",
"Read(**/.claude/.credentials.json)",
"Read(**/secrets.fish)",
"Read(**/*.token)",
"Read(**/.envrc)"
],
"defaultMode": "auto"
},
"model": "opus[1m]",
"enableWorkflows": false,
"skipWorkflowUsageWarning": true,
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
},
"statusLine": {
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/statusline.py",
"padding": 0,
"hideVimModeIndicator": true
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/hooks/format.py",
"timeout": 20,
"statusMessage": "format"
}
]
}
]
},
"worktree": {
"baseRef": "fresh"
},
"effortLevel": "xhigh",
"advisorModel": "fable",
"tui": "fullscreen",
"theme": "dark",
"editorMode": "vim",
"verbose": true,
"autoCompactEnabled": true,
"remoteControlAtStartup": false,
"inputNeededNotifEnabled": false,
"agentPushNotifEnabled": false,
"skipAutoPermissionPrompt": true
}
@@ -0,0 +1,42 @@
---
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.
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:*), Read, Glob, Write
---
# Draft a commit message for the staged changes
Context for the current commit — what's staged, plus this repo's recent style:
!`echo "=== Staged (git diff --cached --stat) ==="; git --no-pager diff --cached --stat 2>/dev/null; echo; echo "=== Working tree (git status --short) ==="; git --no-pager status --short 2>/dev/null; echo; echo "=== Recent subjects — match this convention ==="; git --no-pager log -20 --pretty=format:'%s' 2>/dev/null`
## What to do
1. **See exactly what's being committed.** Read the full staged diff with
`git --no-pager diff --cached`. If nothing is staged (the stat above is empty), tell me and
**stop** — I stage changes myself; never run `git add`.
2. **Learn the repo's commit style** from the recent subjects above — prefix convention (this repo
uses `[Scope] summary`), imperative vs. past tense, subject length, whether bodies are used.
Match what's already there; don't impose Conventional Commits / gitmoji if the repo doesn't use
them.
3. **Write the message from the *actual* staged diff** (not a guess):
- A concise subject in the repo's style (~50 chars, imperative). Fold in my hint if given:
**$ARGUMENTS**.
- 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.
5. **Save it to a file in the repo** so I can recover it any time: resolve the path with
`git rev-parse --git-path CLAUDE_COMMIT_MSG` (per-worktree correct), then use the Write tool to
write the raw message there — **not** `COMMIT_EDITMSG`, which git overwrites on every
`git commit` before the editor opens. My `prepare-commit-msg` hook prefills the commit editor
from `CLAUDE_COMMIT_MSG` (one-shot) on the next `git commit` / lazygit `C`. The file lives
inside the git dir so it never shows up in `git status`.
6. **Also copy it to the clipboard** as a convenience: `wl-copy < <path from step 5>`. Then
tell me all three places it is: **printed above**, **saved to `CLAUDE_COMMIT_MSG`** (hook
prefills the next commit), 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
manually. Draft only.
@@ -0,0 +1,36 @@
---
description: Draft a ready-to-paste PR description by filling this repo's GitHub PR template from the branch diff and commits, then copy it to the clipboard. Use after finishing a feature.
argument-hint: [base-branch]
disable-model-invocation: true
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git merge-base:*), Bash(git symbolic-ref:*), Bash(git rev-parse:*), Bash(git show-ref:*), Bash(ls:*), Bash(cat:*), Bash(wl-copy:*), Bash(mktemp:*), Bash(rm:*), Read, Glob
---
# Draft this branch's PR description
This repo's PR template (first match), plus the branch's changes:
!`for f in .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE.md docs/pull_request_template.md docs/PULL_REQUEST_TEMPLATE.md PULL_REQUEST_TEMPLATE.md pull_request_template.md; do if [ -f "$f" ]; then echo "=== PR template: $f ==="; cat "$f"; found=1; break; fi; done; if [ -z "$found" ]; then if [ -d .github/PULL_REQUEST_TEMPLATE ]; then echo "=== template dir .github/PULL_REQUEST_TEMPLATE/ (pick one) ==="; ls .github/PULL_REQUEST_TEMPLATE/; else echo "(no PR template found — use the fallback structure)"; fi; fi`
!`b=$(git symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@'); [ -z "$b" ] && { git show-ref -q --verify refs/heads/main && b=main || b=master; }; m=$(git merge-base "$b" HEAD 2>/dev/null); echo "Branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null) Base: $b Range: ${m:-?}..HEAD"; echo; echo "Commits:"; git --no-pager log --oneline "${m}..HEAD" 2>/dev/null; echo; echo "Files changed:"; git --no-pager diff --stat "${m}...HEAD" 2>/dev/null`
## What to do
1. **Resolve the base branch** (use the invocation argument if given — **$ARGUMENTS** — else the
detected base) and read the full diff + commit messages for `merge-base..HEAD`.
2. **Fill the PR template shown above** from the *actual* changes:
- Write the Summary / motivation and any "Implementation Notes" from the diff and commits.
- Fill the **Type of Change** field or boxes with the kinds that genuinely match (feature /
fix / refactor / perf / tests / docs / chore).
- Tick `Self-reviewed` and `LLM-assisted` if those boxes exist.
- **Leave verification boxes unchecked** — e.g. "tests passed", "`validate:full` passes",
"pre-commit hooks passed". I confirm those myself; never tick them for me.
- Fill **Related Issues** from issue numbers in the branch name or commit trailers; delete any
placeholder lines you can't fill (never leave a dangling `Closes #`), and drop the whole
section if there are no issues to reference.
- If a multi-template dir was listed (no single file), pick the most fitting one and say which.
- **If no template exists**, use this fallback: `## Summary` / `## Changes` / `## Testing` /
`## Notes`.
3. **Output** the filled description as a single fenced ```markdown code block, copy-paste ready.
4. **Copy it to the clipboard**: write the same markdown to a temp file (`mktemp`) and run
`wl-copy < "$tmpfile"`, then remove the temp file. Tell me it's on the clipboard.
5. **Do not** create, push, or edit the PR (`gh pr ...`) — I add the text to the PR myself.
@@ -0,0 +1,275 @@
---
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:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup
---
# Watch this PR and resolve review comments (prep-only)
A self-paced loop over one PR's review feedback: fix the comments that don't need my judgement,
stage them, draft a commit message, then wait ~10 min and re-check — until there are no unresolved
comments left or the automated reviewers (Codex/Claude) hit a usage limit.
**The contract — read this first:**
- **I never commit, push, or merge.** Each cycle you *prepare*: edit + `git add` + draft a commit
message + reply on the threads you handled. Then you **ping me to commit & push**, and the loop
resumes after I do. This honours the global no-commit/no-push rule; nothing in `settings.json`
changes.
- **React, don't reply — then resolve.** A handled thread gets a 👍 **reaction** on the reviewer's
comment while unpushed — never an "Addressed — …" reply, those are just noise. Once I've pushed
and its fix is live, you **resolve the thread on GitHub** (or minimize the bot comment as
RESOLVED) rather than leaving it open — see step 6. Never resolve unpushed work or a needs-input
thread. The **only** comments you ever post are the one-line decision questions for needs-input
items (step 7) and the one-line reason on a dismissal.
- **The marker vocabulary — exactly these three, never others.** Every item you triage gets exactly
one reaction, so I can read the PR's state at a glance without opening threads:
- 👍 `+1`**addressed**: fix staged; the thread gets resolved once I push.
- 👀 `eyes`**parked**: needs a decision from me, paired with the one-line question (step 7).
- 👎 `-1`**dismissed**: a *bot* finding that's wrong, paired with a one-line why, then
resolved/minimized straight away (step 8).
**Never 👎 a human's comment.** If I'm the one who's wrong, write the reasoning as a reply and
park it as 👀 — I decide, not you. And don't invent other reactions (🎉/❤️/🚀/😄/😕): an
unexplained emoji is worse than no emoji.
- The 10-minute cadence uses `ScheduleWakeup`, so the loop only advances **while this Claude
session stays open**. **To stop it: press `Esc` while I'm idle between cycles** (that clears the
queued wake-up); closing the session also stops it. A plain message does **not** cancel a pending
wake-up — use `Esc`.
- **Every token counts.** Each wake-up re-reads the whole conversation with a cold prompt cache
(the 10-min cadence outlives the 5-min cache TTL), so cost compounds with every cycle and every
extra line in context. Therefore: fetch **filtered** data only (use the `--jq` filters below —
never let raw unfiltered JSON into the conversation), summarize deltas rather than restating
unchanged state, and keep idle cycles to **one cheap API call and ≤2 lines of output**.
Current state (auth + the current branch's PR):
!`echo "=== gh auth ==="; gh auth status 2>&1 | head -3; echo; echo "=== PR (current branch) ==="; gh pr view --json number,url,state,headRefName,baseRefName,headRefOid 2>/dev/null || echo "(no open PR for current branch — or gh not authed)"; echo; echo "=== repo ==="; gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null`
## Each cycle (one invocation / wake-up)
1. **Preflight.** If `gh auth status` failed above → **stop** and tell me to run `! gh auth login`,
then re-run `/pr-loop`. Do nothing else. Capture `owner`/`repo` from `nameWithOwner`.
2. **Load state & resolve the PR.** Read `<git-dir>/pr-loop-state.json` (find `<git-dir>` with
`git rev-parse --absolute-git-dir`). It's inside `.git/`, so it never shows in `git status` and
survives wake-ups. Shape:
```json
{ "number": 0, "lastHeadOid": "", "lastSeenUpdatedAt": "", "handledThreadIds": [],
"handledCommentIds": [], "needsInputThreadIds": [], "needsInputCommentIds": [],
"dismissedIds": [], "awaitingPush": false, "idleCount": 0, "cycleCount": 0 }
```
Resolve the target PR **in this order**: my argument if given (**$ARGUMENTS**) → the `number`
in the state file (wake-ups re-invoke `/pr-loop` *without* arguments — the state file is the
durable copy) → the current branch's PR from the context block. If none → **stop** and say so.
If the state file is missing or for a different `number`, start fresh. Increment `cycleCount`.
**Then print one status line before any network call** — so I always see the loop is alive:
> 🔄 pr-loop cycle <N> — PR #<num>: checking…
3. **Cheap activity check — one API call, before any heavy fetch.**
`gh pr view NUM --json headRefOid,updatedAt`
- If `updatedAt == lastSeenUpdatedAt` **and** `headRefOid == lastHeadOid` → nothing happened at
all (no comment, review, or push). This is an **idle cycle**: `idleCount += 1`; if a safety
cap (step 5) is now exceeded, STOP per step 5. Otherwise save state and jump straight to
step 10's reschedule. Total output ≤2 lines — if `awaitingPush`, one line is a brief
commit-&-push reminder (remind at most twice across idle cycles, then just the status line).
- Otherwise: remember the fetched `headRefOid` for step 6 and continue. (Don't update
`lastSeenUpdatedAt` yet — step 10 refreshes it *after* you've posted replies, so your own
replies don't defeat the next cycle's cheap check.)
4. **Fetch review state — filtered at the source, both calls in parallel** (one message, two
tool calls). Never fetch unfiltered `reviews,comments`.
Unresolved review threads (resolved/unresolved is GraphQL-only):
```bash
gh api graphql --paginate -F owner=OWNER -F repo=REPO -F number=NUM -f query='
query($owner:String!,$repo:String!,$number:Int!,$cursor:String){
repository(owner:$owner,name:$repo){ pullRequest(number:$number){
reviewThreads(first:100,after:$cursor){ pageInfo{hasNextPage endCursor}
nodes{ id isResolved isOutdated
comments(first:100){ nodes{ id databaseId author{login} path line body } } } } } } }' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[]
| select(.isResolved | not)
| {id, isOutdated,
comments: [.comments.nodes[] | {id, databaseId, author: .author.login, path, line, body}]}'
```
Mind the two id kinds: the thread's own `id` (`PRRT_…`) is what you resolve; a
`comments[].id` (`PRRC_…`) is what you react to.
Automated-reviewer activity + `@claude` requests from the PR conversation — latest review per
bot (for the limit check in step 5) and only the relevant comments, not the whole history:
```bash
gh pr view NUM --json reviews,comments --jq '{
latestBotReviews: ([.reviews[] | select(.author.login | test("codex|claude"; "i"))]
| group_by(.author.login) | map(max_by(.submittedAt))
| map({author: .author.login, state, submittedAt, body})),
relevantComments: ([.comments[] | select((.isMinimized | not)
and ((.body | test("@claude")) or (.author.login | test("codex|claude"; "i"))))
| {id, author: .author.login, body}] | .[-10:])
}'
```
Act on feedback from **any** reviewer — especially the automated ones, **Codex and Claude**
(`author.login` matching `codex`/`claude`, or a Bot account). Treat any comment — inline review
**or** PR conversation — whose body **mentions `@claude`** as an explicit request to act on,
same as a review comment.
Some bots post **findings as a plain PR-conversation comment** rather than inline review threads
(e.g. Claude when its tooling can't post inline). Triage those findings like thread findings;
since they have no thread, you "resolve" them by **minimizing** the comment once fixed (step 8).
`relevantComments[].id` is the comment's GraphQL node id (`IC_…`) used for that, and the
`isMinimized` filter above already drops ones you've resolved. A bot's pure **summary / status /
usage-limit** comment carries no finding — triage/minimize only comments with concrete asks.
5. **Check stop conditions — before doing any work:**
- **Nothing left to address** → no unresolved review threads, no un-minimized bot **findings**
comments, and no open `@claude` requests. Anything in `dismissedIds` doesn't count as
outstanding (if a resolve/minimize failed, the 👎 + reason still stands). The PR is clean:
report done, save state, and **do not** schedule another wake-up. STOP.
- **The automated reviewers are spent** → an automated reviewer's most recent review/comment
body matches a limit signal (case-insensitive: `rate/usage/quota/credit … limit`,
`limit reached`, `exceeded … quota`, `out of … credits`). Automated reviewers = authors whose
`login` matches `codex` or `claude` (or a `[bot]` that leaves review comments; confirm with
`gh api users/<login> --jq .type` → `Bot`). STOP and report which reviewer hit the limit
**only** when no unresolved threads (incl. `@claude` requests) remain that you can still act
on. If one reviewer is limited but other threads still need work, handle those first, then
re-check.
- **Safety caps** (these apply on idle cycles too) → if `cycleCount > 20`, or `idleCount > 4`,
or `gh api rate_limit --jq .resources.core.remaining` is `< 100` → STOP and hand back to me
with a summary.
**When any stop fires:** save state, do **not** call `ScheduleWakeup`, and end with a clear
final line so I know the loop is over and won't run again — e.g.:
> ✅ **Finished — no more active issues.** I won't re-check again.
Adapt it to the reason: `✅ Finished — <reviewer> hit its usage limit; nothing left to address,
I won't re-check again.` or `⏹ Stopped — hit safety cap (<which>); re-run \`/pr-loop\` to resume.`
6. **Did I push since last cycle?** Compare the `headRefOid` from step 3 to `lastHeadOid` (no
extra API call).
- If **unchanged and `awaitingPush` is true** → I haven't pushed yet. Don't re-fix anything for
threads already handled: `idleCount += 1`, then continue with step 7 **only for genuinely new
threads/comments** (something new must exist, or step 3 would have short-circuited).
- If **changed** → I pushed. Set `awaitingPush=false`, `idleCount=0`, update `lastHeadOid`. Then,
**before handling anything new, resolve on GitHub every already-handled item whose fix is now
live** — don't leave them lingering as open, 👍-only threads. For each id in `handledThreadIds`
that is still unresolved and not re-flagged, run `resolveReviewThread`; for each id in
`handledCommentIds` not re-flagged, `minimizeComment` as RESOLVED (mutations in step 8).
Resolved threads drop out of the step-4 fetch, so each fix is resolved exactly once. Then
continue to any genuinely new threads.
7. **Triage** each unresolved review thread, every bot **findings** comment on the PR conversation,
and every `@claude` request **not already handled**:
- **Auto-handle (no input needed):** typos, lint/format, naming, missing null/error checks,
applying the reviewer's concrete suggested diff, docs/comments, and localized bugs with one
correct fix — **including fixes that touch a shared type/contract or span multiple layers,
when the repo's existing conventions determine the shape.** Blast radius (multi-file,
contract-touching, "I'd have to pick among a few representations") is **not** a reason to
defer: pick the minimal idiomatic shape that matches existing patterns, implement it, and let
me veto. A reviewer finding that names the concrete fix is almost always auto-handleable.
- **Leave for me (needs input) — 👀:** defer **only** for genuine ambiguity — two or more
*materially different* correct behaviours, a real security/performance trade-off, or missing
product/domain knowledge that existing code can't settle. Never guess these. Post the
`path:line` + a **one-line decision I can answer in a word**, and record the id in
`needsInputThreadIds` (or `needsInputCommentIds` for a plain conversation comment) so step 3
re-surfaces it every idle cycle instead of letting it fall silent.
- **Not an issue (dismiss) — 👎:** a **bot** finding that is simply wrong — it misread the code,
the behaviour is intentional and the surrounding code proves it, or the concern is already
handled elsewhere. Don't "fix" it to make it go away, and don't park it as needs-input either:
that leaves a false positive propping the loop open until a safety cap fires. Reply with the
one-line reason (facts, not opinion: the line/behaviour that disproves it), 👎 it, then resolve
the thread / minimize the comment **immediately** — no push required, since there's no fix to
land. Record the id in `dismissedIds` so a re-fetch can't re-litigate it.
**Only bots get dismissed.** A *human* comment you think is wrong is a needs-input item: state
your reasoning in a reply, mark it 👀, and let me settle it. If you'd be dismissing more than
one or two findings in a cycle, you're probably the one who's wrong — park them for me instead.
Apply the same split to Codex's and Claude's review suggestions and to every `@claude` request:
a concrete ask is auto-handled; a genuine judgement call is left for me. Skip anything whose id
is already in `handledThreadIds` / `handledCommentIds` / `dismissedIds`. When I answer a needs-input item (or you
push a fix for it), drop its id from the needs-input arrays and handle/resolve it normally.
8. **Prepare the auto-handled set (no commit, no push):**
- Apply the edits (the PostToolUse format hook auto-formats). `git add` the changed files.
- **Mark every triaged item with its reaction — never an "Addressed — …" reply.** The reaction
goes on the reviewer's comment (for a thread, its *first* comment — the finding itself):
👍 what you fixed this cycle, 👀 what you parked for me, 👎 a dismissed bot finding.
One mutation covers both surfaces. Pass the **comment** node id — `comments[].id` (`PRRC_…`)
from the thread query, or `relevantComments[].id` (`IC_…`) for a conversation comment; *not*
the thread's own `PRRT_…` id. `content` is `THUMBS_UP` / `EYES` / `THUMBS_DOWN`:
```bash
gh api graphql -f query='mutation($id:ID!,$c:ReactionContent!){
addReaction(input:{subjectId:$id,content:$c}){ reaction{ content } } }' \
-F id=COMMENT_NODE_ID -F c=THUMBS_UP
```
Re-adding the same reaction is a harmless no-op, so a repeat cycle can't double-post. When an
item **changes class** — I answer a 👀, or a reviewer re-flags something you'd 👍'd — clear the
stale marker first, same call shape, so nothing ever carries two contradictory markers:
```bash
gh api graphql -f query='mutation($id:ID!,$c:ReactionContent!){
removeReaction(input:{subjectId:$id,content:$c}){ reaction{ content } } }' \
-F id=COMMENT_NODE_ID -F c=EYES
```
- Add handled review-thread ids to `handledThreadIds` and handled conversation/`@claude`
comment ids to `handledCommentIds`; set `awaitingPush=true`.
- **Resolve** a thread — `gh api graphql -f query='mutation($id:ID!){
resolveReviewThread(input:{threadId:$id}){ thread{ id isResolved } } }' -F id=THREAD_ID` —
**only** once its fix is live (a cycle where `headRefOid` advanced, per step 6) and the
reviewer hasn't re-flagged it. Never resolve unpushed work or a "needs input" thread. This
resolves the review *conversation*, not any linked GitHub Issue.
**The one exception is a 👎 dismissal:** there's no fix to land, so resolve/minimize it in the
same cycle you post the reason — otherwise the false positive keeps the loop alive forever.
- **Resolve a plain PR-conversation findings comment** (a bot finding with no inline thread —
e.g. Claude's) by **minimizing it as resolved** — the issue-comment equivalent, under the same
rules (only once its fix is live and not re-flagged):
```bash
gh api graphql -f query='mutation($id:ID!){ minimizeComment(input:{subjectId:$id,
classifier:RESOLVED}){ minimizedComment{ isMinimized } } }' -F id=COMMENT_NODE_ID
```
`COMMENT_NODE_ID` is the `relevantComments[].id` (`IC_…`). Never minimize unpushed work, a
"needs input" comment, or a summary/status/usage-limit comment.
9. **Draft the commit message — exactly like `/commit-msg`:** from the staged diff and the repo's
recent `git log` style (this repo uses `[Scope] summary`), write a message that matches. Then:
- **(a) Print it** in your reply as a fenced ` ```text ` block — the durable copy.
- **(b) Write it** with the Write tool to the path from `git rev-parse --git-path CLAUDE_COMMIT_MSG`
(**not** `COMMIT_EDITMSG` — git overwrites that on `git commit`; the `prepare-commit-msg` hook
prefills the editor from `CLAUDE_COMMIT_MSG`).
- **(c) Copy it:** `wl-copy < <that path>`.
- Tell me all three 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:
- 👍 fixed & staged this cycle: threads (with `path:line`);
- 👀 left for you: threads + one-line reason each;
- 👎 dismissed as not-an-issue: thread + the one-line why (call these out explicitly — a
dismissal is me trusting your judgement, so I should see every one);
- resolved on GitHub this cycle (if any);
- the commit message (printed above), then "commit & push when ready".
Refresh `lastSeenUpdatedAt` with one `gh pr view NUM --json updatedAt` call **after** any
needs-input questions are posted (reactions don't bump `updatedAt`, comments do — so your own
activity doesn't defeat the next cheap check), then save state
to `<git-dir>/pr-loop-state.json`. Then schedule:
- **normal cycle:** `ScheduleWakeup(delaySeconds=600, prompt="/pr-loop", reason="recheck PR
#<num> review comments")`;
- **idle cycle** (step 3 short-circuited): back off — `delaySeconds=1200`, I'm clearly away;
re-running `/pr-loop` checks immediately.
End the turn with a sign-off that states when you'll run again and how to stop, e.g.:
> ⏳ **Next check in ~10 min** (~20 when idle). To stop, press `Esc` while I'm idle between
> cycles (or close the session).
(When a stop condition in step 5 fired, skip both the wake-up and this sign-off — use the
**Finished** line from step 5 instead.)
## Guardrails
- **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.
- 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.
- If anything is unclear or risky, leave it for me rather than guessing.
@@ -0,0 +1,69 @@
---
description: Review all changes on the current branch (diff vs its base) for bugs, security issues, optimizations, and readability/scannability, then apply the readability + safe improvements after you approve. Use right before opening a PR.
argument-hint: [base-branch]
disable-model-invocation: true
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git merge-base:*), Bash(git symbolic-ref:*), Bash(git rev-parse:*), Bash(git show-ref:*), Bash(git branch:*), Read, Grep, Glob
---
# Review this branch
Context auto-collected for the current branch vs. its base:
!`b=$(git symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@'); [ -z "$b" ] && { git show-ref -q --verify refs/heads/main && b=main || b=master; }; m=$(git merge-base "$b" HEAD 2>/dev/null); echo "Current branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)"; echo "Base branch: $b"; echo "Diff range: ${m:-?}..HEAD"; echo; echo "Commits:"; git --no-pager log --oneline "${m}..HEAD" 2>/dev/null; echo; echo "Files changed:"; git --no-pager diff --stat "${m}...HEAD" 2>/dev/null`
## What to do
1. **Resolve the base branch.** If a base branch was passed when invoking (it appears here:
**$ARGUMENTS** — empty if none), use that. Otherwise use the base detected above. The changes
to review are the diff `git merge-base <base> HEAD`..`HEAD`.
2. **Gather context.** Read the full diff for that range (`git diff <merge-base>...HEAD`) and
review **only the lines this branch changed** — not pre-existing code. Read surrounding code
with the Read tool when you need it to judge a finding. Also read the repo's `CLAUDE.md` (root,
and any in the directories the branch touched) and treat its guidance as a review lens.
3. **Find issues** across four lenses:
- 🐞 **Correctness / bugs** — logic errors, unhandled edge cases, swallowed/ignored errors,
race conditions, wrong/loose types, off-by-ones, and anything that violates the repo's
`CLAUDE.md`.
- 🔒 **Security** — injection (SQL / command / path traversal), missing input validation or
output encoding, authn/authz gaps, secrets or credentials committed / logged / echoed,
unsafe deserialization, SSRF, weak crypto or randomness, unsafe defaults, and sensitive data
leaked in logs or error messages.
-**Optimizations** — redundant work, needless allocations/copies, N+1 patterns, a simpler
or standard-library equivalent.
- 📖 **Readability & scannability** — naming, structure, dead code, stale/misleading comments,
formatting, over-long functions, unclear control flow.
4. **Verify the bugs and security findings before reporting.** Double-check each 🐞 and 🔒
finding against the real code and keep only the ones you're confident are real and exploitable
/ will bite in practice. **Do not flag:**
- issues on lines the branch didn't change (pre-existing);
- theoretical vulnerabilities with no reachable exploit path in the changed code, or anything a
SAST / dependency scanner would own;
- anything a linter / type-checker / compiler / CI catches (imports, type errors, formatting) —
assume those run separately;
- pedantic nitpicks a senior engineer wouldn't raise, or "needs more tests/docs" unless the
repo's `CLAUDE.md` requires it;
- changes that are clearly intentional and part of the feature.
(This filter is for 🐞 bugs and 🔒 security. ⚡ optimizations and 📖 readability are the
deliberate polish pass — minor suggestions there are welcome, since the goal is PR-readiness.)
5. **Report**, grouped by the four lenses, most-severe-first, each finding one or two lines with
a `path:line` reference so it's scannable. If a category is clean, say so in one line. No walls
of text.
6. **Then stop and ask** whether to apply the fixes. On approval, apply **only** the readability
improvements and behavior-preserving optimizations. Anything that changes behavior, public API,
or semantics: list it separately and let me decide — do **not** apply it silently. Treat 🔒
security fixes the same way: propose them, but never apply them silently. After editing, re-run
the repo's formatter/linter if it has one.
7. **Point me at the relevant built-in follow-ups.** After reporting, list the Claude Code
commands worth running next as separate, deeper passes — tailored to what this review
surfaced, not a blanket dump. Only include the ones that actually fit:
- `/security-review` — a deeper, whole-branch security pass; suggest whenever the 🔒 lens
flagged something or the diff touches auth, crypto, input handling, secrets, or network I/O.
- `/code-review` (add `--fix` to apply, `--comment` for inline PR comments) — a second review
of the working-tree diff at a chosen effort level.
- `/simplify` — apply reuse / simplification / efficiency cleanups beyond the safe ones applied
here.
- `/verify` — run the app to confirm the change behaves, if this branch changed behavior.
- `/pr-description` — draft the PR text from this branch once the review is clean.
- `/review` — review the PR on GitHub after it's open.
8. **Never** run `git commit` or `git push` — I do that manually.
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""Claude Code status line.
One line: vim mode | dir (repo-relative, hidden at the root) |
git(repo/branch +dirty +ahead/behind) | model (+effort) |
context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) | 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;
tmux's status bar owns those now -- plus velocity, cache hit %, api ratio, version,
style.)
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 json
import os
import re
import shutil
import subprocess
import sys
import time
import unicodedata
# --- ANSI helpers -----------------------------------------------------------
RESET = "\033[0m"
DIM = "" # disabled: "\033[2m" blended the gray detail text into dark backgrounds
BOLD = "\033[1m"
C = {
"cyan": "\033[36m",
"green": "\033[32m",
"yellow": "\033[33m",
"red": "\033[31m",
"magenta": "\033[35m",
"blue": "\033[34m",
"gray": "\033[37m", # light gray (was "\033[90m" bright-black, too dark to read)
}
def color(text, name="", dim=False, bold=False):
pre = ""
if dim:
pre += DIM
if bold:
pre += BOLD
pre += C.get(name, "")
return f"{pre}{text}{RESET}" if pre else str(text)
def bucket(value, lo, hi, invert=False):
"""green/yellow/red by threshold. invert=True -> high is good."""
if invert:
return "green" if value >= hi else ("yellow" if value >= lo else "red")
return "green" if value < lo else ("yellow" if value < hi else "red")
DIV = color("", "gray", dim=True)
def join_line(segs):
return DIV.join(s for s in segs if s)
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def vlen(text):
"""Visible width of a rendered string: ANSI stripped, emoji counted as 2."""
text = _ANSI_RE.sub("", text)
w = 0
for ch in text:
if ch in ("\u200d", "\ufe0f", "\ufe0e") or unicodedata.combining(ch):
continue # ZWJ / variation selectors / combining marks: zero width
o = ord(ch)
if o >= 0x1F000 or 0x2600 <= o <= 0x27BF or unicodedata.east_asian_width(ch) in ("W", "F"):
w += 2
else:
w += 1
return w
def term_cols():
"""Terminal width. Claude Code exports COLUMNS; 0 if unknown -> stay one line."""
try:
return shutil.get_terminal_size(fallback=(0, 0)).columns
except Exception:
return 0
# --- shared transcript usage ------------------------------------------------
def last_usage(data):
"""Most recent `message.usage` block from the transcript, or {}."""
try:
path = data.get("transcript_path")
if not path or not os.path.exists(path):
return {}
usage = {}
with open(path, "r", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line or '"usage"' not in line:
continue
try:
obj = json.loads(line)
except Exception:
continue
msg = obj.get("message")
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
usage = msg["usage"]
return usage
except Exception:
return {}
# --- row 1: work ------------------------------------------------------------
def vim_segment(data):
"""Vim editor mode as a compact [I]/[N]/[V] (only present with editorMode: vim).
Pair with `"hideVimModeIndicator": true` in the statusLine settings block so
Claude's own built-in indicator doesn't duplicate this one.
"""
mode = (data.get("vim") or {}).get("mode")
if not mode:
return ""
m = mode.upper()
if m.startswith("INSERT"):
return color("[I]", "green", bold=True)
if m.startswith("VISUAL"):
return color("[V]", "magenta", bold=True)
if m.startswith("NORMAL"):
return color("[N]", "blue", bold=True)
return color(f"[{m[:1]}]", "gray")
def abbrev(path):
"""fish-style prompt_pwd: shrink every component but the last to one char,
keeping a leading dot (`.config` -> `.c`)."""
parts = path.split(os.sep)
out = []
for i, p in enumerate(parts):
if i == len(parts) - 1 or p in ("", "~"):
out.append(p)
elif p.startswith("."):
out.append(p[:2])
else:
out.append(p[:1])
return os.sep.join(out)
def dir_segment(cwd, top=None):
"""Path *inside* the repo -- git_segment already names the repo itself, so
repeating the full ~-path here was pure duplication. Empty at the repo root;
a fish-shortened ~-path when we're not in a repo at all."""
try:
rel = os.path.relpath(cwd, top) if top else None
if rel and not rel.startswith(os.pardir): # `..` -> cwd is outside `top` (symlinked in); use the ~-path
if rel == os.curdir:
return ""
shown = rel if len(rel) <= 28 else abbrev(rel)
else:
home = os.path.expanduser("~")
shown = cwd
if cwd == home:
shown = "~"
elif cwd.startswith(home + os.sep):
shown = "~" + cwd[len(home):]
shown = abbrev(shown)
return "📁 " + color(shown, "cyan")
except Exception:
return ""
def _git(cwd, *args):
return subprocess.run(
["git", "-C", cwd, *args], capture_output=True, text=True, timeout=1
)
def git_top(cwd):
"""Absolute path of the enclosing work tree, or None."""
try:
r = _git(cwd, "rev-parse", "--show-toplevel")
return r.stdout.strip() or None if r.returncode == 0 else None
except Exception:
return None
def git_segment(cwd, top):
try:
if not top:
return ""
repo = os.path.basename(top) or "repo"
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "?"
dirty = bool(_git(cwd, "status", "--porcelain").stdout.strip())
# ahead/behind vs upstream (omitted when no upstream is configured)
ab = ""
rl = _git(cwd, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
if rl.returncode == 0 and rl.stdout.strip():
try:
behind, ahead = (int(x) for x in rl.stdout.split())
bits = []
if ahead:
bits.append(color(f"{ahead}", "cyan"))
if behind:
bits.append(color(f"{behind}", "yellow"))
if bits:
ab = " " + "".join(bits)
except Exception:
ab = ""
mark = color("*", "red") if dirty else ""
label = color(repo, "magenta", bold=True)
br = color(branch, "yellow" if dirty else "green")
return f"🌿 {label} {br}{mark}{ab}"
except Exception:
return ""
def model_segment(data):
name = (data.get("model") or {}).get("display_name")
if not name:
return ""
name = name.replace(" context)", ")") # "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)"
out = "🤖 " + color(name, "blue")
level = (data.get("effort") or {}).get("level")
if level:
out += color(f" · {level}", "gray")
return out
def context_segment(data, usage):
try:
if not usage:
return ""
used = (
usage.get("input_tokens", 0)
+ usage.get("cache_creation_input_tokens", 0)
+ usage.get("cache_read_input_tokens", 0)
)
model_id = (data.get("model") or {}).get("id", "")
window = 1_000_000 if "1m" in model_id.lower() else 200_000
pct = used / window * 100 if window else 0
col = bucket(pct, 50, 80)
out = "📝 " + color(f"{pct:.0f}%", col) + color(f" ({used / 1000:.0f}k)", "gray", dim=True)
if pct >= 80:
out += color(" ⚠compact", "red", bold=True)
return out
except Exception:
return ""
def usage_segment(data):
"""5-hour rolling usage window: used % (+ time until it resets)."""
try:
five = (data.get("rate_limits") or {}).get("five_hour") or {}
pct = five.get("used_percentage")
if pct is None:
return ""
out = "📊 " + color(f"{pct:.0f}%", bucket(pct, 50, 80))
resets = five.get("resets_at")
if resets:
secs = int(resets) - int(time.time())
if secs > 0:
h, m = divmod(secs // 60, 60)
out += color(f" {h}h{m:02d}m" if h else f" {m}m", "gray")
return out
except Exception:
return ""
def cost_segment(data):
try:
cost = data.get("cost") or {}
usd = cost.get("total_cost_usd")
if usd is None:
return ""
out = "💰 " + color(f"${usd:.2f}", "yellow")
dur_ms = cost.get("total_duration_ms") or 0
if dur_ms > 30_000: # need a meaningful window before extrapolating
per_hr = usd / (dur_ms / 3_600_000)
out += color(f" ${per_hr:.2f}/h", "gray", dim=True)
return out
except Exception:
return ""
def velocity_segment(data):
try:
cost = data.get("cost") or {}
added = cost.get("total_lines_added", 0)
removed = cost.get("total_lines_removed", 0)
if not (added or removed):
return ""
out = "✏️ " + color(f"+{added}", "green") + color("/", "gray", dim=True) + color(f"-{removed}", "red")
dur_ms = cost.get("total_duration_ms") or 0
if dur_ms > 30_000:
per_min = added / (dur_ms / 60_000)
out += color(f" {per_min:.0f}/m", "gray", dim=True)
return out
except Exception:
return ""
def cache_segment(usage):
try:
if not usage:
return ""
read = usage.get("cache_read_input_tokens", 0)
total = (
usage.get("input_tokens", 0)
+ usage.get("cache_creation_input_tokens", 0)
+ read
)
if total <= 0:
return ""
pct = read / total * 100
return "♻️ " + color(f"{pct:.0f}%", bucket(pct, 50, 80, invert=True))
except Exception:
return ""
def api_segment(data):
"""Share of wall-clock time spent in API/inference (rough 'busy' ratio)."""
try:
cost = data.get("cost") or {}
wall = cost.get("total_duration_ms") or 0
api = cost.get("total_api_duration_ms") or 0
if wall <= 0 or api <= 0:
return ""
pct = min(api / wall * 100, 100)
return "⚙️ " + color(f"{pct:.0f}%", "blue")
except Exception:
return ""
# --- row 2: system ----------------------------------------------------------
def ram_segment():
try:
info = {}
with open("/proc/meminfo") as fh:
for line in fh:
k, _, v = line.partition(":")
info[k.strip()] = int(v.split()[0]) # kB
total = info.get("MemTotal", 0)
avail = info.get("MemAvailable", info.get("MemFree", 0))
if total <= 0:
return ""
used = total - avail
pct = used / total * 100
g = 1024 * 1024
return (
"🧠 " + color(f"{pct:.0f}%", bucket(pct, 70, 85))
+ color(f" {used / g:.1f}/{total / g:.0f}G", "gray", dim=True)
)
except Exception:
return ""
def disk_segment(cwd):
try:
st = os.statvfs(cwd)
total = st.f_blocks
if total <= 0:
return ""
used = total - st.f_bfree
pct = used / total * 100
return "💾 " + color(f"{pct:.0f}%", bucket(pct, 75, 90))
except Exception:
return ""
def _cpu_pct():
"""Live CPU% diffed against a cached /proc/stat snapshot (no sleeping)."""
try:
with open("/proc/stat") as fh:
parts = fh.readline().split()
if not parts or parts[0] != "cpu":
return None
vals = [int(x) for x in parts[1:]]
idle = vals[3] + (vals[4] if len(vals) > 4 else 0) # idle + iowait
total = sum(vals)
state = os.path.expanduser("~/.claude/cache/statusline.cpu")
prev = None
try:
with open(state) as fh:
pt, pi = fh.read().split()
prev = (int(pt), int(pi))
except Exception:
prev = None
try:
os.makedirs(os.path.dirname(state), exist_ok=True)
with open(state, "w") as fh:
fh.write(f"{total} {idle}")
except Exception:
pass
if not prev:
return None
dt = total - prev[0]
di = idle - prev[1]
if dt <= 0 or di < 0:
return None
return max(0.0, min(100.0, (1 - di / dt) * 100))
except Exception:
return None
def cpu_segment():
try:
load1 = os.getloadavg()[0]
cores = os.cpu_count() or 1
pct = _cpu_pct()
if pct is not None:
head = color(f"{pct:.0f}%", bucket(pct, 60, 85))
tail = color(f" {load1:.1f} {cores}c", "gray", dim=True)
else:
head = color(f"{load1:.2f}", bucket(load1 / cores, 0.7, 1.0))
tail = color(f" {cores}c", "gray", dim=True)
return "🖥️ " + head + tail
except Exception:
return ""
def temp_segment():
"""Hottest CPU-ish thermal zone in °C, falling back to the max zone."""
try:
base = "/sys/class/thermal"
if not os.path.isdir(base):
return ""
prefer = ("x86_pkg_temp", "coretemp", "cpu", "k10temp", "tctl")
chosen = None
fallback = None
for name in os.listdir(base):
if not name.startswith("thermal_zone"):
continue
d = os.path.join(base, name)
try:
with open(os.path.join(d, "temp")) as fh:
milli = int(fh.read().strip())
except Exception:
continue
ztype = ""
try:
with open(os.path.join(d, "type")) as fh:
ztype = fh.read().strip().lower()
except Exception:
pass
if fallback is None or milli > fallback:
fallback = milli
if any(p in ztype for p in prefer) and (chosen is None or milli > chosen):
chosen = milli
milli = chosen if chosen is not None else fallback
if milli is None:
return ""
c = milli / 1000.0
return "🌡️ " + color(f"{c:.0f}°C", bucket(c, 60, 80))
except Exception:
return ""
def version_segment(data):
v = data.get("version")
return color(f"v{v}", "gray", dim=True) if v else ""
def style_segment(data):
name = (data.get("output_style") or {}).get("name")
return color(f"🎨{name}", "gray", dim=True) if name else ""
def main():
try:
data = json.load(sys.stdin)
except Exception:
data = {}
cwd = (
(data.get("workspace") or {}).get("current_dir")
or data.get("cwd")
or os.getcwd()
)
usage = last_usage(data)
top = git_top(cwd)
# One row: work (left) then system (right), joined. If it would overflow the
# terminal, trailing (lowest-priority) segments are dropped so it stays one line.
# Dormant helpers kept above for easy re-add: ram_segment, cpu_segment,
# temp_segment (retired -- they only refresh on message events, so they sat
# stale; the tmux status bar owns system metrics now), velocity_segment,
# cache_segment, api_segment, version_segment, style_segment.
work = [s for s in (
vim_segment(data),
dir_segment(cwd, top),
git_segment(cwd, top),
model_segment(data),
context_segment(data, usage),
usage_segment(data),
cost_segment(data),
) if s]
system = [s for s in (
disk_segment(cwd),
) if s]
segs = work + system
cols = term_cols()
if cols: # trim from the right until it fits one line (cols=0 -> width unknown, keep all)
while len(segs) > 1 and vlen(join_line(segs)) > cols:
segs.pop()
sys.stdout.write(join_line(segs))
if __name__ == "__main__":
main()