[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:
@@ -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.
|
||||
@@ -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/`.
|
||||
@@ -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%` | `50–80%` | `≥ 80%` (⚠compact) |
|
||||
| 5h usage | `< 50%` | `50–80%` | `≥ 80%` |
|
||||
| Disk | `< 75%` | `75–90%` | `≥ 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.
|
||||
Executable
+149
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+97
@@ -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
|
||||
@@ -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.
|
||||
Executable
+517
@@ -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()
|
||||
Reference in New Issue
Block a user